From d844698ffc448f20fbd30fa154a52e84c0643d87 Mon Sep 17 00:00:00 2001 From: "Roberto.silva" Date: Fri, 6 Mar 2026 08:44:41 -0600 Subject: [PATCH 1/3] Chats de la IA en segundo plano se agregan funciones para asignatura fix #42 --- .../create-chat-conversation/index.ts | 379 +++++++----------- .../create-chat-conversation/lib/plan.ts | 31 ++ 2 files changed, 167 insertions(+), 243 deletions(-) diff --git a/supabase/functions/create-chat-conversation/index.ts b/supabase/functions/create-chat-conversation/index.ts index 4c4cb34..522d7a1 100644 --- a/supabase/functions/create-chat-conversation/index.ts +++ b/supabase/functions/create-chat-conversation/index.ts @@ -3,7 +3,7 @@ import { corsHeaders, withCors } from "./lib/cors.ts"; import { HttpError, jsonResponse } from "./lib/errors.ts"; import { getOpenAI } from "./lib/openai.ts"; import { getSupabaseServiceClient, requireUser } from "./lib/supabase.ts"; -import { assertUuid, pickSchemaFields, safePlanForPrompt } from "./lib/plan.ts"; +import { assertUuid, pickSchemaFields, safePlanForPrompt,pickSchemaAsignaturaFields } from "./lib/plan.ts"; type CreateBody = { plan_estudio_id: string; @@ -125,20 +125,17 @@ app.post(`${prefix}/plan/conversations`, async (c) => { app.post(`${prefix}/asignatura/conversations`, async (c) => { try { const body = (await c.req.json().catch(() => ({}))) as Partial; - const asignatura_id = body.asignatura_id; assertUuid(asignatura_id ?? "", "asignatura_id"); const instanciador = body.instanciador ?? "unknown"; - - const system_prompt = - body.system_prompt ?? - "En caso de que te pidan algo que no tiene nada que ver con la asignatura responde con un refusal."; + const system_prompt = body.system_prompt ?? + "Eres un asistente experto en currículo académico. Si te piden algo ajeno a la asignatura, responde con un refusal."; const supabase = getSupabaseServiceClient(); const openai = getOpenAI(); - // 🔥 Cargar asignatura + // 1. Verificar que la asignatura existe const { data: asignatura, error: asigErr } = await supabase .from("asignaturas") .select("*") @@ -146,15 +143,10 @@ app.post(`${prefix}/asignatura/conversations`, async (c) => { .single(); if (asigErr || !asignatura) { - throw new HttpError( - 404, - "asignatura_not_found", - "Asignatura no encontrada", - asigErr, - ); + throw new HttpError(404, "asignatura_not_found", "Asignatura no encontrada"); } - // 🔥 Crear conversación en OpenAI + // 2. Crear conversación en OpenAI const conv = await openai.conversations.create({ metadata: { tabla: "asignaturas", @@ -164,28 +156,22 @@ app.post(`${prefix}/asignatura/conversations`, async (c) => { items: [{ type: "message", role: "system", content: system_prompt }], }); - // 🔥 Insertar en conversaciones_asignatura + // 3. Insertar en conversaciones_asignatura (coincidiendo con tu SQL) const { data: row, error: insErr } = await supabase .from("conversaciones_asignatura") .insert({ openai_conversation_id: conv.id, - asignatura_id: asignatura.id, // ✅ CORRECTO + asignatura_id: asignatura.id, estado: "ACTIVA", + conversacion_json: [], // Inicializamos como array vacío para los mensajes + // creado_por: user.id // Opcional si tienes el ID del usuario }) .select("id, asignatura_id, openai_conversation_id, estado") .single(); if (insErr || !row) { - try { - await openai.conversations.delete(conv.id); - } catch (_) {} - - throw new HttpError( - 500, - "db_insert_failed", - "No se pudo registrar la conversación", - insErr, - ); + try { await openai.conversations.delete(conv.id); } catch (_) {} + throw new HttpError(500, "db_insert_failed", "Error al registrar conversación", insErr); } return withCors(jsonResponse({ conversation_asignatura: row }, 201)); @@ -338,261 +324,168 @@ app.post(`${prefix}/conversations/plan/:id/messages`, async (c) => { return withCors(handleErr(err)); } }); + +export async function handlePlanMensajesResponse(response: OpenAI.Responses.Response) { + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! + ); + + const { id: registroId } = response.metadata as { id: string }; + const outputText = (response as any).output_text; + + if (!outputText) return; + + const rawJson = JSON.parse(outputText); + const isRefusal = !!rawJson.is_refusal; + const aiMessage = rawJson["ai-message"] || ""; + + // Construir las recomendaciones (tu lógica original) + const recommendations = Object.entries(rawJson) + .filter(([k]) => k !== "ai-message" && k !== "is_refusal") + .map(([campo_afectado, texto_mejora]) => ({ + campo_afectado, + texto_mejora, + aplicada: false, + })); + + // ACTUALIZAR EL MOLDE QUE DEJAMOS EN "GENERANDO" + await supabase + .from("plan_mensajes_ia") + .update({ + respuesta: aiMessage, + propuesta: { recommendations }, + is_refusal: isRefusal, + estado: "COMPLETADO" // Ahora el front ya puede mostrarlo + }) + .eq("id", registroId); +} + app.post(`${prefix}/conversations/asignatura/:id/messages`, async (c) => { try { - /* const auth = c.req.header("authorization"); - const user = await requireUser(auth); */ + const conversation_asig_id = c.req.param("id"); + assertUuid(conversation_asig_id, "conversation_asig_id"); - const conversation_plan_id = c.req.param("id"); - assertUuid(conversation_plan_id, "conversation_plan_id"); - - const body = (await c.req.json().catch(() => ({}))) as Partial< - AddMessageBody - >; - if (!body.content || typeof body.content !== "string") { - throw new HttpError(400, "bad_input", "content es requerido"); - } + const body = (await c.req.json().catch(() => ({}))) as Partial; + if (!body.content) throw new HttpError(400, "bad_input", "content es requerido"); const supabase = getSupabaseServiceClient(); const openai = getOpenAI(); - // Traer conversacion + plan + estructura + // 1. Traer datos const { data: row, error } = await supabase - .from("conversaciones_asignatura") - .select( - "id, openai_conversation_id, asignatura_id, estado, asignaturas(*)" - ) - .eq("id", conversation_plan_id) - .single(); + .from("conversaciones_asignatura") + .select(`*, asignaturas(*, estructuras_asignatura(definicion))`) + .eq("id", conversation_asig_id) + .single(); - if (error || !row) { - throw new HttpError( - 404, - "conversation_not_found", - "Conversación no encontrada", - error, - ); - } - if (row.estado === "ARCHIVADA") { - throw new HttpError( - 409, - "already_archived", - "La conversación ya está archivada", - ); - } + if (error || !row) throw new HttpError(404, "not_found", "Conversación no encontrada"); - const plan = (row as any).planes_estudio; - const definicion = plan?.estructuras_plan?.definicion; + const asignatura = row.asignaturas; + const definicion = asignatura?.estructuras_asignatura?.definicion; + const wantsStructured = !!definicion && (body.campos?.length ?? 0) > 0; - // Si NO hay schema o no piden campos: solo agregamos mensaje y regresamos ok - const wantsStructured = !!definicion; + let aiMessage = ""; + let isRefusal = false; + let recommendations: any[] = []; + let openAiRespId = ""; + // --- FLUJO A: NO ESTRUCTURADO --- if (!wantsStructured) { - await openai.responses.create({ + const resp = await openai.responses.create({ conversation: row.openai_conversation_id, model: CREATE_CHAT_CONVERSATION_NONSTRUCTURED_MODELO, input: [ - { - role: "system", - content: `Este es el plan de estudios actual ${ - JSON.stringify(plan) - }. Si te hacen una pregunta que no tiene nada que ver con el plan de estudio, responde con un refusal.`, - }, + { role: "system", content: "Eres un experto en diseño curricular." }, { role: "user", content: body.content }, ], - metadata: { - usuario: /* user.email ?? user.id ??*/ "unknown", - plan_estudio_id: row.plan_estudio_id, - }, text: { format: { type: "json_schema", - name: "definicion", + name: "respuesta_basica", + strict: true, schema: { - // Si no hay schema, igual podemos pedir mejoras estructuradas en un campo libre, pero sin validación estricta type: "object", properties: { - "ai-message": { - type: "string", - description: - "Mensaje de la IA para el usuario final basado en la solicitud", - examples: [ - "Excelente, actualmente tu plan de estudio tiene una redacción clara, pero podrías mejorar el perfil de ingreso para hacerlo más atractivo.", - ], - }, - "is_refusal": { - type: "boolean", - description: - "Indica si la respuesta es un refusal (es decir, la pregunta no tiene que ver con el plan de estudio)", - }, + "ai-message": { type: "string" }, + "is_refusal": { type: "boolean" }, }, + required: ["ai-message", "is_refusal"], + additionalProperties: false, }, }, }, }); - return withCors(jsonResponse({ ok: true })); - } + const resJson = JSON.parse(resp.output_text ?? "{}"); + aiMessage = resJson["ai-message"] ?? ""; + isRefusal = !!resJson["is_refusal"]; + openAiRespId = resp.id; - // Pedimos respuesta estructurada con responses.create - const schema = pickSchemaFields(definicion, body.campos ?? []); - const planForPrompt = safePlanForPrompt(plan); - - const model = CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO; - const prompt = body.user_prompt ?? body.content; - - // append message of the user to conversacion_json (which guarantees a JSONB default to '[]') - /** - * appended includes timestamp, user, prompt and fields (if any) - */ - - type AppendedMessage = { - timestamp: string; - user: string; - prompt: string; - fields?: string[]; - }; - - type AppendedResponse = { - timestamp: string; - user: "assistant"; - refusal: boolean; - message: string; - recommendations?: { - texto_mejora: string; - campo_afectado: string; - aplicada: false; - }; - }; - - type AppendedItem = AppendedMessage | AppendedResponse; - - let appended: AppendedItem = { - timestamp: new Date().toISOString(), - user: /* user.email ?? user.id ??*/ "unknown", - prompt, - fields: body.campos, - }; - - const { error: appendErr } = await supabase.rpc( - "append_conversacion_asignatura", - { - p_id: conversation_plan_id, - p_append: appended, - }, - ); - - if (appendErr) { - throw new HttpError( - 500, - "append_conversation_failed", - "No se pudo agregar el mensaje a la conversación", - appendErr, - ); - } - - const resp = await openai.responses.create({ - conversation: row.openai_conversation_id, - model, - text: { format: { type: "json_schema", name: "definicion", schema } }, - metadata: { - usuario: /* user.email ?? user.id ??*/ "unknown", - plan_estudio_id: row.plan_estudio_id, - }, - input: [ - { - role: "system", - content: - `Eres un asistente que ayuda a mejorar este plan de estudio: ${ - JSON.stringify(planForPrompt) - }. ` + - `Si te hacen una pregunta que no tiene nada que ver con el plan de estudio, responde con un refusal.`, + } 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 }, - { role: "user", content: prompt }, - ], + 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, + })); + } + + // 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 }, }); - const respuestaJSON = JSON.parse(resp.output_text ?? "{}"); - const refusal = respuestaJSON["is-refusal"] === true; - //remove the is-refusal field from respuestaJSON to avoid confusion - delete respuestaJSON["is-refusal"]; - - // Now an item with the assistant response and the structured data (if any) should be - appended = { - timestamp: new Date().toISOString(), - user: "assistant", - refusal, - // the ai-message field is the response - message: respuestaJSON?.["ai-message"] ?? "", - recommendations: resp.output_text - ? Object.entries(respuestaJSON).filter(([k]) => k !== "ai-message") - .map( - ([campo_afectado, texto_mejora]) => ({ - campo_afectado, - texto_mejora, - aplicada: false, - }), - ) - : undefined, - } as AppendedResponse; - - const { error: appendRespErr } = await supabase.rpc("append_conversacion_asignatura", { - p_id: conversation_plan_id, - p_append: appended, - }); - - // Construir propuesta estructurada - const propuesta = { - prompt, - respuesta: respuestaJSON?.["ai-message"] ?? "", - recommendations: resp.output_text - ? Object.entries(respuestaJSON) - .filter(([k]) => k !== "ai-message") - .map(([campo_afectado, texto_mejora]) => ({ - campo_afectado, - texto_mejora, - aplicada: false, - })) - : [], - }; - - // Insertar en plan_mensajes_ia - const { error: insertErr } = await supabase - .from("asignatura_mensajes_ia") - .insert({ - conversacion_asignatura_id: conversation_plan_id, - enviado_por: "00000000-0000-0000-0000-000000000000", - mensaje: prompt, - respuesta: respuestaJSON?.["ai-message"] ?? "", - campos: body.campos ?? [], - propuesta, - is_refusal: refusal, - estado: "COMPLETADO", - }); - - if (insertErr) { - throw new HttpError( - 500, - "insert_plan_mensaje_failed", - "No se pudo guardar el mensaje en plan_mensajes_ia", - insertErr, - ); - } - - if (appendRespErr) { - throw new HttpError( - 500, - "append_response_failed", - "No se pudo agregar la respuesta a la conversación", - appendRespErr, - ); - } + 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", + }); return withCors(jsonResponse({ ok: true, - openai_response_id: resp.id, - raw: resp.output_text ?? null, + openai_response_id: openAiRespId, + ai_message: aiMessage, + recommendations })); + } catch (err) { return withCors(handleErr(err)); } diff --git a/supabase/functions/create-chat-conversation/lib/plan.ts b/supabase/functions/create-chat-conversation/lib/plan.ts index 07aa611..487dbf8 100644 --- a/supabase/functions/create-chat-conversation/lib/plan.ts +++ b/supabase/functions/create-chat-conversation/lib/plan.ts @@ -46,6 +46,37 @@ 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 + if (!definicion || definicion.type !== "object" || !definicion.properties) { + return { type: "object", properties: {}, required: [], 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) + ); + 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. Limpieza de OpenAI: Forzar additionalProperties a false + out.additionalProperties = false; + + return out; +} + export function safePlanForPrompt(plan: any) { const copy = structuredClone(plan); if (copy?.estructuras_plan) delete copy.estructuras_plan; From 749ec7a9629cd79ad8c6386e5c1dfd170c7f7964 Mon Sep 17 00:00:00 2001 From: "Roberto.silva" Date: Fri, 6 Mar 2026 11:06:56 -0600 Subject: [PATCH 2/3] se agrega wh --- .../functions/ai-generate-subject/index.ts | 2 +- .../create-chat-conversation/index.ts | 181 +++++------------- .../create-chat-conversation/plan/crear.ts | 34 ++++ .../openai-webhook-responses/index.ts | 10 +- .../planes_estudio/crear.ts | 63 ++++++ 5 files changed, 159 insertions(+), 131 deletions(-) create mode 100644 supabase/functions/create-chat-conversation/plan/crear.ts diff --git a/supabase/functions/ai-generate-subject/index.ts b/supabase/functions/ai-generate-subject/index.ts index 1d5c2fc..379a64a 100644 --- a/supabase/functions/ai-generate-subject/index.ts +++ b/supabase/functions/ai-generate-subject/index.ts @@ -458,7 +458,7 @@ Deno.serve(async (req: Request): Promise => { const { data: estructura, error: estructuraError } = await supabaseService .from("estructuras_asignatura") - .select("id,nombre,definicion,version") + .select("id,nombre,definicion") .eq("id", resolved.estructura_id) .single(); if (estructuraError) { diff --git a/supabase/functions/create-chat-conversation/index.ts b/supabase/functions/create-chat-conversation/index.ts index 522d7a1..b9a2c21 100644 --- a/supabase/functions/create-chat-conversation/index.ts +++ b/supabase/functions/create-chat-conversation/index.ts @@ -4,6 +4,7 @@ import { HttpError, jsonResponse } from "./lib/errors.ts"; import { getOpenAI } from "./lib/openai.ts"; import { getSupabaseServiceClient, requireUser } from "./lib/supabase.ts"; import { assertUuid, pickSchemaFields, safePlanForPrompt,pickSchemaAsignaturaFields } from "./lib/plan.ts"; +import { OpenAIService } from "../_shared/openai-service.ts"; type CreateBody = { plan_estudio_id: string; @@ -196,128 +197,81 @@ app.post(`${prefix}/conversations/plan/:id/messages`, async (c) => { throw new HttpError(400, "bad_input", "content es requerido"); } + console.log("Iniciando generación en background para mensaje_id:"); const supabase = getSupabaseServiceClient(); - const openai = getOpenAI(); + const svc = OpenAIService.fromEnv(); - // 1. Traer datos de la conversación y el plan + // 1. Validar existencia y estado de la conversación const { data: row, error } = await supabase .from("conversaciones_plan") - .select( - "id, openai_conversation_id, plan_estudio_id, estado, planes_estudio(*, estructuras_plan(definicion))", - ) + .select("id, openai_conversation_id, plan_estudio_id, estado, planes_estudio(*, estructuras_plan(definicion))") .eq("id", conversation_plan_id) .single(); - if (error || !row) { - throw new HttpError(404, "conversation_not_found", "Conversación no encontrada", error); - } - - if (row.estado === "ARCHIVADA") { - throw new HttpError(409, "already_archived", "La conversación ya está archivada"); - } + if (error || !row) throw new HttpError(404, "not_found", "Conversación no encontrada"); + if (row.estado === "ARCHIVADA") throw new HttpError(409, "archived", "Conversación archivada"); const plan = (row as any).planes_estudio; const definicion = plan?.estructuras_plan?.definicion; - const wantsStructured = !!definicion; + const isStructured = !!definicion; - // --- LÓGICA PARA CASO 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: `Este es el plan de estudios actual ${JSON.stringify(plan)}. Si la pregunta no tiene que ver con el plan, responde con un refusal.`, - }, - { role: "user", content: body.content }, - ], - text: { - format: { - type: "json_schema", - name: "definicion", - schema: { - type: "object", - properties: { - "ai-message": { type: "string" }, - "is_refusal": { type: "boolean" }, - }, - }, - }, - }, - }); - - const respuestaJSON = JSON.parse(resp.output_text ?? "{}"); - - // Guardar en la nueva tabla aunque no sea estructurado - await supabase.from("plan_mensajes_ia").insert({ - conversacion_plan_id, - enviado_por: "00000000-0000-0000-0000-000000000000", + // 2. Insertar el mensaje en estado PENDIENTE + // Guardamos los metadatos necesarios para procesar la respuesta después + const { data: mensajeInsertado, error: insertErr } = await supabase + .from("plan_mensajes_ia") + .insert({ + conversacion_plan_id:conversation_plan_id, + enviado_por: "00000000-0000-0000-0000-000000000000", mensaje: body.content, - respuesta: respuestaJSON?.["ai-message"] ?? "", - campos: [], - propuesta: { recommendations: [] }, - is_refusal: !!respuestaJSON.is_refusal, - estado: "COMPLETADO", - }); + campos: body.campos ?? [], + estado: "PROCESANDO", // Estado inicial + }) + .select() + .single(); - return withCors(jsonResponse({ ok: true })); - } + if (insertErr) throw new HttpError(500, "db_error", "No se pudo crear el registro"); - // --- LÓGICA PARA CASO ESTRUCTURADO --- - const schema = pickSchemaFields(definicion, body.campos ?? []); - const planForPrompt = safePlanForPrompt(plan); - const prompt = body.user_prompt ?? body.content; + // 3. Preparar Schema y Prompt + const schema = isStructured ? pickSchemaFields(definicion, body.campos ?? []) : { + type: "object", + properties: { "ai-message": { type: "string" }, "is_refusal": { type: "boolean" } } + }; - const resp = await openai.responses.create({ + // 4. Llamada asincrónica a OpenAI con Webhook + // Nota: El SDK de OpenAI permite pasar webhooks en ciertos modelos/endpoints + console.log("mandando a openaai "); + + const aiResult = await svc.createStructuredResponse({ conversation: row.openai_conversation_id, - model: CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO, - text: { format: { type: "json_schema", name: "definicion", schema } }, + model: isStructured ? CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO : CREATE_CHAT_CONVERSATION_NONSTRUCTURED_MODELO, + background: true, // <--- ESTO ES LO QUE TE FALTABA + metadata: { + tabla: "plan_mensajes_ia", + mensaje_id: String(mensajeInsertado.id), // Siempre string + is_structured: String(isStructured) + }, + text: { + format: { + type: "json_schema", + name: "definicion", + schema: schema + } + }, input: [ - { - role: "system", - content: `Eres un asistente que ayuda a mejorar este plan: ${JSON.stringify(planForPrompt)}. Si la pregunta no es pertinente, responde con un refusal.`, - }, - { role: "user", content: prompt }, + { role: "system", content: `Asistente de plan: ${JSON.stringify(safePlanForPrompt(plan))}` }, + { role: "user", content: body.content }, ], }); - const respuestaJSON = JSON.parse(resp.output_text ?? "{}"); - const refusal = respuestaJSON["is-refusal"] === true; - delete respuestaJSON["is-refusal"]; - - // 2. Construir el objeto de propuestas/recomendaciones - const recommendations = resp.output_text - ? Object.entries(respuestaJSON) - .filter(([k]) => k !== "ai-message") - .map(([campo_afectado, texto_mejora]) => ({ - campo_afectado, - texto_mejora, - aplicada: false, - })) - : []; - - // 3. Insertar ÚNICAMENTE en plan_mensajes_ia - const { error: insertErr } = await supabase - .from("plan_mensajes_ia") - .insert({ - conversacion_plan_id: conversation_plan_id, - enviado_por: "00000000-0000-0000-0000-000000000000", - mensaje: prompt, - respuesta: respuestaJSON?.["ai-message"] ?? "", - campos: body.campos ?? [], - propuesta: { recommendations }, - is_refusal: refusal, - estado: "COMPLETADO", - }); - - if (insertErr) { - throw new HttpError(500, "insert_plan_mensaje_failed", "Error al guardar mensaje", insertErr); + if (!aiResult.ok) { + throw new HttpError(500, "openai_error", "No se pudo encolar la respuesta"); } + // 5. Responder al cliente de inmediato return withCors(jsonResponse({ ok: true, - openai_response_id: resp.id, + mensaje_id: mensajeInsertado.id, + openai_response_id: aiResult.responseId // Para seguimiento })); } catch (err) { @@ -325,41 +279,10 @@ app.post(`${prefix}/conversations/plan/:id/messages`, async (c) => { } }); -export async function handlePlanMensajesResponse(response: OpenAI.Responses.Response) { - const supabase = createClient( - Deno.env.get("SUPABASE_URL")!, - Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! - ); - const { id: registroId } = response.metadata as { id: string }; - const outputText = (response as any).output_text; - - if (!outputText) return; - const rawJson = JSON.parse(outputText); - const isRefusal = !!rawJson.is_refusal; - const aiMessage = rawJson["ai-message"] || ""; - // Construir las recomendaciones (tu lógica original) - const recommendations = Object.entries(rawJson) - .filter(([k]) => k !== "ai-message" && k !== "is_refusal") - .map(([campo_afectado, texto_mejora]) => ({ - campo_afectado, - texto_mejora, - aplicada: false, - })); - // ACTUALIZAR EL MOLDE QUE DEJAMOS EN "GENERANDO" - await supabase - .from("plan_mensajes_ia") - .update({ - respuesta: aiMessage, - propuesta: { recommendations }, - is_refusal: isRefusal, - estado: "COMPLETADO" // Ahora el front ya puede mostrarlo - }) - .eq("id", registroId); -} app.post(`${prefix}/conversations/asignatura/:id/messages`, async (c) => { try { diff --git a/supabase/functions/create-chat-conversation/plan/crear.ts b/supabase/functions/create-chat-conversation/plan/crear.ts new file mode 100644 index 0000000..bfbd398 --- /dev/null +++ b/supabase/functions/create-chat-conversation/plan/crear.ts @@ -0,0 +1,34 @@ +// ./plan_mensajes_ia/index.ts +import { getSupabaseServiceClient } from "../_shared/supabase.ts"; + +export async function handlePlanMensajesResponse(response: any) { + const supabase = getSupabaseServiceClient(); + const { mensaje_id, is_structured } = response.metadata; + + // Extraer el texto de la respuesta (usando tu lógica de filtrado de output) + const outputText = response.output_text || ""; // Ajustar según cómo venga el objeto + const respuestaJSON = JSON.parse(outputText || "{}"); + + const is_refusal = !!respuestaJSON.is_refusal || respuestaJSON["is-refusal"] === true; + + let recommendations = []; + if (is_structured) { + recommendations = Object.entries(respuestaJSON) + .filter(([k]) => k !== "ai-message" && k !== "is-refusal") + .map(([campo, valor]) => ({ + campo_afectado: campo, + texto_mejora: valor, + aplicada: false, + })); + } + + await supabase + .from("plan_mensajes_ia") + .update({ + respuesta: respuestaJSON["ai-message"] || "", + propuesta: { recommendations }, + is_refusal, + estado: "COMPLETADO", + }) + .eq("id", mensaje_id); +} \ No newline at end of file diff --git a/supabase/functions/openai-webhook-responses/index.ts b/supabase/functions/openai-webhook-responses/index.ts index 246fe78..6ed7d7d 100644 --- a/supabase/functions/openai-webhook-responses/index.ts +++ b/supabase/functions/openai-webhook-responses/index.ts @@ -8,6 +8,8 @@ import OpenAI from "openai"; import { ResponseMetadata } from "../_shared/utils.ts"; import { handlePlanesEstudioResponse } from "./planes_estudio/index.ts"; import { handleAsignaturasResponse } from "./asignaturas/index.ts"; +import { handlePlanMensajesResponse } from "./planes_estudio/crear.ts"; + console.log("Starting OpenAI webhook responses function"); const client = new OpenAI({ @@ -20,7 +22,8 @@ async function handleCompletedResponse( const response_id = event.data.id; const response = await client.responses.retrieve(response_id); const metadata = response.metadata as ResponseMetadata | null; - + console.log(("entre")); + if (!metadata || !metadata.tabla) { console.warn("No se recibió metadata o tabla en la respuesta"); return; @@ -33,6 +36,11 @@ async function handleCompletedResponse( case "asignaturas": await handleAsignaturasResponse(response); break; + case "plan_mensajes_ia": // <-- Nueva tabla añadida + console.log("entre aqui"); + + await handlePlanMensajesResponse(response); + break; default: console.warn("Tabla no reconocida:", metadata.tabla); } diff --git a/supabase/functions/openai-webhook-responses/planes_estudio/crear.ts b/supabase/functions/openai-webhook-responses/planes_estudio/crear.ts index 6f133bf..966607f 100644 --- a/supabase/functions/openai-webhook-responses/planes_estudio/crear.ts +++ b/supabase/functions/openai-webhook-responses/planes_estudio/crear.ts @@ -106,3 +106,66 @@ export async function handleCrearPlanEstudio( return; } } + +export async function handlePlanMensajesResponse( + response: OpenAI.Responses.Response, +): Promise { + const metadata = response.metadata as any; + const mensajeId = metadata?.mensaje_id; +console.log("ya entre aqui"); + + const isStructured = metadata?.is_structured === "true" || metadata?.is_structured === true; + if (!mensajeId) { + console.warn("No se recibió mensaje_id en la metadata del webhook"); + 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}`); + } + + const is_refusal = !!respuestaJSON.is_refusal || respuestaJSON["is-refusal"] === true; + + let recommendations = []; + if (isStructured && !is_refusal) { + recommendations = Object.entries(respuestaJSON) + .filter(([k]) => k !== "ai-message" && k !== "is-refusal" && k !== "is_refusal") + .map(([campo, valor]) => ({ + campo_afectado: campo, + texto_mejora: valor, + aplicada: false, + })); + } + + const { error } = await supabase + .from("plan_mensajes_ia") + .update({ + respuesta: respuestaJSON["ai-message"] || "", + propuesta: { recommendations }, + is_refusal, + estado: "COMPLETADO", + }) + .eq("id", mensajeId); + + if (error) { + throw error; + } + + } catch (e) { + console.error("Error procesando handlePlanMensajesResponse:", { mensajeId, e }); + // Opcional: Marcar el mensaje como fallido en la tabla si tienes ese estado + await supabase + .from("plan_mensajes_ia") + .update({ estado: "ERROR" }) + .eq("id", mensajeId); + } +} \ No newline at end of file From ed4891594bbbacd60fb51c36d4ab1b0feca5ca73 Mon Sep 17 00:00:00 2001 From: "Roberto.silva" Date: Mon, 9 Mar 2026 12:45:54 -0600 Subject: [PATCH 3/3] Se agrega webhook de conversacion plan --- .../create-chat-conversation/plan/crear.ts | 110 +++++++++++++----- .../openai-webhook-responses/index.ts | 7 +- .../planes_estudio/crear.ts | 63 ---------- 3 files changed, 86 insertions(+), 94 deletions(-) diff --git a/supabase/functions/create-chat-conversation/plan/crear.ts b/supabase/functions/create-chat-conversation/plan/crear.ts index bfbd398..74673a0 100644 --- a/supabase/functions/create-chat-conversation/plan/crear.ts +++ b/supabase/functions/create-chat-conversation/plan/crear.ts @@ -1,34 +1,90 @@ // ./plan_mensajes_ia/index.ts -import { getSupabaseServiceClient } from "../_shared/supabase.ts"; +import type { OpenAI } from "openai"; -export async function handlePlanMensajesResponse(response: any) { - const supabase = getSupabaseServiceClient(); - const { mensaje_id, is_structured } = response.metadata; +import type { Json } from "../../_shared/database.types.ts"; +import { ResponseMetadata } from "../../_shared/utils.ts"; +import { supabase } from "../../openai-webhook-responses/supabase.ts"; - // Extraer el texto de la respuesta (usando tu lógica de filtrado de output) - const outputText = response.output_text || ""; // Ajustar según cómo venga el objeto - const respuestaJSON = JSON.parse(outputText || "{}"); - const is_refusal = !!respuestaJSON.is_refusal || respuestaJSON["is-refusal"] === true; - - let recommendations = []; - if (is_structured) { - recommendations = Object.entries(respuestaJSON) - .filter(([k]) => k !== "ai-message" && k !== "is-refusal") - .map(([campo, valor]) => ({ - campo_afectado: campo, - texto_mejora: valor, - aplicada: false, - })); +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 handlePlanMensajesResponse( + response: OpenAI.Responses.Response, +): Promise { + const metadata = response.metadata as any; + const mensajeId = metadata?.mensaje_id; +console.log("ya entre aqui"); + + const isStructured = metadata?.is_structured === "true" || metadata?.is_structured === true; + if (!mensajeId) { + console.warn("No se recibió mensaje_id en la metadata del webhook"); + return; } - await supabase - .from("plan_mensajes_ia") - .update({ - respuesta: respuestaJSON["ai-message"] || "", - propuesta: { recommendations }, - is_refusal, - estado: "COMPLETADO", - }) - .eq("id", mensaje_id); + 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}`); + } + + const is_refusal = !!respuestaJSON.is_refusal || respuestaJSON["is-refusal"] === true; + + let recommendations = []; + if (isStructured && !is_refusal) { + recommendations = Object.entries(respuestaJSON) + .filter(([k]) => k !== "ai-message" && k !== "is-refusal" && k !== "is_refusal") + .map(([campo, valor]) => ({ + campo_afectado: campo, + texto_mejora: valor, + aplicada: false, + })); + } + + const { error } = await supabase + .from("plan_mensajes_ia") + .update({ + respuesta: respuestaJSON["ai-message"] || "", + propuesta: { recommendations }, + is_refusal, + estado: "COMPLETADO", + }) + .eq("id", mensajeId); + + if (error) { + throw error; + } + + } catch (e) { + console.error("Error procesando handlePlanMensajesResponse:", { mensajeId, e }); + // Opcional: Marcar el mensaje como fallido en la tabla si tienes ese estado + await supabase + .from("plan_mensajes_ia") + .update({ estado: "ERROR" }) + .eq("id", mensajeId); + } } \ No newline at end of file diff --git a/supabase/functions/openai-webhook-responses/index.ts b/supabase/functions/openai-webhook-responses/index.ts index 6ed7d7d..9a43c11 100644 --- a/supabase/functions/openai-webhook-responses/index.ts +++ b/supabase/functions/openai-webhook-responses/index.ts @@ -8,7 +8,8 @@ import OpenAI from "openai"; import { ResponseMetadata } from "../_shared/utils.ts"; import { handlePlanesEstudioResponse } from "./planes_estudio/index.ts"; import { handleAsignaturasResponse } from "./asignaturas/index.ts"; -import { handlePlanMensajesResponse } from "./planes_estudio/crear.ts"; +import { handlePlanMensajesResponse } from "../create-chat-conversation/plan/crear.ts"; + console.log("Starting OpenAI webhook responses function"); @@ -36,9 +37,7 @@ async function handleCompletedResponse( case "asignaturas": await handleAsignaturasResponse(response); break; - case "plan_mensajes_ia": // <-- Nueva tabla añadida - console.log("entre aqui"); - + case "plan_mensajes_ia": await handlePlanMensajesResponse(response); break; default: diff --git a/supabase/functions/openai-webhook-responses/planes_estudio/crear.ts b/supabase/functions/openai-webhook-responses/planes_estudio/crear.ts index 966607f..6f133bf 100644 --- a/supabase/functions/openai-webhook-responses/planes_estudio/crear.ts +++ b/supabase/functions/openai-webhook-responses/planes_estudio/crear.ts @@ -106,66 +106,3 @@ export async function handleCrearPlanEstudio( return; } } - -export async function handlePlanMensajesResponse( - response: OpenAI.Responses.Response, -): Promise { - const metadata = response.metadata as any; - const mensajeId = metadata?.mensaje_id; -console.log("ya entre aqui"); - - const isStructured = metadata?.is_structured === "true" || metadata?.is_structured === true; - if (!mensajeId) { - console.warn("No se recibió mensaje_id en la metadata del webhook"); - 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}`); - } - - const is_refusal = !!respuestaJSON.is_refusal || respuestaJSON["is-refusal"] === true; - - let recommendations = []; - if (isStructured && !is_refusal) { - recommendations = Object.entries(respuestaJSON) - .filter(([k]) => k !== "ai-message" && k !== "is-refusal" && k !== "is_refusal") - .map(([campo, valor]) => ({ - campo_afectado: campo, - texto_mejora: valor, - aplicada: false, - })); - } - - const { error } = await supabase - .from("plan_mensajes_ia") - .update({ - respuesta: respuestaJSON["ai-message"] || "", - propuesta: { recommendations }, - is_refusal, - estado: "COMPLETADO", - }) - .eq("id", mensajeId); - - if (error) { - throw error; - } - - } catch (e) { - console.error("Error procesando handlePlanMensajesResponse:", { mensajeId, e }); - // Opcional: Marcar el mensaje como fallido en la tabla si tienes ese estado - await supabase - .from("plan_mensajes_ia") - .update({ estado: "ERROR" }) - .eq("id", mensajeId); - } -} \ No newline at end of file