Files
genesis-2/supabase/functions/ai-generate-subject/index.ts
T

943 lines
31 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, 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<typeof JsonUpdateSchema>;
function withColumnDefsAndRefs(
schemaDef: Record<string, unknown>,
): Record<string, unknown> {
const nextSchema: Record<string, unknown> = {
...schemaDef,
$defs: definicionesDeEstructurasDeColumnas,
};
const props = nextSchema["properties"];
if (!props || typeof props !== "object" || Array.isArray(props)) {
return nextSchema;
}
const nextProps: Record<string, unknown> = {
...props as Record<string, unknown>,
};
for (const [key, value] of Object.entries(nextProps)) {
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
const xColumn = (value as Record<string, unknown>)["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<string, unknown> } {
if (!aiOutput || typeof aiOutput !== "object" || Array.isArray(aiOutput)) {
return { aiOutputJson: aiOutput as unknown as Json, columnasGeneradas: {} };
}
const record = aiOutput as Record<string, unknown>;
const stringsOnly: Record<string, string> = {};
const columnasGeneradas: Record<string, unknown> = {};
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<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") {
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<Database>(
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<string, unknown> =
typeof estructura?.definicion === "object" &&
estructura?.definicion !== null
? (estructura.definicion as Record<string, unknown>)
: {};
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<string, unknown>)["x-column"];
const xDef = (value as Record<string, unknown>)["x-definicion"];
if (typeof xColumn !== "string" || !xColumn.length) continue;
(updatePatch as unknown as Record<string, unknown>)[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<string, unknown> =
typeof estructura?.definicion === "object" &&
estructura?.definicion !== null
? (estructura.definicion as Record<string, unknown>)
: {};
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<string, unknown>)["x-column"];
const xDef = (value as Record<string, unknown>)["x-definicion"];
if (typeof xColumn !== "string" || !xColumn.length) continue;
(asignaturaInsert as unknown as Record<string, unknown>)[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 = <T extends z.ZodTypeAny>(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 = <T extends z.ZodTypeAny>(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<typeof SolicitudSchema>;
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,
},
};