Agregar contexto de la asignatura al chat #55 #58

Merged
roberto.silva merged 2 commits from issue/51-chats-de-ia-en-segundo-plano-para-asignaturas into main 2026-03-10 22:15:47 +00:00
2 changed files with 82 additions and 40 deletions
@@ -3,7 +3,7 @@ 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 } from "./lib/plan.ts";
import { assertUuid, pickSchemaFields, safePlanForPrompt,pickSchemaAsignaturaFields, safeAsignaturaForPrompt, getAsignaturaSystemPrompt } from "./lib/plan.ts";
import { OpenAIService } from "../_shared/openai-service.ts";
type CreateBody = {
@@ -309,8 +309,8 @@ app.post(`${prefix}/conversations/asignatura/:id/messages`, async (c) => {
const asignatura = (row as any).asignaturas;
const definicion = asignatura?.estructuras_asignatura?.definicion;
const isStructured = !!definicion && (body.campos?.length ?? 0) > 0;
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")
@@ -339,6 +339,8 @@ app.post(`${prefix}/conversations/asignatura/:id/messages`, async (c) => {
additionalProperties: false
};
// 4. Llamada asincrónica con background: true
const aiResult = await svc.createStructuredResponse({
conversation: row.openai_conversation_id,
@@ -353,17 +355,12 @@ app.post(`${prefix}/conversations/asignatura/:id/messages`, async (c) => {
text: {
format: {
type: "json_schema",
name: isStructured ? "mejora_asignatura" : "respuesta_basica",
name: "mejoras_asignatura",
schema: schema
}
},
input: [
{
role: "system",
content: isStructured
? `Asistente de asignatura. Datos: ${JSON.stringify(asignatura)}`
: "Eres un experto en diseño curricular."
},
{ role: "system", content: getAsignaturaSystemPrompt(asignatura, campos) },
{ role: "user", content: body.content },
],
});
@@ -49,46 +49,54 @@ export function pickSchemaAsignaturaFields(
definicion: any,
campos: string[],
) {
// 1. Validación inicial
console.log(campos);
console.log(definicion);
if (!definicion || definicion.type !== "object" || !definicion.properties) {
return {
type: "object",
properties: {
"ai-message": { type: "string" },
"is_refusal": { type: "boolean" }
},
required: ["ai-message", "is_refusal"],
additionalProperties: false
};
return definicion;
}
// 2. Filtrar solo las propiedades técnicas que el usuario pidió
const filteredProperties = Object.fromEntries(
Object.entries(definicion.properties).filter(([k]) => campos.includes(k))
);
// 3. RECONSTRUIR el esquema incluyendo SIEMPRE los campos de control
const finalSchema = {
type: "object",
// Campos extra de control
const extra = {
properties: {
"ai-message": {
type: "string",
description: "Tu respuesta conversacional dirigida al profesor."
"ai-message": {
type: "string",
description: "Tu respuesta conversacional dirigida al profesor explicando qué mejoraste.",
},
"is_refusal": {
type: "boolean",
description: "Indica si la solicitud es inapropiada o no relacionada."
"is_refusal": {
type: "boolean",
description: "Indica si la solicitud es inapropiada.",
},
...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
};
return finalSchema;
}
const out = structuredClone(definicion);
// FILTRADO: Si el usuario pidió campos específicos, filtramos.
// Si campos está vacío, la lógica de Planes devolvería todo,
// pero aquí asumimos que siempre vienen campos si isStructured es true.
const entries = Object.entries(out.properties).filter(([k]) =>
campos.includes(k)
);
out.properties = Object.fromEntries(entries);
if (Array.isArray(out.required)) {
out.required = out.required.filter((k: string) => campos.includes(k));
}
// SIEMPRE agregamos los campos de control (ai-message, is_refusal)
out.properties = { ...out.properties, ...extra.properties };
// Reconstruimos los requeridos para que OpenAI sepa que DEBE llenar los campos solicitados
out.required = Array.isArray(out.required)
? [...new Set([...out.required, ...Object.keys(extra.properties)])]
: Object.keys(extra.properties);
// Forzamos que no invente campos
out.additionalProperties = false;
return out;
}
export function safePlanForPrompt(plan: any) {
const copy = structuredClone(plan);
if (copy?.estructuras_plan) delete copy.estructuras_plan;
@@ -101,3 +109,40 @@ export function assertUuid(v: string, name: string) {
throw new HttpError(400, "bad_input", `Invalid ${name}`);
}
}
export function safeAsignaturaForPrompt(asignatura: any) {
const copy = structuredClone(asignatura);
// Eliminamos la definición de la estructura para que no ensucie el prompt
// y solo queden los datos reales de la asignatura
if (copy?.estructuras_asignatura) delete copy.estructuras_asignatura;
return copy;
}
export function getAsignaturaSystemPrompt(asignatura: any, campos: string[]) {
const asignaturaLimpia = safeAsignaturaForPrompt(asignatura);
const nombreAsig = asignaturaLimpia?.nombre || "la asignatura";
if (campos.length === 0) {
return `Eres un asistente experto en diseño curricular.
Tu objetivo es ayudar al profesor a mejorar su asignatura: "${nombreAsig}".
DATOS ACTUALES: ${JSON.stringify(asignaturaLimpia)}.
COMPORTAMIENTO:
- El usuario aún no ha seleccionado campos específicos para mejorar.
- NO propongas cambios técnicos detallados ni rellenes campos del JSON todavía.
- Saluda cordialmente y menciona qué partes de esta asignatura puedes ayudar a mejorar (objetivos, contenidos, criterios, etc.).
- Mantén una conversación fluida y espera a que el usuario elija qué quiere trabajar.`;
}
return `Eres un asistente experto en diseño curricular trabajando sobre: "${nombreAsig}".
DATOS ACTUALES: ${JSON.stringify(asignaturaLimpia)}.
TAREA CRÍTICA:
El usuario ha solicitado mejorar estos ${campos.length} campos: ${campos.join(", ")}.
REGLAS DE ORO:
1. Debes proporcionar una propuesta de mejora para CADA UNO de los campos solicitados.
2. No omitas ninguno. Si un campo no requiere cambios drásticos, optimiza su redacción técnica.
3. En el JSON, cada campo debe contener tu propuesta de texto mejorado.
4. En 'ai-message', resume los cambios hechos en cada uno de los campos solicitados.`;
}