// Follow this setup guide to integrate the Deno language server with your editor: // https://deno.land/manual/getting_started/setup_your_environment // This enables autocomplete, go to definition, etc. // Setup type definitions for built-in Supabase Runtime APIs import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { corsHeaders } from "../_shared/cors.ts"; import { createClient } from "npm:@supabase/supabase-js@2"; import type { Database, Json } from "../_shared/database.types.ts"; import type { AIGeneratePlanInput } from "./types.ts"; import { z } from "zod"; import { systemPrompt } from "./prompts.ts"; import { OpenAIService } from "../_shared/openai-service.ts"; import type { StructuredResponseOptions } from "../_shared/openai-service.ts"; // Typed aliases for strict field unions type NivelType = Database["public"]["Tables"]["planes_estudio"]["Insert"]["nivel"]; type TipoCicloType = Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"]; Deno.serve(async (req) => { const url = new URL(req.url); const functionName = url.pathname.split("/").pop(); console.log( `[${new Date().toISOString()}][${functionName}]: Request received`, ); if (req.method === "OPTIONS") { return new Response(null, { status: 204, headers: corsHeaders }); } try { const method = req.method; if (method !== "POST") { console.error( `[${ new Date().toISOString() }][${functionName}]: Invalid method: ${method}`, ); return new Response( JSON.stringify({ error: "Method not allowed" }), { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 405, }, ); } const authHeaderRaw = req.headers.get("Authorization") ?? req.headers.get("authorization"); if (!authHeaderRaw) { console.error( `[${ new Date().toISOString() }][${functionName}]: Missing Authorization header`, ); return new Response( JSON.stringify({ error: "Authorization header missing" }), { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 401, }, ); } const contentType = (req.headers.get("content-type") || "").toLowerCase(); if (!contentType.startsWith("multipart/form-data")) { console.error( `[${ new Date().toISOString() }][${functionName}]: Unsupported content type: ${contentType}`, ); return new Response( JSON.stringify({ error: "Unsupported content type" }), { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 400, }, ); } const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY"); if (!SUPABASE_URL || !SUPABASE_ANON_KEY) { throw new Error("Supabase environment variables are not set"); } // If needed for RLS-protected reads, create an anon client with user's JWT // Currently not used; kept here for future expansion. // const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { // global: { // headers: { // Authorization: authHeaderRaw, // }, // }, // }); const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); if (!SERVICE_ROLE_KEY) { throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set"); } const supabaseService = createClient( SUPABASE_URL, SERVICE_ROLE_KEY, ); const formData = await req.formData(); const validation = parseAndValidate(formData); if (!validation.success) { console.error( `[${new Date().toISOString()}][${functionName}]: Validation errors:`, validation.errors, ); return new Response( JSON.stringify({ errors: validation.errors }), { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 400, }, ); } const payload: AIGeneratePlanInput = validation.data; const { data: estructuraPlan, error: estructuraPlanError } = await supabaseService .from("estructuras_plan") .select("id,nombre,tipo,template_id,definicion") .eq("id", payload.datosBasicos.estructuraPlanId) .single(); if (estructuraPlanError) { throw new Error( "Error fetching estructuraPlan: " + estructuraPlanError.message, ); } const userPrompt = `Genera un borrador completo del PLAN DE ESTUDIOS con base en lo siguiente: - Descripción del enfoque: ${payload.iaConfig.descripcionEnfoque} - Notas adicionales: ${payload.iaConfig.notasAdicionales ?? "Ninguna"}`; // Ensure the JSON schema is an object as required by OpenAI types const schemaDef: Record = typeof estructuraPlan?.definicion === "object" && estructuraPlan?.definicion !== null ? estructuraPlan.definicion as Record : {}; const aiStructuredPayload: StructuredResponseOptions = { model: "gpt-5-nano", input: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, ], text: { format: { type: "json_schema", name: "plan_de_estudios_standard", schema: schemaDef, strict: true, }, }, }; // Use shared OpenAI service directly (no HTTP invoke) const svc = OpenAIService.fromEnv(); if (!(svc instanceof OpenAIService)) { throw new Error(`OpenAI service misconfiguration: ${svc.message}`); } const aiResult = await svc.createStructuredResponse( aiStructuredPayload, payload.archivosAdjuntos, ); if (!aiResult.ok) { throw new Error( `OpenAI call failed [${aiResult.code}]: ${aiResult.message}`, ); } // Prefer parsed output; fallback to parse outputText const aiOutput = aiResult.output ?? (aiResult.outputText ? JSON.parse(aiResult.outputText) : null); const aiOutputJson: Json = aiOutput as unknown as Json; if (!aiOutput) { throw new Error("OpenAI response did not contain structured output"); } // TODO: Insertar interacciones con IA y quizas forzar a que la informacion de datosBasicos sea la misma que la recibida const { data: estado } = await supabaseService .from("estados_plan") .select("id,clave,orden") .ilike("clave", "BORRADOR%") .order("orden", { ascending: true }) .limit(1) .maybeSingle(); const { data: carrera, error: carreraError } = await supabaseService .from("carreras") .select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)") .eq("id", payload.datosBasicos.carreraId) .maybeSingle(); if (carreraError) { throw new Error("Error fetching carrera: " + carreraError.message); } const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] = { carrera_id: carrera?.id as string, estructura_id: estructuraPlan?.id as string, nombre: payload.datosBasicos.nombrePlan, nivel: payload.datosBasicos.nivel as NivelType, tipo_ciclo: payload.datosBasicos.tipoCiclo as TipoCicloType, numero_ciclos: payload.datosBasicos.numCiclos, datos: aiOutputJson, estado_actual_id: estado?.id ?? undefined, activo: true, tipo_origen: "IA", meta_origen: { generado_por: "ai_generate_plan", ai_structured: { responseId: aiResult.responseId ?? null, conversationId: aiResult.conversationId ?? null, model: aiResult.model, usage: aiResult.usage ?? null, }, referencias: { archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null, repositoriosIds: payload.iaConfig?.repositoriosIds ?? null, }, iaConfig: { descripcionEnfoque: payload.iaConfig?.descripcionEnfoque ?? null, notasAdicionales: payload.iaConfig?.notasAdicionales ?? null, usarMCP: Boolean(payload.iaConfig?.usarMCP), }, } as unknown as Json, }; const { data: plan, error: planError } = await supabaseService .from("planes_estudio") .insert(planInsert) .select( "id,nombre,nivel,tipo_ciclo,numero_ciclos,carrera_id,estructura_id,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,datos", ) .single(); // TODO: interaccion con IA y cambio de estado if (planError) { throw new Error("Error inserting plan: " + planError.message); } // TODO: update a interaccion_ia y e insert a cambios_plan con id de plan generado const jsonResponse = { ok: true, plan, }; console.log( `[${ new Date().toISOString() }][${functionName}]: Request processed successfully`, ); return new Response( JSON.stringify(jsonResponse), { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 200, }, ); } catch (error) { // Log full error server-side for diagnostics if (error instanceof Error) { console.error( `[${ new Date().toISOString() }][${functionName}]: Request handler error:`, error.message, ); } const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 400, }); } }); // 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({ nombrePlan: z.string().min(1, "El nombre es requerido"), carreraId: z.string().uuid("carreraId debe ser un UUID"), facultadId: z.string().uuid("facultadId debe ser un UUID").optional(), nivel: z.string().min(1, "Nivel es requerido"), tipoCiclo: z.enum(["Semestre", "Cuatrimestre", "Trimestre", "Otro"]), numCiclos: z.number().int().positive(), estructuraPlanId: z.string().uuid("estructuraPlanId debe ser un UUID"), }); const IAConfigSchema: z.ZodType = z.object({ descripcionEnfoque: z.string().min(10, "La descripción es muy corta"), notasAdicionales: z.string().optional(), archivosReferencia: z.array(z.string().uuid()).optional(), repositoriosIds: z.array(z.string().uuid()).optional(), 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[]; } { // 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"), }; // 2. Dejamos que Zod haga TODO el trabajo sucio (null check, json parse, validación) const result = SolicitudSchema.safeParse(rawInput); 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}`; }); return { success: false, errors }; } // 3. Retorno exitoso con tipos inferidos automáticamente return { success: true, data: result.data }; }