diff --git a/deno.json b/deno.json index 60f816d..2128fe4 100644 --- a/deno.json +++ b/deno.json @@ -7,6 +7,10 @@ "imports": { "@openai/openai": "jsr:@openai/openai@^6.16.0", "@zod/zod": "jsr:@zod/zod@^4.3.5", - "@david/dax": "jsr:@david/dax@^0.44.2" + "@david/dax": "jsr:@david/dax@^0.44.2", + "zod": "https://deno.land/x/zod@v3.22.4/mod.ts", + "openai": "npm:openai@6.16.0", + "@supabase/supabase-js": "npm:@supabase/supabase-js@2", + "@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts" } } diff --git a/diff.txt b/diff.txt index b2704fc..e901abf 100644 Binary files a/diff.txt and b/diff.txt differ diff --git a/supabase/functions/_shared/database.types.ts b/supabase/functions/_shared/database.types.ts index fa3ef8e..bf4b960 100644 --- a/supabase/functions/_shared/database.types.ts +++ b/supabase/functions/_shared/database.types.ts @@ -4,7 +4,7 @@ | boolean | null | { [key: string]: Json | undefined } - | Json[]; + | Array; export type Database = { // Allows to automatically instantiate createClient with right options @@ -90,6 +90,7 @@ export type Database = { Row: { actualizado_en: string; actualizado_por: string | null; + asignatura_hash: string | null; codigo: string | null; contenido_tematico: Json; creado_en: string; @@ -97,8 +98,8 @@ export type Database = { creditos: number; datos: Json; estructura_id: string | null; - facultad_propietaria_id: string | null; - horas_semana: number | null; + horas_academicas: number | null; + horas_independientes: number | null; id: string; linea_plan_id: string | null; meta_origen: Json; @@ -114,6 +115,7 @@ export type Database = { Insert: { actualizado_en?: string; actualizado_por?: string | null; + asignatura_hash?: string | null; codigo?: string | null; contenido_tematico?: Json; creado_en?: string; @@ -121,8 +123,8 @@ export type Database = { creditos: number; datos?: Json; estructura_id?: string | null; - facultad_propietaria_id?: string | null; - horas_semana?: number | null; + horas_academicas?: number | null; + horas_independientes?: number | null; id?: string; linea_plan_id?: string | null; meta_origen?: Json; @@ -138,6 +140,7 @@ export type Database = { Update: { actualizado_en?: string; actualizado_por?: string | null; + asignatura_hash?: string | null; codigo?: string | null; contenido_tematico?: Json; creado_en?: string; @@ -145,8 +148,8 @@ export type Database = { creditos?: number; datos?: Json; estructura_id?: string | null; - facultad_propietaria_id?: string | null; - horas_semana?: number | null; + horas_academicas?: number | null; + horas_independientes?: number | null; id?: string; linea_plan_id?: string | null; meta_origen?: Json; @@ -181,14 +184,6 @@ export type Database = { referencedRelation: "estructuras_asignatura"; referencedColumns: ["id"]; }, - { - foreignKeyName: - "asignaturas_facultad_propietaria_id_fkey"; - columns: ["facultad_propietaria_id"]; - isOneToOne: false; - referencedRelation: "facultades"; - referencedColumns: ["id"]; - }, { foreignKeyName: "asignaturas_linea_plan_fk_compuesta"; columns: ["linea_plan_id", "plan_estudio_id"]; @@ -332,8 +327,8 @@ export type Database = { cambiado_por: string | null; campo: string | null; id: string; - interaccion_ia_id: string | null; plan_estudio_id: string; + response_id: string | null; tipo: Database["public"]["Enums"]["tipo_cambio"]; valor_anterior: Json | null; valor_nuevo: Json | null; @@ -343,8 +338,8 @@ export type Database = { cambiado_por?: string | null; campo?: string | null; id?: string; - interaccion_ia_id?: string | null; plan_estudio_id: string; + response_id?: string | null; tipo: Database["public"]["Enums"]["tipo_cambio"]; valor_anterior?: Json | null; valor_nuevo?: Json | null; @@ -354,8 +349,8 @@ export type Database = { cambiado_por?: string | null; campo?: string | null; id?: string; - interaccion_ia_id?: string | null; plan_estudio_id?: string; + response_id?: string | null; tipo?: Database["public"]["Enums"]["tipo_cambio"]; valor_anterior?: Json | null; valor_nuevo?: Json | null; @@ -368,20 +363,6 @@ export type Database = { referencedRelation: "usuarios_app"; referencedColumns: ["id"]; }, - { - foreignKeyName: "cambios_plan_plan_estudio_id_fkey"; - columns: ["plan_estudio_id"]; - isOneToOne: false; - referencedRelation: "planes_estudio"; - referencedColumns: ["id"]; - }, - { - foreignKeyName: "cambios_plan_plan_estudio_id_fkey"; - columns: ["plan_estudio_id"]; - isOneToOne: false; - referencedRelation: "plantilla_plan"; - referencedColumns: ["plan_estudio_id"]; - }, ]; }; carreras: { @@ -708,6 +689,7 @@ export type Database = { actualizado_en: string; actualizado_por: string | null; carrera_id: string; + conversation_id: string | null; creado_en: string; creado_por: string | null; datos: Json; @@ -719,6 +701,7 @@ export type Database = { nombre: string; nombre_search: string | null; numero_ciclos: number; + plan_hash: string | null; tipo_ciclo: Database["public"]["Enums"]["tipo_ciclo"]; tipo_origen: | Database["public"]["Enums"]["tipo_origen"] @@ -729,6 +712,7 @@ export type Database = { actualizado_en?: string; actualizado_por?: string | null; carrera_id: string; + conversation_id?: string | null; creado_en?: string; creado_por?: string | null; datos?: Json; @@ -740,6 +724,7 @@ export type Database = { nombre: string; nombre_search?: string | null; numero_ciclos: number; + plan_hash?: string | null; tipo_ciclo: Database["public"]["Enums"]["tipo_ciclo"]; tipo_origen?: | Database["public"]["Enums"]["tipo_origen"] @@ -750,6 +735,7 @@ export type Database = { actualizado_en?: string; actualizado_por?: string | null; carrera_id?: string; + conversation_id?: string | null; creado_en?: string; creado_por?: string | null; datos?: Json; @@ -761,6 +747,7 @@ export type Database = { nombre?: string; nombre_search?: string | null; numero_ciclos?: number; + plan_hash?: string | null; tipo_ciclo?: Database["public"]["Enums"]["tipo_ciclo"]; tipo_origen?: | Database["public"]["Enums"]["tipo_origen"] @@ -1140,7 +1127,7 @@ export type Database = { fuente_cambio: "HUMANO" | "IA"; nivel_plan_estudio: | "Licenciatura" - | "Maestr├¡a" + | "Maestría" | "Doctorado" | "Especialidad" | "Diplomado" @@ -1163,7 +1150,8 @@ export type Database = { | "ACTUALIZACION_MAPA" | "TRANSICION_ESTADO" | "OTRO" - | "CREACION"; + | "CREACION" + | "ACTUALIZACION"; tipo_ciclo: "Semestre" | "Cuatrimestre" | "Trimestre" | "Otro"; tipo_estructura_plan: "CURRICULAR" | "NO_CURRICULAR"; tipo_fuente_bibliografia: "MANUAL" | "BIBLIOTECA"; @@ -1343,7 +1331,7 @@ export const Constants = { fuente_cambio: ["HUMANO", "IA"], nivel_plan_estudio: [ "Licenciatura", - "Maestr├¡a", + "Maestría", "Doctorado", "Especialidad", "Diplomado", @@ -1370,6 +1358,7 @@ export const Constants = { "TRANSICION_ESTADO", "OTRO", "CREACION", + "ACTUALIZACION", ], tipo_ciclo: ["Semestre", "Cuatrimestre", "Trimestre", "Otro"], tipo_estructura_plan: ["CURRICULAR", "NO_CURRICULAR"], diff --git a/supabase/functions/ai-generate-plan/index.ts b/supabase/functions/ai-generate-plan/index.ts index 0f3185b..1db2eef 100644 --- a/supabase/functions/ai-generate-plan/index.ts +++ b/supabase/functions/ai-generate-plan/index.ts @@ -3,10 +3,10 @@ // 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 "@supabase/functions-js/edge-runtime.d.ts"; import { corsHeaders } from "../_shared/cors.ts"; import { HttpError, sendError, sendSuccess } from "../_shared/utils.ts"; -import { createClient } from "npm:@supabase/supabase-js@2"; +import { createClient } from "@supabase/supabase-js"; import type { Database, Json } from "../_shared/database.types.ts"; import type { AIGeneratePlanInput } from "./types.ts"; import { z } from "zod"; diff --git a/supabase/functions/ai-generate-subject/deno.json b/supabase/functions/ai-generate-subject/deno.json index f6ca845..8b204ca 100644 --- a/supabase/functions/ai-generate-subject/deno.json +++ b/supabase/functions/ai-generate-subject/deno.json @@ -1,3 +1,8 @@ { - "imports": {} + "imports": { + "zod": "https://deno.land/x/zod@v3.22.4/mod.ts", + "openai": "npm:openai@6.16.0", + "@supabase/supabase-js": "npm:@supabase/supabase-js@2", + "@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts" + } } diff --git a/supabase/functions/ai-generate-subject/index.ts b/supabase/functions/ai-generate-subject/index.ts index e974ea3..9045854 100644 --- a/supabase/functions/ai-generate-subject/index.ts +++ b/supabase/functions/ai-generate-subject/index.ts @@ -1,12 +1,15 @@ -import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import "@supabase/functions-js/edge-runtime.d.ts"; import { corsHeaders } from "../_shared/cors.ts"; import { HttpError, sendError, sendSuccess } from "../_shared/utils.ts"; -import { createClient } from "npm:@supabase/supabase-js@2"; +import { createClient } from "@supabase/supabase-js"; import type { Database, Json } from "../_shared/database.types.ts"; +import { z } from "zod"; +import { + OpenAIService, + type StructuredResponseOptions, +} from "../_shared/openai-service.ts"; -console.log("Hello from Functions!"); - -Deno.serve(async (req) => { +Deno.serve(async (req: Request): Promise => { const url = new URL(req.url); const functionName = url.pathname.split("/").pop(); console.log( @@ -18,6 +21,52 @@ Deno.serve(async (req) => { } try { + const method = req.method; + if (method !== "POST") { + console.error( + `[${ + new Date().toISOString() + }][${functionName}]: Invalid method: ${method}`, + ); + throw new HttpError( + 405, + "Método no permitido.", + "METHOD_NOT_ALLOWED", + { method }, + ); + } + + const authHeaderRaw = req.headers.get("Authorization") ?? + req.headers.get("authorization"); + if (!authHeaderRaw) { + console.error( + `[${ + new Date().toISOString() + }][${functionName}]: Missing Authorization header`, + ); + throw new HttpError( + 401, + "No autorizado.", + "UNAUTHORIZED", + { reason: "missing_authorization_header" }, + ); + } + + const contentType = (req.headers.get("content-type") || "").toLowerCase(); + if (!contentType.startsWith("multipart/form-data")) { + console.error( + `[${ + new Date().toISOString() + }][${functionName}]: Unsupported content type: ${contentType}`, + ); + throw new HttpError( + 415, + "Content-Type no soportado.", + "UNSUPPORTED_MEDIA_TYPE", + { contentType }, + ); + } + const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY"); if (!SUPABASE_URL || !SUPABASE_ANON_KEY) { @@ -59,20 +108,207 @@ Deno.serve(async (req) => { SERVICE_ROLE_KEY, ); - // INICIO DE CODIGO PARA DEBBUGGING + const formData = await req.formData(); + const validation = parseAndValidate(formData); + if (!validation.success) { + console.error( + `[${new Date().toISOString()}][${functionName}]: Validation errors:`, + validation.errors, + ); + const message = validation.errors + .map((e, i) => `${i + 1}. ${e}`) + .join("\n"); + throw new HttpError( + 422, + message, + "VALIDATION_ERROR", + { errors: validation.errors }, + ); + } + + const payload = validation.data; + + const { data: estructura, error: estructuraError } = await supabaseService + .from("estructuras_asignatura") + .select("id,nombre,definicion,version") + .eq("id", payload.datosBasicos.estructuraId) + .single(); + if (estructuraError) { + const maybeCode = (estructuraError as { code?: string }).code; + if (maybeCode === "PGRST116") { + throw new HttpError( + 404, + "No se encontró la estructura de la asignatura.", + "NOT_FOUND", + { + table: "estructuras_asignatura", + id: payload.datosBasicos.estructuraId, + }, + ); + } + throw new HttpError( + 500, + "No se pudo obtener la estructura de la asignatura.", + "SUPABASE_QUERY_FAILED", + estructuraError, + ); + } + + const systemPrompt = + "Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el JSON Schema proporcionado."; + + const userPrompt = + `Genera un borrador completo completo de una ASIGNATURA con base en lo siguiente:\n` + + `- Nombre de la asignatura: ${payload.datosBasicos.nombre}\n` + + `- Código (clave de la asignatura): ${ + payload.datosBasicos.codigo ?? "(no especificado)" + }\n` + + `- Tipo: ${payload.datosBasicos.tipo ?? "(no especificado)"}\n` + + `- Número de créditos: ${payload.datosBasicos.creditos}\n` + + `- Horas académicas: ${ + payload.datosBasicos.horasAcademicas ?? "(no especificado)" + }\n` + + `- Horas independientes: ${ + payload.datosBasicos.horasIndependientes ?? "(no especificado)" + }\n` + + `- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${ + payload.iaConfig?.descripcionEnfoqueAcademico ?? "(ninguna)" + }\n` + + `- Instrucciones adicionales (sobre el formato de la respuesta generada): ${ + payload.iaConfig?.instruccionesAdicionalesIA ?? "(ninguna)" + }`; + + const schemaDef: Record = + typeof estructura?.definicion === "object" && + estructura?.definicion !== null + ? (estructura.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: "asignatura_contenido_tematico", + schema: schemaDef, + strict: true, + }, + }, + }; + + const svc = OpenAIService.fromEnv(); + if (!(svc instanceof OpenAIService)) { + throw new HttpError( + 500, + "Configuración del servidor incompleta.", + "OPENAI_MISCONFIGURED", + svc, + ); + } + + const aiResult = await svc.createStructuredResponse( + aiStructuredPayload, + payload.archivosAdjuntos, + ); + if (!aiResult.ok) { + const status = aiResult.code === "MissingEnv" ? 500 : 502; + throw new HttpError( + status, + "No se pudo generar la asignatura con IA.", + "OPENAI_REQUEST_FAILED", + aiResult, + ); + } + + let aiOutput = aiResult.output ?? null; + if (aiOutput == null && aiResult.outputText) { + try { + aiOutput = JSON.parse(aiResult.outputText); + } catch { + throw new HttpError( + 502, + "La respuesta de la IA no es JSON válido.", + "OPENAI_INVALID_JSON", + { outputText: aiResult.outputText }, + ); + } + } + if (!aiOutput) { + throw new HttpError( + 502, + "La respuesta de la IA no contiene salida estructurada.", + "OPENAI_MISSING_STRUCTURED_OUTPUT", + { outputText: aiResult.outputText ?? null }, + ); + } + + const aiOutputJson: Json = aiOutput as unknown as Json; + + const asignaturaInsert: + Database["public"]["Tables"]["asignaturas"]["Insert"] = { + plan_estudio_id: payload.plan_estudio_id, + estructura_id: payload.datosBasicos.estructuraId, + nombre: payload.datosBasicos.nombre, + codigo: payload.datosBasicos.codigo ?? null, + creditos: payload.datosBasicos.creditos, + horas_academicas: payload.datosBasicos.horasAcademicas ?? null, + horas_independientes: payload.datosBasicos.horasIndependientes ?? null, + tipo: (payload.datosBasicos.tipo ?? undefined) as Database["public"][ + "Tables" + ]["asignaturas"]["Insert"]["tipo"], + tipo_origen: "IA", + datos: aiOutputJson, + meta_origen: { + generado_por: "ai_generate_subject", + ai: { + responseId: aiResult.responseId ?? null, + conversationId: aiResult.conversationId ?? null, + model: aiResult.model, + usage: aiResult.usage ?? null, + }, + referencias: { + archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null, + repositoriosReferencia: payload.iaConfig?.repositoriosReferencia ?? + null, + }, + iaConfig: { + descripcionEnfoqueAcademico: + payload.iaConfig?.descripcionEnfoqueAcademico ?? null, + instruccionesAdicionalesIA: + payload.iaConfig?.instruccionesAdicionalesIA ?? null, + }, + } as unknown as Json, + }; + + const { data: asignatura, error: asignaturaError } = await supabaseService + .from("asignaturas") + .insert(asignaturaInsert) + .select( + "id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,estructura_id,plan_estudio_id,contenido_tematico,meta_origen,creado_en,actualizado_en", + ) + .single(); + + if (asignaturaError) { + const maybeCode = (asignaturaError as { code?: string }).code; + const status = maybeCode ? 409 : 500; + throw new HttpError( + status, + "No se pudo guardar la asignatura.", + "SUPABASE_INSERT_FAILED", + { ...asignaturaError, code: maybeCode }, + ); + } + console.log( `[${ new Date().toISOString() }][${functionName}]: Request processed successfully`, ); - const { data: asignatura_debug, error: asignatura_error } = - await supabaseService - .from("asignaturas") - .select("*") - .eq("id", "9d4dda6a-488f-428a-8a07-38081592a641") - .single(); - return sendSuccess(asignatura_debug); - // FIN DE CODIGO PARA DEBBUGGING + return sendSuccess(asignatura); } catch (error) { if (error instanceof HttpError) { console.error( @@ -105,3 +341,83 @@ Deno.serve(async (req) => { ); } }); + +const jsonFromString = (schema: T) => + z.string().transform((str, ctx) => { + try { + return JSON.parse(str); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "El formato no es un JSON válido", + }); + return z.NEVER; + } + }).pipe(schema); + +const jsonFromStringOptional = (schema: T) => + z.string().optional().default("{}").transform((str, ctx) => { + try { + return JSON.parse(str); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "El formato no es un JSON válido", + }); + return z.NEVER; + } + }).pipe(schema); + +const DatosBasicosSchema = z.object({ + nombre: z.string().min(1, "El nombre es requerido"), + codigo: z.string().min(1).optional().nullable(), + tipo: z.union([z.string().min(1), z.null()]).optional().transform((v) => + v ?? null + ), + creditos: z.number({ invalid_type_error: "Créditos debe ser numérico" }) + .positive("Créditos debe ser >= 0"), + horasAcademicas: z.number().int().nonnegative().optional().nullable(), + horasIndependientes: z.number().int().nonnegative().optional().nullable(), + estructuraId: z.string().uuid("estructuraId debe ser un UUID"), +}); + +const IAConfigSchema = z.object({ + descripcionEnfoqueAcademico: z.string().optional(), + instruccionesAdicionalesIA: z.string().optional(), + archivosReferencia: z.array(z.string().uuid()).optional(), + repositoriosReferencia: z.array(z.string().uuid()).optional(), +}).partial(); + +const SolicitudSchema = z.object({ + plan_estudio_id: z.string().uuid("plan_estudio_id debe ser un UUID"), + datosBasicos: jsonFromString(DatosBasicosSchema), + iaConfig: jsonFromStringOptional(IAConfigSchema), + archivosAdjuntos: z.array(z.instanceof(File)).optional().default([]), +}); + +type EdgeAIGenerateSubjectInput = z.infer; + +function parseAndValidate( + formData: FormData, +): { success: true; data: EdgeAIGenerateSubjectInput } | { + success: false; + errors: string[]; +} { + const rawInput = { + plan_estudio_id: formData.get("plan_estudio_id"), + datosBasicos: formData.get("datosBasicos"), + iaConfig: formData.get("iaConfig"), + archivosAdjuntos: formData.getAll("archivosAdjuntos"), + }; + + const result = SolicitudSchema.safeParse(rawInput); + if (!result.success) { + const errors = result.error.errors.map((issue) => { + const path = issue.path.length ? `${issue.path.join(".")}: ` : ""; + return `${path}${issue.message}`; + }); + return { success: false, errors }; + } + + return { success: true, data: result.data }; +} diff --git a/supabase/functions/ai-generate-subject/types.ts b/supabase/functions/ai-generate-subject/types.ts new file mode 100644 index 0000000..ec9d6e4 --- /dev/null +++ b/supabase/functions/ai-generate-subject/types.ts @@ -0,0 +1,37 @@ +import { Tables } from "../_shared/database.types.ts"; + +export interface UploadedFile { + id: string; // Necesario para React (key) + file: File; // La fuente de verdad (contiene name, size, type) + preview?: string; // Opcional: si fueran imágenes +} + +export type AIGenerateSubjectInput = { + plan_estudio_id: Tables<"asignaturas">["plan_estudio_id"]; + datosBasicos: { + nombre: Tables<"asignaturas">["nombre"]; + codigo?: Tables<"asignaturas">["codigo"]; + tipo: Tables<"asignaturas">["tipo"] | null; + creditos: Tables<"asignaturas">["creditos"] | null; + horasAcademicas?: Tables<"asignaturas">["horas_academicas"] | null; + horasIndependientes?: Tables<"asignaturas">["horas_independientes"] | null; + estructuraId: Tables<"asignaturas">["estructura_id"] | null; + }; + // clonInterno?: { + // facultadId?: string + // carreraId?: string + // planOrigenId?: string + // asignaturaOrigenId?: string | null + // } + // clonTradicional?: { + // archivoWordAsignaturaId: string | null + // archivosAdicionalesIds: Array + // } + iaConfig?: { + descripcionEnfoqueAcademico: string; + instruccionesAdicionalesIA: string; + archivosReferencia: Array; + repositoriosReferencia?: Array; + }; + archivosAdjuntos?: Array; +};