Files
genesis-2/supabase/functions/create-chat-conversation/index.ts
T
alexrg 7ee9944ab5 feat: add @toon-format/toon dependency and integrate into OpenAI notebooks
- Updated deno.lock and package.json to include @toon-format/toon@^2.1.0.
- Modified background-openai.ipynb to handle error responses and adjust execution counts.
- Enhanced definitions-openai.ipynb to include additional metadata for perishables and origen schemas.
- Refactored embeddings-openai.ipynb to improve error handling and output formatting.
- Updated create-chat-conversation function to append messages and responses to conversations.
- Added SQL functions for appending conversation data to the database.
2026-02-24 15:20:19 -06:00

543 lines
15 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 } 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();
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}/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));
}
});
/**
* 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) => {
const rawText = it.content.map((c: any) => c.text).join("");
let parsedContent;
try {
parsedContent = JSON.parse(rawText); // 👈 aquí lo convertimos
} catch {
parsedContent = rawText; // si no es JSON, lo dejamos normal
}
return {
role: it.role,
content: parsedContent,
};
});
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: 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: "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.",
],
},
"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)",
},
},
},
},
},
});
return withCors(jsonResponse({ ok: true }));
}
// 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_plan",
{
p_id: conversation_plan_id,
p_appended: JSON.stringify(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.`,
},
{ role: "user", content: prompt },
],
});
const respuestaJSON = JSON.parse(resp.output_text ?? "{}");
// Now an item with the assistant response and the structured data (if any) should be
appended = {
timestamp: new Date().toISOString(),
user: "assistant",
refusal: resp.output_text === "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_plan",
{
p_id: conversation_plan_id,
p_appended: JSON.stringify(appended),
},
);
if (appendRespErr) {
throw new HttpError(
500,
"append_response_failed",
"No se pudo agregar la respuesta a la conversación",
appendRespErr,
);
}
return withCors(jsonResponse({
ok: true,
openai_response_id: resp.id,
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);