Files
genesis-2/supabase/functions/create-chat-conversation/index.ts
T
2026-03-06 11:06:56 -06:00

448 lines
14 KiB
TypeScript

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 } 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<CreateBody>;
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<CreateBody>;
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<AddMessageBody>;
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<AddMessageBody>;
if (!body.content) throw new HttpError(400, "bad_input", "content es requerido");
const supabase = getSupabaseServiceClient();
const openai = getOpenAI();
// 1. Traer datos
const { data: row, error } = await supabase
.from("conversaciones_asignatura")
.select(`*, 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 definicion = asignatura?.estructuras_asignatura?.definicion;
const wantsStructured = !!definicion && (body.campos?.length ?? 0) > 0;
let aiMessage = "";
let isRefusal = false;
let recommendations: any[] = [];
let openAiRespId = "";
// --- 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,
},
},
},
});
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,
}));
}
// 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",
});
return withCors(jsonResponse({
ok: true,
openai_response_id: openAiRespId,
ai_message: aiMessage,
recommendations
}));
} 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);