From 24b6338074875d34089efd8610943ad8f8fbbfa2 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Wed, 11 Feb 2026 13:12:47 -0600 Subject: [PATCH] =?UTF-8?q?Implementar=20validaci=C3=B3n=20y=20generaci?= =?UTF-8?q?=C3=B3n=20de=20sugerencias=20de=20asignaturas=20con=20integraci?= =?UTF-8?q?=C3=B3n=20a=20Supabase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../generate-subject-suggestions/index.ts | 214 +++++++++++++++++- 1 file changed, 203 insertions(+), 11 deletions(-) diff --git a/supabase/functions/generate-subject-suggestions/index.ts b/supabase/functions/generate-subject-suggestions/index.ts index 2bb5ea1..312b26f 100644 --- a/supabase/functions/generate-subject-suggestions/index.ts +++ b/supabase/functions/generate-subject-suggestions/index.ts @@ -1,7 +1,8 @@ import "@supabase/functions-js/edge-runtime.d.ts"; import { corsHeaders } from "../_shared/cors.ts"; import { HttpError, sendError, sendSuccess } from "../_shared/utils.ts"; -import type { Tables } from "../_shared/database.types.ts"; +import { createClient } from "@supabase/supabase-js"; +import type { Database, Tables } from "../_shared/database.types.ts"; import { z } from "zod"; import { zodTextFormat } from "openai/helpers/zod"; import { @@ -32,14 +33,38 @@ const AsignaturaSugeridaItemSchema: z.ZodType = z descripcion: z.string(), }); -const AsignaturaSugeridaSchema = z - .object({ - sugerencias: z - .array(AsignaturaSugeridaItemSchema) - .length(6) - .describe("Arreglo de 6 sugerencias de asignatura"), - }) - .describe("Respuesta estructurada con 6 sugerencias de asignatura"); +const RequestSchema = z.object({ + plan_estudio_id: z.string().uuid(), + numero_de_ciclo: z.number().int().positive(), + enfoque: z.string().trim().min(1).optional(), + cantidad_de_sugerencias: z.number().int().positive().max(50), + sugerencias_conservadas: z + .array( + z.object({ + nombre: z.string().trim().min(1), + descripcion: z.string().trim().min(1), + }), + ) + .default([]), +}); + +function normalizeName(value: string): string { + return value + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); +} + +function formatZodIssues(issues: z.ZodIssue[]): string { + return issues + .map((issue, i) => { + const path = issue.path.length ? issue.path.join(".") : "(root)"; + return `${i + 1}. ${path}: ${issue.message}`; + }) + .join("\n"); +} Deno.serve(async (req: Request): Promise => { const url = new URL(req.url); @@ -74,6 +99,173 @@ Deno.serve(async (req: Request): Promise => { ); } + const contentType = (req.headers.get("content-type") || "").toLowerCase(); + if (!contentType.includes("application/json")) { + throw new HttpError( + 415, + "Content-Type no soportado.", + "UNSUPPORTED_MEDIA_TYPE", + { contentType }, + ); + } + + let rawBody: unknown; + try { + rawBody = await req.json(); + } catch (e) { + throw new HttpError( + 400, + "Body JSON inválido.", + "INVALID_JSON", + { cause: e }, + ); + } + + const validated = RequestSchema.safeParse(rawBody); + if (!validated.success) { + throw new HttpError( + 422, + formatZodIssues(validated.error.issues), + "VALIDATION_ERROR", + validated.error, + ); + } + + const { + plan_estudio_id, + numero_de_ciclo, + enfoque, + cantidad_de_sugerencias, + sugerencias_conservadas, + } = validated.data; + + const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); + const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { + throw new HttpError( + 500, + "Configuración del servidor incompleta.", + "MISSING_ENV", + { + missing: [ + !SUPABASE_URL ? "SUPABASE_URL" : null, + !SERVICE_ROLE_KEY ? "SUPABASE_SERVICE_ROLE_KEY" : null, + ].filter(Boolean), + }, + ); + } + + const supabaseService = createClient( + SUPABASE_URL, + SERVICE_ROLE_KEY, + ); + + const { data: plan, error: planError } = await supabaseService + .from("planes_estudio") + .select("id,nombre,nivel,tipo_ciclo,numero_ciclos,datos") + .eq("id", plan_estudio_id) + .single(); + if (planError) { + const maybeCode = (planError as { code?: string }).code; + if (maybeCode === "PGRST116") { + throw new HttpError( + 404, + "No se encontró el plan de estudio.", + "NOT_FOUND", + { table: "planes_estudio", id: plan_estudio_id }, + ); + } + throw new HttpError( + 500, + "No se pudo obtener el plan de estudio.", + "SUPABASE_QUERY_FAILED", + planError, + ); + } + + const { data: asignaturas, error: asignaturasError } = await supabaseService + .from("asignaturas") + .select("id,nombre,numero_ciclo,codigo,tipo,creditos") + .eq("plan_estudio_id", plan_estudio_id); + if (asignaturasError) { + throw new HttpError( + 500, + "No se pudieron obtener las asignaturas del plan de estudio.", + "SUPABASE_QUERY_FAILED", + asignaturasError, + ); + } + + const existingNames = new Set( + (asignaturas ?? []) + .map((a) => normalizeName(a.nombre)) + .filter(Boolean), + ); + + const conservedNames = new Set( + (sugerencias_conservadas ?? []) + .map((s) => normalizeName(s.nombre)) + .filter(Boolean), + ); + + const forbiddenNames = Array.from( + new Set([...existingNames, ...conservedNames]), + ); + + const asignaturasResumen = (asignaturas ?? []) + .map((a) => { + const ciclo = a.numero_ciclo == null ? "(sin ciclo)" : a.numero_ciclo; + const codigo = a.codigo ? ` - ${a.codigo}` : ""; + return `- [ciclo ${ciclo}] ${a.nombre}${codigo}`; + }) + .join("\n"); + + const sugerenciasConservadasResumen = (sugerencias_conservadas ?? []) + .map((s) => `- ${s.nombre}: ${s.descripcion}`) + .join("\n"); + + const systemPrompt = + "Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el esquema proporcionado."; + + const userPrompt = + `Necesito sugerencias NUEVAS de asignaturas para un plan de estudios.\n\n` + + `Plan de estudio:\n` + + `- id: ${plan.id}\n` + + `- nombre: ${plan.nombre}\n` + + `- nivel: ${plan.nivel}\n` + + `- tipo_ciclo: ${plan.tipo_ciclo}\n` + + `- numero_ciclos: ${plan.numero_ciclos}\n\n` + + `Datos del plan (JSON):\n${JSON.stringify(plan.datos)}\n\n` + + `Asignaturas existentes en el plan (NO repetir):\n${ + asignaturasResumen || "(ninguna)" + }\n\n` + + `Sugerencias conservadas por el usuario (NO repetir):\n${ + sugerenciasConservadasResumen || "(ninguna)" + }\n\n` + + `Ciclo objetivo: ${numero_de_ciclo}\n` + + `Enfoque (opcional): ${enfoque ?? "(ninguno)"}\n\n` + + `Requisitos estrictos:\n` + + `1) Genera EXACTAMENTE ${cantidad_de_sugerencias} sugerencias.\n` + + `2) No repitas nombres que ya existan en el plan ni los nombres de las sugerencias conservadas.\n` + + `3) Tampoco repitas nombres entre tus nuevas sugerencias.\n` + + `4) Evita nombres demasiado similares (diferencias solo por mayúsculas, tildes, signos o palabras triviales).\n` + + `5) Cada sugerencia debe incluir un nombre y una descripción clara y útil.\n\n` + + `Lista de nombres prohibidos (normalizados):\n` + + forbiddenNames.map((n) => `- ${n}`).join("\n"); + + const AsignaturaSugeridaSchema = z + .object({ + sugerencias: z + .array(AsignaturaSugeridaItemSchema) + .length(cantidad_de_sugerencias) + .describe( + `Arreglo de ${cantidad_de_sugerencias} sugerencias de asignatura`, + ), + }) + .describe( + `Respuesta estructurada con ${cantidad_de_sugerencias} sugerencias de asignatura`, + ); + const svc = OpenAIService.fromEnv(); if (!(svc instanceof OpenAIService)) { throw new HttpError( @@ -87,13 +279,13 @@ Deno.serve(async (req: Request): Promise => { const options: StructuredResponseOptions = { model: "gpt-5-mini", input: [ + { role: "system", content: systemPrompt }, { role: "user", content: [ { type: "input_text", - text: - "Genera 6 sugerencias de asignatura para el ciclo 6 de 9 del plan de estudios de una carrera de ingeniería mecánica.", + text: userPrompt, }, ], },