diff --git a/supabase/functions/ai-generate-plan/deno.json b/supabase/functions/ai-generate-plan/deno.json index f6ca845..f5db280 100644 --- a/supabase/functions/ai-generate-plan/deno.json +++ b/supabase/functions/ai-generate-plan/deno.json @@ -1,3 +1,5 @@ { - "imports": {} + "imports": { + "zod": "https://deno.land/x/zod@v3.22.4/mod.ts" + } } diff --git a/supabase/functions/ai-generate-plan/index.ts b/supabase/functions/ai-generate-plan/index.ts index a484361..64bae32 100644 --- a/supabase/functions/ai-generate-plan/index.ts +++ b/supabase/functions/ai-generate-plan/index.ts @@ -5,35 +5,52 @@ // Setup type definitions for built-in Supabase Runtime APIs import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { corsHeaders } from "../_shared/cors.ts"; - -console.log("Hello from Functions!"); +import { createClient } from "npm:@supabase/supabase-js@2"; +import type { AIGeneratePlanInput } from "./types.ts"; +import { z } from "zod"; Deno.serve(async (req) => { if (req.method === "OPTIONS") { return new Response(null, { status: 204, headers: corsHeaders }); } + const url = new URL(req.url); + const functionName = url.pathname.split("/").pop(); try { - const contentType = (req.headers.get("content-type") || "").toLowerCase(); - - if (contentType.startsWith("multipart/form-data")) { - const formData = await req.formData(); - console.log("Received multipart/form-data:", formData); - // 1. Usa .getAll() para sacar TODOS los valores de esa llave - const archivos = formData.getAll("archivosAdjuntos"); - - // 2. Imprime la longitud y el arreglo explícito - console.log(`Total archivos recibidos: ${archivos.length}`); - console.log("Lista de archivos:", archivos); + const method = req.method; + if (method !== "POST") { + console.error( + `[${Date.now()}][${functionName}]: Invalid method: ${method}`, + ); return new Response( - JSON.stringify({ message: "Multipart/form-data received" }), + JSON.stringify({ error: "Method not allowed" }), { headers: { ...corsHeaders, "Content-Type": "application/json" }, - status: 200, + status: 405, }, ); - } else { - console.error("Unsupported content type:", contentType); + } + + const authHeaderRaw = req.headers.get("Authorization") ?? + req.headers.get("authorization"); + if (!authHeaderRaw) { + console.error( + `[${Date.now()}][${functionName}]: Missing Authorization header`, + ); + return new Response( + JSON.stringify({ error: "Authorization header missing" }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 401, + }, + ); + } + + const contentType = (req.headers.get("content-type") || "").toLowerCase(); + if (!contentType.startsWith("multipart/form-data")) { + console.error( + `[${Date.now()}][${functionName}]: Unsupported content type: ${contentType}`, + ); return new Response( JSON.stringify({ error: "Unsupported content type" }), { @@ -42,10 +59,93 @@ Deno.serve(async (req) => { }, ); } + + 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 Error("Supabase environment variables are not set"); + } + + const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { + headers: { + Authorization: authHeaderRaw, + }, + }, + }); + + // TODO: iniciar sesion con el usuario + + const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!SERVICE_ROLE_KEY) { + throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set"); + } + + const supabaseService = createClient( + SUPABASE_URL, + SERVICE_ROLE_KEY, + ); + + const formData = await req.formData(); + const validation = parseAndValidate(formData); + if (!validation.success) { + console.error( + `[${Date.now()}][${functionName}]: Validation errors:`, + validation.errors, + ); + return new Response( + JSON.stringify({ errors: validation.errors }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 400, + }, + ); + } + + const payload: AIGeneratePlanInput = validation.data; + + const respuestaSubida: string[] = subirAStorage( + supabaseService, + payload.archivosAdjuntos, + "ai-storage", + "tmp", + ); + + 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 Error("Error fetching carrera: " + carreraError.message); + } + + const { data: estructuraPlan, error: estructuraPlanError } = + await supabaseService + .from("estructuras_plan") + .select("id,nombre,tipo,template_id,definicion") + .eq("id", payload.datosBasicos.estructuraPlanId) + .single(); + if (estructuraPlanError) { + throw new Error( + "Error fetching estructuraPlan: " + estructuraPlanError.message, + ); + } + + return new Response( + JSON.stringify({ message: "Multipart/form-data received" }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 200, + }, + ); } catch (error) { // Log full error server-side for diagnostics if (error instanceof Error) { - console.error("Request handler error:", error.message); + console.error( + `[${Date.now()}][${functionName}]: Request handler error:`, + error.message, + ); } const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { @@ -54,3 +154,136 @@ Deno.serve(async (req) => { }); } }); + +// --- VALIDACIÓN ESTRICTA DE DATOS BÁSICOS --- +const DatosBasicosSchema: z.ZodType = z + .object({ + nombrePlan: z.string().min(1, "El nombre es requerido"), + carreraId: z.string().uuid("carreraId debe ser un UUID"), + facultadId: z.string().uuid("facultadId debe ser un UUID").optional(), + nivel: z.string().min(1, "Nivel es requerido"), + tipoCiclo: z.enum(["Semestre", "Cuatrimestre", "Trimestre", "Otro"]), + numCiclos: z.number().int().positive(), + estructuraPlanId: z.string().uuid("estructuraPlanId debe ser un UUID"), + }); + +const IAConfigSchema: z.ZodType = z.object({ + descripcionEnfoque: z.string().min(10, "La descripción es muy corta"), + notasAdicionales: z.string().optional(), + archivosReferencia: z.array(z.string().uuid()).optional(), + repositoriosIds: z.array(z.string().uuid()).optional(), + usarMCP: z.boolean().optional(), +}); + +function parseAndValidate( + formData: FormData, +): { success: true; data: AIGeneratePlanInput } | { + success: false; + errors: string[]; +} { + const errors: string[] = []; + + // --- A. Extracción de Strings --- + const rawBasicos = formData.get("datosBasicos"); + const rawIA = formData.get("iaConfig"); + + if (!rawBasicos) { + return { + success: false, + errors: ["Falta el campo 'datosBasicos' en el FormData"], + }; + } + if (!rawIA) { + return { + success: false, + errors: ["Falta el campo 'iaConfig' en el FormData"], + }; + } + + // --- B. Parsing de JSON (Usamos 'unknown' en lugar de 'any') --- + let jsonBasicos: unknown; + let jsonIA: unknown; + + try { + jsonBasicos = JSON.parse(rawBasicos as string); + } catch (_error) { + return { + success: false, + errors: ["El JSON de 'datosBasicos' tiene un formato inválido"], + }; + } + + try { + jsonIA = JSON.parse(rawIA as string); + } catch (_error) { + return { + success: false, + errors: ["El JSON de 'iaConfig' tiene un formato inválido"], + }; + } + + // --- C. Validación con Zod --- + const parsedBasicos = DatosBasicosSchema.safeParse(jsonBasicos); + const parsedIA = IAConfigSchema.safeParse(jsonIA); + + // Recolectamos errores de Zod si existen + if (!parsedBasicos.success) { + parsedBasicos.error.errors.forEach((issue) => { + errors.push( + `Datos Básicos (${issue.path.join(". ")}): ${issue.message}`, + ); + }); + } + + if (!parsedIA.success) { + parsedIA.error.errors.forEach((issue) => { + errors.push(`IA Config (${issue.path.join(". ")}): ${issue.message}`); + }); + } + + // --- D. Validación Manual de Archivos --- + const filesRaw = formData.getAll("archivosAdjuntos"); + const filesClean: File[] = []; + + filesRaw.forEach((entry, index) => { + if (entry instanceof File) { + filesClean.push(entry); + } else { + errors.push( + `El adjunto en la posición ${index} no es un archivo válido.`, + ); + } + }); + + // --- E. Retorno de Errores --- + if (errors.length > 0) { + console.error("❌ Errores de validación:", errors); + return { success: false, errors }; + } + + // --- F. Retorno de Éxito (Narrowing de Tipos) --- + + // TypeScript necesita esta verificación explícita. + // Aunque lógicamente sabemos que si errors.length === 0, entonces success es true, + // TS no conecta el array de errores con el resultado de Zod automáticamente. + if (!parsedBasicos.success || !parsedIA.success) { + // Este caso teóricamente es inalcanzable si la lógica de arriba es correcta, + // pero satisface al compilador para acceder a .data abajo. + return { + success: false, + errors: ["Error interno de estado inconsistente."], + }; + } + + // Ahora sí, TS sabe que parsedBasicos.data existe y es del tipo correcto + const finalInput: AIGeneratePlanInput = { + datosBasicos: parsedBasicos.data, + iaConfig: parsedIA.data, + archivosAdjuntos: filesClean, + }; + + return { success: true, data: finalInput }; +} + +async function subirAStorage(supabase: supa) { +} diff --git a/supabase/functions/ai-generate-plan/indexPrototipo.ts b/supabase/functions/ai-generate-plan/indexPrototipo.ts new file mode 100644 index 0000000..6c250ad --- /dev/null +++ b/supabase/functions/ai-generate-plan/indexPrototipo.ts @@ -0,0 +1,919 @@ +// 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_id,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_id,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_id,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_id: estructura?.template_id ?? 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 archivosReferenciaIds: string[] = + Array.isArray(iaConfig?.archivosReferencia) + ? iaConfig.archivosReferencia + : []; + const repositoriosIds: string[] = Array.isArray(iaConfig?.repositoriosIds) + ? iaConfig.repositoriosIds + : []; + + log("Reference IDs received", { + archivosReferenciaIdsCount: archivosReferenciaIds.length, + repositoriosIdsCount: repositoriosIds.length, + usarMCP: Boolean(iaConfig?.usarMCP), + }); + + const archivosInfo: any[] = []; + const vectorInfo: any[] = []; + + if (archivosReferenciaIds.length) { + const { data, error } = await supabaseAdmin + .from("archivos") + .select("id,nombre,mime_type,openai_file_id,ruta_storage") + .in("id", archivosReferenciaIds); + 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, + archivosReferenciaIds, + }, + 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_id: estructura.template_id, + tipo: estructura.tipo, + }, + request: aiStructuredBody, + computed, + }, + respuesta: aiJson, + aceptada: false, + conversacion_id: aiJson?.conversationId ?? null, + ids_archivos: archivosReferenciaIds, + 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_id: estructura.template_id, + tipo: estructura.tipo, + }, + request: aiStructuredBody, + computed, + }, + respuesta: aiJson, + aceptada: true, // se está usando para crear el plan + conversacion_id: aiJson?.conversationId ?? null, + ids_archivos: archivosReferenciaIds, + 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: { + archivosReferenciaIds, + 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: { + archivosReferenciaIds, + 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, + ); + } +}); diff --git a/supabase/functions/ai-generate-plan/types.ts b/supabase/functions/ai-generate-plan/types.ts new file mode 100644 index 0000000..08b016a --- /dev/null +++ b/supabase/functions/ai-generate-plan/types.ts @@ -0,0 +1,19 @@ +export type AIGeneratePlanInput = { + datosBasicos: { + nombrePlan: string; + carreraId: string; + facultadId?: string; + nivel: string; + tipoCiclo: "Semestre" | "Cuatrimestre" | "Trimestre" | "Otro"; + numCiclos: number; + estructuraPlanId: string; + }; + iaConfig: { + descripcionEnfoque: string; + notasAdicionales?: string; + archivosReferencia?: Array; + repositoriosIds?: Array; + usarMCP?: boolean; + }; + archivosAdjuntos?: Array; +}; diff --git a/supabase/functions/tests/ai-generate-test-prototipo.ts b/supabase/functions/tests/ai-generate-test-prototipo.ts new file mode 100644 index 0000000..1ab5425 --- /dev/null +++ b/supabase/functions/tests/ai-generate-test-prototipo.ts @@ -0,0 +1,147 @@ +// supabase/functions/ai-structured/ai_structured.test.ts +/// +/// +/// + +import { assert, assertEquals } from "jsr:@std/assert@1"; +import "jsr:@std/dotenv/load"; +import { createClient, type SupabaseClient } from "npm:@supabase/supabase-js@2"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; +const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? ""; // (o PUBLISHABLE_KEY si así lo nombraste) +const EMAIL = Deno.env.get("TEST_EMAIL") ?? "guillermo.arrieta@lasalle.mx"; +const PASSWORD = Deno.env.get("TEST_PASSWORD") ?? "admin"; + +const options = { + auth: { + autoRefreshToken: false, + persistSession: false, + detectSessionInUrl: false, + }, +}; + +function mustEnv() { + if (!SUPABASE_URL) throw new Error("SUPABASE_URL is required"); + if (!SUPABASE_ANON_KEY) throw new Error("SUPABASE_ANON_KEY is required"); +} + +async function getAuthedClient(): Promise< + { client: SupabaseClient; accessToken: string } +> { + mustEnv(); + const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, options); + + const { data, error } = await client.auth.signInWithPassword({ + email: EMAIL, + password: PASSWORD, + }); + if (error) throw new Error("Sign-in failed: " + error.message); + + const accessToken = data.session?.access_token; + if (!accessToken) { + throw new Error("No access_token returned from signInWithPassword"); + } + + return { client, accessToken }; +} + +Deno.test("ai_generate_plan creates plan and returns structured data", async () => { + const { client, accessToken } = await getAuthedClient(); + + const { data: carrera, error: carreraError } = await client + .from("carreras") + .select("id") + .limit(1) + .single(); + if (carreraError) { + throw new Error("Failed to fetch carrera: " + carreraError.message); + } + if (!carrera) throw new Error("No carrera found"); + + const datosBasicos = { + nombrePlan: + "Plan de estudios de Ingeniería en Sistemas Computacionales", + carreraId: carrera.id, + nivel: "Licenciatura", + tipoCiclo: "Semestre", // important: normalizes to "Semestre" + numCiclos: 8, + }; + + const { data, error } = await client.functions.invoke("ai_generate_plan", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: { datosBasicos }, + }); + + if (error) throw new Error("Invoke failed: " + error.message); + assert(data, "Expected data from function"); + + // Top-level result + assertEquals(data.ok, true); + + // `plan` (DB record) + assert(typeof data.plan?.id === "string"); + assertEquals(data.plan.nombre, datosBasicos.nombrePlan); + assertEquals(data.plan.nivel, "Licenciatura"); + assertEquals(data.plan.tipo_ciclo, "Semestre"); + assertEquals(data.plan.numero_ciclos, datosBasicos.numCiclos); + + // `plan_datos` (normalized structured payload) + assert(typeof data.plan_datos === "object"); + assertEquals(data.plan_datos.nombre, datosBasicos.nombrePlan); + assertEquals(data.plan_datos.nivel, "Licenciatura"); + assertEquals( + data.plan_datos.total_de_ciclos_del_plan_de_estudios, + String(datosBasicos.numCiclos), + ); + assertEquals(data.plan_datos.duracion_del_ciclo_escolar, "16"); // Semestre -> 16 +}); +/* +Deno.test("ai-structured (multipart + file)", async () => { + const { client, accessToken } = await getAuthedClient(); + + // Lee un PDF local (ajusta path) + const bytes = await Deno.readFile("files/carta.pdf"); + const file = new File([bytes], "carta.pdf", { type: "application/pdf" }); + + const payload = { + response: { input: "Resume estos documentos en JSON.", model: "gpt-5" }, + structured: { + type: "json_schema", + name: "resumen", + strict: true, + schema: { + type: "object", + properties: { resumen: { type: "string" } }, + required: ["resumen"], + additionalProperties: false, + }, + }, + storage: { prefix: "tmp" }, + }; + + const fd = new FormData(); + fd.append("payload", JSON.stringify(payload)); + fd.append("files", file); + + const { data, error } = await client.functions.invoke("ai-structured", { + headers: { + Authorization: `Bearer ${accessToken}`, // 👈 necesario + // NO pongas Content-Type: fetch lo setea con boundary para FormData ✅ + }, + method: "POST", + body: fd, + }); + + if (error) throw new Error("Invoke failed: " + error.message); + assert(data, "Expected data from function"); + + assertEquals(data.ok, true); + assert(typeof data.output?.resumen === "string"); + assert(data.output.resumen.length > 0); + + // opcional: valida que registró upload(s) a storage + assert(Array.isArray(data.references?.uploadedToStorage)); +}); + */