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 } from "./lib/plan.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(); // Preflight CORS app.options( "*", (c) => new Response(null, { status: 204, headers: corsHeaders }), ); const prefix = "/create-chat-conversation"; app.get(`${prefix}/health`, (c) => withCors(jsonResponse({ ok: true }))); /** * POST /conversations * Crea conversación OpenAI + registro en conversaciones_plan */ app.post(`${prefix}/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)); } }); /** * GET /conversations/:conversation_plan_id/messages * Lista mensajes (assistant/user) desde OpenAI */ app.get(`${prefix}/conversations/:id/messages`, async (c) => { try { /* const auth = c.req.header("authorization"); await requireUser(auth); */ const conversation_plan_id = c.req.param("id"); assertUuid(conversation_plan_id, "conversation_plan_id"); const supabase = getSupabaseServiceClient(); const openai = getOpenAI(); const { data: convRow, error } = await supabase .from("conversaciones_plan") .select("openai_conversation_id, estado") .eq("id", conversation_plan_id) .single(); if (error || !convRow) { throw new HttpError( 404, "conversation_not_found", "Conversación no encontrada", error, ); } if (convRow.estado === "ARCHIVADA") { // si ya está archivada, devolvemos lo guardado const { data: archived } = await supabase .from("conversaciones_plan") .select("conversacion_json") .eq("id", conversation_plan_id) .single(); return withCors( jsonResponse({ source: "supabase", items: archived?.conversacion_json ?? null, }), ); } const items = await openai.conversations.items.list( convRow.openai_conversation_id, ); const conversacion = items.data.filter((it: any) => it.type === "message" && (it.role === "assistant" || it.role === "user") ).map((it: any) => ({ role: it.role, content: it.content.map((c: any) => c.text).join(""), })); return withCors(jsonResponse({ source: "openai", items: conversacion })); } 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/:id/messages`, async (c) => { try { /* const auth = c.req.header("authorization"); const user = await requireUser(auth); */ 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 supabase = getSupabaseServiceClient(); const openai = getOpenAI(); // Traer conversacion + plan + estructura 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, "conversation_not_found", "Conversación no encontrada", error, ); } if (row.estado === "ARCHIVADA") { throw new HttpError( 409, "already_archived", "La conversación ya está archivada", ); } const plan = (row as any).planes_estudio; const definicion = plan?.estructuras_plan?.definicion; // Si NO hay schema o no piden campos: solo agregamos mensaje y regresamos ok const wantsStructured = !!definicion; if (!wantsStructured) { await openai.responses.create({ conversation: row.openai_conversation_id, model: "gpt-5-nano", 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: "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", 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.", ], }, }, }, }, }, }); return withCors(jsonResponse({ ok: true })); } // Pedimos respuesta estructurada con responses.create const schema = pickSchemaFields(definicion, body.campos); const planForPrompt = safePlanForPrompt(plan); const model = "gpt-5-nano"; const prompt = body.user_prompt ?? body.content; 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.`, }, { role: "user", content: prompt }, ], }); let parsed: any = null; try { parsed = JSON.parse(resp.output_text ?? "null"); } catch (_) {} return withCors(jsonResponse({ ok: true, openai_response_id: resp.id, data: parsed, raw: resp.output_text ?? null, })); } catch (err) { return withCors(handleErr(err)); } }); /** * DELETE /conversations/:conversation_plan_id/archive * Guarda items en Supabase y elimina la conversación de OpenAI */ app.delete(`${prefix}/conversations/:id/archive`, async (c) => { try { /* const auth = c.req.header("authorization"); await requireUser(auth); */ const conversation_plan_id = c.req.param("id"); assertUuid(conversation_plan_id, "conversation_plan_id"); const supabase = getSupabaseServiceClient(); const openai = getOpenAI(); const { data: row, error } = await supabase .from("conversaciones_plan") .select("id, openai_conversation_id, estado") .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") { return withCors(jsonResponse({ ok: true, already: true })); } // Marcar estado await supabase.from("conversaciones_plan") .update({ estado: "ARCHIVANDO" }) .eq("id", conversation_plan_id); // Descargar items de OpenAI const items = await openai.conversations.items.list( row.openai_conversation_id, ); const conversacion = items.data.filter((it: any) => it.type === "message" && (it.role === "assistant" || it.role === "user") ).map((it: any) => ({ role: it.role, content: it.content.map((c: any) => c.text).join(""), })); // Guardar y marcar como ARCHIVADA const { error: upErr } = await supabase.from("conversaciones_plan") .update({ estado: "ARCHIVADA", conversacion_json: conversacion, }) .eq("id", conversation_plan_id); if (upErr) { throw new HttpError( 500, "archive_save_failed", "No se pudo guardar el archivo en Supabase", upErr, ); } // Borrar conversación en OpenAI (best effort) try { await openai.conversations.delete(row.openai_conversation_id); } catch (delErr) { // Queda archivada en Supabase, pero reportamos warning return withCors(jsonResponse({ ok: true, warning: "Archivada en Supabase, pero no se pudo borrar en OpenAI", details: String(delErr), }, 200)); } return withCors(jsonResponse({ ok: true })); } 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);