From 26c6ad7c20b6eb02a306f9f29c9abe03cc878ff9 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Mon, 23 Feb 2026 17:06:34 -0600 Subject: [PATCH] =?UTF-8?q?close=20#31:=20Se=20genera=20el=20contenido=20t?= =?UTF-8?q?em=C3=A1tico=20con=20la=20IA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supabase/functions/_shared/database.types.ts | 3 + .../functions/ai-generate-subject/index.ts | 154 +++++++++++++++++- 2 files changed, 151 insertions(+), 6 deletions(-) diff --git a/supabase/functions/_shared/database.types.ts b/supabase/functions/_shared/database.types.ts index c0dc240..3e1d507 100644 --- a/supabase/functions/_shared/database.types.ts +++ b/supabase/functions/_shared/database.types.ts @@ -460,6 +460,7 @@ export type Database = { estado: Database["public"]["Enums"]["estado_conversacion"] id: string intento_archivado: number + nombre: string | null openai_conversation_id: string plan_estudio_id: string } @@ -472,6 +473,7 @@ export type Database = { estado?: Database["public"]["Enums"]["estado_conversacion"] id?: string intento_archivado?: number + nombre?: string | null openai_conversation_id: string plan_estudio_id: string } @@ -484,6 +486,7 @@ export type Database = { estado?: Database["public"]["Enums"]["estado_conversacion"] id?: string intento_archivado?: number + nombre?: string | null openai_conversation_id?: string plan_estudio_id?: string } diff --git a/supabase/functions/ai-generate-subject/index.ts b/supabase/functions/ai-generate-subject/index.ts index 175cb58..b83a63e 100644 --- a/supabase/functions/ai-generate-subject/index.ts +++ b/supabase/functions/ai-generate-subject/index.ts @@ -9,7 +9,9 @@ import { type StructuredResponseOptions, } from "../_shared/openai-service.ts"; -addEventListener("beforeunload", (ev: any) => { +type BeforeUnloadWithDetail = Event & { detail?: { reason?: unknown } }; + +addEventListener("beforeunload", (ev: BeforeUnloadWithDetail) => { console.error( "ALERTA: La función se va a apagar. Razón:", ev?.detail?.reason, @@ -67,6 +69,55 @@ const JsonUpdateSchema = z type EdgeAIGenerateSubjectJsonUpdateInput = z.infer; +function withColumnDefsAndRefs( + schemaDef: Record, +): Record { + const nextSchema: Record = { + ...schemaDef, + $defs: definicionesDeEstructurasDeColumnas, + }; + + const props = nextSchema["properties"]; + if (!props || typeof props !== "object" || Array.isArray(props)) { + return nextSchema; + } + + const nextProps: Record = { + ...props as Record, + }; + for (const [key, value] of Object.entries(nextProps)) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const xColumn = (value as Record)["x-column"]; + if (typeof xColumn !== "string" || !xColumn.length) continue; + nextProps[key] = { $ref: `#/$defs/${xColumn}` }; + } + + nextSchema["properties"] = nextProps; + return nextSchema; +} + +function splitAiOutputStringsAndColumns( + aiOutput: unknown, +): { aiOutputJson: Json; columnasGeneradas: Record } { + if (!aiOutput || typeof aiOutput !== "object" || Array.isArray(aiOutput)) { + return { aiOutputJson: aiOutput as unknown as Json, columnasGeneradas: {} }; + } + + const record = aiOutput as Record; + const stringsOnly: Record = {}; + const columnasGeneradas: Record = {}; + + for (const [key, value] of Object.entries(record)) { + if (typeof value === "string") { + stringsOnly[key] = value; + } else { + columnasGeneradas[key] = value; + } + } + + return { aiOutputJson: stringsOnly as unknown as Json, columnasGeneradas }; +} + function formatZodIssues(issues: z.ZodIssue[]): string { return issues .map((issue, i) => { @@ -313,7 +364,11 @@ Deno.serve(async (req: Request): Promise => { }\n` + `- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${ payload.descripcionEnfoqueAcademico ?? "(ninguna)" - }`; + }\n\n` + + `REGLA ESTRICTA MATEMÁTICA:\n` + + `Si generas el 'contenido_tematico', la suma total de las 'horasEstimadas' de todos los temas en todas las unidades DEBE coincidir exactamente con el total de Horas académicas indicadas arriba (${ + resolved.horas_academicas ?? 0 + }). No te pases ni te falten horas.`; const schemaDef: Record = typeof estructura?.definicion === "object" && @@ -321,6 +376,8 @@ Deno.serve(async (req: Request): Promise => { ? (estructura.definicion as Record) : {}; + const schemaForAI = withColumnDefsAndRefs(schemaDef); + const aiStructuredPayload: StructuredResponseOptions = { model: AI_GENERATE_SUBJECT_UPDATE_MODELO, input: [ @@ -331,7 +388,7 @@ Deno.serve(async (req: Request): Promise => { format: { type: "json_schema", name: "asignatura_contenido_tematico", - schema: schemaDef, + schema: schemaForAI, strict: true, }, }, @@ -380,7 +437,8 @@ Deno.serve(async (req: Request): Promise => { ); } - const aiOutputJson: Json = aiOutput as unknown as Json; + const { aiOutputJson, columnasGeneradas } = + splitAiOutputStringsAndColumns(aiOutput); const updatePatch: Database["public"]["Tables"]["asignaturas"]["Update"] = { @@ -389,6 +447,17 @@ Deno.serve(async (req: Request): Promise => { estado: "borrador", }; + for (const value of Object.values(columnasGeneradas)) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + continue; + } + const xColumn = (value as Record)["x-column"]; + const xDef = (value as Record)["x-definicion"]; + if (typeof xColumn !== "string" || !xColumn.length) continue; + (updatePatch as unknown as Record)[xColumn] = + xDef as unknown as Json; + } + // Apply only provided JSON fields (do not overwrite if missing) if (payload.plan_estudio_id !== undefined) { updatePatch.plan_estudio_id = payload.plan_estudio_id; @@ -561,6 +630,8 @@ Deno.serve(async (req: Request): Promise => { ? (estructura.definicion as Record) : {}; + const schemaForAI = withColumnDefsAndRefs(schemaDef); + const aiStructuredPayload: StructuredResponseOptions = { model: AI_GENERATE_SUBJECT_INSERT_MODELO, input: [ @@ -571,7 +642,7 @@ Deno.serve(async (req: Request): Promise => { format: { type: "json_schema", name: "asignatura_contenido_tematico", - schema: schemaDef, + schema: schemaForAI, strict: true, }, }, @@ -623,7 +694,9 @@ Deno.serve(async (req: Request): Promise => { ); } - const aiOutputJson: Json = aiOutput as unknown as Json; + const { aiOutputJson, columnasGeneradas } = splitAiOutputStringsAndColumns( + aiOutput, + ); const asignaturaInsert: Database["public"]["Tables"]["asignaturas"]["Insert"] = { @@ -661,6 +734,15 @@ Deno.serve(async (req: Request): Promise => { } as unknown as Json, }; + for (const value of Object.values(columnasGeneradas)) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const xColumn = (value as Record)["x-column"]; + const xDef = (value as Record)["x-definicion"]; + if (typeof xColumn !== "string" || !xColumn.length) continue; + (asignaturaInsert as unknown as Record)[xColumn] = + xDef as unknown as Json; + } + const { data: asignatura, error: asignaturaError } = await supabaseService .from("asignaturas") .insert(asignaturaInsert) @@ -798,3 +880,63 @@ function parseAndValidate( return { success: true, data: result.data }; } + +const definicionesDeEstructurasDeColumnas = { + contenido_tematico: { + "type": "object", + "properties": { + "x-column": { + "type": "string", + "enum": [ + "contenido_tematico", + ], + }, + "x-definicion": { + "type": "array", + "items": { + "type": "object", + "properties": { + "unidad": { + "type": "integer", + }, + "titulo": { + "type": "string", + }, + "temas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "nombre": { + "type": "string", + }, + "horasEstimadas": { + "type": "integer", + "description": + "Horas del tema. La suma de todas las horasEstimadas debe ser igual a las horas académicas del prompt.", + }, + }, + "required": [ + "nombre", + "horasEstimadas", + ], + "additionalProperties": false, + }, + }, + }, + "required": [ + "unidad", + "titulo", + "temas", + ], + "additionalProperties": false, + }, + }, + }, + "required": [ + "x-column", + "x-definicion", + ], + "additionalProperties": false, + }, +};