diff --git a/supabase/functions/ai-structured/config.toml b/supabase/functions/ai-structured/config.toml
deleted file mode 100644
index 4d86786..0000000
--- a/supabase/functions/ai-structured/config.toml
+++ /dev/null
@@ -1 +0,0 @@
-verify_jwt = false
diff --git a/supabase/functions/ai-structured/deno.json b/supabase/functions/ai-structured/deno.json
deleted file mode 100644
index d766bc9..0000000
--- a/supabase/functions/ai-structured/deno.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "imports": {
- "openai": "npm:openai@6.16.0"
- }
-}
diff --git a/supabase/functions/ai-structured/index.ts b/supabase/functions/ai-structured/index.ts
deleted file mode 100644
index 3fcbbc6..0000000
--- a/supabase/functions/ai-structured/index.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-// Function deprecated. This endpoint was replaced by shared module `_shared/openai-service.ts`.
-import "jsr:@supabase/functions-js/edge-runtime.d.ts";
-
-const json = (body: unknown, status = 200) =>
- new Response(JSON.stringify(body), {
- status,
- headers: {
- "content-type": "application/json; charset=utf-8",
- "access-control-allow-origin": "*",
- "access-control-allow-headers": "*",
- },
- });
-
-Deno.serve((req) => {
- if (req.method === "OPTIONS") return json({ ok: true });
- return json(
- {
- ok: false,
- error:
- "This endpoint is deprecated. Use the shared module _shared/openai-service.ts from your functions.",
- },
- 410,
- );
-});
diff --git a/supabase/functions/ai_generate_plan/index.ts b/supabase/functions/ai_generate_plan/index.ts
deleted file mode 100644
index 9851a66..0000000
--- a/supabase/functions/ai_generate_plan/index.ts
+++ /dev/null
@@ -1,779 +0,0 @@
-// supabase/functions/ai_generate_plan/index.ts
-///
-import "jsr:@supabase/functions-js/edge-runtime.d.ts";
-import { createClient } from "npm:@supabase/supabase-js@2";
-
-const corsHeaders = {
- "Access-Control-Allow-Origin": "*",
- "Access-Control-Allow-Headers":
- "authorization, x-client-info, apikey, content-type",
-};
-
-function json(body: unknown, status = 200) {
- return new Response(JSON.stringify(body, null, 2), {
- status,
- headers: { ...corsHeaders, "Content-Type": "application/json" },
- });
-}
-
-/** ----------------- LOGGING HELPERS (diagnóstico) ----------------- */
-function nowIso() {
- return new Date().toISOString();
-}
-
-function redactAuthHeader(v: string | null) {
- if (!v) return null;
- const parts = v.split(" ");
- if (parts.length >= 2) return `${parts[0]} [REDACTED]`;
- return "[REDACTED]";
-}
-
-function safePreview(val: unknown, maxLen = 1400) {
- try {
- const jsonStr = JSON.stringify(val, (k, v) => {
- if (typeof v === "string") {
- if (v.length > 800) return v.slice(0, 800) + "…[TRUNCATED]";
- if (v.startsWith("data:") && v.includes(";base64,")) return "[DATA_URL_REDACTED]";
- }
- return v;
- });
- if (!jsonStr) return String(val);
- return jsonStr.length > maxLen ? jsonStr.slice(0, maxLen) + "…[TRUNCATED]" : jsonStr;
- } catch {
- return String(val);
- }
-}
-
-function headersSnapshot(req: Request) {
- const h: Record = {};
- for (const [k, v] of req.headers.entries()) {
- if (k.toLowerCase() === "authorization") h[k] = redactAuthHeader(v) ?? "";
- else h[k] = v;
- }
- return h;
-}
-
-function normalizeNivel(raw: string): string {
- const s = (raw ?? "").trim().toLowerCase();
- if (!s) return "Otro";
- if (s === "licenciatura" || s === "lic") return "Licenciatura";
- if (s === "maestria" || s === "maestría" || s === "msc") return "Maestría";
- if (s === "doctorado" || s === "phd") return "Doctorado";
- if (s === "especialidad") return "Especialidad";
- if (s === "diplomado") return "Diplomado";
- return "Otro";
-}
-
-function normalizeTipoCiclo(raw: string): string {
- const s = (raw ?? "").trim().toLowerCase();
- if (!s) return "Otro";
- if (s === "semestre" || s === "semestres" || s === "sem" || s === "semestre(s)" ||
- s === "semestre" || s === "semestre" || s === "semestre") return "Semestre";
- if (s === "cuatrimestre" || s === "cuatrimestres" || s === "cuatri") return "Cuatrimestre";
- if (s === "trimestre" || s === "trimestres" || s === "trim") return "Trimestre";
- // si viene en mayúsculas tipo frontend
- if (s === "semestre" || s === "semestre".toLowerCase()) return "Semestre";
- if (s === "cuatrimestre" || s === "cuatrimestre".toLowerCase()) return "Cuatrimestre";
- if (s === "trimestre" || s === "trimestre".toLowerCase()) return "Trimestre";
- if (s === "otro") return "Otro";
- // variantes comunes
- if (s === "semestre".toLowerCase()) return "Semestre";
- if (s === "cuatrimestre".toLowerCase()) return "Cuatrimestre";
- if (s === "trimestre".toLowerCase()) return "Trimestre";
- // si llega como SEMESTRE / CUATRIMESTRE / TRIMESTRE
- if (s === "semestre".toLowerCase()) return "Semestre";
- if (s === "cuatrimestre".toLowerCase()) return "Cuatrimestre";
- if (s === "trimestre".toLowerCase()) return "Trimestre";
- // fallback
- return "Otro";
-}
-
-function suggestedWeeksFor(tipoCiclo: string) {
- switch (tipoCiclo) {
- case "Semestre":
- return "16";
- case "Cuatrimestre":
- return "16";
- case "Trimestre":
- return "12";
- default:
- return "16";
- }
-}
-
-function buildPrompt(params: {
- datosBasicos: any;
- carrera?: any;
- estructura?: any;
- iaConfig?: any;
-}) {
- const { datosBasicos, carrera, estructura, iaConfig } = params;
-
- const nombrePlan = String(datosBasicos?.nombrePlan ?? "").trim();
- const nivel = normalizeNivel(String(datosBasicos?.nivel ?? ""));
- const tipoCiclo = normalizeTipoCiclo(String(datosBasicos?.tipoCiclo ?? ""));
- const numCiclos = Number(datosBasicos?.numCiclos ?? 0) || 0;
-
- const semanas = suggestedWeeksFor(tipoCiclo);
-
- const carreraTxt = carrera
- ? `Carrera: ${carrera?.nombre ?? ""} (id: ${carrera?.id ?? ""})\nFacultad: ${carrera?.facultades?.nombre ?? carrera?.facultad?.nombre ?? ""}`
- : "";
-
- const system = [
- "Eres un experto en diseño curricular (México) y redacción institucional.",
- "Debes generar SOLO JSON válido que cumpla EXACTAMENTE el schema (sin campos extra).",
- "Usa español neutro y redacción formal. Si un campo es opcional y no aplica, usa null.",
- "Respeta enumeraciones (p.ej. modalidad_educativa, diseno_curricular).",
- "Asegura consistencia interna entre nivel, fines, perfiles y área de estudio.",
- "La carga_horaria_a_la_semana NO debe exceder 50.",
- ].join(" ");
-
- const user = [
- "Genera un borrador completo del PLAN DE ESTUDIOS con base en lo siguiente:",
- "",
- "== Datos básicos (NO los cambies) ==",
- `- nivel: ${nivel}`,
- `- nombre: ${nombrePlan}`,
- `- tipo de ciclo: ${tipoCiclo}`,
- `- número de ciclos: ${numCiclos}`,
- `- duración sugerida del ciclo escolar (semanas efectivas): ${semanas}`,
- "",
- carreraTxt ? `== Contexto institucional ==\n${carreraTxt}\n` : "",
- "== Enfoque / Población / Notas ==",
- `- descripcionEnfoque: ${iaConfig?.descripcionEnfoque ?? ""}`,
- `- notasAdicionales: ${iaConfig?.notasAdicionales ?? ""}`,
- "",
- "== Reglas adicionales ==",
- "- Alinea: fines_de_aprendizaje_o_formacion ↔ perfil_de_egreso ↔ area_de_estudio.",
- `- total_de_ciclos_del_plan_de_estudios debe ser \"${String(numCiclos)}\" (string).`,
- `- duracion_del_ciclo_escolar debe ser \"${semanas}\" (string).`,
- "- diseno_curricular: elige 'Rígido' o 'Flexible' coherente con la administración.",
- "- Si modalidad_educativa es 'No escolarizada' o 'Mixta', llena justificacion_de_la_propuesta_curricular; si es 'Escolar', puede ser null.",
- "- clave_del_plan_de_estudios: genera una clave administrativa verosímil (p.ej. 2026-XXX-YYY).",
- "",
- "Entrega únicamente el JSON final (sin Markdown, sin explicación).",
- ].join("\n");
-
- return { system, user, computed: { nivel, tipoCiclo, numCiclos, semanas, nombrePlan } };
-}
-
-Deno.serve(async (req) => {
- const cid = crypto.randomUUID();
- const t0 = performance.now();
-
- const log = (msg: string, extra?: unknown) => {
- if (extra !== undefined) {
- console.log(`[${nowIso()}][ai_generate_plan][${cid}] ${msg}`, safePreview(extra));
- } else {
- console.log(`[${nowIso()}][ai_generate_plan][${cid}] ${msg}`);
- }
- };
-
- const warn = (msg: string, extra?: unknown) => {
- if (extra !== undefined) {
- console.warn(`[${nowIso()}][ai_generate_plan][${cid}] ⚠️ ${msg}`, safePreview(extra));
- } else {
- console.warn(`[${nowIso()}][ai_generate_plan][${cid}] ⚠️ ${msg}`);
- }
- };
-
- const errlog = (msg: string, extra?: unknown) => {
- if (extra !== undefined) {
- console.error(`[${nowIso()}][ai_generate_plan][${cid}] ❌ ${msg}`, safePreview(extra));
- } else {
- console.error(`[${nowIso()}][ai_generate_plan][${cid}] ❌ ${msg}`);
- }
- };
-
- if (req.method === "OPTIONS") {
- log("OPTIONS preflight ✅", {
- method: req.method,
- url: req.url,
- headers: headersSnapshot(req),
- });
- return new Response(null, { status: 204, headers: corsHeaders });
- }
-
- if (req.method !== "POST") {
- warn("Method not allowed", { method: req.method });
- return json({ ok: false, error: "Method not allowed", cid }, 405);
- }
-
- log("START 🚀", { method: req.method, url: req.url });
- log("Headers snapshot 🧾", headersSnapshot(req));
-
- try {
- // 1) Auth requerido ✅
- log("Step 1: Auth check 🔐");
- const authHeaderRaw = req.headers.get("Authorization") ??
- req.headers.get("authorization");
- log("Authorization header present?", {
- present: Boolean(authHeaderRaw),
- value: redactAuthHeader(authHeaderRaw),
- });
-
- if (!authHeaderRaw) {
- warn("Missing Authorization header");
- return json({ ok: false, error: "Missing Authorization header", cid }, 401);
- }
-
- const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
- const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
- log("Env presence (Supabase) 🌍", {
- hasSUPABASE_URL: Boolean(SUPABASE_URL),
- hasSUPABASE_ANON_KEY: Boolean(SUPABASE_ANON_KEY),
- });
-
- if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
- errlog("Missing SUPABASE_URL / SUPABASE_ANON_KEY");
- return json(
- { ok: false, error: "Missing SUPABASE_URL / SUPABASE_ANON_KEY", cid },
- 500,
- );
- }
-
- const supabaseUser = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
- global: { headers: { Authorization: authHeaderRaw } },
- });
-
- const tAuth = performance.now();
- const { data: userData, error: userErr } = await supabaseUser.auth.getUser();
- log("Supabase auth.getUser() done ⏱️", { ms: Math.round(performance.now() - tAuth) });
-
- if (userErr || !userData?.user) {
- warn("Invalid token", { userErr: userErr?.message ?? userErr ?? null });
- return json({ ok: false, error: "Invalid token", cid }, 401);
- }
-
- const user = userData.user;
- log("Authenticated user ✅", { userId: user.id, email: user.email ?? null });
-
- const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
- log("Env presence (service role) 🗝️", { hasSERVICE_ROLE: Boolean(SERVICE_ROLE) });
-
- const supabaseAdmin = SERVICE_ROLE
- ? createClient(SUPABASE_URL, SERVICE_ROLE)
- : supabaseUser;
-
- log("Supabase client selected 🧩", { adminMode: Boolean(SERVICE_ROLE) });
-
- // 2) Parse body 🧾
- log("Step 2: Parse body 🧾");
- let payload: any = {};
- const tParse = performance.now();
- try {
- payload = await req.json();
- } catch (e) {
- errlog("Invalid JSON body", { error: (e as Error)?.message ?? String(e) });
- return json({ ok: false, error: "Invalid JSON body", cid }, 400);
- }
- log("Body parsed ⏱️", { ms: Math.round(performance.now() - tParse) });
- log("Payload preview", { keys: Object.keys(payload ?? {}), payload: payload ? { ...payload, iaConfig: undefined } : null });
-
- const datosBasicos = payload?.datosBasicos ?? payload?.input?.datosBasicos;
- const iaConfig = payload?.iaConfig ?? payload?.input?.iaConfig ?? {};
-
- if (!datosBasicos?.nombrePlan || !datosBasicos?.carreraId || !datosBasicos?.nivel || !datosBasicos?.tipoCiclo || !datosBasicos?.numCiclos) {
- warn("Missing required datosBasicos fields", { datosBasicos });
- return json({
- ok: false,
- error: "Missing required fields in datosBasicos (nombrePlan,carreraId,nivel,tipoCiclo,numCiclos).",
- cid,
- }, 400);
- }
-
- // 3) Asegura usuarios_app (por FK en logs) 👤
- log("Step 3: Ensure usuarios_app row 👤");
- const tUserUpsert = performance.now();
- const upsertUser = {
- id: user.id,
- email: user.email ?? null,
- nombre_completo: (user.user_metadata as any)?.full_name ??
- (user.user_metadata as any)?.name ?? null,
- };
- const { error: upsertErr } = await supabaseAdmin
- .from("usuarios_app")
- .upsert(upsertUser, { onConflict: "id" });
- log("usuarios_app upsert done ⏱️", { ms: Math.round(performance.now() - tUserUpsert), ok: !upsertErr });
- if (upsertErr) {
- errlog("usuarios_app upsert failed", { message: upsertErr.message });
- return json({ ok: false, error: "usuarios_app upsert failed", details: upsertErr.message, cid }, 500);
- }
-
- // 4) Trae carrera + facultad (contexto prompt) 🎓
- log("Step 4: Fetch carrera/facultad 🎓");
- const carreraId = datosBasicos.carreraId;
- const tCarr = performance.now();
- const { data: carrera, error: carreraErr } = await supabaseAdmin
- .from("carreras")
- .select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)")
- .eq("id", carreraId)
- .maybeSingle();
- log("Carrera fetch done ⏱️", { ms: Math.round(performance.now() - tCarr), found: Boolean(carrera), carreraId });
- if (carreraErr) warn("Carrera fetch error (continuing)", { message: carreraErr.message });
-
- // 5) Resolver estructura (schema) desde estructuras_plan.definicion 🧩
- log("Step 5: Resolve estructura_plan 🧩");
- const estructuraIdFromPayload = datosBasicos?.estructuraId ?? payload?.estructuraId ?? null;
-
- let estructura: any = null;
- const tEst = performance.now();
-
- if (estructuraIdFromPayload) {
- log("Using estructuraId from payload", { estructuraIdFromPayload });
- const { data, error } = await supabaseAdmin
- .from("estructuras_plan")
- .select("id,nombre,tipo,template,definicion")
- .eq("id", estructuraIdFromPayload)
- .single();
- if (error) {
- errlog("estructuraId provided but not found", { message: error.message, estructuraIdFromPayload });
- return json({ ok: false, error: "estructuraId not found", details: error.message, cid }, 400);
- }
- estructura = data;
- } else {
- // default: la más reciente tipo CURRICULAR, si no existe, la más reciente en general
- const { data, error } = await supabaseAdmin
- .from("estructuras_plan")
- .select("id,nombre,tipo,template,definicion")
- .eq("tipo", "CURRICULAR")
- .order("actualizado_en", { ascending: false })
- .limit(1)
- .maybeSingle();
-
- if (error) {
- warn("Error fetching CURRICULAR estructura (fallback to any)", { message: error.message });
- }
- if (data) estructura = data;
-
- if (!estructura) {
- const { data: anyE, error: anyErr } = await supabaseAdmin
- .from("estructuras_plan")
- .select("id,nombre,tipo,template,definicion")
- .order("actualizado_en", { ascending: false })
- .limit(1)
- .maybeSingle();
- if (anyErr) {
- errlog("No estructuras_plan found", { message: anyErr.message });
- return json({ ok: false, error: "No estructuras_plan found", details: anyErr.message, cid }, 500);
- }
- estructura = anyE;
- }
- }
-
- log("Estructura resolved ⏱️", {
- ms: Math.round(performance.now() - tEst),
- estructuraId: estructura?.id ?? null,
- nombre: estructura?.nombre ?? null,
- tipo: estructura?.tipo ?? null,
- template: estructura?.template ?? null,
- definicionKeys: estructura?.definicion ? Object.keys(estructura.definicion) : null,
- });
-
- if (!estructura?.definicion) {
- errlog("estructuras_plan.definicion missing", { estructuraId: estructura?.id ?? null });
- return json({ ok: false, error: "estructuras_plan.definicion missing", cid }, 500);
- }
-
- // 6) Resolver referencias (archivos / repositorios) 📎
- log("Step 6: Resolve references 📎");
- const uploadedToStorage: string[] = Array.isArray(iaConfig?.uploadedToStorage)
- ? iaConfig.uploadedToStorage
- : [];
- const repositoriosIds: string[] = Array.isArray(iaConfig?.repositoriosIds)
- ? iaConfig.repositoriosIds
- : [];
-
- log("Reference IDs received", {
- uploadedToStorageCount: uploadedToStorage.length,
- repositoriosIdsCount: repositoriosIds.length,
- usarMCP: Boolean(iaConfig?.usarMCP),
- });
-
- const archivosInfo: any[] = [];
- const vectorInfo: any[] = [];
-
- if (uploadedToStorage.length) {
- const { data, error } = await supabaseAdmin
- .from("archivos")
- .select("id,nombre,mime_type,openai_file_id,ruta_storage")
- .in("id", uploadedToStorage);
- if (error) warn("archivos lookup failed (continuing)", { message: error.message });
- for (const a of data ?? []) archivosInfo.push(a);
- }
-
- if (repositoriosIds.length) {
- const { data, error } = await supabaseAdmin
- .from("vector_stores")
- .select("id,nombre,openai_vector_id")
- .in("id", repositoriosIds);
- if (error) warn("vector_stores lookup failed (continuing)", { message: error.message });
- for (const v of data ?? []) vectorInfo.push(v);
- }
-
- const openaiFileIds: string[] = archivosInfo
- .map((a) => a?.openai_file_id)
- .filter((x) => typeof x === "string" && x.length > 0);
- const vectorStoreIds: string[] = vectorInfo
- .map((v) => v?.openai_vector_id)
- .filter((x) => typeof x === "string" && x.length > 0);
-
- const rutasStorage: string[] = archivosInfo
- .map((a) => a?.ruta_storage)
- .filter((x) => typeof x === "string" && x.length > 0);
-
- log("Resolved reference OpenAI IDs", {
- openaiFileIdsCount: openaiFileIds.length,
- vectorStoreIdsCount: vectorStoreIds.length,
- rutasStorageCount: rutasStorage.length,
- });
-
- // 7) Construye prompt + structured wrapper 🧠
- log("Step 7: Build prompt + structured schema 🧠");
- const { system, user: userPrompt, computed } = buildPrompt({
- datosBasicos,
- carrera,
- estructura,
- iaConfig,
- });
-
- // definicion en DB puede venir como (schema) o como (format). Normalizamos.
- let structured: any = null;
- const definicion = estructura.definicion;
-
- if (definicion?.type === "json_schema" && definicion?.schema) {
- structured = definicion;
- } else if (definicion?.format?.type === "json_schema" && definicion?.format?.schema) {
- structured = definicion;
- } else {
- structured = {
- type: "json_schema",
- name: "plan_datos",
- strict: true,
- schema: definicion,
- };
- }
-
- log("Structured wrapper summary", {
- type: structured?.type ?? null,
- name: structured?.name ?? null,
- strict: structured?.strict ?? null,
- hasSchema: Boolean(structured?.schema),
- schemaTopKeys: structured?.schema ? Object.keys(structured.schema).slice(0, 20) : null,
- });
-
- // 8) Llamar ai-structured (otra Edge Function) 🔁
- log("Step 8: Call ai-structured 🔁");
- const aiStructuredUrl = `${SUPABASE_URL}/functions/v1/ai-structured`;
-
- const model = payload?.model ?? payload?.openai?.model ?? payload?.response?.model ?? "gpt-5";
-
- const aiStructuredBody = {
- response: {
- model,
- input: [
- { role: "system", content: system },
- { role: "user", content: userPrompt },
- ],
- },
- structured,
- references: {
- openaiFileIds,
- vectorStoreIds,
- uploadedToStorage,
- },
- usarMCP: Boolean(iaConfig?.usarMCP),
- };
-
- log("ai-structured request snapshot 🧪", {
- url: aiStructuredUrl,
- model,
- inputChars: { system: system.length, user: userPrompt.length },
- openaiFileIdsCount: openaiFileIds.length,
- vectorStoreIdsCount: vectorStoreIds.length,
- usarMCP: Boolean(iaConfig?.usarMCP),
- });
-
- const tAi = performance.now();
- const aiResp = await fetch(aiStructuredUrl, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: authHeaderRaw,
- apikey: SUPABASE_ANON_KEY,
- },
- body: JSON.stringify(aiStructuredBody),
- });
-
- const aiJson = await aiResp.json().catch(() => null);
-
- log("ai-structured response received ⏱️", {
- ms: Math.round(performance.now() - tAi),
- status: aiResp.status,
- okHttp: aiResp.ok,
- ok: aiJson?.ok ?? null,
- aiCid: aiJson?.cid ?? null,
- responseId: aiJson?.responseId ?? null,
- conversationId: aiJson?.conversationId ?? null,
- outputParseError: aiJson?.outputParseError ?? null,
- });
-
- if (!aiResp.ok || !aiJson) {
- errlog("ai-structured HTTP failed", { status: aiResp.status, body: aiJson });
- return json({
- ok: false,
- error: "ai-structured HTTP failed",
- status: aiResp.status,
- details: aiJson,
- cid,
- }, 500);
- }
-
- if (!aiJson.ok || !aiJson.output) {
- errlog("ai-structured returned ok=false or missing output", { aiJson });
- // Aun así, registra interaccion_ia (sin plan) para auditoría
- const { data: inter, error: interErr } = await supabaseAdmin
- .from("interacciones_ia")
- .insert({
- usuario_id: user.id,
- plan_estudio_id: null,
- tipo: "GENERAR",
- modelo: aiJson?.model ?? model,
- temperatura: null,
- prompt: {
- datosBasicos,
- iaConfig,
- estructura: { id: estructura.id, nombre: estructura.nombre, template: estructura.template, tipo: estructura.tipo },
- request: aiStructuredBody,
- computed,
- },
- respuesta: aiJson,
- aceptada: false,
- conversacion_id: aiJson?.conversationId ?? null,
- ids_archivos: uploadedToStorage,
- ids_vector_store: repositoriosIds,
- rutas_storage: rutasStorage,
- })
- .select("id")
- .maybeSingle();
-
- if (interErr) warn("interacciones_ia insert failed (error path)", { message: interErr.message });
-
- return json({
- ok: false,
- error: "AI generation failed (structured output invalid).",
- cid,
- ai: aiJson,
- interaccion_ia_id: inter?.id ?? null,
- }, 500);
- }
-
- // 9) Normaliza/asegura campos clave del output ✅
- log("Step 9: Normalize output ✅");
- const output = aiJson.output as Record;
-
- // fuerza consistencia con datosBasicos
- output.nombre = computed.nombrePlan;
- output.nivel = computed.nivel;
- output.total_de_ciclos_del_plan_de_estudios = String(computed.numCiclos);
- output.duracion_del_ciclo_escolar = String(computed.semanas);
-
- // 10) Inserta log de IA (interacciones_ia) 🧾
- log("Step 10: Insert interacciones_ia 🧾");
- const tInter = performance.now();
- const { data: interaccion, error: interErr } = await supabaseAdmin
- .from("interacciones_ia")
- .insert({
- usuario_id: user.id,
- plan_estudio_id: null, // se linkea luego (cuando exista el plan)
- tipo: "GENERAR",
- modelo: aiJson?.model ?? model,
- temperatura: null,
- prompt: {
- datosBasicos,
- iaConfig,
- estructura: {
- id: estructura.id,
- nombre: estructura.nombre,
- template: estructura.template,
- tipo: estructura.tipo,
- },
- request: aiStructuredBody,
- computed,
- },
- respuesta: aiJson,
- aceptada: true, // se está usando para crear el plan
- conversacion_id: aiJson?.conversationId ?? null,
- ids_archivos: uploadedToStorage,
- ids_vector_store: repositoriosIds,
- rutas_storage: rutasStorage,
- })
- .select("id")
- .single();
-
- log("interacciones_ia insert done ⏱️", {
- ms: Math.round(performance.now() - tInter),
- interaccionId: interaccion?.id ?? null,
- ok: !interErr,
- });
-
- if (interErr) {
- errlog("interacciones_ia insert failed", { message: interErr.message });
- return json(
- { ok: false, error: "interacciones_ia insert failed", details: interErr.message, cid },
- 500,
- );
- }
-
- // 11) Crear plan (planes_estudio) 🏗️
- log("Step 11: Insert planes_estudio 🏗️");
- const nivelDB = normalizeNivel(String(datosBasicos?.nivel ?? ""));
- const tipoCicloDB = normalizeTipoCiclo(String(datosBasicos?.tipoCiclo ?? ""));
- const numeroCiclosDB = Number(datosBasicos?.numCiclos ?? 0);
-
- // intenta setear estado BORRADOR si existe
- let estadoBorradorId: string | null = null;
- try {
- const { data: estado } = await supabaseAdmin
- .from("estados_plan")
- .select("id,clave,orden")
- .ilike("clave", "BORRADOR%")
- .order("orden", { ascending: true })
- .limit(1)
- .maybeSingle();
- estadoBorradorId = estado?.id ?? null;
- } catch {
- // ignore
- }
-
- const tPlan = performance.now();
- const { data: plan, error: planErr } = await supabaseAdmin
- .from("planes_estudio")
- .insert({
- carrera_id: carreraId,
- estructura_id: estructura.id,
- nombre: computed.nombrePlan,
- nivel: nivelDB,
- tipo_ciclo: tipoCicloDB,
- numero_ciclos: numeroCiclosDB,
- datos: output,
- estado_actual_id: estadoBorradorId,
- activo: true,
- tipo_origen: "IA",
- meta_origen: {
- generado_por: "ai_generate_plan",
- cid,
- ai_structured: {
- cid: aiJson?.cid ?? null,
- responseId: aiJson?.responseId ?? null,
- conversationId: aiJson?.conversationId ?? null,
- model: aiJson?.model ?? model,
- usage: aiJson?.usage ?? null,
- },
- referencias: {
- uploadedToStorage,
- repositoriosIds,
- openaiFileIds,
- vectorStoreIds,
- },
- iaConfig: {
- descripcionEnfoque: iaConfig?.descripcionEnfoque ?? null,
- poblacionObjetivo: iaConfig?.poblacionObjetivo ?? null,
- notasAdicionales: iaConfig?.notasAdicionales ?? null,
- usarMCP: Boolean(iaConfig?.usarMCP),
- },
- },
- creado_por: user.id,
- actualizado_por: user.id,
- })
- .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();
-
- log("planes_estudio insert done ⏱️", {
- ms: Math.round(performance.now() - tPlan),
- planId: plan?.id ?? null,
- ok: !planErr,
- });
-
- if (planErr) {
- errlog("planes_estudio insert failed", { message: planErr.message });
- return json(
- { ok: false, error: "planes_estudio insert failed", details: planErr.message, cid },
- 500,
- );
- }
-
- // 12) Link interaccion_ia -> plan + historial (cambios_plan) 🧩
- log("Step 12: Link interaccion + insert cambios_plan 🧩");
-
- const { error: linkErr } = await supabaseAdmin
- .from("interacciones_ia")
- .update({ plan_estudio_id: plan.id })
- .eq("id", interaccion.id);
-
- if (linkErr) warn("Could not link interaccion_ia to plan (continuing)", { message: linkErr.message });
-
- const { error: cambioErr } = await supabaseAdmin
- .from("cambios_plan")
- .insert({
- plan_estudio_id: plan.id,
- cambiado_por: user.id,
- tipo: "ACTUALIZACION_CAMPO",
- campo: "datos",
- valor_anterior: null,
- valor_nuevo: output,
- interaccion_ia_id: interaccion.id,
- });
-
- if (cambioErr) warn("cambios_plan insert failed (continuing)", { message: cambioErr.message });
-
- // 13) (OPCIONAL) Generar materias automáticamente (dejado comentado) 📚
- // ---------------------------------------------------------------
- // const AUTO_GENERATE_SUBJECTS = false; // 👈 activar cuando decidas
- // const AUTO_SUBJECTS_COUNT = 10; // 👈 constante pedida (número de materias)
- // if (AUTO_GENERATE_SUBJECTS) {
- // for (let i = 1; i <= AUTO_SUBJECTS_COUNT; i++) {
- // // TODO: insertar en `asignaturas` con plan_estudio_id = plan.id
- // // Recomendación: usar una estructura_asignatura default y llenar datos mínimos.
- // }
- // }
- // ---------------------------------------------------------------
-
- log("RETURN ✅", {
- ok: true,
- planId: plan.id,
- interaccionIaId: interaccion.id,
- totalMs: Math.round(performance.now() - t0),
- });
-
- return json({
- ok: true,
- cid,
- plan,
- plan_datos: output,
- interaccion_ia_id: interaccion.id,
- ai: {
- cid: aiJson?.cid ?? null,
- responseId: aiJson?.responseId ?? null,
- conversationId: aiJson?.conversationId ?? null,
- model: aiJson?.model ?? model,
- usage: aiJson?.usage ?? null,
- },
- references: {
- uploadedToStorage,
- repositoriosIds,
- openaiFileIds,
- vectorStoreIds,
- rutasStorage,
- },
- });
- } catch (e) {
- errlog("Unhandled error 💥", {
- message: (e as Error)?.message ?? String(e),
- stack: (e as Error)?.stack ?? null,
- totalMs: Math.round(performance.now() - t0),
- });
- return json({ ok: false, error: (e as Error)?.message ?? String(e), cid }, 500);
- }
-});