|
|
|
@@ -3,7 +3,8 @@ 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";
|
|
|
|
|
import { assertUuid, pickSchemaFields, safePlanForPrompt,pickSchemaAsignaturaFields } from "./lib/plan.ts";
|
|
|
|
|
import { OpenAIService } from "../_shared/openai-service.ts";
|
|
|
|
|
|
|
|
|
|
type CreateBody = {
|
|
|
|
|
plan_estudio_id: string;
|
|
|
|
@@ -125,20 +126,17 @@ app.post(`${prefix}/plan/conversations`, async (c) => {
|
|
|
|
|
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 ??
|
|
|
|
|
"En caso de que te pidan algo que no tiene nada que ver con la asignatura responde con un refusal.";
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
// 🔥 Cargar asignatura
|
|
|
|
|
// 1. Verificar que la asignatura existe
|
|
|
|
|
const { data: asignatura, error: asigErr } = await supabase
|
|
|
|
|
.from("asignaturas")
|
|
|
|
|
.select("*")
|
|
|
|
@@ -146,15 +144,10 @@ app.post(`${prefix}/asignatura/conversations`, async (c) => {
|
|
|
|
|
.single();
|
|
|
|
|
|
|
|
|
|
if (asigErr || !asignatura) {
|
|
|
|
|
throw new HttpError(
|
|
|
|
|
404,
|
|
|
|
|
"asignatura_not_found",
|
|
|
|
|
"Asignatura no encontrada",
|
|
|
|
|
asigErr,
|
|
|
|
|
);
|
|
|
|
|
throw new HttpError(404, "asignatura_not_found", "Asignatura no encontrada");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 🔥 Crear conversación en OpenAI
|
|
|
|
|
// 2. Crear conversación en OpenAI
|
|
|
|
|
const conv = await openai.conversations.create({
|
|
|
|
|
metadata: {
|
|
|
|
|
tabla: "asignaturas",
|
|
|
|
@@ -164,28 +157,22 @@ app.post(`${prefix}/asignatura/conversations`, async (c) => {
|
|
|
|
|
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
|
|
|
|
|
.from("conversaciones_asignatura")
|
|
|
|
|
.insert({
|
|
|
|
|
openai_conversation_id: conv.id,
|
|
|
|
|
asignatura_id: asignatura.id, // ✅ CORRECTO
|
|
|
|
|
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",
|
|
|
|
|
"No se pudo registrar la conversación",
|
|
|
|
|
insErr,
|
|
|
|
|
);
|
|
|
|
|
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));
|
|
|
|
@@ -210,389 +197,218 @@ app.post(`${prefix}/conversations/plan/:id/messages`, async (c) => {
|
|
|
|
|
throw new HttpError(400, "bad_input", "content es requerido");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log("Iniciando generación en background para mensaje_id:");
|
|
|
|
|
const supabase = getSupabaseServiceClient();
|
|
|
|
|
const openai = getOpenAI();
|
|
|
|
|
const svc = OpenAIService.fromEnv();
|
|
|
|
|
|
|
|
|
|
// 1. Traer datos de la conversación y el plan
|
|
|
|
|
// 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))",
|
|
|
|
|
)
|
|
|
|
|
.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");
|
|
|
|
|
}
|
|
|
|
|
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 wantsStructured = !!definicion;
|
|
|
|
|
const isStructured = !!definicion;
|
|
|
|
|
|
|
|
|
|
// --- LÓGICA PARA CASO 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: `Este es el plan de estudios actual ${JSON.stringify(plan)}. Si la pregunta no tiene que ver con el plan, responde con un refusal.`,
|
|
|
|
|
},
|
|
|
|
|
{ role: "user", content: body.content },
|
|
|
|
|
],
|
|
|
|
|
text: {
|
|
|
|
|
format: {
|
|
|
|
|
type: "json_schema",
|
|
|
|
|
name: "definicion",
|
|
|
|
|
schema: {
|
|
|
|
|
type: "object",
|
|
|
|
|
properties: {
|
|
|
|
|
"ai-message": { type: "string" },
|
|
|
|
|
"is_refusal": { type: "boolean" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const respuestaJSON = JSON.parse(resp.output_text ?? "{}");
|
|
|
|
|
|
|
|
|
|
// Guardar en la nueva tabla aunque no sea estructurado
|
|
|
|
|
await supabase.from("plan_mensajes_ia").insert({
|
|
|
|
|
conversacion_plan_id,
|
|
|
|
|
// 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,
|
|
|
|
|
respuesta: respuestaJSON?.["ai-message"] ?? "",
|
|
|
|
|
campos: [],
|
|
|
|
|
propuesta: { recommendations: [] },
|
|
|
|
|
is_refusal: !!respuestaJSON.is_refusal,
|
|
|
|
|
estado: "COMPLETADO",
|
|
|
|
|
});
|
|
|
|
|
campos: body.campos ?? [],
|
|
|
|
|
estado: "PROCESANDO", // Estado inicial
|
|
|
|
|
})
|
|
|
|
|
.select()
|
|
|
|
|
.single();
|
|
|
|
|
|
|
|
|
|
return withCors(jsonResponse({ ok: true }));
|
|
|
|
|
}
|
|
|
|
|
if (insertErr) throw new HttpError(500, "db_error", "No se pudo crear el registro");
|
|
|
|
|
|
|
|
|
|
// --- LÓGICA PARA CASO ESTRUCTURADO ---
|
|
|
|
|
const schema = pickSchemaFields(definicion, body.campos ?? []);
|
|
|
|
|
const planForPrompt = safePlanForPrompt(plan);
|
|
|
|
|
const prompt = body.user_prompt ?? body.content;
|
|
|
|
|
// 3. Preparar Schema y Prompt
|
|
|
|
|
const schema = isStructured ? pickSchemaFields(definicion, body.campos ?? []) : {
|
|
|
|
|
type: "object",
|
|
|
|
|
properties: { "ai-message": { type: "string" }, "is_refusal": { type: "boolean" } }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const resp = await openai.responses.create({
|
|
|
|
|
// 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: CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO,
|
|
|
|
|
text: { format: { type: "json_schema", name: "definicion", schema } },
|
|
|
|
|
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: `Eres un asistente que ayuda a mejorar este plan: ${JSON.stringify(planForPrompt)}. Si la pregunta no es pertinente, responde con un refusal.`,
|
|
|
|
|
},
|
|
|
|
|
{ role: "user", content: prompt },
|
|
|
|
|
{ role: "system", content: `Asistente de plan: ${JSON.stringify(safePlanForPrompt(plan))}` },
|
|
|
|
|
{ role: "user", content: body.content },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const respuestaJSON = JSON.parse(resp.output_text ?? "{}");
|
|
|
|
|
const refusal = respuestaJSON["is-refusal"] === true;
|
|
|
|
|
delete respuestaJSON["is-refusal"];
|
|
|
|
|
|
|
|
|
|
// 2. Construir el objeto de propuestas/recomendaciones
|
|
|
|
|
const recommendations = resp.output_text
|
|
|
|
|
? Object.entries(respuestaJSON)
|
|
|
|
|
.filter(([k]) => k !== "ai-message")
|
|
|
|
|
.map(([campo_afectado, texto_mejora]) => ({
|
|
|
|
|
campo_afectado,
|
|
|
|
|
texto_mejora,
|
|
|
|
|
aplicada: false,
|
|
|
|
|
}))
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
// 3. Insertar ÚNICAMENTE en plan_mensajes_ia
|
|
|
|
|
const { error: insertErr } = await supabase
|
|
|
|
|
.from("plan_mensajes_ia")
|
|
|
|
|
.insert({
|
|
|
|
|
conversacion_plan_id: conversation_plan_id,
|
|
|
|
|
enviado_por: "00000000-0000-0000-0000-000000000000",
|
|
|
|
|
mensaje: prompt,
|
|
|
|
|
respuesta: respuestaJSON?.["ai-message"] ?? "",
|
|
|
|
|
campos: body.campos ?? [],
|
|
|
|
|
propuesta: { recommendations },
|
|
|
|
|
is_refusal: refusal,
|
|
|
|
|
estado: "COMPLETADO",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (insertErr) {
|
|
|
|
|
throw new HttpError(500, "insert_plan_mensaje_failed", "Error al guardar mensaje", insertErr);
|
|
|
|
|
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,
|
|
|
|
|
openai_response_id: resp.id,
|
|
|
|
|
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 auth = c.req.header("authorization");
|
|
|
|
|
const user = await requireUser(auth); */
|
|
|
|
|
const conversation_asig_id = c.req.param("id");
|
|
|
|
|
assertUuid(conversation_asig_id, "conversation_asig_id");
|
|
|
|
|
|
|
|
|
|
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 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();
|
|
|
|
|
|
|
|
|
|
// Traer conversacion + plan + estructura
|
|
|
|
|
// 1. Traer datos
|
|
|
|
|
const { data: row, error } = await supabase
|
|
|
|
|
.from("conversaciones_asignatura")
|
|
|
|
|
.select(
|
|
|
|
|
"id, openai_conversation_id, asignatura_id, estado, asignaturas(*)"
|
|
|
|
|
)
|
|
|
|
|
.eq("id", conversation_plan_id)
|
|
|
|
|
.single();
|
|
|
|
|
.from("conversaciones_asignatura")
|
|
|
|
|
.select(`*, asignaturas(*, estructuras_asignatura(definicion))`)
|
|
|
|
|
.eq("id", conversation_asig_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",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (error || !row) throw new HttpError(404, "not_found", "Conversación no encontrada");
|
|
|
|
|
|
|
|
|
|
const plan = (row as any).planes_estudio;
|
|
|
|
|
const definicion = plan?.estructuras_plan?.definicion;
|
|
|
|
|
const asignatura = row.asignaturas;
|
|
|
|
|
const definicion = asignatura?.estructuras_asignatura?.definicion;
|
|
|
|
|
const wantsStructured = !!definicion && (body.campos?.length ?? 0) > 0;
|
|
|
|
|
|
|
|
|
|
// Si NO hay schema o no piden campos: solo agregamos mensaje y regresamos ok
|
|
|
|
|
const wantsStructured = !!definicion;
|
|
|
|
|
let aiMessage = "";
|
|
|
|
|
let isRefusal = false;
|
|
|
|
|
let recommendations: any[] = [];
|
|
|
|
|
let openAiRespId = "";
|
|
|
|
|
|
|
|
|
|
// --- FLUJO A: NO ESTRUCTURADO ---
|
|
|
|
|
if (!wantsStructured) {
|
|
|
|
|
await openai.responses.create({
|
|
|
|
|
const resp = 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: "system", content: "Eres un experto en diseño curricular." },
|
|
|
|
|
{ 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",
|
|
|
|
|
name: "respuesta_basica",
|
|
|
|
|
strict: true,
|
|
|
|
|
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)",
|
|
|
|
|
},
|
|
|
|
|
"ai-message": { type: "string" },
|
|
|
|
|
"is_refusal": { type: "boolean" },
|
|
|
|
|
},
|
|
|
|
|
required: ["ai-message", "is_refusal"],
|
|
|
|
|
additionalProperties: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return withCors(jsonResponse({ ok: true }));
|
|
|
|
|
}
|
|
|
|
|
const resJson = JSON.parse(resp.output_text ?? "{}");
|
|
|
|
|
aiMessage = resJson["ai-message"] ?? "";
|
|
|
|
|
isRefusal = !!resJson["is_refusal"];
|
|
|
|
|
openAiRespId = resp.id;
|
|
|
|
|
|
|
|
|
|
// 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) {
|
|
|
|
|
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.`,
|
|
|
|
|
} 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
|
|
|
|
|
},
|
|
|
|
|
{ role: "user", content: prompt },
|
|
|
|
|
],
|
|
|
|
|
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 },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const respuestaJSON = JSON.parse(resp.output_text ?? "{}");
|
|
|
|
|
const refusal = respuestaJSON["is-refusal"] === true;
|
|
|
|
|
//remove the is-refusal field from respuestaJSON to avoid confusion
|
|
|
|
|
delete respuestaJSON["is-refusal"];
|
|
|
|
|
|
|
|
|
|
// Now an item with the assistant response and the structured data (if any) should be
|
|
|
|
|
appended = {
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
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]) => ({
|
|
|
|
|
campo_afectado,
|
|
|
|
|
texto_mejora,
|
|
|
|
|
aplicada: false,
|
|
|
|
|
}))
|
|
|
|
|
: [],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Insertar en plan_mensajes_ia
|
|
|
|
|
const { error: insertErr } = await supabase
|
|
|
|
|
.from("asignatura_mensajes_ia")
|
|
|
|
|
.insert({
|
|
|
|
|
conversacion_asignatura_id: conversation_plan_id,
|
|
|
|
|
enviado_por: "00000000-0000-0000-0000-000000000000",
|
|
|
|
|
mensaje: prompt,
|
|
|
|
|
respuesta: respuestaJSON?.["ai-message"] ?? "",
|
|
|
|
|
campos: body.campos ?? [],
|
|
|
|
|
propuesta,
|
|
|
|
|
is_refusal: refusal,
|
|
|
|
|
estado: "COMPLETADO",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (insertErr) {
|
|
|
|
|
throw new HttpError(
|
|
|
|
|
500,
|
|
|
|
|
"insert_plan_mensaje_failed",
|
|
|
|
|
"No se pudo guardar el mensaje en plan_mensajes_ia",
|
|
|
|
|
insertErr,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (appendRespErr) {
|
|
|
|
|
throw new HttpError(
|
|
|
|
|
500,
|
|
|
|
|
"append_response_failed",
|
|
|
|
|
"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({
|
|
|
|
|
ok: true,
|
|
|
|
|
openai_response_id: resp.id,
|
|
|
|
|
raw: resp.output_text ?? null,
|
|
|
|
|
openai_response_id: openAiRespId,
|
|
|
|
|
ai_message: aiMessage,
|
|
|
|
|
recommendations
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return withCors(handleErr(err));
|
|
|
|
|
}
|
|
|
|
|