diff --git a/supabase/functions/_shared/openai-service.ts b/supabase/functions/_shared/openai-service.ts index a0a0258..733bafd 100644 --- a/supabase/functions/_shared/openai-service.ts +++ b/supabase/functions/_shared/openai-service.ts @@ -112,6 +112,11 @@ export class OpenAIService { const openaiRaw = (await this.openai.responses.create( newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming, )) as OpenAITypes.OpenAI.Responses.Response; + + const isBackground = + (newOptions as unknown as { background?: boolean }) + .background === + true; const { model, id: responseId } = openaiRaw; const usage = openaiRaw?.usage ?? null; const conversationId = ( @@ -119,6 +124,22 @@ export class OpenAIService { conversation_id?: string | null; } ).conversation_id ?? null; + + if (isBackground) { + return { + ok: true, + output: undefined, + outputText: undefined, + model: String(model), + usage, + responseId: String(responseId), + conversationId: conversationId + ? String(conversationId) + : null, + references: { uploadedToStorage, openaiFileIds }, + openaiRaw, + }; + } // Try to read structured JSON output let output: TOutput | undefined = undefined; let outputText: string | undefined = undefined; diff --git a/supabase/functions/_shared/utils.ts b/supabase/functions/_shared/utils.ts index eb598fb..d073101 100644 --- a/supabase/functions/_shared/utils.ts +++ b/supabase/functions/_shared/utils.ts @@ -51,4 +51,5 @@ export function sendError( export interface ResponseMetadata extends Record { tabla?: string; accion?: string; + id?: string; } diff --git a/supabase/functions/ai-generate-plan/index.ts b/supabase/functions/ai-generate-plan/index.ts index f98a22e..26d364d 100644 --- a/supabase/functions/ai-generate-plan/index.ts +++ b/supabase/functions/ai-generate-plan/index.ts @@ -19,11 +19,11 @@ type NivelType = type TipoCicloType = Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"]; -addEventListener("beforeunload", (ev: any) => { - // ev.detail.reason te dirá si es "timeout", "memory_limit" o "idle" - console.error("ALERTA: La función se va a apagar. Razón:", ev.detail?.reason); +type BeforeUnloadWithDetail = Event & { detail?: { reason?: unknown } }; - // Aquí puedes intentar un último log antes de que todo muera +// Re-registramos con tipo estricto (evita `any` en análisis) +addEventListener("beforeunload", (ev: BeforeUnloadWithDetail) => { + console.error("ALERTA: La función se va a apagar. Razón:", ev.detail?.reason); }); Deno.serve(async (req: Request): Promise => { @@ -195,8 +195,98 @@ Deno.serve(async (req: Request): Promise => { ? estructuraPlan.definicion as Record : {}; + const { data: estado } = await supabaseService + .from("estados_plan") + .select("id,clave,orden") + .eq("clave", "GENERANDO") + .maybeSingle(); + + if (!estado?.id) { + throw new HttpError( + 500, + "No se encontró el estado GENERANDO.", + "MISSING_STATE", + { clave: "GENERANDO" }, + ); + } + + const { data: carrera, error: carreraError } = await supabaseService + .from("carreras") + .select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)") + .eq("id", payload.datosBasicos.carreraId) + .maybeSingle(); + if (carreraError) { + throw new HttpError( + 500, + "No se pudo obtener la carrera.", + "SUPABASE_QUERY_FAILED", + carreraError, + ); + } + if (!carrera) { + throw new HttpError( + 404, + "No se encontró la carrera.", + "NOT_FOUND", + { table: "carreras", id: payload.datosBasicos.carreraId }, + ); + } + + const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] = + { + carrera_id: carrera.id as string, + estructura_id: estructuraPlan?.id as string, + nombre: payload.datosBasicos.nombrePlan, + nivel: payload.datosBasicos.nivel as NivelType, + tipo_ciclo: payload.datosBasicos.tipoCiclo as TipoCicloType, + numero_ciclos: payload.datosBasicos.numCiclos, + // IMPORTANTE: se inserta SIN `datos` (se actualiza vía webhook) + estado_actual_id: estado.id, + activo: true, + tipo_origen: "IA", + meta_origen: { + generado_por: "ai-generate-plan", + referencias: { + archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null, + repositoriosIds: payload.iaConfig?.repositoriosIds ?? null, + }, + iaConfig: { + descripcionEnfoqueAcademico: + payload.iaConfig?.descripcionEnfoqueAcademico ?? null, + instruccionesAdicionalesIA: + payload.iaConfig?.instruccionesAdicionalesIA ?? null, + usarMCP: Boolean(payload.iaConfig?.usarMCP), + }, + } as unknown as Json, + }; + + const { data: plan, error: planError } = await supabaseService + .from("planes_estudio") + .insert(planInsert) + .select( + "id,nombre,nivel,tipo_ciclo,numero_ciclos,carrera_id,estructura_id,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,datos", + ) + .single(); + + if (planError) { + const maybeCode = (planError as { code?: string }).code; + const status = maybeCode ? 409 : 500; + throw new HttpError( + status, + "No se pudo guardar el plan de estudios.", + "SUPABASE_INSERT_FAILED", + { ...planError, code: maybeCode }, + ); + } + const aiStructuredPayload: StructuredResponseOptions = { model: AI_GENERATE_PLAN_MODELO, + background: true, + metadata: { + tabla: "planes_estudio", + accion: "crear", + id: String(plan.id), + }, input: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, @@ -244,126 +334,12 @@ Deno.serve(async (req: Request): Promise => { const status = aiResult.code === "MissingEnv" ? 500 : 502; throw new HttpError( status, - "No se pudo generar el plan con IA.", + "No se pudo iniciar la generación del plan con IA.", "OPENAI_REQUEST_FAILED", aiResult, ); } - // Prefer parsed output; fallback to parse outputText - let aiOutput = aiResult.output ?? null; - if (aiOutput == null && aiResult.outputText) { - try { - aiOutput = JSON.parse(aiResult.outputText); - } catch { - throw new HttpError( - 502, - "La respuesta de la IA no es JSON válido.", - "OPENAI_INVALID_JSON", - { outputText: aiResult.outputText }, - ); - } - } - const aiOutputJson: Json = aiOutput as unknown as Json; - if (!aiOutput) { - throw new HttpError( - 502, - "La respuesta de la IA no contiene salida estructurada.", - "OPENAI_MISSING_STRUCTURED_OUTPUT", - { outputText: aiResult.outputText ?? null }, - ); - } - - // TODO: Insertar interacciones con IA y quizas forzar a que la informacion de datosBasicos sea la misma que la recibida - - const { data: estado } = await supabaseService - .from("estados_plan") - .select("id,clave,orden") - .ilike("clave", "BORRADOR%") - .order("orden", { ascending: true }) - .limit(1) - .maybeSingle(); - - const { data: carrera, error: carreraError } = await supabaseService - .from("carreras") - .select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)") - .eq("id", payload.datosBasicos.carreraId) - .maybeSingle(); - if (carreraError) { - throw new HttpError( - 500, - "No se pudo obtener la carrera.", - "SUPABASE_QUERY_FAILED", - carreraError, - ); - } - if (!carrera) { - throw new HttpError( - 404, - "No se encontró la carrera.", - "NOT_FOUND", - { table: "carreras", id: payload.datosBasicos.carreraId }, - ); - } - - const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] = - { - carrera_id: carrera?.id as string, - estructura_id: estructuraPlan?.id as string, - nombre: payload.datosBasicos.nombrePlan, - nivel: payload.datosBasicos.nivel as NivelType, - tipo_ciclo: payload.datosBasicos.tipoCiclo as TipoCicloType, - numero_ciclos: payload.datosBasicos.numCiclos, - datos: aiOutputJson, - estado_actual_id: estado?.id ?? undefined, - activo: true, - tipo_origen: "IA", - meta_origen: { - generado_por: "ai_generate_plan", - ai_structured: { - responseId: aiResult.responseId ?? null, - conversationId: aiResult.conversationId ?? null, - model: aiResult.model, - usage: aiResult.usage ?? null, - }, - referencias: { - archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null, - repositoriosIds: payload.iaConfig?.repositoriosIds ?? null, - }, - iaConfig: { - descripcionEnfoqueAcademico: - payload.iaConfig?.descripcionEnfoqueAcademico ?? null, - instruccionesAdicionalesIA: - payload.iaConfig?.instruccionesAdicionalesIA ?? null, - usarMCP: Boolean(payload.iaConfig?.usarMCP), - }, - } as unknown as Json, - }; - - const { data: plan, error: planError } = await supabaseService - .from("planes_estudio") - .insert(planInsert) - .select( - "id,nombre,nivel,tipo_ciclo,numero_ciclos,carrera_id,estructura_id,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,datos", - ) - .single(); - - // TODO: interaccion con IA y cambio de estado - - if (planError) { - const maybeCode = (planError as { code?: string }).code; - // Common cases: - // - foreign key / constraint violations -> 409 - // - others -> 500 - const status = maybeCode ? 409 : 500; - throw new HttpError( - status, - "No se pudo guardar el plan de estudios.", - "SUPABASE_INSERT_FAILED", - { ...planError, code: maybeCode }, - ); - } - // TODO: update a interaccion_ia y e insert a cambios_plan con id de plan generado console.log( diff --git a/supabase/functions/openai-webhook-responses/index.ts b/supabase/functions/openai-webhook-responses/index.ts index ad93451..d004d0f 100644 --- a/supabase/functions/openai-webhook-responses/index.ts +++ b/supabase/functions/openai-webhook-responses/index.ts @@ -27,20 +27,32 @@ async function handleCompletedResponse( switch (metadata.tabla) { case "planes_estudio": - handlePlanesEstudioResponse(response); + await handlePlanesEstudioResponse(response); break; default: console.warn("Tabla no reconocida:", metadata.tabla); } - const output_text = response.output - .filter((item: any) => item.type === "message") - .flatMap((item: any) => item.content) - .filter((contentItem: any) => contentItem.type === "output_text") - .map((contentItem: any) => contentItem.text) + const direct = (response as unknown as { output_text?: unknown }).output_text; + if (typeof direct === "string" && direct.length) { + console.log("Response output:", direct); + return; + } + + const output = (response as unknown as { output?: unknown }).output; + if (!Array.isArray(output)) { + console.log("Response output: (no output)"); + return; + } + + const outputText = output + .filter((item) => (item as { type?: unknown })?.type === "message") + .flatMap((item) => (item as { content?: unknown })?.content ?? []) + .filter((c) => (c as { type?: unknown })?.type === "output_text") + .map((c) => String((c as { text?: unknown })?.text ?? "")) .join(""); - console.log("Response output:", output_text); + console.log("Response output:", outputText); } Deno.serve(async (req) => { diff --git a/supabase/functions/openai-webhook-responses/planes_estudio/crear.ts b/supabase/functions/openai-webhook-responses/planes_estudio/crear.ts index 67f613c..6f133bf 100644 --- a/supabase/functions/openai-webhook-responses/planes_estudio/crear.ts +++ b/supabase/functions/openai-webhook-responses/planes_estudio/crear.ts @@ -1,6 +1,108 @@ import type { OpenAI } from "openai"; import { supabase } from "../supabase.ts"; -import type { TablesUpdate } from "../../_shared/database.types.ts"; +import type { Json } from "../../_shared/database.types.ts"; +import { ResponseMetadata } from "../../_shared/utils.ts"; -export function handleCrearPlanEstudio(response: OpenAI.Responses.Response) { +async function getEstadoId(clave: string): Promise { + const { data, error } = await supabase + .from("estados_plan") + .select("id") + .eq("clave", clave) + .maybeSingle(); + if (error) { + console.warn("No se pudo obtener estado:", clave, error); + return null; + } + return data?.id ?? null; +} + +async function marcarFallido(planId: string): Promise { + const fallId = await getEstadoId("FALLDA"); + if (!fallId) { + console.warn("No existe estado FALLDA; no se pudo marcar fallo", { + planId, + }); + return; + } + const { error } = await supabase + .from("planes_estudio") + .update({ estado_actual_id: fallId }) + .eq("id", planId); + if (error) { + console.warn("No se pudo marcar plan como FALLDA", { planId, error }); + } +} + +function extractOutputText(response: OpenAI.Responses.Response): string { + const direct = (response as unknown as { output_text?: unknown }).output_text; + if (typeof direct === "string") return direct; + + const output = (response as unknown as { output?: unknown }).output; + if (!Array.isArray(output)) return ""; + + // Fallback similar al usado en index.ts + try { + return output + .filter((item) => (item as { type?: unknown })?.type === "message") + .flatMap((item) => (item as { content?: unknown })?.content ?? []) + .filter((c) => (c as { type?: unknown })?.type === "output_text") + .map((c) => String((c as { text?: unknown })?.text ?? "")) + .join(""); + } catch { + return ""; + } +} + +export async function handleCrearPlanEstudio( + response: OpenAI.Responses.Response, +): Promise { + const metadata = response.metadata as ResponseMetadata | null; + const planId = metadata?.id; + if (!planId) { + console.warn("No se recibió metadata.id para actualizar el plan"); + return; + } + + try { + const borradorId = await getEstadoId("BORRADOR"); + if (!borradorId) { + console.warn("No existe estado BORRADOR"); + await marcarFallido(planId); + return; + } + + const outputText = extractOutputText(response); + if (!outputText) { + console.warn("La respuesta no contiene output_text"); + await marcarFallido(planId); + return; + } + + let datos: Json; + try { + datos = JSON.parse(outputText) as Json; + } catch (e) { + console.warn("No se pudo parsear JSON de la respuesta", e); + await marcarFallido(planId); + return; + } + + const { error } = await supabase + .from("planes_estudio") + .update({ datos, estado_actual_id: borradorId }) + .eq("id", planId); + + if (error) { + console.warn("No se pudo actualizar el plan con datos", { + planId, + error, + }); + await marcarFallido(planId); + return; + } + } catch (e) { + console.warn("Fallo inesperado procesando plan", { planId, e }); + await marcarFallido(planId); + return; + } } diff --git a/supabase/functions/openai-webhook-responses/planes_estudio/index.ts b/supabase/functions/openai-webhook-responses/planes_estudio/index.ts index f5e47f6..07234fd 100644 --- a/supabase/functions/openai-webhook-responses/planes_estudio/index.ts +++ b/supabase/functions/openai-webhook-responses/planes_estudio/index.ts @@ -1,9 +1,10 @@ import { ResponseMetadata } from "../../_shared/utils.ts"; import type { OpenAI } from "openai"; +import { handleCrearPlanEstudio } from "./crear.ts"; -export function handlePlanesEstudioResponse( +export async function handlePlanesEstudioResponse( response: OpenAI.Responses.Response, -) { +): Promise { const metadata = response.metadata as ResponseMetadata | null; if (!metadata || !metadata.accion) { console.warn("No se recibió acción en la metadata"); @@ -12,14 +13,14 @@ export function handlePlanesEstudioResponse( switch (metadata.accion) { case "crear": - // Lógica para crear un plan de estudio - break; + await handleCrearPlanEstudio(response); + return; case "actualizar": // Lógica para actualizar un plan de estudio - break; + return; case "eliminar": // Lógica para eliminar un plan de estudio - break; + return; default: console.warn("Acción no reconocida:", metadata.accion); } diff --git a/supabase/functions/openai-webhook-responses/supabase.ts b/supabase/functions/openai-webhook-responses/supabase.ts index 7cb6c6a..91f4861 100644 --- a/supabase/functions/openai-webhook-responses/supabase.ts +++ b/supabase/functions/openai-webhook-responses/supabase.ts @@ -1,15 +1,19 @@ import { createClient } from "@supabase/supabase-js"; +import type { Database } from "../_shared/database.types.ts"; const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY"); +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); -if (!SUPABASE_URL || !SUPABASE_ANON_KEY) { +if (!SUPABASE_URL || (!SUPABASE_SERVICE_ROLE_KEY && !SUPABASE_ANON_KEY)) { console.warn("Missing Supabase environment variables"); } -const supabase = createClient( +const supabaseKey = (SUPABASE_SERVICE_ROLE_KEY ?? SUPABASE_ANON_KEY) as string; + +const supabase = createClient( SUPABASE_URL as string, - SUPABASE_ANON_KEY as string, + supabaseKey, ); export { supabase };