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, ); }); /** * JSON input signature (when `Content-Type: application/json`) * * Required: * - `id` (uuid) - corresponde a la columna `id` de `asignaturas` * * Optional patch fields (any subset is allowed): * - `nombre` (string) * - `codigo` (string | null) * - `tipo` (string | null) // must match DB enum values * - `creditos` (number) * - `horas_academicas` (number | null) * - `horas_independientes` (number | null) * - `numero_ciclo` (number | null) * - `estructura_id` (uuid | null) * - `plan_estudio_id` (uuid) * - `linea_plan_id` (uuid | null) * - `orden_celda` (number | null) * * IA config (optional): * - `descripcionEnfoqueAcademico` (string) * * Notes: * - This JSON flow does NOT accept `instruccionesAdicionalesIA`. * - Missing optional fields are ignored (the existing DB values remain). */ const JsonUpdateSchema = z .object({ id: z.string().uuid("id debe ser un UUID"), // patch fields (all optional) — nombres coinciden con columnas DB 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(), estructura_id: z.string().uuid().nullable().optional(), plan_estudio_id: z.string().uuid().optional(), linea_plan_id: z.string().uuid().nullable().optional(), orden_celda: z.number().int().nonnegative().nullable().optional(), // IA config (no instruccionesAdicionalesIA) descripcionEnfoqueAcademico: z.string().optional(), }) .strict(); type EdgeAIGenerateSubjectJsonUpdateInput = z.infer; 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 splitAiOutputStringsAndColumns( aiOutput: unknown, ): { aiOutputJson: Json; columnasGeneradas: Record } { if (!aiOutput || typeof aiOutput !== "object" || Array.isArray(aiOutput)) { return { aiOutputJson: aiOutput as unknown as Json, columnasGeneradas: {} }; } const record = aiOutput as Record; const stringsOnly: Record = {}; const columnasGeneradas: Record = {}; for (const [key, value] of Object.entries(record)) { if (typeof value === "string") { stringsOnly[key] = value; } else { columnasGeneradas[key] = value; } } return { aiOutputJson: stringsOnly as unknown as Json, columnasGeneradas }; } 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 isMultipart = contentType.startsWith("multipart/form-data"); const isJson = contentType.includes("application/json"); if (!isMultipart && !isJson) { console.error( `[${ new Date().toISOString() }][${functionName}]: Unsupported content type: ${contentType}`, ); throw new HttpError( 415, "Content-Type no soportado.", "UNSUPPORTED_MEDIA_TYPE", { contentType }, ); } 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"; // ----------------------------- // JSON update flow // ----------------------------- if (isJson) { let rawBody: unknown; try { rawBody = await req.json(); } catch (e) { throw new HttpError( 400, "Body JSON inválido.", "INVALID_JSON", { cause: e }, ); } const parsedBody = JsonUpdateSchema.safeParse(rawBody); if (!parsedBody.success) { throw new HttpError( 422, formatZodIssues(parsedBody.error.issues), "VALIDATION_ERROR", parsedBody.error, ); } const payload: EdgeAIGenerateSubjectJsonUpdateInput = parsedBody.data; const { data: existingAsignatura, error: existingAsignaturaError } = await supabaseService .from("asignaturas") .select( "id,plan_estudio_id,estructura_id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,numero_ciclo,linea_plan_id,orden_celda", ) .eq("id", payload.id) .single(); if (existingAsignaturaError) { const maybeCode = (existingAsignaturaError as { code?: string }).code; if (maybeCode === "PGRST116") { throw new HttpError( 404, "No se encontró la asignatura.", "NOT_FOUND", { table: "asignaturas", id: payload.id }, ); } throw new HttpError( 500, "No se pudo obtener la asignatura.", "SUPABASE_QUERY_FAILED", existingAsignaturaError, ); } const resolved = { plan_estudio_id: payload.plan_estudio_id ?? existingAsignatura.plan_estudio_id, estructura_id: payload.estructura_id ?? existingAsignatura.estructura_id, nombre: payload.nombre ?? existingAsignatura.nombre, codigo: payload.codigo ?? existingAsignatura.codigo, tipo: payload.tipo ?? existingAsignatura.tipo, creditos: payload.creditos ?? existingAsignatura.creditos, horas_academicas: payload.horas_academicas ?? existingAsignatura.horas_academicas, horas_independientes: payload.horas_independientes ?? existingAsignatura.horas_independientes, numero_ciclo: payload.numero_ciclo ?? existingAsignatura.numero_ciclo, linea_plan_id: payload.linea_plan_id ?? existingAsignatura.linea_plan_id, orden_celda: payload.orden_celda ?? existingAsignatura.orden_celda, }; if (!resolved.estructura_id) { throw new HttpError( 422, "estructura_id es requerido (no está presente ni en el JSON ni en la asignatura existente).", "VALIDATION_ERROR", { id: payload.id, existing_estructura_id: existingAsignatura.estructura_id, }, ); } const { data: estructura, error: estructuraError } = await supabaseService .from("estructuras_asignatura") .select("id,nombre,definicion,version") .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 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): ${ payload.descripcionEnfoqueAcademico ?? "(ninguna)" }\n\n` + `REGLA ESTRICTA MATEMÁTICA:\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.`; const schemaDef: Record = typeof estructura?.definicion === "object" && estructura?.definicion !== null ? (estructura.definicion as Record) : {}; const schemaForAI = withColumnDefsAndRefs(schemaDef); const aiStructuredPayload: StructuredResponseOptions = { model: AI_GENERATE_SUBJECT_UPDATE_MODELO, input: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, ], text: { format: { type: "json_schema", name: "asignatura_contenido_tematico", schema: schemaForAI, strict: true, }, }, }; const svc = OpenAIService.fromEnv(); if (!(svc instanceof OpenAIService)) { throw new HttpError( 500, "Configuración del servidor incompleta.", "OPENAI_MISCONFIGURED", svc, ); } const aiResult = await svc.createStructuredResponse(aiStructuredPayload); if (!aiResult.ok) { const status = aiResult.code === "MissingEnv" ? 500 : 502; throw new HttpError( status, "No se pudo generar la asignatura con IA.", "OPENAI_REQUEST_FAILED", aiResult, ); } let aiOutput = aiResult.output ?? null; if (aiOutput == null && aiResult.outputText) { try { aiOutput = 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 (!aiOutput) { throw new HttpError( 502, "La respuesta de la IA no contiene salida estructurada.", "OPENAI_MISSING_STRUCTURED_OUTPUT", { outputText: aiResult.outputText ?? null }, ); } const { aiOutputJson, columnasGeneradas } = splitAiOutputStringsAndColumns(aiOutput); const updatePatch: Database["public"]["Tables"]["asignaturas"]["Update"] = { datos: aiOutputJson, tipo_origen: "IA", estado: "borrador", }; for (const value of Object.values(columnasGeneradas)) { if (!value || typeof value !== "object" || Array.isArray(value)) { continue; } const xColumn = (value as Record)["x-column"]; const xDef = (value as Record)["x-definicion"]; if (typeof xColumn !== "string" || !xColumn.length) continue; (updatePatch as unknown as Record)[xColumn] = xDef as unknown as Json; } // Apply only provided JSON fields (do not overwrite if missing) if (payload.plan_estudio_id !== undefined) { updatePatch.plan_estudio_id = payload.plan_estudio_id; } if (payload.estructura_id !== undefined) { updatePatch.estructura_id = payload.estructura_id; } if (payload.nombre !== undefined) { updatePatch.nombre = payload.nombre; } if (payload.codigo !== undefined) { updatePatch.codigo = payload.codigo; } if (payload.tipo !== undefined) { updatePatch.tipo = payload.tipo as Database["public"]["Tables"][ "asignaturas" ]["Update"]["tipo"]; } if (payload.creditos !== undefined) { updatePatch.creditos = payload.creditos; } if (payload.horas_academicas !== undefined) { updatePatch.horas_academicas = payload.horas_academicas; } if (payload.horas_independientes !== undefined) { updatePatch.horas_independientes = payload.horas_independientes; } if (payload.numero_ciclo !== undefined) { updatePatch.numero_ciclo = payload.numero_ciclo; } if (payload.linea_plan_id !== undefined) { updatePatch.linea_plan_id = payload.linea_plan_id; } if (payload.orden_celda !== undefined) { updatePatch.orden_celda = payload.orden_celda; } updatePatch.meta_origen = { generado_por: "ai_generate_subject_update_json", ai: { responseId: aiResult.responseId ?? null, conversationId: aiResult.conversationId ?? null, model: aiResult.model, usage: aiResult.usage ?? null, }, iaConfig: { descripcionEnfoqueAcademico: payload.descripcionEnfoqueAcademico ?? null, }, } as unknown as Json; const { data: asignatura, error: asignaturaError } = await supabaseService .from("asignaturas") .update(updatePatch) .eq("id", payload.id) .select( "id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,estructura_id,plan_estudio_id,contenido_tematico,meta_origen,datos,creado_en,actualizado_en,numero_ciclo,linea_plan_id,orden_celda", ) .single(); if (asignaturaError) { const maybeCode = (asignaturaError as { code?: string }).code; if (maybeCode === "PGRST116") { throw new HttpError( 404, "No se encontró la asignatura.", "NOT_FOUND", { table: "asignaturas", id: payload.id }, ); } throw new HttpError( 500, "No se pudo actualizar la asignatura.", "SUPABASE_UPDATE_FAILED", asignaturaError, ); } console.log( `[${ new Date().toISOString() }][${functionName}]: JSON update processed successfully`, ); return sendSuccess(asignatura); } // ----------------------------- // Multipart create flow (existing) // ----------------------------- const formData = await req.formData(); const validation = parseAndValidate(formData); if (!validation.success) { console.error( `[${new Date().toISOString()}][${functionName}]: Validation errors:`, validation.errors, ); const message = validation.errors .map((e, i) => `${i + 1}. ${e}`) .join("\n"); throw new HttpError( 422, message, "VALIDATION_ERROR", { errors: validation.errors }, ); } const payload = validation.data; const { data: estructura, error: estructuraError } = await supabaseService .from("estructuras_asignatura") .select("id,nombre,definicion,version") .eq("id", payload.datosBasicos.estructuraId) .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: payload.datosBasicos.estructuraId, }, ); } 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. # REGLAS DE FORMATO PARA CAMPOS DE TEXTO (STRING) 1. **Prioridad:** Estas reglas aplican por defecto. Si el prompt del usuario solicita explícitamente un formato distinto, esa instrucción tiene prioridad sobre estas reglas. 2. **Estilo Visual:** Redacta el contenido exclusivamente para visualización en texto plano (estilo 'white-space: pre-wrap'). 3. **Estructura Vertical:** Utiliza saltos de línea explícitos para romper líneas y doble salto de línea para separar párrafos. 4. **Indentación Estricta:** Usa exactamente 2 espacios para la indentación jerárquica. No uses tabuladores. 5. **Listas:** Utiliza un guion seguido de un espacio ("- ") para los elementos de lista. 6. **Prohibiciones:** No incluyas etiquetas HTML, sintaxis Markdown ni caracteres de escape literales visibles en el texto final. Asegúrate de que el JSON final contenga saltos de línea válidos ('\n') y no texto escapado.`; const userPrompt = `Genera un borrador completo completo de una ASIGNATURA con base en lo siguiente:\n` + `- Nombre de la asignatura: ${payload.datosBasicos.nombre}\n` + `- Código (clave de la asignatura): ${ payload.datosBasicos.codigo ?? "(no especificado)" }\n` + `- Tipo: ${payload.datosBasicos.tipo ?? "(no especificado)"}\n` + `- Número de créditos: ${payload.datosBasicos.creditos}\n` + `- Horas académicas: ${ payload.datosBasicos.horasAcademicas ?? "(no especificado)" }\n` + `- Horas independientes: ${ payload.datosBasicos.horasIndependientes ?? "(no especificado)" }\n` + `- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${ payload.iaConfig?.descripcionEnfoqueAcademico ?? "(ninguna)" }\n` + `- Instrucciones adicionales (sobre el formato de la respuesta generada): ${ payload.iaConfig?.instruccionesAdicionalesIA ?? "(ninguna)" }`; const schemaDef: Record = typeof estructura?.definicion === "object" && estructura?.definicion !== null ? (estructura.definicion as Record) : {}; const schemaForAI = withColumnDefsAndRefs(schemaDef); const aiStructuredPayload: StructuredResponseOptions = { model: AI_GENERATE_SUBJECT_INSERT_MODELO, input: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, ], text: { format: { type: "json_schema", name: "asignatura_contenido_tematico", schema: schemaForAI, strict: true, }, }, }; const svc = OpenAIService.fromEnv(); if (!(svc instanceof OpenAIService)) { throw new HttpError( 500, "Configuración del servidor incompleta.", "OPENAI_MISCONFIGURED", svc, ); } const aiResult = await svc.createStructuredResponse( aiStructuredPayload, payload.archivosAdjuntos, ); if (!aiResult.ok) { const status = aiResult.code === "MissingEnv" ? 500 : 502; throw new HttpError( status, "No se pudo generar la asignatura con IA.", "OPENAI_REQUEST_FAILED", aiResult, ); } let aiOutput = aiResult.output ?? null; if (aiOutput == null && aiResult.outputText) { try { aiOutput = 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 (!aiOutput) { throw new HttpError( 502, "La respuesta de la IA no contiene salida estructurada.", "OPENAI_MISSING_STRUCTURED_OUTPUT", { outputText: aiResult.outputText ?? null }, ); } const { aiOutputJson, columnasGeneradas } = splitAiOutputStringsAndColumns( aiOutput, ); const asignaturaInsert: Database["public"]["Tables"]["asignaturas"]["Insert"] = { plan_estudio_id: payload.plan_estudio_id, estructura_id: payload.datosBasicos.estructuraId, nombre: payload.datosBasicos.nombre, codigo: payload.datosBasicos.codigo ?? null, creditos: payload.datosBasicos.creditos, horas_academicas: payload.datosBasicos.horasAcademicas ?? null, horas_independientes: payload.datosBasicos.horasIndependientes ?? null, tipo: (payload.datosBasicos.tipo ?? undefined) as Database["public"][ "Tables" ]["asignaturas"]["Insert"]["tipo"], tipo_origen: "IA", datos: aiOutputJson, meta_origen: { generado_por: "ai_generate_subject", ai: { responseId: aiResult.responseId ?? null, conversationId: aiResult.conversationId ?? null, model: aiResult.model, usage: aiResult.usage ?? null, }, referencias: { archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null, repositoriosReferencia: payload.iaConfig?.repositoriosReferencia ?? null, }, iaConfig: { descripcionEnfoqueAcademico: payload.iaConfig?.descripcionEnfoqueAcademico ?? null, instruccionesAdicionalesIA: payload.iaConfig?.instruccionesAdicionalesIA ?? null, }, } as unknown as Json, }; for (const value of Object.values(columnasGeneradas)) { if (!value || typeof value !== "object" || Array.isArray(value)) continue; const xColumn = (value as Record)["x-column"]; const xDef = (value as Record)["x-definicion"]; if (typeof xColumn !== "string" || !xColumn.length) continue; (asignaturaInsert as unknown as Record)[xColumn] = xDef as unknown as Json; } const { data: asignatura, error: asignaturaError } = await supabaseService .from("asignaturas") .insert(asignaturaInsert) .select( "id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,estructura_id,plan_estudio_id,contenido_tematico,meta_origen,creado_en,actualizado_en", ) .single(); if (asignaturaError) { const maybeCode = (asignaturaError as { code?: string }).code; const status = maybeCode ? 409 : 500; throw new HttpError( status, "No se pudo guardar la asignatura.", "SUPABASE_INSERT_FAILED", { ...asignaturaError, code: maybeCode }, ); } console.log( `[${ new Date().toISOString() }][${functionName}]: Request processed successfully`, ); return sendSuccess(asignatura); } 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 jsonFromString = (schema: T) => z.string().transform((str, ctx) => { try { return JSON.parse(str); } catch { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "El formato no es un JSON válido", }); return z.NEVER; } }).pipe(schema); const jsonFromStringOptional = (schema: T) => z.string().optional().default("{}").transform((str, ctx) => { try { return JSON.parse(str); } catch { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "El formato no es un JSON válido", }); return z.NEVER; } }).pipe(schema); const DatosBasicosSchema = z.object({ nombre: z.string().min(1, "El nombre es requerido"), codigo: z.string().optional().nullable(), tipo: z.union([z.string().min(1), z.null()]).optional().transform((v) => v ?? null ), creditos: z.number({ invalid_type_error: "Créditos debe ser numérico" }) .positive("Créditos debe ser >= 0"), horasAcademicas: z.number().int().nonnegative().optional().nullable(), horasIndependientes: z.number().int().nonnegative().optional().nullable(), estructuraId: z.string().uuid("estructuraId debe ser un UUID"), }); const IAConfigSchema = z.object({ descripcionEnfoqueAcademico: z.string().optional(), instruccionesAdicionalesIA: z.string().optional(), archivosReferencia: z.array(z.string().uuid()).optional(), repositoriosReferencia: z.array(z.string().uuid()).optional(), }).partial(); const SolicitudSchema = z.object({ plan_estudio_id: z.string().uuid("plan_estudio_id debe ser un UUID"), datosBasicos: jsonFromString(DatosBasicosSchema), iaConfig: jsonFromStringOptional(IAConfigSchema), archivosAdjuntos: z.array(z.instanceof(File)).optional().default([]), }); type EdgeAIGenerateSubjectInput = z.infer; function parseAndValidate( formData: FormData, ): { success: true; data: EdgeAIGenerateSubjectInput } | { success: false; errors: string[]; } { const rawInput = { plan_estudio_id: formData.get("plan_estudio_id"), datosBasicos: formData.get("datosBasicos"), iaConfig: formData.get("iaConfig"), archivosAdjuntos: formData.getAll("archivosAdjuntos"), }; const result = SolicitudSchema.safeParse(rawInput); if (!result.success) { const errors = result.error.errors.map((issue) => { const path = issue.path.length ? `${issue.path.join(".")}: ` : ""; return `${path}${issue.message}`; }); return { success: false, errors }; } return { success: true, data: result.data }; } 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, }, };