415 lines
13 KiB
TypeScript
415 lines
13 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, 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<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 || 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);
|