427 lines
12 KiB
TypeScript
427 lines
12 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();
|
|
|
|
// 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<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) => ({
|
|
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);
|