9981b30260
CI / test (pull_request) Failing after 8s
- Enhanced the subject suggestion guidelines to specify that descriptions must not exceed 200 characters. - Deleted the migration file for adding conversation_id to asignaturas and the associated view, as it is no longer needed.
385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
import "@supabase/functions-js/edge-runtime.d.ts";
|
|
import { corsHeaders } from "../_shared/cors.ts";
|
|
import { HttpError, sendError, sendSuccess } from "../_shared/utils.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 {
|
|
OpenAIService,
|
|
type StructuredResponseOptions,
|
|
} from "../_shared/openai-service.ts";
|
|
|
|
export type DataAsignaturaSugerida = {
|
|
nombre: Tables<"asignaturas">["nombre"];
|
|
codigo?: Tables<"asignaturas">["codigo"];
|
|
tipo: Tables<"asignaturas">["tipo"] | null;
|
|
creditos: Tables<"asignaturas">["creditos"] | null;
|
|
horasAcademicas?: number | null;
|
|
horasIndependientes?: number | null;
|
|
descripcion: string;
|
|
};
|
|
|
|
const AsignaturaSugeridaItemSchema: z.ZodType<DataAsignaturaSugerida> = z
|
|
.object({
|
|
nombre: z.string().describe("Nombre de la asignatura a crear"),
|
|
codigo: z.string().optional().nullable().describe(
|
|
"Código o clave de la asignatura. Un string único que la identifique, como 'MAT101' o 'FIS202'. Opcional, pero recomendado para evitar confusiones.",
|
|
),
|
|
tipo: z.enum(["TRONCAL", "OBLIGATORIA", "OPTATIVA", "OTRA"]).nullable(),
|
|
creditos: z.number().nullable(),
|
|
horasAcademicas: z.number().optional().nullable(),
|
|
horasIndependientes: z.number().optional().nullable(),
|
|
descripcion: z.string().max(200),
|
|
});
|
|
|
|
const RequestSchema = z.object({
|
|
plan_estudio_id: z.string().uuid(),
|
|
// numero_de_ciclo: z.number().int().positive(), // ya no se usa
|
|
enfoque: z.string().trim().min(1).optional(),
|
|
cantidad_de_sugerencias: z.number().int().positive().max(15),
|
|
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}`;
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
Deno.serve(async (req: Request): Promise<Response> => {
|
|
const url = new URL(req.url);
|
|
const functionName = url.pathname.split("/").pop();
|
|
console.log(
|
|
`[${new Date().toISOString()}][${functionName}]: Request received`,
|
|
);
|
|
|
|
if (req.method === "OPTIONS") {
|
|
return new Response(null, { status: 204, headers: corsHeaders });
|
|
}
|
|
|
|
try {
|
|
const method = req.method;
|
|
if (method !== "POST") {
|
|
throw new HttpError(
|
|
405,
|
|
"Método no permitido.",
|
|
"METHOD_NOT_ALLOWED",
|
|
{ method },
|
|
);
|
|
}
|
|
|
|
const authHeaderRaw = req.headers.get("Authorization") ??
|
|
req.headers.get("authorization");
|
|
if (!authHeaderRaw) {
|
|
throw new HttpError(
|
|
401,
|
|
"No autorizado.",
|
|
"UNAUTHORIZED",
|
|
{ reason: "missing_authorization_header" },
|
|
);
|
|
}
|
|
|
|
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` +
|
|
`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 (sin pasarse del límite de 200 caracteres).\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(
|
|
500,
|
|
"Configuración del servidor incompleta.",
|
|
"OPENAI_MISCONFIGURED",
|
|
svc,
|
|
);
|
|
}
|
|
|
|
const options: StructuredResponseOptions = {
|
|
model: "gpt-5-mini",
|
|
input: [
|
|
{ role: "system", content: systemPrompt },
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "input_text",
|
|
text: userPrompt,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
text: {
|
|
format: zodTextFormat(
|
|
AsignaturaSugeridaSchema,
|
|
"estructura_asignaturas",
|
|
),
|
|
},
|
|
};
|
|
|
|
const aiResult = await svc.createStructuredResponse<
|
|
typeof AsignaturaSugeridaSchema._output
|
|
>(
|
|
options,
|
|
);
|
|
if (!aiResult.ok) {
|
|
const status = aiResult.code === "MissingEnv" ? 500 : 502;
|
|
throw new HttpError(
|
|
status,
|
|
"No se pudieron generar sugerencias con IA.",
|
|
"OPENAI_REQUEST_FAILED",
|
|
aiResult,
|
|
);
|
|
}
|
|
|
|
let output = aiResult.output ?? null;
|
|
if (output == null && aiResult.outputText) {
|
|
try {
|
|
output = JSON.parse(aiResult.outputText);
|
|
} catch {
|
|
throw new HttpError(
|
|
502,
|
|
"La respuesta de la IA no es JSON válido.",
|
|
"OPENAI_INVALID_JSON",
|
|
{ outputText: aiResult.outputText },
|
|
);
|
|
}
|
|
}
|
|
if (output == null) {
|
|
throw new HttpError(
|
|
502,
|
|
"La respuesta de la IA no contiene salida estructurada.",
|
|
"OPENAI_MISSING_STRUCTURED_OUTPUT",
|
|
{ outputText: aiResult.outputText ?? null },
|
|
);
|
|
}
|
|
|
|
const parsed = AsignaturaSugeridaSchema.safeParse(output);
|
|
if (!parsed.success) {
|
|
throw new HttpError(
|
|
502,
|
|
"La salida estructurada no coincide con el esquema esperado.",
|
|
"OPENAI_SCHEMA_MISMATCH",
|
|
parsed.error,
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`[${
|
|
new Date().toISOString()
|
|
}][${functionName}]: Request processed successfully`,
|
|
);
|
|
return sendSuccess(parsed.data.sugerencias);
|
|
} catch (error) {
|
|
if (error instanceof HttpError) {
|
|
console.error(
|
|
`[${new Date().toISOString()}][${functionName}] ⚠️ Handled Error:`,
|
|
{
|
|
message: error.message,
|
|
code: error.code,
|
|
internalDetails: error.internalDetails || "N/A",
|
|
},
|
|
);
|
|
|
|
return sendError(error.status, error.message, error.code);
|
|
}
|
|
|
|
const unexpectedError = error instanceof Error
|
|
? error
|
|
: new Error(String(error));
|
|
|
|
console.error(
|
|
`[${
|
|
new Date().toISOString()
|
|
}][${functionName}] 💥 CRITICAL UNHANDLED ERROR:`,
|
|
unexpectedError.stack || unexpectedError.message,
|
|
);
|
|
|
|
return sendError(
|
|
500,
|
|
"Ocurrió un error inesperado en el servidor.",
|
|
"INTERNAL_SERVER_ERROR",
|
|
);
|
|
}
|
|
});
|