From 4a6ef4b09dfd0bb3503a76aa171a25a26d454e7f Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Tue, 20 Jan 2026 13:25:53 -0600 Subject: [PATCH] wip --- supabase/functions/ai-generate-plan/index.ts | 141 ++++++------------- supabase/functions/ai-structured/index.ts | 9 +- 2 files changed, 51 insertions(+), 99 deletions(-) diff --git a/supabase/functions/ai-generate-plan/index.ts b/supabase/functions/ai-generate-plan/index.ts index f872c18..a80776b 100644 --- a/supabase/functions/ai-generate-plan/index.ts +++ b/supabase/functions/ai-generate-plan/index.ts @@ -137,7 +137,7 @@ Deno.serve(async (req) => { - Descripción del enfoque: ${payload.iaConfig.descripcionEnfoque} - Notas adicionales: ${payload.iaConfig.notasAdicionales ?? "Ninguna"}`; const aiStructuredPayload = { - model: "gpt-5-nano", + model: "gpt-5-mini", input: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, @@ -145,7 +145,7 @@ Deno.serve(async (req) => { text: { format: { type: "json_schema", - name: `${estructuraPlan?.nombre}`, + name: "plan_de_estudios_standard", schema: estructuraPlan?.definicion, strict: true, }, @@ -277,6 +277,21 @@ Deno.serve(async (req) => { } }); +// Este helper recibe un esquema (ej. DatosBasicosSchema) y devuelve un validador +// que acepta un string JSON y lo valida contra ese esquema. +const jsonFromString = (schema: T) => + z.string().transform((str, ctx) => { + try { + return JSON.parse(str); + } catch (e) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "El formato no es un JSON válido", + }); + return z.NEVER; // Detiene la ejecución aquí si falla el parseo + } + }).pipe(schema); // Si el parseo es exitoso, pasa los datos al esquema real + // --- VALIDACIÓN ESTRICTA DE DATOS BÁSICOS --- const DatosBasicosSchema: z.ZodType = z .object({ @@ -297,112 +312,44 @@ const IAConfigSchema: z.ZodType = z.object({ usarMCP: z.boolean().optional(), }); +const SolicitudSchema = z.object({ + // Usamos el helper aquí. Zod recibe string -> parsea -> valida estructura + datosBasicos: jsonFromString(DatosBasicosSchema), + + iaConfig: jsonFromString(IAConfigSchema), + + // Validamos directamente que sea un array de Archivos + // z.instanceof(File) funciona en entornos Web/Deno + archivosAdjuntos: z.array(z.instanceof(File)).optional().default([]), +}); + function parseAndValidate( formData: FormData, ): { success: true; data: AIGeneratePlanInput } | { success: false; errors: string[]; } { - const errors: string[] = []; + // 1. Convertimos el FormData a un objeto plano de JS para que Zod lo lea + const rawInput = { + datosBasicos: formData.get("datosBasicos"), + iaConfig: formData.get("iaConfig"), + // getAll es clave para obtener el array de archivos + archivosAdjuntos: formData.getAll("archivosAdjuntos"), + }; - // --- A. Extracción de Strings --- - const rawBasicos = formData.get("datosBasicos"); - const rawIA = formData.get("iaConfig"); + // 2. Dejamos que Zod haga TODO el trabajo sucio (null check, json parse, validación) + const result = SolicitudSchema.safeParse(rawInput); - if (!rawBasicos) { - return { - success: false, - errors: ["Falta el campo 'datosBasicos' en el FormData"], - }; - } - if (!rawIA) { - return { - success: false, - errors: ["Falta el campo 'iaConfig' en el FormData"], - }; - } - - // --- B. Parsing de JSON (Usamos 'unknown' en lugar de 'any') --- - let jsonBasicos: unknown; - let jsonIA: unknown; - - try { - jsonBasicos = JSON.parse(rawBasicos as string); - } catch (_error) { - return { - success: false, - errors: ["El JSON de 'datosBasicos' tiene un formato inválido"], - }; - } - - try { - jsonIA = JSON.parse(rawIA as string); - } catch (_error) { - return { - success: false, - errors: ["El JSON de 'iaConfig' tiene un formato inválido"], - }; - } - - // --- C. Validación con Zod --- - const parsedBasicos = DatosBasicosSchema.safeParse(jsonBasicos); - const parsedIA = IAConfigSchema.safeParse(jsonIA); - - // Recolectamos errores de Zod si existen - if (!parsedBasicos.success) { - parsedBasicos.error.errors.forEach((issue) => { - errors.push( - `Datos Básicos (${issue.path.join(". ")}): ${issue.message}`, - ); + if (!result.success) { + // Aplanamos los errores para devolver un array de strings limpio + const errors = result.error.errors.map((issue) => { + const path = issue.path.join("."); + return `${path}: ${issue.message}`; }); - } - if (!parsedIA.success) { - parsedIA.error.errors.forEach((issue) => { - errors.push(`IA Config (${issue.path.join(". ")}): ${issue.message}`); - }); - } - - // --- D. Validación Manual de Archivos --- - const filesRaw = formData.getAll("archivosAdjuntos"); - const filesClean: File[] = []; - - filesRaw.forEach((entry, index) => { - if (entry instanceof File) { - filesClean.push(entry); - } else { - errors.push( - `El adjunto en la posición ${index} no es un archivo válido.`, - ); - } - }); - - // --- E. Retorno de Errores --- - if (errors.length > 0) { - console.error("❌ Errores de validación:", errors); return { success: false, errors }; } - // --- F. Retorno de Éxito (Narrowing de Tipos) --- - - // TypeScript necesita esta verificación explícita. - // Aunque lógicamente sabemos que si errors.length === 0, entonces success es true, - // TS no conecta el array de errores con el resultado de Zod automáticamente. - if (!parsedBasicos.success || !parsedIA.success) { - // Este caso teóricamente es inalcanzable si la lógica de arriba es correcta, - // pero satisface al compilador para acceder a .data abajo. - return { - success: false, - errors: ["Error interno de estado inconsistente."], - }; - } - - // Ahora sí, TS sabe que parsedBasicos.data existe y es del tipo correcto - const finalInput: AIGeneratePlanInput = { - datosBasicos: parsedBasicos.data, - iaConfig: parsedIA.data, - archivosAdjuntos: filesClean, - }; - - return { success: true, data: finalInput }; + // 3. Retorno exitoso con tipos inferidos automáticamente + return { success: true, data: result.data }; } diff --git a/supabase/functions/ai-structured/index.ts b/supabase/functions/ai-structured/index.ts index 5dc46c4..e018c0d 100644 --- a/supabase/functions/ai-structured/index.ts +++ b/supabase/functions/ai-structured/index.ts @@ -61,7 +61,11 @@ Deno.serve(async (req) => { // 1) Upload a Supabase Storage const supabase_paths: string[] = []; for (const file of inputForm.files ?? []) { - const path = `${crypto.randomUUID()}-${(file.name)}`; + const safeName = file.name + .normalize("NFD") // 1. Descompone letras de tildes (í -> i + ´) + .replace(/[\u0300-\u036f]/g, "") // 2. Borra las tildes + .replace(/[^a-zA-Z0-9.-]/g, "_"); // 3. Reemplaza espacios y símbolos raros por "_" + const path = `${crypto.randomUUID()}-${safeName}`; const { data, error } = await supabase.storage .from(SUPABASE_BUCKET) .upload(path, file, { @@ -119,7 +123,7 @@ Deno.serve(async (req) => { type SuccessResponse = { ok: true; - openai_raw: unknown; + openai_raw: OpenAITypes.OpenAI.Responses.Response; supabase_paths: string[]; openai_file_ids: string[]; options: unknown; @@ -132,6 +136,7 @@ Deno.serve(async (req) => { openai_file_ids, // IDs en OpenAI options: inputForm.options, // opciones usadas }; + response.openai_raw.output. return json(response); } catch (e) {