Chats de la IA en segundo plano se agregan funciones para asignatura

fix #42
This commit is contained in:
2026-03-06 08:44:41 -06:00
parent 189225f369
commit d844698ffc
2 changed files with 167 additions and 243 deletions
@@ -3,7 +3,7 @@ import { corsHeaders, withCors } from "./lib/cors.ts";
import { HttpError, jsonResponse } from "./lib/errors.ts"; import { HttpError, jsonResponse } from "./lib/errors.ts";
import { getOpenAI } from "./lib/openai.ts"; import { getOpenAI } from "./lib/openai.ts";
import { getSupabaseServiceClient, requireUser } from "./lib/supabase.ts"; import { getSupabaseServiceClient, requireUser } from "./lib/supabase.ts";
import { assertUuid, pickSchemaFields, safePlanForPrompt } from "./lib/plan.ts"; import { assertUuid, pickSchemaFields, safePlanForPrompt,pickSchemaAsignaturaFields } from "./lib/plan.ts";
type CreateBody = { type CreateBody = {
plan_estudio_id: string; plan_estudio_id: string;
@@ -125,20 +125,17 @@ app.post(`${prefix}/plan/conversations`, async (c) => {
app.post(`${prefix}/asignatura/conversations`, async (c) => { app.post(`${prefix}/asignatura/conversations`, async (c) => {
try { try {
const body = (await c.req.json().catch(() => ({}))) as Partial<CreateBody>; const body = (await c.req.json().catch(() => ({}))) as Partial<CreateBody>;
const asignatura_id = body.asignatura_id; const asignatura_id = body.asignatura_id;
assertUuid(asignatura_id ?? "", "asignatura_id"); assertUuid(asignatura_id ?? "", "asignatura_id");
const instanciador = body.instanciador ?? "unknown"; const instanciador = body.instanciador ?? "unknown";
const system_prompt = body.system_prompt ??
const system_prompt = "Eres un asistente experto en currículo académico. Si te piden algo ajeno a la asignatura, responde con un refusal.";
body.system_prompt ??
"En caso de que te pidan algo que no tiene nada que ver con la asignatura responde con un refusal.";
const supabase = getSupabaseServiceClient(); const supabase = getSupabaseServiceClient();
const openai = getOpenAI(); const openai = getOpenAI();
// 🔥 Cargar asignatura // 1. Verificar que la asignatura existe
const { data: asignatura, error: asigErr } = await supabase const { data: asignatura, error: asigErr } = await supabase
.from("asignaturas") .from("asignaturas")
.select("*") .select("*")
@@ -146,15 +143,10 @@ app.post(`${prefix}/asignatura/conversations`, async (c) => {
.single(); .single();
if (asigErr || !asignatura) { if (asigErr || !asignatura) {
throw new HttpError( throw new HttpError(404, "asignatura_not_found", "Asignatura no encontrada");
404,
"asignatura_not_found",
"Asignatura no encontrada",
asigErr,
);
} }
// 🔥 Crear conversación en OpenAI // 2. Crear conversación en OpenAI
const conv = await openai.conversations.create({ const conv = await openai.conversations.create({
metadata: { metadata: {
tabla: "asignaturas", tabla: "asignaturas",
@@ -164,28 +156,22 @@ app.post(`${prefix}/asignatura/conversations`, async (c) => {
items: [{ type: "message", role: "system", content: system_prompt }], items: [{ type: "message", role: "system", content: system_prompt }],
}); });
// 🔥 Insertar en conversaciones_asignatura // 3. Insertar en conversaciones_asignatura (coincidiendo con tu SQL)
const { data: row, error: insErr } = await supabase const { data: row, error: insErr } = await supabase
.from("conversaciones_asignatura") .from("conversaciones_asignatura")
.insert({ .insert({
openai_conversation_id: conv.id, openai_conversation_id: conv.id,
asignatura_id: asignatura.id, // ✅ CORRECTO asignatura_id: asignatura.id,
estado: "ACTIVA", 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") .select("id, asignatura_id, openai_conversation_id, estado")
.single(); .single();
if (insErr || !row) { if (insErr || !row) {
try { try { await openai.conversations.delete(conv.id); } catch (_) {}
await openai.conversations.delete(conv.id); throw new HttpError(500, "db_insert_failed", "Error al registrar conversación", insErr);
} catch (_) {}
throw new HttpError(
500,
"db_insert_failed",
"No se pudo registrar la conversación",
insErr,
);
} }
return withCors(jsonResponse({ conversation_asignatura: row }, 201)); return withCors(jsonResponse({ conversation_asignatura: row }, 201));
@@ -338,261 +324,168 @@ app.post(`${prefix}/conversations/plan/:id/messages`, async (c) => {
return withCors(handleErr(err)); return withCors(handleErr(err));
} }
}); });
app.post(`${prefix}/conversations/asignatura/:id/messages`, async (c) => {
try {
/* const auth = c.req.header("authorization");
const user = await requireUser(auth); */
const conversation_plan_id = c.req.param("id"); export async function handlePlanMensajesResponse(response: OpenAI.Responses.Response) {
assertUuid(conversation_plan_id, "conversation_plan_id"); const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
const body = (await c.req.json().catch(() => ({}))) as Partial< Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
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_asignatura")
.select(
"id, openai_conversation_id, asignatura_id, estado, asignaturas(*)"
)
.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_asignatura",
{
p_id: conversation_plan_id,
p_append: appended,
},
); );
if (appendErr) { const { id: registroId } = response.metadata as { id: string };
throw new HttpError( const outputText = (response as any).output_text;
500,
"append_conversation_failed",
"No se pudo agregar el mensaje a la conversación",
appendErr,
);
}
const resp = await openai.responses.create({ if (!outputText) return;
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 ?? "{}"); const rawJson = JSON.parse(outputText);
const refusal = respuestaJSON["is-refusal"] === true; const isRefusal = !!rawJson.is_refusal;
//remove the is-refusal field from respuestaJSON to avoid confusion const aiMessage = rawJson["ai-message"] || "";
delete respuestaJSON["is-refusal"];
// Now an item with the assistant response and the structured data (if any) should be // Construir las recomendaciones (tu lógica original)
appended = { const recommendations = Object.entries(rawJson)
timestamp: new Date().toISOString(), .filter(([k]) => k !== "ai-message" && k !== "is_refusal")
user: "assistant",
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_asignatura", {
p_id: conversation_plan_id,
p_append: appended,
});
// Construir propuesta estructurada
const propuesta = {
prompt,
respuesta: respuestaJSON?.["ai-message"] ?? "",
recommendations: resp.output_text
? Object.entries(respuestaJSON)
.filter(([k]) => k !== "ai-message")
.map(([campo_afectado, texto_mejora]) => ({ .map(([campo_afectado, texto_mejora]) => ({
campo_afectado, campo_afectado,
texto_mejora, texto_mejora,
aplicada: false, aplicada: false,
})) }));
: [],
};
// Insertar en plan_mensajes_ia // ACTUALIZAR EL MOLDE QUE DEJAMOS EN "GENERANDO"
const { error: insertErr } = await supabase await supabase
.from("asignatura_mensajes_ia") .from("plan_mensajes_ia")
.insert({ .update({
conversacion_asignatura_id: conversation_plan_id, respuesta: aiMessage,
enviado_por: "00000000-0000-0000-0000-000000000000", propuesta: { recommendations },
mensaje: prompt, is_refusal: isRefusal,
respuesta: respuestaJSON?.["ai-message"] ?? "", estado: "COMPLETADO" // Ahora el front ya puede mostrarlo
campos: body.campos ?? [], })
propuesta, .eq("id", registroId);
is_refusal: refusal, }
estado: "COMPLETADO",
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,
},
},
},
}); });
if (insertErr) { const resJson = JSON.parse(resp.output_text ?? "{}");
throw new HttpError( aiMessage = resJson["ai-message"] ?? "";
500, isRefusal = !!resJson["is_refusal"];
"insert_plan_mensaje_failed", openAiRespId = resp.id;
"No se pudo guardar el mensaje en plan_mensajes_ia",
insertErr, } 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,
}));
} }
if (appendRespErr) { // 2. Guardar Histórico y Mensaje (Común a ambos flujos)
throw new HttpError( await supabase.rpc("append_conversacion_asignatura", {
500, p_id: conversation_asig_id,
"append_response_failed", p_append: { timestamp: new Date().toISOString(), user: "user", prompt: body.content },
"No se pudo agregar la respuesta a la conversación", });
appendRespErr,
); 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({ return withCors(jsonResponse({
ok: true, ok: true,
openai_response_id: resp.id, openai_response_id: openAiRespId,
raw: resp.output_text ?? null, ai_message: aiMessage,
recommendations
})); }));
} catch (err) { } catch (err) {
return withCors(handleErr(err)); return withCors(handleErr(err));
} }
@@ -46,6 +46,37 @@ export function pickSchemaFields(
return out; return out;
} }
export function pickSchemaAsignaturaFields(
definicion: any,
campos: string[],
) {
// Si no hay definición válida, devolvemos un objeto vacío seguro
if (!definicion || definicion.type !== "object" || !definicion.properties) {
return { type: "object", properties: {}, required: [], additionalProperties: false };
}
// Clonamos para no mutar el original
const out = structuredClone(definicion);
// 1. Filtrar las propiedades: solo las que el usuario pidió en 'campos'
const entries = Object.entries(out.properties).filter(([k]) =>
campos.includes(k)
);
out.properties = Object.fromEntries(entries);
// 2. Filtrar los requeridos: solo si están en el nuevo set de propiedades
if (Array.isArray(out.required)) {
out.required = out.required.filter((k: string) => campos.includes(k));
} else {
out.required = [];
}
// 3. Limpieza de OpenAI: Forzar additionalProperties a false
out.additionalProperties = false;
return out;
}
export function safePlanForPrompt(plan: any) { export function safePlanForPrompt(plan: any) {
const copy = structuredClone(plan); const copy = structuredClone(plan);
if (copy?.estructuras_plan) delete copy.estructuras_plan; if (copy?.estructuras_plan) delete copy.estructuras_plan;