Generación de asignaturas con sugerencias #22
@@ -1,7 +1,8 @@
|
||||
import "@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import { HttpError, sendError, sendSuccess } from "../_shared/utils.ts";
|
||||
import type { Tables } from "../_shared/database.types.ts";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import type { Database, Tables } from "../_shared/database.types.ts";
|
||||
import { z } from "zod";
|
||||
import { zodTextFormat } from "openai/helpers/zod";
|
||||
import {
|
||||
@@ -32,14 +33,38 @@ const AsignaturaSugeridaItemSchema: z.ZodType<DataAsignaturaSugerida> = z
|
||||
descripcion: z.string(),
|
||||
});
|
||||
|
||||
const AsignaturaSugeridaSchema = z
|
||||
.object({
|
||||
sugerencias: z
|
||||
.array(AsignaturaSugeridaItemSchema)
|
||||
.length(6)
|
||||
.describe("Arreglo de 6 sugerencias de asignatura"),
|
||||
const RequestSchema = z.object({
|
||||
plan_estudio_id: z.string().uuid(),
|
||||
numero_de_ciclo: z.number().int().positive(),
|
||||
enfoque: z.string().trim().min(1).optional(),
|
||||
cantidad_de_sugerencias: z.number().int().positive().max(50),
|
||||
sugerencias_conservadas: z
|
||||
.array(
|
||||
z.object({
|
||||
nombre: z.string().trim().min(1),
|
||||
descripcion: z.string().trim().min(1),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
function normalizeName(value: string): string {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/\p{Diacritic}/gu, "")
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function formatZodIssues(issues: z.ZodIssue[]): string {
|
||||
return issues
|
||||
.map((issue, i) => {
|
||||
const path = issue.path.length ? issue.path.join(".") : "(root)";
|
||||
return `${i + 1}. ${path}: ${issue.message}`;
|
||||
})
|
||||
.describe("Respuesta estructurada con 6 sugerencias de asignatura");
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
Deno.serve(async (req: Request): Promise<Response> => {
|
||||
const url = new URL(req.url);
|
||||
@@ -74,6 +99,173 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = (req.headers.get("content-type") || "").toLowerCase();
|
||||
if (!contentType.includes("application/json")) {
|
||||
throw new HttpError(
|
||||
415,
|
||||
"Content-Type no soportado.",
|
||||
"UNSUPPORTED_MEDIA_TYPE",
|
||||
{ contentType },
|
||||
);
|
||||
}
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await req.json();
|
||||
} catch (e) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"Body JSON inválido.",
|
||||
"INVALID_JSON",
|
||||
{ cause: e },
|
||||
);
|
||||
}
|
||||
|
||||
const validated = RequestSchema.safeParse(rawBody);
|
||||
if (!validated.success) {
|
||||
throw new HttpError(
|
||||
422,
|
||||
formatZodIssues(validated.error.issues),
|
||||
"VALIDATION_ERROR",
|
||||
validated.error,
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
plan_estudio_id,
|
||||
numero_de_ciclo,
|
||||
enfoque,
|
||||
cantidad_de_sugerencias,
|
||||
sugerencias_conservadas,
|
||||
} = validated.data;
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
|
||||
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
|
||||
throw new HttpError(
|
||||
500,
|
||||
"Configuración del servidor incompleta.",
|
||||
"MISSING_ENV",
|
||||
{
|
||||
missing: [
|
||||
!SUPABASE_URL ? "SUPABASE_URL" : null,
|
||||
!SERVICE_ROLE_KEY ? "SUPABASE_SERVICE_ROLE_KEY" : null,
|
||||
].filter(Boolean),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const supabaseService = createClient<Database>(
|
||||
SUPABASE_URL,
|
||||
SERVICE_ROLE_KEY,
|
||||
);
|
||||
|
||||
const { data: plan, error: planError } = await supabaseService
|
||||
.from("planes_estudio")
|
||||
.select("id,nombre,nivel,tipo_ciclo,numero_ciclos,datos")
|
||||
.eq("id", plan_estudio_id)
|
||||
.single();
|
||||
if (planError) {
|
||||
const maybeCode = (planError as { code?: string }).code;
|
||||
if (maybeCode === "PGRST116") {
|
||||
throw new HttpError(
|
||||
404,
|
||||
"No se encontró el plan de estudio.",
|
||||
"NOT_FOUND",
|
||||
{ table: "planes_estudio", id: plan_estudio_id },
|
||||
);
|
||||
}
|
||||
throw new HttpError(
|
||||
500,
|
||||
"No se pudo obtener el plan de estudio.",
|
||||
"SUPABASE_QUERY_FAILED",
|
||||
planError,
|
||||
);
|
||||
}
|
||||
|
||||
const { data: asignaturas, error: asignaturasError } = await supabaseService
|
||||
.from("asignaturas")
|
||||
.select("id,nombre,numero_ciclo,codigo,tipo,creditos")
|
||||
.eq("plan_estudio_id", plan_estudio_id);
|
||||
if (asignaturasError) {
|
||||
throw new HttpError(
|
||||
500,
|
||||
"No se pudieron obtener las asignaturas del plan de estudio.",
|
||||
"SUPABASE_QUERY_FAILED",
|
||||
asignaturasError,
|
||||
);
|
||||
}
|
||||
|
||||
const existingNames = new Set(
|
||||
(asignaturas ?? [])
|
||||
.map((a) => normalizeName(a.nombre))
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const conservedNames = new Set(
|
||||
(sugerencias_conservadas ?? [])
|
||||
.map((s) => normalizeName(s.nombre))
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const forbiddenNames = Array.from(
|
||||
new Set([...existingNames, ...conservedNames]),
|
||||
);
|
||||
|
||||
const asignaturasResumen = (asignaturas ?? [])
|
||||
.map((a) => {
|
||||
const ciclo = a.numero_ciclo == null ? "(sin ciclo)" : a.numero_ciclo;
|
||||
const codigo = a.codigo ? ` - ${a.codigo}` : "";
|
||||
return `- [ciclo ${ciclo}] ${a.nombre}${codigo}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const sugerenciasConservadasResumen = (sugerencias_conservadas ?? [])
|
||||
.map((s) => `- ${s.nombre}: ${s.descripcion}`)
|
||||
.join("\n");
|
||||
|
||||
const systemPrompt =
|
||||
"Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el esquema proporcionado.";
|
||||
|
||||
const userPrompt =
|
||||
`Necesito sugerencias NUEVAS de asignaturas para un plan de estudios.\n\n` +
|
||||
`Plan de estudio:\n` +
|
||||
`- id: ${plan.id}\n` +
|
||||
`- nombre: ${plan.nombre}\n` +
|
||||
`- nivel: ${plan.nivel}\n` +
|
||||
`- tipo_ciclo: ${plan.tipo_ciclo}\n` +
|
||||
`- numero_ciclos: ${plan.numero_ciclos}\n\n` +
|
||||
`Datos del plan (JSON):\n${JSON.stringify(plan.datos)}\n\n` +
|
||||
`Asignaturas existentes en el plan (NO repetir):\n${
|
||||
asignaturasResumen || "(ninguna)"
|
||||
}\n\n` +
|
||||
`Sugerencias conservadas por el usuario (NO repetir):\n${
|
||||
sugerenciasConservadasResumen || "(ninguna)"
|
||||
}\n\n` +
|
||||
`Ciclo objetivo: ${numero_de_ciclo}\n` +
|
||||
`Enfoque (opcional): ${enfoque ?? "(ninguno)"}\n\n` +
|
||||
`Requisitos estrictos:\n` +
|
||||
`1) Genera EXACTAMENTE ${cantidad_de_sugerencias} sugerencias.\n` +
|
||||
`2) No repitas nombres que ya existan en el plan ni los nombres de las sugerencias conservadas.\n` +
|
||||
`3) Tampoco repitas nombres entre tus nuevas sugerencias.\n` +
|
||||
`4) Evita nombres demasiado similares (diferencias solo por mayúsculas, tildes, signos o palabras triviales).\n` +
|
||||
`5) Cada sugerencia debe incluir un nombre y una descripción clara y útil.\n\n` +
|
||||
`Lista de nombres prohibidos (normalizados):\n` +
|
||||
forbiddenNames.map((n) => `- ${n}`).join("\n");
|
||||
|
||||
const AsignaturaSugeridaSchema = z
|
||||
.object({
|
||||
sugerencias: z
|
||||
.array(AsignaturaSugeridaItemSchema)
|
||||
.length(cantidad_de_sugerencias)
|
||||
.describe(
|
||||
`Arreglo de ${cantidad_de_sugerencias} sugerencias de asignatura`,
|
||||
),
|
||||
})
|
||||
.describe(
|
||||
`Respuesta estructurada con ${cantidad_de_sugerencias} sugerencias de asignatura`,
|
||||
);
|
||||
|
||||
const svc = OpenAIService.fromEnv();
|
||||
if (!(svc instanceof OpenAIService)) {
|
||||
throw new HttpError(
|
||||
@@ -87,13 +279,13 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
||||
const options: StructuredResponseOptions = {
|
||||
model: "gpt-5-mini",
|
||||
input: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text:
|
||||
"Genera 6 sugerencias de asignatura para el ciclo 6 de 9 del plan de estudios de una carrera de ingeniería mecánica.",
|
||||
text: userPrompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user