Merge pull request 'Chats de ia en segundo plano para asignaturas #51' (#52) from issue/51-chats-de-ia-en-segundo-plano-para-asignaturas into main
Reviewed-on: AlexRG/genesis-2#52
This commit was merged in pull request #52.
This commit is contained in:
@@ -0,0 +1,100 @@
|
|||||||
|
// ./plan_mensajes_ia/index.ts
|
||||||
|
import type { OpenAI } from "openai";
|
||||||
|
|
||||||
|
import type { Json } from "../../_shared/database.types.ts";
|
||||||
|
import { ResponseMetadata } from "../../_shared/utils.ts";
|
||||||
|
import { supabase } from "../../openai-webhook-responses/supabase.ts";
|
||||||
|
|
||||||
|
function extractOutputText(response: OpenAI.Responses.Response): string {
|
||||||
|
const direct = (response as any).output_text;
|
||||||
|
if (typeof direct === "string") return direct;
|
||||||
|
|
||||||
|
const output = (response as any).output;
|
||||||
|
if (!Array.isArray(output)) return "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
return output
|
||||||
|
.filter((item) => item?.type === "message")
|
||||||
|
.flatMap((item) => item?.content ?? [])
|
||||||
|
.filter((c) => c?.type === "output_text")
|
||||||
|
.map((c) => String(c?.text ?? ""))
|
||||||
|
.join("");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAsignaturaMensajesResponse(
|
||||||
|
response: OpenAI.Responses.Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const metadata = response.metadata as any;
|
||||||
|
const mensajeId = metadata?.mensaje_id;
|
||||||
|
|
||||||
|
console.log("Procesando Webhook para Asignatura. Mensaje ID:", mensajeId);
|
||||||
|
|
||||||
|
const isStructured = metadata?.is_structured === "true" || metadata?.is_structured === true;
|
||||||
|
|
||||||
|
if (!mensajeId) {
|
||||||
|
console.warn("No se recibió mensaje_id en la metadata del webhook de asignatura");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outputText = extractOutputText(response);
|
||||||
|
if (!outputText) {
|
||||||
|
throw new Error("La respuesta de OpenAI está vacía");
|
||||||
|
}
|
||||||
|
|
||||||
|
let respuestaJSON: any;
|
||||||
|
try {
|
||||||
|
respuestaJSON = JSON.parse(outputText);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Error parseando JSON de OpenAI: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalización de campos de la IA
|
||||||
|
const aiMessage = respuestaJSON["ai-message"] || respuestaJSON["ai_message"] || "";
|
||||||
|
const is_refusal = !!respuestaJSON.is_refusal || respuestaJSON["is-refusal"] === true;
|
||||||
|
|
||||||
|
let recommendations: any[] = [];
|
||||||
|
|
||||||
|
// Si es estructurado y no es un rechazo de la IA, generamos las recomendaciones
|
||||||
|
if (isStructured && !is_refusal) {
|
||||||
|
recommendations = Object.entries(respuestaJSON)
|
||||||
|
.filter(([k]) => !["ai-message", "ai_message", "is-refusal", "is_refusal"].includes(k))
|
||||||
|
.map(([campo, valor]) => ({
|
||||||
|
campo_afectado: campo,
|
||||||
|
texto_mejora: valor,
|
||||||
|
aplicada: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CAMBIO CLAVE: TABLA 'asignatura_mensajes_ia' ---
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("asignatura_mensajes_ia")
|
||||||
|
.update({
|
||||||
|
respuesta: aiMessage,
|
||||||
|
// Guardamos la propuesta completa para mantener historial
|
||||||
|
propuesta: {
|
||||||
|
respuesta: aiMessage,
|
||||||
|
recommendations
|
||||||
|
},
|
||||||
|
is_refusal,
|
||||||
|
estado: "COMPLETADO",
|
||||||
|
})
|
||||||
|
.eq("id", mensajeId);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
console.log(`Mensaje de asignatura ${mensajeId} actualizado con éxito.`);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error en handleAsignaturaMensajesResponse:", { mensajeId, error: e.message });
|
||||||
|
|
||||||
|
// Marcamos como error en la tabla correcta para que el front deje de mostrar el spinner
|
||||||
|
await supabase
|
||||||
|
.from("asignatura_mensajes_ia")
|
||||||
|
.update({ estado: "ERROR" })
|
||||||
|
.eq("id", mensajeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -290,123 +290,93 @@ app.post(`${prefix}/conversations/asignatura/:id/messages`, async (c) => {
|
|||||||
assertUuid(conversation_asig_id, "conversation_asig_id");
|
assertUuid(conversation_asig_id, "conversation_asig_id");
|
||||||
|
|
||||||
const body = (await c.req.json().catch(() => ({}))) as Partial<AddMessageBody>;
|
const body = (await c.req.json().catch(() => ({}))) as Partial<AddMessageBody>;
|
||||||
if (!body.content) throw new HttpError(400, "bad_input", "content es requerido");
|
if (!body.content || typeof body.content !== "string") {
|
||||||
|
throw new HttpError(400, "bad_input", "content es requerido");
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = getSupabaseServiceClient();
|
const supabase = getSupabaseServiceClient();
|
||||||
const openai = getOpenAI();
|
// Usamos el servicio que ya tienes configurado para background
|
||||||
|
const svc = OpenAIService.fromEnv();
|
||||||
|
|
||||||
// 1. Traer datos
|
// 1. Traer datos de la asignatura
|
||||||
const { data: row, error } = await supabase
|
const { data: row, error } = await supabase
|
||||||
.from("conversaciones_asignatura")
|
.from("conversaciones_asignatura")
|
||||||
.select(`*, asignaturas(*, estructuras_asignatura(definicion))`)
|
.select(`id, openai_conversation_id, asignatura_id, asignaturas(*, estructuras_asignatura(definicion))`)
|
||||||
.eq("id", conversation_asig_id)
|
.eq("id", conversation_asig_id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error || !row) throw new HttpError(404, "not_found", "Conversación no encontrada");
|
if (error || !row) throw new HttpError(404, "not_found", "Conversación no encontrada");
|
||||||
|
|
||||||
const asignatura = row.asignaturas;
|
const asignatura = (row as any).asignaturas;
|
||||||
const definicion = asignatura?.estructuras_asignatura?.definicion;
|
const definicion = asignatura?.estructuras_asignatura?.definicion;
|
||||||
const wantsStructured = !!definicion && (body.campos?.length ?? 0) > 0;
|
const isStructured = !!definicion && (body.campos?.length ?? 0) > 0;
|
||||||
|
|
||||||
let aiMessage = "";
|
// 2. Insertar el mensaje en estado PROCESANDO (para que el front vea el spinner)
|
||||||
let isRefusal = false;
|
const { data: mensajeInsertado, error: insertErr } = await supabase
|
||||||
let recommendations: any[] = [];
|
.from("asignatura_mensajes_ia")
|
||||||
let openAiRespId = "";
|
.insert({
|
||||||
|
conversacion_asignatura_id: conversation_asig_id,
|
||||||
|
enviado_por: "00000000-0000-0000-0000-000000000000",
|
||||||
|
mensaje: body.content,
|
||||||
|
campos: body.campos ?? [],
|
||||||
|
estado: "PROCESANDO",
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
// --- FLUJO A: NO ESTRUCTURADO ---
|
if (insertErr) throw new HttpError(500, "db_error", "No se pudo crear el registro");
|
||||||
if (!wantsStructured) {
|
|
||||||
const resp = await openai.responses.create({
|
// 3. Preparar Schema (Usando tu lógica de asignatura)
|
||||||
conversation: row.openai_conversation_id,
|
const schema = isStructured
|
||||||
model: CREATE_CHAT_CONVERSATION_NONSTRUCTURED_MODELO,
|
? pickSchemaAsignaturaFields(definicion, body.campos ?? [])
|
||||||
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",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
"ai-message": { type: "string" },
|
"ai-message": { type: "string" },
|
||||||
"is_refusal": { type: "boolean" },
|
"is_refusal": { type: "boolean" }
|
||||||
},
|
},
|
||||||
required: ["ai-message", "is_refusal"],
|
required: ["ai-message", "is_refusal"],
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const resJson = JSON.parse(resp.output_text ?? "{}");
|
|
||||||
aiMessage = resJson["ai-message"] ?? "";
|
|
||||||
isRefusal = !!resJson["is_refusal"];
|
|
||||||
openAiRespId = resp.id;
|
|
||||||
|
|
||||||
} 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
|
additionalProperties: false
|
||||||
};
|
};
|
||||||
|
|
||||||
const resp = await openai.responses.create({
|
// 4. Llamada asincrónica con background: true
|
||||||
|
const aiResult = await svc.createStructuredResponse({
|
||||||
conversation: row.openai_conversation_id,
|
conversation: row.openai_conversation_id,
|
||||||
model: CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO,
|
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: {
|
text: {
|
||||||
format: { type: "json_schema", name: "mejora_asignatura", strict: true, schema: finalSchema }
|
format: {
|
||||||
|
type: "json_schema",
|
||||||
|
name: isStructured ? "mejora_asignatura" : "respuesta_basica",
|
||||||
|
schema: schema
|
||||||
|
}
|
||||||
},
|
},
|
||||||
input: [
|
input: [
|
||||||
{ role: "system", content: `Asistente curricular. Datos: ${JSON.stringify(asignatura)}` },
|
{
|
||||||
|
role: "system",
|
||||||
|
content: isStructured
|
||||||
|
? `Asistente de asignatura. Datos: ${JSON.stringify(asignatura)}`
|
||||||
|
: "Eres un experto en diseño curricular."
|
||||||
|
},
|
||||||
{ role: "user", content: body.content },
|
{ role: "user", content: body.content },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const resJson = JSON.parse(resp.output_text ?? "{}");
|
if (!aiResult.ok) {
|
||||||
aiMessage = resJson["ai-message"] ?? "";
|
throw new HttpError(500, "openai_error", "No se pudo encolar la respuesta");
|
||||||
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)
|
// 5. Responder al cliente de inmediato
|
||||||
await supabase.rpc("append_conversacion_asignatura", {
|
|
||||||
p_id: conversation_asig_id,
|
|
||||||
p_append: { timestamp: new Date().toISOString(), user: "user", prompt: body.content },
|
|
||||||
});
|
|
||||||
|
|
||||||
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: openAiRespId,
|
mensaje_id: mensajeInsertado.id,
|
||||||
ai_message: aiMessage,
|
openai_response_id: aiResult.responseId
|
||||||
recommendations
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -45,36 +45,48 @@ export function pickSchemaFields(
|
|||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pickSchemaAsignaturaFields(
|
export function pickSchemaAsignaturaFields(
|
||||||
definicion: any,
|
definicion: any,
|
||||||
campos: string[],
|
campos: string[],
|
||||||
) {
|
) {
|
||||||
// Si no hay definición válida, devolvemos un objeto vacío seguro
|
// 1. Validación inicial
|
||||||
if (!definicion || definicion.type !== "object" || !definicion.properties) {
|
if (!definicion || definicion.type !== "object" || !definicion.properties) {
|
||||||
return { type: "object", properties: {}, required: [], additionalProperties: false };
|
return {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
"ai-message": { type: "string" },
|
||||||
|
"is_refusal": { type: "boolean" }
|
||||||
|
},
|
||||||
|
required: ["ai-message", "is_refusal"],
|
||||||
|
additionalProperties: false
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clonamos para no mutar el original
|
// 2. Filtrar solo las propiedades técnicas que el usuario pidió
|
||||||
const out = structuredClone(definicion);
|
const filteredProperties = Object.fromEntries(
|
||||||
|
Object.entries(definicion.properties).filter(([k]) => campos.includes(k))
|
||||||
// 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
|
// 3. RECONSTRUIR el esquema incluyendo SIEMPRE los campos de control
|
||||||
if (Array.isArray(out.required)) {
|
const finalSchema = {
|
||||||
out.required = out.required.filter((k: string) => campos.includes(k));
|
type: "object",
|
||||||
} else {
|
properties: {
|
||||||
out.required = [];
|
"ai-message": {
|
||||||
}
|
type: "string",
|
||||||
|
description: "Tu respuesta conversacional dirigida al profesor."
|
||||||
|
},
|
||||||
|
"is_refusal": {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Indica si la solicitud es inapropiada o no relacionada."
|
||||||
|
},
|
||||||
|
...filteredProperties // Aquí entran objetivo, contenido, etc.
|
||||||
|
},
|
||||||
|
// Forzamos que ai-message e is_refusal sean obligatorios siempre
|
||||||
|
required: ["ai-message", "is_refusal", ...Object.keys(filteredProperties)],
|
||||||
|
additionalProperties: false
|
||||||
|
};
|
||||||
|
|
||||||
// 3. Limpieza de OpenAI: Forzar additionalProperties a false
|
return finalSchema;
|
||||||
out.additionalProperties = false;
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function safePlanForPrompt(plan: any) {
|
export function safePlanForPrompt(plan: any) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ResponseMetadata } from "../_shared/utils.ts";
|
|||||||
import { handlePlanesEstudioResponse } from "./planes_estudio/index.ts";
|
import { handlePlanesEstudioResponse } from "./planes_estudio/index.ts";
|
||||||
import { handleAsignaturasResponse } from "./asignaturas/index.ts";
|
import { handleAsignaturasResponse } from "./asignaturas/index.ts";
|
||||||
import { handlePlanMensajesResponse } from "../create-chat-conversation/plan/crear.ts";
|
import { handlePlanMensajesResponse } from "../create-chat-conversation/plan/crear.ts";
|
||||||
|
import { handleAsignaturaMensajesResponse } from "../create-chat-conversation/asignatura/crear.ts";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -40,6 +41,11 @@ async function handleCompletedResponse(
|
|||||||
case "plan_mensajes_ia":
|
case "plan_mensajes_ia":
|
||||||
await handlePlanMensajesResponse(response);
|
await handlePlanMensajesResponse(response);
|
||||||
break;
|
break;
|
||||||
|
case "asignatura_mensajes_ia":
|
||||||
|
console.log("modificando asignatura");
|
||||||
|
|
||||||
|
await handleAsignaturaMensajesResponse(response);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn("Tabla no reconocida:", metadata.tabla);
|
console.warn("Tabla no reconocida:", metadata.tabla);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user