import { Hono } from "jsr:@hono/hono"; 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,pickSchemaAsignaturaFields, safeAsignaturaForPrompt, getAsignaturaSystemPrompt } from "./lib/plan.ts"; import { OpenAIService } from "../_shared/openai-service.ts"; type CreateBody = { plan_estudio_id: string; instanciador?: string; system_prompt?: string; }; type AddMessageBody = { // Guarda mensaje en OpenAI conversation content: string; // Si quieres forzar mejoras estructuradas: campos?: string[]; user_prompt?: string; // si no mandas, usa content model?: string; // default gpt-5-nano }; const app = new Hono(); addEventListener("beforeunload", (ev: any) => { console.error( "ALERTA: La función se va a apagar. Razón:", ev?.detail?.reason, ); }); // Preflight CORS app.options( "*", (c) => new Response(null, { status: 204, headers: corsHeaders }), ); const prefix = "/create-chat-conversation"; // Model names (module-level) — pueden ser sobrescritos por variables de entorno const CREATE_CHAT_CONVERSATION_NONSTRUCTURED_MODELO = Deno.env.get( "CREATE_CHAT_CONVERSATION_NONSTRUCTURED_MODELO", ) ?? "gpt-5-nano"; const CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO = Deno.env.get( "CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO", ) ?? "gpt-5-nano"; app.get(`${prefix}/health`, (c) => withCors(jsonResponse({ ok: true }))); /** * POST /conversations * Crea conversación OpenAI + registro en conversaciones_plan */ app.post(`${prefix}/plan/conversations`, async (c) => { try { /* const auth = c.req.header("authorization"); const user = await requireUser(auth); */ const body = (await c.req.json().catch(() => ({}))) as Partial; const plan_estudio_id = body.plan_estudio_id; assertUuid(plan_estudio_id ?? "", "plan_estudio_id"); const instanciador = /* user.email ?? user.id ?? */ body.instanciador ?? "unknown"; const system_prompt = body.system_prompt ?? "En caso de que te pidan algo que no tiene nada que ver con planes de estudio o asignatura responde con un refusal."; const supabase = getSupabaseServiceClient(); const openai = getOpenAI(); // Cargar plan + estructura const { data: plan, error: planErr } = await supabase .from("planes_estudio") .select("*, estructuras_plan (definicion)") .eq("id", plan_estudio_id) .single(); if (planErr || !plan) { throw new HttpError( 404, "plan_not_found", "Plan de estudio no encontrado", planErr, ); } // Crear conversación en OpenAI const conv = await openai.conversations.create({ metadata: { tabla: "planes_estudio", id: plan.id, instanciador, }, items: [{ type: "message", role: "system", content: system_prompt }], }); // Crear registro en Supabase const { data: row, error: insErr } = await supabase .from("conversaciones_plan") .insert({ openai_conversation_id: conv.id, plan_estudio_id: plan.id, estado: "ACTIVA", }) .select("id, plan_estudio_id, openai_conversation_id, estado") .single(); if (insErr || !row) { // rollback best-effort try { await openai.conversations.delete(conv.id); } catch (_) {} throw new HttpError( 500, "db_insert_failed", "No se pudo registrar la conversación", insErr, ); } return withCors(jsonResponse({ conversation_plan: row }, 201)); } catch (err) { return withCors(handleErr(err)); } }); 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 ?? "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(); // 1. Verificar que la asignatura existe const { data: asignatura, error: asigErr } = await supabase .from("asignaturas") .select("*") .eq("id", asignatura_id) .single(); if (asigErr || !asignatura) { throw new HttpError(404, "asignatura_not_found", "Asignatura no encontrada"); } // 2. Crear conversación en OpenAI const conv = await openai.conversations.create({ metadata: { tabla: "asignaturas", id: asignatura.id, instanciador, }, items: [{ type: "message", role: "system", content: system_prompt }], }); // 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, 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", "Error al registrar conversación", insErr); } return withCors(jsonResponse({ conversation_asignatura: row }, 201)); } catch (err) { return withCors(handleErr(err)); } }); /** * POST /conversations/:conversation_plan_id/messages * Agrega mensaje y opcionalmente solicita respuesta estructurada (json_schema) */ app.post(`${prefix}/conversations/plan/:id/messages`, async (c) => { try { const conversation_plan_id = c.req.param("id"); assertUuid(conversation_plan_id, "conversation_plan_id"); const body = (await c.req.json().catch(() => ({}))) as Partial; if (!body.content || typeof body.content !== "string") { throw new HttpError(400, "bad_input", "content es requerido"); } console.log("Iniciando generación en background para mensaje_id:"); const supabase = getSupabaseServiceClient(); const svc = OpenAIService.fromEnv(); // 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))") .eq("id", conversation_plan_id) .single(); 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 isStructured = !!definicion; // 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, campos: body.campos ?? [], estado: "PROCESANDO", // Estado inicial }) .select() .single(); if (insertErr) throw new HttpError(500, "db_error", "No se pudo crear el registro"); // 3. Preparar Schema y Prompt const schema = isStructured ? pickSchemaFields(definicion, body.campos ?? []) : { type: "object", properties: { "ai-message": { type: "string" }, "is_refusal": { type: "boolean" } } }; // 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: 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: `Asistente de plan: ${JSON.stringify(safePlanForPrompt(plan))}` }, { role: "user", content: body.content }, ], }); 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, mensaje_id: mensajeInsertado.id, openai_response_id: aiResult.responseId // Para seguimiento })); } catch (err) { return withCors(handleErr(err)); } }); app.post(`${prefix}/conversations/asignatura/:id/messages`, async (c) => { try { const conversation_asig_id = c.req.param("id"); assertUuid(conversation_asig_id, "conversation_asig_id"); const body = (await c.req.json().catch(() => ({}))) as Partial; if (!body.content || typeof body.content !== "string") { throw new HttpError(400, "bad_input", "content es requerido"); } const supabase = getSupabaseServiceClient(); // Usamos el servicio que ya tienes configurado para background const svc = OpenAIService.fromEnv(); // 1. Traer datos de la asignatura const { data: row, error } = await supabase .from("conversaciones_asignatura") .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 as any).asignaturas; const definicion = asignatura?.estructuras_asignatura?.definicion; const campos = body.campos ?? []; const isStructured = !!definicion && campos.length > 0; // 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(); 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: "mejoras_asignatura", schema: schema } }, input: [ { role: "system", content: getAsignaturaSystemPrompt(asignatura, campos) }, { role: "user", content: body.content }, ], }); 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, mensaje_id: mensajeInsertado.id, openai_response_id: aiResult.responseId })); } catch (err) { return withCors(handleErr(err)); } }); /** * Unknown routes */ app.all( "*", (c) => withCors( jsonResponse({ error: "not_found", message: `Route ${c.req.url} not found`, }, 404), ), ); function handleErr(err: unknown): Response { if (err instanceof HttpError) { return jsonResponse( { error: err.code, message: err.message, details: err.details ?? null }, err.status, ); } console.error("Unhandled error:", err); return jsonResponse( { error: "internal_error", message: "Unexpected error" }, 500, ); } Deno.serve(app.fetch);