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); - } -});