From 16092e94a36d853bc3c86453c5ce0abfd9542400 Mon Sep 17 00:00:00 2001 From: "Roberto.silva" Date: Mon, 9 Mar 2026 16:27:27 -0600 Subject: [PATCH] Chats de ia en segundo plano para asignaturas fix #51 --- .../asignatura/crear.ts | 100 +++++++++++ .../create-chat-conversation/index.ts | 164 +++++++----------- .../create-chat-conversation/lib/plan.ts | 52 +++--- .../openai-webhook-responses/index.ts | 6 + 4 files changed, 205 insertions(+), 117 deletions(-) create mode 100644 supabase/functions/create-chat-conversation/asignatura/crear.ts diff --git a/supabase/functions/create-chat-conversation/asignatura/crear.ts b/supabase/functions/create-chat-conversation/asignatura/crear.ts new file mode 100644 index 0000000..6ad1ef2 --- /dev/null +++ b/supabase/functions/create-chat-conversation/asignatura/crear.ts @@ -0,0 +1,100 @@ +// ./plan_mensajes_ia/index.ts +import type { OpenAI } from "openai"; + +import type { Json } from "../../_shared/database.types.ts"; +import { ResponseMetadata } from "../../_shared/utils.ts"; +import { supabase } from "../../openai-webhook-responses/supabase.ts"; + +function extractOutputText(response: OpenAI.Responses.Response): string { + const direct = (response as any).output_text; + if (typeof direct === "string") return direct; + + const output = (response as any).output; + if (!Array.isArray(output)) return ""; + + try { + return output + .filter((item) => item?.type === "message") + .flatMap((item) => item?.content ?? []) + .filter((c) => c?.type === "output_text") + .map((c) => String(c?.text ?? "")) + .join(""); + } catch { + return ""; + } +} + +export async function handleAsignaturaMensajesResponse( + response: OpenAI.Responses.Response, +): Promise { + const metadata = response.metadata as any; + const mensajeId = metadata?.mensaje_id; + + console.log("Procesando Webhook para Asignatura. Mensaje ID:", mensajeId); + + const isStructured = metadata?.is_structured === "true" || metadata?.is_structured === true; + + if (!mensajeId) { + console.warn("No se recibió mensaje_id en la metadata del webhook de asignatura"); + return; + } + + try { + const outputText = extractOutputText(response); + if (!outputText) { + throw new Error("La respuesta de OpenAI está vacía"); + } + + let respuestaJSON: any; + try { + respuestaJSON = JSON.parse(outputText); + } catch (e) { + throw new Error(`Error parseando JSON de OpenAI: ${e.message}`); + } + + // Normalización de campos de la IA + const aiMessage = respuestaJSON["ai-message"] || respuestaJSON["ai_message"] || ""; + const is_refusal = !!respuestaJSON.is_refusal || respuestaJSON["is-refusal"] === true; + + let recommendations: any[] = []; + + // Si es estructurado y no es un rechazo de la IA, generamos las recomendaciones + if (isStructured && !is_refusal) { + recommendations = Object.entries(respuestaJSON) + .filter(([k]) => !["ai-message", "ai_message", "is-refusal", "is_refusal"].includes(k)) + .map(([campo, valor]) => ({ + campo_afectado: campo, + texto_mejora: valor, + aplicada: false, + })); + } + + // --- CAMBIO CLAVE: TABLA 'asignatura_mensajes_ia' --- + const { error } = await supabase + .from("asignatura_mensajes_ia") + .update({ + respuesta: aiMessage, + // Guardamos la propuesta completa para mantener historial + propuesta: { + respuesta: aiMessage, + recommendations + }, + is_refusal, + estado: "COMPLETADO", + }) + .eq("id", mensajeId); + + if (error) throw error; + + console.log(`Mensaje de asignatura ${mensajeId} actualizado con éxito.`); + + } catch (e) { + console.error("Error en handleAsignaturaMensajesResponse:", { mensajeId, error: e.message }); + + // Marcamos como error en la tabla correcta para que el front deje de mostrar el spinner + await supabase + .from("asignatura_mensajes_ia") + .update({ estado: "ERROR" }) + .eq("id", mensajeId); + } +} \ No newline at end of file diff --git a/supabase/functions/create-chat-conversation/index.ts b/supabase/functions/create-chat-conversation/index.ts index b9a2c21..c0fc6fd 100644 --- a/supabase/functions/create-chat-conversation/index.ts +++ b/supabase/functions/create-chat-conversation/index.ts @@ -290,123 +290,93 @@ app.post(`${prefix}/conversations/asignatura/:id/messages`, async (c) => { assertUuid(conversation_asig_id, "conversation_asig_id"); const body = (await c.req.json().catch(() => ({}))) as Partial; - if (!body.content) throw new HttpError(400, "bad_input", "content es requerido"); + if (!body.content || typeof body.content !== "string") { + throw new HttpError(400, "bad_input", "content es requerido"); + } const supabase = getSupabaseServiceClient(); - const openai = getOpenAI(); + // Usamos el servicio que ya tienes configurado para background + const svc = OpenAIService.fromEnv(); - // 1. Traer datos + // 1. Traer datos de la asignatura const { data: row, error } = await supabase .from("conversaciones_asignatura") - .select(`*, asignaturas(*, estructuras_asignatura(definicion))`) + .select(`id, openai_conversation_id, asignatura_id, asignaturas(*, estructuras_asignatura(definicion))`) .eq("id", conversation_asig_id) .single(); if (error || !row) throw new HttpError(404, "not_found", "Conversación no encontrada"); - const asignatura = row.asignaturas; + const asignatura = (row as any).asignaturas; const definicion = asignatura?.estructuras_asignatura?.definicion; - const wantsStructured = !!definicion && (body.campos?.length ?? 0) > 0; + const isStructured = !!definicion && (body.campos?.length ?? 0) > 0; - let aiMessage = ""; - let isRefusal = false; - let recommendations: any[] = []; - let openAiRespId = ""; + // 2. Insertar el mensaje en estado PROCESANDO (para que el front vea el spinner) + const { data: mensajeInsertado, error: insertErr } = await supabase + .from("asignatura_mensajes_ia") + .insert({ + conversacion_asignatura_id: conversation_asig_id, + enviado_por: "00000000-0000-0000-0000-000000000000", + mensaje: body.content, + campos: body.campos ?? [], + estado: "PROCESANDO", + }) + .select() + .single(); - // --- FLUJO A: NO ESTRUCTURADO --- - if (!wantsStructured) { - const resp = await openai.responses.create({ - conversation: row.openai_conversation_id, - model: CREATE_CHAT_CONVERSATION_NONSTRUCTURED_MODELO, - input: [ - { role: "system", content: "Eres un experto en diseño curricular." }, - { role: "user", content: body.content }, - ], - text: { - format: { - type: "json_schema", - name: "respuesta_basica", - strict: true, - schema: { - type: "object", - properties: { - "ai-message": { type: "string" }, - "is_refusal": { type: "boolean" }, - }, - required: ["ai-message", "is_refusal"], - additionalProperties: false, - }, + if (insertErr) throw new HttpError(500, "db_error", "No se pudo crear el registro"); + + // 3. Preparar Schema (Usando tu lógica de asignatura) + const schema = isStructured + ? pickSchemaAsignaturaFields(definicion, body.campos ?? []) + : { + type: "object", + properties: { + "ai-message": { type: "string" }, + "is_refusal": { type: "boolean" } }, + required: ["ai-message", "is_refusal"], + additionalProperties: false + }; + + // 4. Llamada asincrónica con background: true + const aiResult = await svc.createStructuredResponse({ + conversation: row.openai_conversation_id, + model: isStructured ? CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO : CREATE_CHAT_CONVERSATION_NONSTRUCTURED_MODELO, + background: true, // <--- Ahora sí, activamos el modo background + metadata: { + tabla: "asignatura_mensajes_ia", // El webhook usará esto para saber dónde hacer el UPDATE + mensaje_id: String(mensajeInsertado.id), + is_structured: String(isStructured), + conversation_id: conversation_asig_id // Extra para el webhook si lo necesita + }, + text: { + format: { + type: "json_schema", + name: isStructured ? "mejora_asignatura" : "respuesta_basica", + schema: schema + } + }, + input: [ + { + role: "system", + content: isStructured + ? `Asistente de asignatura. Datos: ${JSON.stringify(asignatura)}` + : "Eres un experto en diseño curricular." }, - }); + { role: "user", content: body.content }, + ], + }); - const resJson = JSON.parse(resp.output_text ?? "{}"); - aiMessage = resJson["ai-message"] ?? ""; - isRefusal = !!resJson["is_refusal"]; - openAiRespId = resp.id; - - } else { - // --- FLUJO B: ESTRUCTURADO --- - const schemaBase = pickSchemaAsignaturaFields(definicion, body.campos ?? []); - const finalSchema = { - type: "object", - properties: { - "ai-message": { type: "string" }, - "is_refusal": { type: "boolean" }, - ...schemaBase.properties - }, - required: ["ai-message", "is_refusal", ...Object.keys(schemaBase.properties || {})], - additionalProperties: false - }; - - const resp = await openai.responses.create({ - conversation: row.openai_conversation_id, - model: CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO, - text: { - format: { type: "json_schema", name: "mejora_asignatura", strict: true, schema: finalSchema } - }, - input: [ - { role: "system", content: `Asistente curricular. Datos: ${JSON.stringify(asignatura)}` }, - { role: "user", content: body.content }, - ], - }); - - const resJson = JSON.parse(resp.output_text ?? "{}"); - aiMessage = resJson["ai-message"] ?? ""; - isRefusal = !!resJson["is_refusal"]; - openAiRespId = resp.id; - - recommendations = Object.entries(resJson) - .filter(([k]) => k !== "ai-message" && k !== "is_refusal") - .map(([campo, texto]) => ({ - campo_afectado: campo, - texto_mejora: texto, - aplicada: false, - })); + if (!aiResult.ok) { + throw new HttpError(500, "openai_error", "No se pudo encolar la respuesta"); } - // 2. Guardar Histórico y Mensaje (Común a ambos flujos) - await supabase.rpc("append_conversacion_asignatura", { - p_id: conversation_asig_id, - p_append: { timestamp: new Date().toISOString(), user: "user", prompt: body.content }, - }); - - await supabase.from("asignatura_mensajes_ia").insert({ - conversacion_asignatura_id: conversation_asig_id, - mensaje: body.content, - respuesta: aiMessage, - campos: body.campos ?? [], - propuesta: { prompt: body.content, respuesta: aiMessage, recommendations }, - is_refusal: isRefusal, - estado: "COMPLETADO", - enviado_por: "00000000-0000-0000-0000-000000000000", - }); - + // 5. Responder al cliente de inmediato return withCors(jsonResponse({ ok: true, - openai_response_id: openAiRespId, - ai_message: aiMessage, - recommendations + mensaje_id: mensajeInsertado.id, + openai_response_id: aiResult.responseId })); } catch (err) { diff --git a/supabase/functions/create-chat-conversation/lib/plan.ts b/supabase/functions/create-chat-conversation/lib/plan.ts index 487dbf8..5116903 100644 --- a/supabase/functions/create-chat-conversation/lib/plan.ts +++ b/supabase/functions/create-chat-conversation/lib/plan.ts @@ -45,36 +45,48 @@ export function pickSchemaFields( return out; } - export function pickSchemaAsignaturaFields( definicion: any, campos: string[], ) { - // Si no hay definición válida, devolvemos un objeto vacío seguro + // 1. Validación inicial if (!definicion || definicion.type !== "object" || !definicion.properties) { - return { type: "object", properties: {}, required: [], additionalProperties: false }; + return { + type: "object", + properties: { + "ai-message": { type: "string" }, + "is_refusal": { type: "boolean" } + }, + required: ["ai-message", "is_refusal"], + additionalProperties: false + }; } - // Clonamos para no mutar el original - const out = structuredClone(definicion); - - // 1. Filtrar las propiedades: solo las que el usuario pidió en 'campos' - const entries = Object.entries(out.properties).filter(([k]) => - campos.includes(k) + // 2. Filtrar solo las propiedades técnicas que el usuario pidió + const filteredProperties = Object.fromEntries( + Object.entries(definicion.properties).filter(([k]) => campos.includes(k)) ); - out.properties = Object.fromEntries(entries); - // 2. Filtrar los requeridos: solo si están en el nuevo set de propiedades - if (Array.isArray(out.required)) { - out.required = out.required.filter((k: string) => campos.includes(k)); - } else { - out.required = []; - } + // 3. RECONSTRUIR el esquema incluyendo SIEMPRE los campos de control + const finalSchema = { + type: "object", + properties: { + "ai-message": { + type: "string", + description: "Tu respuesta conversacional dirigida al profesor." + }, + "is_refusal": { + type: "boolean", + description: "Indica si la solicitud es inapropiada o no relacionada." + }, + ...filteredProperties // Aquí entran objetivo, contenido, etc. + }, + // Forzamos que ai-message e is_refusal sean obligatorios siempre + required: ["ai-message", "is_refusal", ...Object.keys(filteredProperties)], + additionalProperties: false + }; - // 3. Limpieza de OpenAI: Forzar additionalProperties a false - out.additionalProperties = false; - - return out; + return finalSchema; } export function safePlanForPrompt(plan: any) { diff --git a/supabase/functions/openai-webhook-responses/index.ts b/supabase/functions/openai-webhook-responses/index.ts index 9a43c11..a7c0376 100644 --- a/supabase/functions/openai-webhook-responses/index.ts +++ b/supabase/functions/openai-webhook-responses/index.ts @@ -9,6 +9,7 @@ import { ResponseMetadata } from "../_shared/utils.ts"; import { handlePlanesEstudioResponse } from "./planes_estudio/index.ts"; import { handleAsignaturasResponse } from "./asignaturas/index.ts"; import { handlePlanMensajesResponse } from "../create-chat-conversation/plan/crear.ts"; +import { handleAsignaturaMensajesResponse } from "../create-chat-conversation/asignatura/crear.ts"; @@ -40,6 +41,11 @@ async function handleCompletedResponse( case "plan_mensajes_ia": await handlePlanMensajesResponse(response); break; + case "asignatura_mensajes_ia": + console.log("modificando asignatura"); + + await handleAsignaturaMensajesResponse(response); + break; default: console.warn("Tabla no reconocida:", metadata.tabla); } -- 2.52.0