diff --git a/supabase/functions/ai-structured/index.ts b/supabase/functions/ai-structured/index.ts index 4ccd566..15c449f 100644 --- a/supabase/functions/ai-structured/index.ts +++ b/supabase/functions/ai-structured/index.ts @@ -126,12 +126,16 @@ function safePreview(val: unknown, maxLen = 1200) { if (typeof v === "string") { if (v.length > 500) return v.slice(0, 500) + "…[TRUNCATED]"; // evita imprimir base64/data URLs - if (v.startsWith("data:") && v.includes(";base64,")) return "[DATA_URL_REDACTED]"; + 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; + return jsonStr.length > maxLen + ? jsonStr.slice(0, maxLen) + "…[TRUNCATED]" + : jsonStr; } catch { return String(val); } @@ -187,13 +191,17 @@ function toolsSummary(tools: any[]) { return arr.map((t) => { if (!t) return t; const base: any = { type: t.type }; - if (t.type === "file_search") base.vector_store_ids_count = (t.vector_store_ids ?? []).length; + if (t.type === "file_search") { + base.vector_store_ids_count = (t.vector_store_ids ?? []).length; + } if (t.type === "mcp") { base.server_label = t.server_label ?? null; base.server_url = t.server_url ? "[SET]" : null; base.require_approval = t.require_approval ?? null; base.authorization = t.authorization ? "[REDACTED]" : null; - base.allowed_tools_count = Array.isArray(t.allowed_tools) ? t.allowed_tools.length : null; + base.allowed_tools_count = Array.isArray(t.allowed_tools) + ? t.allowed_tools.length + : null; } return base; }); @@ -207,7 +215,10 @@ Deno.serve(async (req) => { const log = (msg: string, extra?: unknown) => { if (extra !== undefined) { - console.log(`[${nowIso()}][ai-structured][${cid}] ${msg}`, safePreview(extra)); + console.log( + `[${nowIso()}][ai-structured][${cid}] ${msg}`, + safePreview(extra), + ); } else { console.log(`[${nowIso()}][ai-structured][${cid}] ${msg}`); } @@ -215,7 +226,10 @@ Deno.serve(async (req) => { const warn = (msg: string, extra?: unknown) => { if (extra !== undefined) { - console.warn(`[${nowIso()}][ai-structured][${cid}] ⚠️ ${msg}`, safePreview(extra)); + console.warn( + `[${nowIso()}][ai-structured][${cid}] ⚠️ ${msg}`, + safePreview(extra), + ); } else { console.warn(`[${nowIso()}][ai-structured][${cid}] ⚠️ ${msg}`); } @@ -223,7 +237,10 @@ Deno.serve(async (req) => { const errlog = (msg: string, extra?: unknown) => { if (extra !== undefined) { - console.error(`[${nowIso()}][ai-structured][${cid}] ❌ ${msg}`, safePreview(extra)); + console.error( + `[${nowIso()}][ai-structured][${cid}] ❌ ${msg}`, + safePreview(extra), + ); } else { console.error(`[${nowIso()}][ai-structured][${cid}] ❌ ${msg}`); } @@ -247,11 +264,17 @@ Deno.serve(async (req) => { const authHeaderRaw = req.headers.get("Authorization") ?? req.headers.get("authorization"); - log("Authorization header present?", { present: Boolean(authHeaderRaw), value: redactAuthHeader(authHeaderRaw) }); + 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); + return json( + { ok: false, error: "Missing Authorization header", cid }, + 401, + ); } const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); @@ -275,8 +298,11 @@ Deno.serve(async (req) => { }); const tAuth = performance.now(); - const { data: userData, error: userErr } = await supabaseUser.auth.getUser(); - log("Supabase auth.getUser() done ⏱️", { ms: Math.round(performance.now() - tAuth) }); + 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 }); @@ -284,10 +310,15 @@ Deno.serve(async (req) => { } const user = userData.user; - log("Authenticated user ✅", { userId: user.id, email: user.email ?? null }); + 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) }); + log("Env presence (service role) 🗝️", { + hasSERVICE_ROLE: Boolean(SERVICE_ROLE), + }); const supabaseAdmin = SERVICE_ROLE ? createClient(SUPABASE_URL, SERVICE_ROLE) @@ -343,9 +374,13 @@ Deno.serve(async (req) => { }, 400); } - for (const key of ["files", "files[]", "archivos", "archivosTemporales"]) { + for ( + const key of ["files", "files[]", "archivos", "archivosTemporales"] + ) { const vals = form.getAll(key); - if (vals?.length) log(`Found multipart field "${key}"`, { count: vals.length }); + if (vals?.length) { + log(`Found multipart field "${key}"`, { count: vals.length }); + } for (const v of vals) { if (v instanceof File) tempFiles.push(v); } @@ -361,19 +396,29 @@ Deno.serve(async (req) => { log("Multipart files detected 📎", { count: tempFiles.length, - files: tempFiles.map((f) => ({ name: f.name, type: f.type, size: f.size })), + files: tempFiles.map((f) => ({ + name: f.name, + type: f.type, + size: f.size, + })), }); } else { log("Parsing JSON body 🧾"); try { payload = await req.json(); } catch (e) { - errlog("Invalid JSON body", { error: (e as Error)?.message ?? String(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 top-level keys", { keys: Object.keys(payload ?? {}), hasOpenAI: Boolean(payload?.openai), hasResponse: Boolean(payload?.response) }); + log("Payload top-level keys", { + keys: Object.keys(payload ?? {}), + hasOpenAI: Boolean(payload?.openai), + hasResponse: Boolean(payload?.response), + }); // 3) Construye config final para OpenAI (passthrough + overrides) 🧠 log("Step 3: Build responseCfg 🧠"); @@ -389,7 +434,10 @@ Deno.serve(async (req) => { if (responseCfg.input !== undefined) { responseCfg.input = normalizeInputUser(responseCfg.input); - log("responseCfg.input normalized ✅", summarizeInputShape(responseCfg.input)); + log( + "responseCfg.input normalized ✅", + summarizeInputShape(responseCfg.input), + ); } else { warn("responseCfg.input is undefined (is that expected?)"); } @@ -406,7 +454,7 @@ Deno.serve(async (req) => { responseCfg.reasoning = payload.reasoning; } - responseCfg.model = responseCfg.model ?? "gpt-5"; + responseCfg.model = responseCfg.model ?? "gpt-5-nano"; log("OpenAI config summary 🤖", { model: responseCfg.model, hasConversation: Boolean(responseCfg.conversation), @@ -445,11 +493,15 @@ Deno.serve(async (req) => { } if (typeof structured === "string") { - log("Parsing structured from string 🧩", { preview: structured.slice(0, 200) }); + log("Parsing structured from string 🧩", { + preview: structured.slice(0, 200), + }); try { structured = JSON.parse(structured); } catch (e) { - errlog("`structured` string is not valid JSON", { error: (e as Error)?.message ?? String(e) }); + errlog("`structured` string is not valid JSON", { + error: (e as Error)?.message ?? String(e), + }); return json({ ok: false, error: "`structured` is a string but not valid JSON.", @@ -486,7 +538,9 @@ Deno.serve(async (req) => { log("References summary", { openaiFileIdsCount: openaiFileIds.length, vectorStoreIdsCount: vectorStoreIds.length, - usarMCP: Boolean(payload.usarMCP ?? references.usarMCP ?? payload.mcp?.enabled), + usarMCP: Boolean( + payload.usarMCP ?? references.usarMCP ?? payload.mcp?.enabled, + ), }); // 5a) Vector stores => file_search tool @@ -506,7 +560,10 @@ Deno.serve(async (req) => { } responseCfg.include = include; - log("Added file_search tool 🗂️", { vectorStoreIdsCount: vectorStoreIds.length, include: responseCfg.include }); + log("Added file_search tool 🗂️", { + vectorStoreIdsCount: vectorStoreIds.length, + include: responseCfg.include, + }); } // 5b) MCP opcional @@ -518,8 +575,11 @@ Deno.serve(async (req) => { const server_url = mcp.server_url ?? Deno.env.get("MCP_SERVER_URL"); log("MCP config presence", { hasServerUrl: Boolean(server_url), - hasAuthorization: Boolean(mcp.authorization ?? Deno.env.get("MCP_AUTHORIZATION")), - server_label: mcp.server_label ?? Deno.env.get("MCP_SERVER_LABEL") ?? "supabase", + hasAuthorization: Boolean( + mcp.authorization ?? Deno.env.get("MCP_AUTHORIZATION"), + ), + server_label: mcp.server_label ?? Deno.env.get("MCP_SERVER_LABEL") ?? + "supabase", }); if (!server_url) { @@ -553,12 +613,17 @@ Deno.serve(async (req) => { // 5c) OpenAI file IDs => se insertan al input como input_file if (openaiFileIds.length) { - log("Injecting OpenAI file_ids into input 🧷", { count: openaiFileIds.length }); + log("Injecting OpenAI file_ids into input 🧷", { + count: openaiFileIds.length, + }); responseCfg.input = normalizeInputWithOpenAIFileIds( responseCfg.input, openaiFileIds, ); - log("Input after OpenAI file_ids injection", summarizeInputShape(responseCfg.input)); + log( + "Input after OpenAI file_ids injection", + summarizeInputShape(responseCfg.input), + ); } // 6) Archivos temporales => subir a Supabase Storage (NO a OpenAI) ☁️ @@ -583,28 +648,43 @@ Deno.serve(async (req) => { const safeName = (f.name ?? "file").replaceAll(/[^a-zA-Z0-9._-]/g, "_"); const path = `${prefix}/${user.id}/${crypto.randomUUID()}-${safeName}`; - log("Uploading file to storage ⬆️", { name: f.name, type: f.type, size: f.size, path }); + log("Uploading file to storage ⬆️", { + name: f.name, + type: f.type, + size: f.size, + path, + }); const tUp = performance.now(); const bytes = new Uint8Array(await f.arrayBuffer()); // warning útil: btoa + spread puede tronarse con archivos grandes if (bytes.length > 2 * MB) { - warn("Large file detected: base64 building might be heavy (btoa + spread) 🧨", { - name: f.name, - bytes: bytes.length, - approxBase64Chars: Math.ceil(bytes.length * 4 / 3), - }); + warn( + "Large file detected: base64 building might be heavy (btoa + spread) 🧨", + { + name: f.name, + bytes: bytes.length, + approxBase64Chars: Math.ceil(bytes.length * 4 / 3), + }, + ); } const { data, error } = await supabaseAdmin.storage .from(bucket) .upload(path, bytes, { contentType: f.type, upsert: false }); - log("Storage upload done ⏱️", { ms: Math.round(performance.now() - tUp) }); + log("Storage upload done ⏱️", { + ms: Math.round(performance.now() - tUp), + }); if (error) { - errlog("Storage upload failed", { message: error.message, file: f.name, bucket, path }); + errlog("Storage upload failed", { + message: error.message, + file: f.name, + bucket, + path, + }); return json({ ok: false, error: `Storage upload failed: ${error.message}`, @@ -616,7 +696,10 @@ Deno.serve(async (req) => { // Add as an input_file base64 reference to the last user message content const tB64 = performance.now(); const fileBase64 = btoa(String.fromCharCode(...bytes)); // ⚠️ potencialmente pesado - log("Base64 built ⏱️", { ms: Math.round(performance.now() - tB64), base64Chars: fileBase64.length }); + log("Base64 built ⏱️", { + ms: Math.round(performance.now() - tB64), + base64Chars: fileBase64.length, + }); if (Array.isArray(responseCfg.input) && responseCfg.input.length > 0) { const lastMsg = responseCfg.input[responseCfg.input.length - 1]; @@ -633,13 +716,19 @@ Deno.serve(async (req) => { // no imprimimos file_data }); } else { - warn("Could not append input_file: last message is not user/content array", { - lastRole: lastMsg?.role ?? null, - lastContentType: typeof lastMsg?.content, - }); + warn( + "Could not append input_file: last message is not user/content array", + { + lastRole: lastMsg?.role ?? null, + lastContentType: typeof lastMsg?.content, + }, + ); } } else { - warn("responseCfg.input not array or empty; cannot append file_data", summarizeInputShape(responseCfg.input)); + warn( + "responseCfg.input not array or empty; cannot append file_data", + summarizeInputShape(responseCfg.input), + ); } uploadedToStorage.push({ @@ -657,7 +746,9 @@ Deno.serve(async (req) => { // 7) Llamada a OpenAI Responses API 🚀 log("Step 7: OpenAI call 🚀"); const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY"); - log("Env presence (OpenAI) 🔑", { hasOPENAI_API_KEY: Boolean(OPENAI_API_KEY) }); + log("Env presence (OpenAI) 🔑", { + hasOPENAI_API_KEY: Boolean(OPENAI_API_KEY), + }); if (!OPENAI_API_KEY) { errlog("Missing OPENAI_API_KEY"); @@ -710,7 +801,9 @@ Deno.serve(async (req) => { const tParseOut = performance.now(); try { output = JSON.parse(outputText); - log("Output JSON parsed ✅", { ms: Math.round(performance.now() - tParseOut) }); + log("Output JSON parsed ✅", { + ms: Math.round(performance.now() - tParseOut), + }); } catch (e) { outputParseError = (e as Error)?.message ?? String(e); warn("Output JSON parse FAILED 🧯", { @@ -731,7 +824,7 @@ Deno.serve(async (req) => { totalMs: Math.round(performance.now() - t0), }); - return json({ + const response = json({ ok: outputParseError ? false : true, cid, // 👈 útil para correlacionar logs responseId: resp.id, @@ -750,15 +843,30 @@ Deno.serve(async (req) => { archivosReferenciaIds: references.archivosReferenciaIds ?? payload.archivosReferenciaIds ?? payload.archivosReferencia ?? [], }, - tools: sanitizeTools(responseCfg.tools), }); + + // Log final response + log("Response payload preview:", { + references: { + uploadedToStorage, + openaiFileIds, + vectorStoreIds, + archivosReferenciaIds: references.archivosReferenciaIds ?? + payload.archivosReferenciaIds ?? payload.archivosReferencia ?? [], + }, + }); + + return response; } 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); + return json( + { ok: false, error: (e as Error)?.message ?? String(e), cid }, + 500, + ); } }); diff --git a/supabase/functions/ai_generate_plan/index.ts b/supabase/functions/ai_generate_plan/index.ts index b9e9608..9851a66 100644 --- a/supabase/functions/ai_generate_plan/index.ts +++ b/supabase/functions/ai_generate_plan/index.ts @@ -324,7 +324,7 @@ Deno.serve(async (req) => { log("Using estructuraId from payload", { estructuraIdFromPayload }); const { data, error } = await supabaseAdmin .from("estructuras_plan") - .select("id,nombre,tipo,template_id,definicion") + .select("id,nombre,tipo,template,definicion") .eq("id", estructuraIdFromPayload) .single(); if (error) { @@ -336,7 +336,7 @@ Deno.serve(async (req) => { // 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") + .select("id,nombre,tipo,template,definicion") .eq("tipo", "CURRICULAR") .order("actualizado_en", { ascending: false }) .limit(1) @@ -350,7 +350,7 @@ Deno.serve(async (req) => { if (!estructura) { const { data: anyE, error: anyErr } = await supabaseAdmin .from("estructuras_plan") - .select("id,nombre,tipo,template_id,definicion") + .select("id,nombre,tipo,template,definicion") .order("actualizado_en", { ascending: false }) .limit(1) .maybeSingle(); @@ -367,7 +367,7 @@ Deno.serve(async (req) => { estructuraId: estructura?.id ?? null, nombre: estructura?.nombre ?? null, tipo: estructura?.tipo ?? null, - template_id: estructura?.template_id ?? null, + template: estructura?.template ?? null, definicionKeys: estructura?.definicion ? Object.keys(estructura.definicion) : null, }); @@ -378,15 +378,15 @@ Deno.serve(async (req) => { // 6) Resolver referencias (archivos / repositorios) 📎 log("Step 6: Resolve references 📎"); - const archivosReferenciaIds: string[] = Array.isArray(iaConfig?.archivosReferencia) - ? iaConfig.archivosReferencia + const uploadedToStorage: string[] = Array.isArray(iaConfig?.uploadedToStorage) + ? iaConfig.uploadedToStorage : []; const repositoriosIds: string[] = Array.isArray(iaConfig?.repositoriosIds) ? iaConfig.repositoriosIds : []; log("Reference IDs received", { - archivosReferenciaIdsCount: archivosReferenciaIds.length, + uploadedToStorageCount: uploadedToStorage.length, repositoriosIdsCount: repositoriosIds.length, usarMCP: Boolean(iaConfig?.usarMCP), }); @@ -394,11 +394,11 @@ Deno.serve(async (req) => { const archivosInfo: any[] = []; const vectorInfo: any[] = []; - if (archivosReferenciaIds.length) { + if (uploadedToStorage.length) { const { data, error } = await supabaseAdmin .from("archivos") .select("id,nombre,mime_type,openai_file_id,ruta_storage") - .in("id", archivosReferenciaIds); + .in("id", uploadedToStorage); if (error) warn("archivos lookup failed (continuing)", { message: error.message }); for (const a of data ?? []) archivosInfo.push(a); } @@ -481,7 +481,7 @@ Deno.serve(async (req) => { references: { openaiFileIds, vectorStoreIds, - archivosReferenciaIds, + uploadedToStorage, }, usarMCP: Boolean(iaConfig?.usarMCP), }; @@ -544,14 +544,14 @@ Deno.serve(async (req) => { prompt: { datosBasicos, iaConfig, - estructura: { id: estructura.id, nombre: estructura.nombre, template_id: estructura.template_id, tipo: estructura.tipo }, + 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: archivosReferenciaIds, + ids_archivos: uploadedToStorage, ids_vector_store: repositoriosIds, rutas_storage: rutasStorage, }) @@ -596,7 +596,7 @@ Deno.serve(async (req) => { estructura: { id: estructura.id, nombre: estructura.nombre, - template_id: estructura.template_id, + template: estructura.template, tipo: estructura.tipo, }, request: aiStructuredBody, @@ -605,7 +605,7 @@ Deno.serve(async (req) => { respuesta: aiJson, aceptada: true, // se está usando para crear el plan conversacion_id: aiJson?.conversationId ?? null, - ids_archivos: archivosReferenciaIds, + ids_archivos: uploadedToStorage, ids_vector_store: repositoriosIds, rutas_storage: rutasStorage, }) @@ -672,7 +672,7 @@ Deno.serve(async (req) => { usage: aiJson?.usage ?? null, }, referencias: { - archivosReferenciaIds, + uploadedToStorage, repositoriosIds, openaiFileIds, vectorStoreIds, @@ -761,7 +761,7 @@ Deno.serve(async (req) => { usage: aiJson?.usage ?? null, }, references: { - archivosReferenciaIds, + uploadedToStorage, repositoriosIds, openaiFileIds, vectorStoreIds, diff --git a/supabase/functions/tests/ai-generate-plan-test.ts b/supabase/functions/tests/ai-generate-plan-test.ts index 0f1c333..8626403 100644 --- a/supabase/functions/tests/ai-generate-plan-test.ts +++ b/supabase/functions/tests/ai-generate-plan-test.ts @@ -53,7 +53,9 @@ Deno.test("ai_generate_plan creates plan and returns structured data", async () .select("id") .limit(1) .single(); - if (carreraError) throw new Error("Failed to fetch carrera: " + carreraError.message); + if (carreraError) { + throw new Error("Failed to fetch carrera: " + carreraError.message); + } if (!carrera) throw new Error("No carrera found"); const datosBasicos = { @@ -94,38 +96,49 @@ Deno.test("ai_generate_plan creates plan and returns structured data", async () ); assertEquals(data.plan_datos.duracion_del_ciclo_escolar, "16"); // Semestre -> 16 }); -/* -Deno.test("ai-structured (multipart + file)", async () => { + +Deno.test("ai_generate_plan (multipart with files)", 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 { 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 generado con referencias", + carreraId: carrera.id, + nivel: "Maestría", + tipoCiclo: "Semestre", + numCiclos: 4, + }; + + const iaConfig = { + descripcionEnfoque: "Programa de postgrado enfocado en tecnología", + notasAdicionales: "Considerar tendencias actuales en IA", + }; + + // Create a simple test file + const fileContent = "Sample curriculum reference document"; + const file = new File([fileContent], "reference.txt", { type: "text/plain" }); 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" }, + datosBasicos, + iaConfig, }; const fd = new FormData(); fd.append("payload", JSON.stringify(payload)); fd.append("files", file); - const { data, error } = await client.functions.invoke("ai-structured", { + const { data, error } = await client.functions.invoke("ai_generate_plan", { headers: { - Authorization: `Bearer ${accessToken}`, // 👈 necesario - // NO pongas Content-Type: fetch lo setea con boundary para FormData ✅ + Authorization: `Bearer ${accessToken}`, }, method: "POST", body: fd, @@ -135,10 +148,11 @@ Deno.test("ai-structured (multipart + file)", async () => { assert(data, "Expected data from function"); assertEquals(data.ok, true); - assert(typeof data.output?.resumen === "string"); - assert(data.output.resumen.length > 0); + assert(data.plan); + assertEquals(data.plan.nombre, datosBasicos.nombrePlan); + assertEquals(data.plan.nivel, "Maestría"); + assertEquals(data.plan.numero_ciclos, datosBasicos.numCiclos); - // opcional: valida que registró upload(s) a storage - assert(Array.isArray(data.references?.uploadedToStorage)); + assert(data.references); + assertEquals(data.references.tempFilesCount, 1); }); - */