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, Json } from "../_shared/database.types.ts"; import { z } from "zod"; import { OpenAIService, type StructuredResponseOptions, } from "../_shared/openai-service.ts"; type BeforeUnloadWithDetail = Event & { detail?: { reason?: unknown } }; addEventListener("beforeunload", (ev: BeforeUnloadWithDetail) => { console.error( "ALERTA: La función se va a apagar. Razón:", ev?.detail?.reason, ); }); const DatosUpdateSchema = z .object({ id: z.string().uuid("id debe ser un UUID").optional(), // campos de asignatura (nombres coinciden con columnas DB) plan_estudio_id: z.string().uuid("plan_estudio_id debe ser un UUID"), estructura_id: z.string().uuid("estructura_id debe ser un UUID").optional(), nombre: z.string().min(1).optional(), codigo: z.union([z.string().min(1), z.null()]).optional(), tipo: z.union([z.string().min(1), z.null()]).optional(), creditos: z.number().positive().optional(), horas_academicas: z.number().int().nonnegative().nullable().optional(), horas_independientes: z.number().int().nonnegative().nullable().optional(), numero_ciclo: z.number().int().positive().nullable().optional(), linea_plan_id: z.string().uuid().nullable().optional(), orden_celda: z.number().int().nonnegative().nullable().optional(), }) .strict(); const IAConfigSchema = z .object({ descripcionEnfoqueAcademico: z.string().optional(), instruccionesAdicionalesIA: z.string().optional(), archivosAdjuntos: z.array(z.string().min(1)).optional().default([]), }) .strict() .default({}); const UnifiedJsonSchema = z .object({ datosUpdate: DatosUpdateSchema, iaConfig: IAConfigSchema, }) .strict() .superRefine((val, ctx) => { // Si no hay id, es un insert => necesitamos campos mínimos if (!val.datosUpdate.id) { if (!val.datosUpdate.estructura_id) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["datosUpdate", "estructura_id"], message: "estructura_id es requerido para crear", }); } if (!val.datosUpdate.nombre) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["datosUpdate", "nombre"], message: "nombre es requerido para crear", }); } if (val.datosUpdate.creditos === undefined) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["datosUpdate", "creditos"], message: "creditos es requerido para crear", }); } } }); type EdgeAIGenerateSubjectUnifiedInput = z.infer; type AsignaturaBaseSeleccionada = { id: string; plan_estudio_id: string; estructura_id: string | null; nombre: string; codigo: string | null; tipo: Database["public"]["Tables"]["asignaturas"]["Row"]["tipo"]; creditos: number; horas_academicas: number | null; horas_independientes: number | null; numero_ciclo: number | null; linea_plan_id: string | null; orden_celda: number | null; estado: Database["public"]["Enums"]["estado_asignatura"]; }; function withColumnDefsAndRefs( schemaDef: Record, ): Record { const nextSchema: Record = { ...schemaDef, $defs: definicionesDeEstructurasDeColumnas, }; const props = nextSchema["properties"]; if (!props || typeof props !== "object" || Array.isArray(props)) { return nextSchema; } const nextProps: Record = { ...props as Record, }; for (const [key, value] of Object.entries(nextProps)) { if (!value || typeof value !== "object" || Array.isArray(value)) continue; const xColumn = (value as Record)["x-column"]; if (typeof xColumn !== "string" || !xColumn.length) continue; nextProps[key] = { $ref: `#/$defs/${xColumn}` }; } nextSchema["properties"] = nextProps; return nextSchema; } 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 => { 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") { console.error( `[${ new Date().toISOString() }][${functionName}]: Invalid method: ${method}`, ); throw new HttpError( 405, "Método no permitido.", "METHOD_NOT_ALLOWED", { method }, ); } const authHeaderRaw = req.headers.get("Authorization") ?? req.headers.get("authorization"); if (!authHeaderRaw) { console.error( `[${ new Date().toISOString() }][${functionName}]: Missing Authorization header`, ); throw new HttpError( 401, "No autorizado.", "UNAUTHORIZED", { reason: "missing_authorization_header" }, ); } const contentType = (req.headers.get("content-type") || "").toLowerCase(); const isJson = contentType.includes("application/json"); if (!isJson) { console.error( `[${ new Date().toISOString() }][${functionName}]: Unsupported content type: ${contentType}`, ); throw new HttpError( 415, "Content-Type no soportado.", "UNSUPPORTED_MEDIA_TYPE", { contentType, expected: "application/json" }, ); } const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY"); if (!SUPABASE_URL || !SUPABASE_ANON_KEY) { throw new HttpError( 500, "Configuración del servidor incompleta.", "MISSING_ENV", { missing: [ !SUPABASE_URL ? "SUPABASE_URL" : null, !SUPABASE_ANON_KEY ? "SUPABASE_ANON_KEY" : null, ].filter(Boolean), }, ); } // If needed for RLS-protected reads, create an anon client with user's JWT // Currently not used; kept here for future expansion. // const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { // global: { // headers: { // Authorization: authHeaderRaw, // }, // }, // }); const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); if (!SERVICE_ROLE_KEY) { throw new HttpError( 500, "Configuración del servidor incompleta.", "MISSING_ENV", { missing: ["SUPABASE_SERVICE_ROLE_KEY"] }, ); } const supabaseService = createClient( SUPABASE_URL, SERVICE_ROLE_KEY, ); // Model names (override via environment variables) const AI_GENERATE_SUBJECT_UPDATE_MODELO = Deno.env.get( "AI_GENERATE_SUBJECT_UPDATE_MODELO", ) ?? "gpt-5-nano"; const AI_GENERATE_SUBJECT_INSERT_MODELO = Deno.env.get( "AI_GENERATE_SUBJECT_INSERT_MODELO", ) ?? "gpt-5-nano"; // ----------------------------- // Unified JSON create/update flow (background) // ----------------------------- let rawBody: unknown; try { rawBody = await req.json(); } catch (e) { throw new HttpError( 400, "Body JSON inválido.", "INVALID_JSON", { cause: e }, ); } const parsedBody = UnifiedJsonSchema.safeParse(rawBody); if (!parsedBody.success) { throw new HttpError( 422, formatZodIssues(parsedBody.error.issues), "VALIDATION_ERROR", parsedBody.error, ); } const payload: EdgeAIGenerateSubjectUnifiedInput = parsedBody.data; const { datosUpdate, iaConfig } = payload; const isUpdate = Boolean(datosUpdate.id); const svc = OpenAIService.fromEnv(); if (!(svc instanceof OpenAIService)) { throw new HttpError( 500, "Configuración del servidor incompleta.", "OPENAI_MISCONFIGURED", svc, ); } // Resolve fields (merge for update) const selectCols = "id,plan_estudio_id,estructura_id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,numero_ciclo,linea_plan_id,orden_celda,estado"; let asignaturaBase: AsignaturaBaseSeleccionada | null = null; if (isUpdate) { const { data, error } = await supabaseService .from("asignaturas") .select(selectCols) .eq("id", datosUpdate.id as string) .single(); if (error) { const maybeCode = (error as { code?: string }).code; if (maybeCode === "PGRST116") { throw new HttpError( 404, "No se encontró la asignatura.", "NOT_FOUND", { table: "asignaturas", id: datosUpdate.id }, ); } throw new HttpError( 500, "No se pudo obtener la asignatura.", "SUPABASE_QUERY_FAILED", error, ); } asignaturaBase = data as unknown as AsignaturaBaseSeleccionada; } const resolved = { id: (datosUpdate.id ?? asignaturaBase?.id ?? null) as string | null, plan_estudio_id: datosUpdate.plan_estudio_id ?? asignaturaBase?.plan_estudio_id, estructura_id: datosUpdate.estructura_id ?? asignaturaBase?.estructura_id ?? null, nombre: datosUpdate.nombre ?? asignaturaBase?.nombre ?? null, codigo: datosUpdate.codigo ?? asignaturaBase?.codigo ?? null, tipo: datosUpdate.tipo ?? asignaturaBase?.tipo ?? null, creditos: datosUpdate.creditos ?? asignaturaBase?.creditos ?? null, horas_academicas: datosUpdate.horas_academicas ?? asignaturaBase?.horas_academicas ?? null, horas_independientes: datosUpdate.horas_independientes ?? asignaturaBase?.horas_independientes ?? null, numero_ciclo: datosUpdate.numero_ciclo ?? asignaturaBase?.numero_ciclo ?? null, linea_plan_id: datosUpdate.linea_plan_id ?? asignaturaBase?.linea_plan_id ?? null, orden_celda: datosUpdate.orden_celda ?? asignaturaBase?.orden_celda ?? null, }; if (!resolved.estructura_id) { throw new HttpError( 422, "estructura_id es requerido.", "VALIDATION_ERROR", { estructura_id: resolved.estructura_id }, ); } if (!resolved.nombre) { throw new HttpError( 422, "nombre es requerido.", "VALIDATION_ERROR", { nombre: resolved.nombre }, ); } if (resolved.creditos == null) { throw new HttpError( 422, "creditos es requerido.", "VALIDATION_ERROR", { creditos: resolved.creditos }, ); } // Crear/actualizar stub en estado 'generando' let asignaturaId: string; if (isUpdate) { const updatePatch: Database["public"]["Tables"]["asignaturas"]["Update"] = { estado: "generando", tipo_origen: "IA", plan_estudio_id: resolved.plan_estudio_id, estructura_id: resolved.estructura_id, nombre: resolved.nombre, codigo: resolved.codigo, creditos: resolved.creditos, horas_academicas: resolved.horas_academicas, horas_independientes: resolved.horas_independientes, numero_ciclo: resolved.numero_ciclo, linea_plan_id: resolved.linea_plan_id, orden_celda: resolved.orden_celda, tipo: resolved .tipo as Database["public"]["Tables"]["asignaturas"]["Update"][ "tipo" ], meta_origen: { generado_por: "ai_generate_subject_unified", iaConfig: { descripcionEnfoqueAcademico: iaConfig.descripcionEnfoqueAcademico ?? null, instruccionesAdicionalesIA: iaConfig.instruccionesAdicionalesIA ?? null, archivosAdjuntos: iaConfig.archivosAdjuntos ?? [], }, } as unknown as Json, }; const { data, error } = await supabaseService .from("asignaturas") .update(updatePatch) .eq("id", resolved.id as string) .select("id") .single(); if (error) { throw new HttpError( 500, "No se pudo actualizar la asignatura (stub).", "SUPABASE_UPDATE_FAILED", error, ); } asignaturaId = data.id; } else { const insertPatch: Database["public"]["Tables"]["asignaturas"]["Insert"] = { plan_estudio_id: resolved.plan_estudio_id as string, estructura_id: resolved.estructura_id as string, nombre: resolved.nombre as string, codigo: resolved.codigo ?? null, tipo: (resolved.tipo ?? undefined) as Database["public"]["Tables"][ "asignaturas" ]["Insert"]["tipo"], creditos: resolved.creditos as number, horas_academicas: resolved.horas_academicas ?? null, horas_independientes: resolved.horas_independientes ?? null, numero_ciclo: resolved.numero_ciclo ?? null, linea_plan_id: resolved.linea_plan_id ?? null, orden_celda: resolved.orden_celda ?? null, estado: "generando", tipo_origen: "IA", meta_origen: { generado_por: "ai_generate_subject_unified", iaConfig: { descripcionEnfoqueAcademico: iaConfig.descripcionEnfoqueAcademico ?? null, instruccionesAdicionalesIA: iaConfig.instruccionesAdicionalesIA ?? null, archivosAdjuntos: iaConfig.archivosAdjuntos ?? [], }, } as unknown as Json, }; const { data, error } = await supabaseService .from("asignaturas") .insert(insertPatch) .select("id") .single(); if (error) { const maybeCode = (error as { code?: string }).code; const status = maybeCode ? 409 : 500; throw new HttpError( status, "No se pudo crear la asignatura (stub).", "SUPABASE_INSERT_FAILED", { ...error, code: maybeCode }, ); } asignaturaId = data.id; } const { data: estructura, error: estructuraError } = await supabaseService .from("estructuras_asignatura") .select("id,nombre,definicion") .eq("id", resolved.estructura_id) .single(); if (estructuraError) { const maybeCode = (estructuraError as { code?: string }).code; if (maybeCode === "PGRST116") { throw new HttpError( 404, "No se encontró la estructura de la asignatura.", "NOT_FOUND", { table: "estructuras_asignatura", id: resolved.estructura_id }, ); } throw new HttpError( 500, "No se pudo obtener la estructura de la asignatura.", "SUPABASE_QUERY_FAILED", estructuraError, ); } const systemPrompt = `Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el JSON Schema proporcionado.`; const archivosAdjuntosTexto = (iaConfig.archivosAdjuntos ?? []).length ? `\n- Rutas de archivos adjuntos (referencia): ${ (iaConfig.archivosAdjuntos ?? []).join(", ") }` : ""; const userPrompt = `Genera un borrador completo completo de una ASIGNATURA con base en lo siguiente:\n` + `- Nombre de la asignatura: ${resolved.nombre}\n` + `- Código (clave de la asignatura): ${ resolved.codigo ?? "(no especificado)" }\n` + `- Tipo: ${resolved.tipo ?? "(no especificado)"}\n` + `- Número de créditos: ${resolved.creditos}\n` + `- Horas académicas: ${ resolved.horas_academicas ?? "(no especificado)" }\n` + `- Horas independientes: ${ resolved.horas_independientes ?? "(no especificado)" }\n` + `- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${ iaConfig.descripcionEnfoqueAcademico ?? "(ninguna)" }\n` + `- Instrucciones adicionales (sobre el formato de la respuesta generada): ${ iaConfig.instruccionesAdicionalesIA ?? "(ninguna)" }` + archivosAdjuntosTexto + `\n\nREGLAS ESTRICTAS MATEMÁTICAS:\n` + `- Si generas el 'contenido_tematico', la suma total de las 'horasEstimadas' de todos los temas en todas las unidades DEBE coincidir exactamente con el total de Horas académicas indicadas arriba (${ resolved.horas_academicas ?? 0 }). No te pases ni te falten horas.\n` + `- Si generas los 'criterios_de_evaluacion', la suma total de los 'porcentajes' de todos los criterios DEBE ser exactamente 100%. No te pases ni te falten porcentajes.`; const schemaDef: Record = typeof estructura?.definicion === "object" && estructura?.definicion !== null ? (estructura.definicion as Record) : {}; const schemaForAI = withColumnDefsAndRefs(schemaDef); const aiStructuredPayload: StructuredResponseOptions = { model: isUpdate ? AI_GENERATE_SUBJECT_UPDATE_MODELO : AI_GENERATE_SUBJECT_INSERT_MODELO, background: true, metadata: { tabla: "asignaturas", accion: isUpdate ? "actualizar" : "crear", id: asignaturaId, }, input: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, ], text: { format: { type: "json_schema", name: "asignatura_contenido_tematico", schema: schemaForAI, strict: true, }, }, }; const aiResult = await svc.createStructuredResponse(aiStructuredPayload); if (!aiResult.ok) { const status = aiResult.code === "MissingEnv" ? 500 : 502; throw new HttpError( status, "No se pudo iniciar la generación de la asignatura con IA.", "OPENAI_REQUEST_FAILED", aiResult, ); } console.log( `[${ new Date().toISOString() }][${functionName}]: Subject generation started in background`, ); return sendSuccess({ id: asignaturaId, estado: "generando", openai: { responseId: aiResult.responseId ?? null }, }); } 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", ); } }); const definicionesDeEstructurasDeColumnas = { contenido_tematico: { "type": "object", "properties": { "x-column": { "type": "string", "enum": [ "contenido_tematico", ], }, "x-definicion": { "type": "array", "items": { "type": "object", "properties": { "unidad": { "type": "integer", }, "titulo": { "type": "string", }, "temas": { "type": "array", "items": { "type": "object", "properties": { "nombre": { "type": "string", }, "horasEstimadas": { "type": "integer", "description": "Horas del tema. La suma de todas las horasEstimadas debe ser igual a las horas académicas del prompt.", }, }, "required": [ "nombre", "horasEstimadas", ], "additionalProperties": false, }, }, }, "required": [ "unidad", "titulo", "temas", ], "additionalProperties": false, }, }, }, "required": [ "x-column", "x-definicion", ], "additionalProperties": false, }, criterios_de_evaluacion: { "type": "object", "properties": { "x-column": { "type": "string", "enum": [ "criterios_de_evaluacion", ], }, "x-definicion": { "type": "array", "items": { "type": "object", "properties": { "criterio": { "type": "string", }, "porcentaje": { "type": "integer", }, }, "required": [ "criterio", "porcentaje", ], "additionalProperties": false, }, }, }, "required": [ "x-column", "x-definicion", ], "additionalProperties": false, }, };