Se inserta el plan de estudios y se mandan crear los datos generales en segundo plano, y la edge funcion de openai-webhook-responses recibe la respuesta para actualizar el plan de estudio con los datos generales

This commit is contained in:
2026-02-25 17:39:09 -06:00
parent 9263587f0b
commit 527f3aa56f
7 changed files with 254 additions and 137 deletions
+95 -119
View File
@@ -19,11 +19,11 @@ type NivelType =
type TipoCicloType =
Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"];
addEventListener("beforeunload", (ev: any) => {
// ev.detail.reason te dirá si es "timeout", "memory_limit" o "idle"
console.error("ALERTA: La función se va a apagar. Razón:", ev.detail?.reason);
type BeforeUnloadWithDetail = Event & { detail?: { reason?: unknown } };
// Aquí puedes intentar un último log antes de que todo muera
// Re-registramos con tipo estricto (evita `any` en análisis)
addEventListener("beforeunload", (ev: BeforeUnloadWithDetail) => {
console.error("ALERTA: La función se va a apagar. Razón:", ev.detail?.reason);
});
Deno.serve(async (req: Request): Promise<Response> => {
@@ -195,8 +195,98 @@ Deno.serve(async (req: Request): Promise<Response> => {
? estructuraPlan.definicion as Record<string, unknown>
: {};
const { data: estado } = await supabaseService
.from("estados_plan")
.select("id,clave,orden")
.eq("clave", "GENERANDO")
.maybeSingle();
if (!estado?.id) {
throw new HttpError(
500,
"No se encontró el estado GENERANDO.",
"MISSING_STATE",
{ clave: "GENERANDO" },
);
}
const { data: carrera, error: carreraError } = await supabaseService
.from("carreras")
.select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)")
.eq("id", payload.datosBasicos.carreraId)
.maybeSingle();
if (carreraError) {
throw new HttpError(
500,
"No se pudo obtener la carrera.",
"SUPABASE_QUERY_FAILED",
carreraError,
);
}
if (!carrera) {
throw new HttpError(
404,
"No se encontró la carrera.",
"NOT_FOUND",
{ table: "carreras", id: payload.datosBasicos.carreraId },
);
}
const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] =
{
carrera_id: carrera.id as string,
estructura_id: estructuraPlan?.id as string,
nombre: payload.datosBasicos.nombrePlan,
nivel: payload.datosBasicos.nivel as NivelType,
tipo_ciclo: payload.datosBasicos.tipoCiclo as TipoCicloType,
numero_ciclos: payload.datosBasicos.numCiclos,
// IMPORTANTE: se inserta SIN `datos` (se actualiza vía webhook)
estado_actual_id: estado.id,
activo: true,
tipo_origen: "IA",
meta_origen: {
generado_por: "ai-generate-plan",
referencias: {
archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null,
repositoriosIds: payload.iaConfig?.repositoriosIds ?? null,
},
iaConfig: {
descripcionEnfoqueAcademico:
payload.iaConfig?.descripcionEnfoqueAcademico ?? null,
instruccionesAdicionalesIA:
payload.iaConfig?.instruccionesAdicionalesIA ?? null,
usarMCP: Boolean(payload.iaConfig?.usarMCP),
},
} as unknown as Json,
};
const { data: plan, error: planError } = await supabaseService
.from("planes_estudio")
.insert(planInsert)
.select(
"id,nombre,nivel,tipo_ciclo,numero_ciclos,carrera_id,estructura_id,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,datos",
)
.single();
if (planError) {
const maybeCode = (planError as { code?: string }).code;
const status = maybeCode ? 409 : 500;
throw new HttpError(
status,
"No se pudo guardar el plan de estudios.",
"SUPABASE_INSERT_FAILED",
{ ...planError, code: maybeCode },
);
}
const aiStructuredPayload: StructuredResponseOptions = {
model: AI_GENERATE_PLAN_MODELO,
background: true,
metadata: {
tabla: "planes_estudio",
accion: "crear",
id: String(plan.id),
},
input: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
@@ -244,126 +334,12 @@ Deno.serve(async (req: Request): Promise<Response> => {
const status = aiResult.code === "MissingEnv" ? 500 : 502;
throw new HttpError(
status,
"No se pudo generar el plan con IA.",
"No se pudo iniciar la generación del plan con IA.",
"OPENAI_REQUEST_FAILED",
aiResult,
);
}
// Prefer parsed output; fallback to parse outputText
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 },
);
}
}
const aiOutputJson: Json = aiOutput as unknown as Json;
if (!aiOutput) {
throw new HttpError(
502,
"La respuesta de la IA no contiene salida estructurada.",
"OPENAI_MISSING_STRUCTURED_OUTPUT",
{ outputText: aiResult.outputText ?? null },
);
}
// TODO: Insertar interacciones con IA y quizas forzar a que la informacion de datosBasicos sea la misma que la recibida
const { data: estado } = await supabaseService
.from("estados_plan")
.select("id,clave,orden")
.ilike("clave", "BORRADOR%")
.order("orden", { ascending: true })
.limit(1)
.maybeSingle();
const { data: carrera, error: carreraError } = await supabaseService
.from("carreras")
.select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)")
.eq("id", payload.datosBasicos.carreraId)
.maybeSingle();
if (carreraError) {
throw new HttpError(
500,
"No se pudo obtener la carrera.",
"SUPABASE_QUERY_FAILED",
carreraError,
);
}
if (!carrera) {
throw new HttpError(
404,
"No se encontró la carrera.",
"NOT_FOUND",
{ table: "carreras", id: payload.datosBasicos.carreraId },
);
}
const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] =
{
carrera_id: carrera?.id as string,
estructura_id: estructuraPlan?.id as string,
nombre: payload.datosBasicos.nombrePlan,
nivel: payload.datosBasicos.nivel as NivelType,
tipo_ciclo: payload.datosBasicos.tipoCiclo as TipoCicloType,
numero_ciclos: payload.datosBasicos.numCiclos,
datos: aiOutputJson,
estado_actual_id: estado?.id ?? undefined,
activo: true,
tipo_origen: "IA",
meta_origen: {
generado_por: "ai_generate_plan",
ai_structured: {
responseId: aiResult.responseId ?? null,
conversationId: aiResult.conversationId ?? null,
model: aiResult.model,
usage: aiResult.usage ?? null,
},
referencias: {
archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null,
repositoriosIds: payload.iaConfig?.repositoriosIds ?? null,
},
iaConfig: {
descripcionEnfoqueAcademico:
payload.iaConfig?.descripcionEnfoqueAcademico ?? null,
instruccionesAdicionalesIA:
payload.iaConfig?.instruccionesAdicionalesIA ?? null,
usarMCP: Boolean(payload.iaConfig?.usarMCP),
},
} as unknown as Json,
};
const { data: plan, error: planError } = await supabaseService
.from("planes_estudio")
.insert(planInsert)
.select(
"id,nombre,nivel,tipo_ciclo,numero_ciclos,carrera_id,estructura_id,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,datos",
)
.single();
// TODO: interaccion con IA y cambio de estado
if (planError) {
const maybeCode = (planError as { code?: string }).code;
// Common cases:
// - foreign key / constraint violations -> 409
// - others -> 500
const status = maybeCode ? 409 : 500;
throw new HttpError(
status,
"No se pudo guardar el plan de estudios.",
"SUPABASE_INSERT_FAILED",
{ ...planError, code: maybeCode },
);
}
// TODO: update a interaccion_ia y e insert a cambios_plan con id de plan generado
console.log(