From 8ef3f24652059490ff0af247cbb097e781472803 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Tue, 20 Jan 2026 17:03:13 -0600 Subject: [PATCH] =?UTF-8?q?Versi=C3=B3n=20funcional=20de=20creaci=C3=B3n?= =?UTF-8?q?=20de=20plan=20de=20estudios=20con=20IA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supabase/config.toml | 9 +- supabase/functions/_shared/database.types.ts | 1394 +++++++++++++++++ supabase/functions/_shared/openai-service.ts | 226 +++ supabase/functions/ai-generate-plan/index.ts | 125 +- supabase/functions/ai-structured/deno.json | 2 +- supabase/functions/ai-structured/index.ts | 162 +- .../functions/tests/ai-structured-test.ts | 102 +- 7 files changed, 1763 insertions(+), 257 deletions(-) create mode 100644 supabase/functions/_shared/database.types.ts create mode 100644 supabase/functions/_shared/openai-service.ts diff --git a/supabase/config.toml b/supabase/config.toml index d7b89f2..1d4bdc8 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -10,7 +10,8 @@ entrypoint = "./functions/ai-generate-plan/index.ts" # For example, if you want to serve static HTML pages in your function: # static_files = [ "./functions/ai-generate-plan/*.html" ] -[functions.ai-structured] -enabled = true -verify_jwt = true -import_map = "./functions/ai-structured/deno.json" +# [functions.ai-structured] +# DEPRECATED: replaced by shared module `_shared/openai-service.ts` +# enabled = false +# verify_jwt = false +# import_map = "./functions/ai-structured/deno.json" diff --git a/supabase/functions/_shared/database.types.ts b/supabase/functions/_shared/database.types.ts new file mode 100644 index 0000000..fa3ef8e --- /dev/null +++ b/supabase/functions/_shared/database.types.ts @@ -0,0 +1,1394 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[]; + +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "12.2.3 (519615d)"; + }; + graphql_public: { + Tables: { + [_ in never]: never; + }; + Views: { + [_ in never]: never; + }; + Functions: { + graphql: { + Args: { + extensions?: Json; + operationName?: string; + query?: string; + variables?: Json; + }; + Returns: Json; + }; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; + public: { + Tables: { + archivos: { + Row: { + bytes: number | null; + id: string; + mime_type: string | null; + nombre: string; + notas: string | null; + openai_file_id: string | null; + ruta_storage: string; + subido_en: string; + subido_por: string | null; + temporal: boolean; + }; + Insert: { + bytes?: number | null; + id?: string; + mime_type?: string | null; + nombre: string; + notas?: string | null; + openai_file_id?: string | null; + ruta_storage: string; + subido_en?: string; + subido_por?: string | null; + temporal?: boolean; + }; + Update: { + bytes?: number | null; + id?: string; + mime_type?: string | null; + nombre?: string; + notas?: string | null; + openai_file_id?: string | null; + ruta_storage?: string; + subido_en?: string; + subido_por?: string | null; + temporal?: boolean; + }; + Relationships: [ + { + foreignKeyName: "archivos_subido_por_fkey"; + columns: ["subido_por"]; + isOneToOne: false; + referencedRelation: "usuarios_app"; + referencedColumns: ["id"]; + }, + ]; + }; + asignaturas: { + Row: { + actualizado_en: string; + actualizado_por: string | null; + codigo: string | null; + contenido_tematico: Json; + creado_en: string; + creado_por: string | null; + creditos: number; + datos: Json; + estructura_id: string | null; + facultad_propietaria_id: string | null; + horas_semana: number | null; + id: string; + linea_plan_id: string | null; + meta_origen: Json; + nombre: string; + numero_ciclo: number | null; + orden_celda: number | null; + plan_estudio_id: string; + tipo: Database["public"]["Enums"]["tipo_asignatura"]; + tipo_origen: + | Database["public"]["Enums"]["tipo_origen"] + | null; + }; + Insert: { + actualizado_en?: string; + actualizado_por?: string | null; + codigo?: string | null; + contenido_tematico?: Json; + creado_en?: string; + creado_por?: string | null; + creditos: number; + datos?: Json; + estructura_id?: string | null; + facultad_propietaria_id?: string | null; + horas_semana?: number | null; + id?: string; + linea_plan_id?: string | null; + meta_origen?: Json; + nombre: string; + numero_ciclo?: number | null; + orden_celda?: number | null; + plan_estudio_id: string; + tipo?: Database["public"]["Enums"]["tipo_asignatura"]; + tipo_origen?: + | Database["public"]["Enums"]["tipo_origen"] + | null; + }; + Update: { + actualizado_en?: string; + actualizado_por?: string | null; + codigo?: string | null; + contenido_tematico?: Json; + creado_en?: string; + creado_por?: string | null; + creditos?: number; + datos?: Json; + estructura_id?: string | null; + facultad_propietaria_id?: string | null; + horas_semana?: number | null; + id?: string; + linea_plan_id?: string | null; + meta_origen?: Json; + nombre?: string; + numero_ciclo?: number | null; + orden_celda?: number | null; + plan_estudio_id?: string; + tipo?: Database["public"]["Enums"]["tipo_asignatura"]; + tipo_origen?: + | Database["public"]["Enums"]["tipo_origen"] + | null; + }; + Relationships: [ + { + foreignKeyName: "asignaturas_actualizado_por_fkey"; + columns: ["actualizado_por"]; + isOneToOne: false; + referencedRelation: "usuarios_app"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "asignaturas_creado_por_fkey"; + columns: ["creado_por"]; + isOneToOne: false; + referencedRelation: "usuarios_app"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "asignaturas_estructura_id_fkey"; + columns: ["estructura_id"]; + isOneToOne: false; + 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"]; + isOneToOne: false; + referencedRelation: "lineas_plan"; + referencedColumns: ["id", "plan_estudio_id"]; + }, + { + foreignKeyName: "asignaturas_plan_estudio_id_fkey"; + columns: ["plan_estudio_id"]; + isOneToOne: false; + referencedRelation: "planes_estudio"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "asignaturas_plan_estudio_id_fkey"; + columns: ["plan_estudio_id"]; + isOneToOne: false; + referencedRelation: "plantilla_plan"; + referencedColumns: ["plan_estudio_id"]; + }, + ]; + }; + bibliografia_asignatura: { + Row: { + actualizado_en: string; + asignatura_id: string; + biblioteca_item_id: string | null; + cita: string; + creado_en: string; + creado_por: string | null; + id: string; + tipo: Database["public"]["Enums"]["tipo_bibliografia"]; + tipo_fuente: + Database["public"]["Enums"]["tipo_fuente_bibliografia"]; + }; + Insert: { + actualizado_en?: string; + asignatura_id: string; + biblioteca_item_id?: string | null; + cita: string; + creado_en?: string; + creado_por?: string | null; + id?: string; + tipo: Database["public"]["Enums"]["tipo_bibliografia"]; + tipo_fuente?: + Database["public"]["Enums"]["tipo_fuente_bibliografia"]; + }; + Update: { + actualizado_en?: string; + asignatura_id?: string; + biblioteca_item_id?: string | null; + cita?: string; + creado_en?: string; + creado_por?: string | null; + id?: string; + tipo?: Database["public"]["Enums"]["tipo_bibliografia"]; + tipo_fuente?: + Database["public"]["Enums"]["tipo_fuente_bibliografia"]; + }; + Relationships: [ + { + foreignKeyName: + "bibliografia_asignatura_asignatura_id_fkey"; + columns: ["asignatura_id"]; + isOneToOne: false; + referencedRelation: "asignaturas"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: + "bibliografia_asignatura_creado_por_fkey"; + columns: ["creado_por"]; + isOneToOne: false; + referencedRelation: "usuarios_app"; + referencedColumns: ["id"]; + }, + ]; + }; + cambios_asignatura: { + Row: { + asignatura_id: string; + cambiado_en: string; + cambiado_por: string | null; + campo: string | null; + fuente: Database["public"]["Enums"]["fuente_cambio"] | null; + id: string; + interaccion_ia_id: string | null; + tipo: Database["public"]["Enums"]["tipo_cambio"]; + valor_anterior: Json | null; + valor_nuevo: Json | null; + }; + Insert: { + asignatura_id: string; + cambiado_en?: string; + cambiado_por?: string | null; + campo?: string | null; + fuente?: + | Database["public"]["Enums"]["fuente_cambio"] + | null; + id?: string; + interaccion_ia_id?: string | null; + tipo: Database["public"]["Enums"]["tipo_cambio"]; + valor_anterior?: Json | null; + valor_nuevo?: Json | null; + }; + Update: { + asignatura_id?: string; + cambiado_en?: string; + cambiado_por?: string | null; + campo?: string | null; + fuente?: + | Database["public"]["Enums"]["fuente_cambio"] + | null; + id?: string; + interaccion_ia_id?: string | null; + tipo?: Database["public"]["Enums"]["tipo_cambio"]; + valor_anterior?: Json | null; + valor_nuevo?: Json | null; + }; + Relationships: [ + { + foreignKeyName: "cambios_asignatura_asignatura_id_fkey"; + columns: ["asignatura_id"]; + isOneToOne: false; + referencedRelation: "asignaturas"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "cambios_asignatura_cambiado_por_fkey"; + columns: ["cambiado_por"]; + isOneToOne: false; + referencedRelation: "usuarios_app"; + referencedColumns: ["id"]; + }, + ]; + }; + cambios_plan: { + Row: { + cambiado_en: string; + cambiado_por: string | null; + campo: string | null; + id: string; + interaccion_ia_id: string | null; + plan_estudio_id: string; + tipo: Database["public"]["Enums"]["tipo_cambio"]; + valor_anterior: Json | null; + valor_nuevo: Json | null; + }; + Insert: { + cambiado_en?: string; + cambiado_por?: string | null; + campo?: string | null; + id?: string; + interaccion_ia_id?: string | null; + plan_estudio_id: string; + tipo: Database["public"]["Enums"]["tipo_cambio"]; + valor_anterior?: Json | null; + valor_nuevo?: Json | null; + }; + Update: { + cambiado_en?: string; + cambiado_por?: string | null; + campo?: string | null; + id?: string; + interaccion_ia_id?: string | null; + plan_estudio_id?: string; + tipo?: Database["public"]["Enums"]["tipo_cambio"]; + valor_anterior?: Json | null; + valor_nuevo?: Json | null; + }; + Relationships: [ + { + foreignKeyName: "cambios_plan_cambiado_por_fkey"; + columns: ["cambiado_por"]; + isOneToOne: false; + 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: { + Row: { + activa: boolean; + actualizado_en: string; + clave_sep: string | null; + creado_en: string; + facultad_id: string; + id: string; + nombre: string; + nombre_corto: string | null; + }; + Insert: { + activa?: boolean; + actualizado_en?: string; + clave_sep?: string | null; + creado_en?: string; + facultad_id: string; + id?: string; + nombre: string; + nombre_corto?: string | null; + }; + Update: { + activa?: boolean; + actualizado_en?: string; + clave_sep?: string | null; + creado_en?: string; + facultad_id?: string; + id?: string; + nombre?: string; + nombre_corto?: string | null; + }; + Relationships: [ + { + foreignKeyName: "carreras_facultad_id_fkey"; + columns: ["facultad_id"]; + isOneToOne: false; + referencedRelation: "facultades"; + referencedColumns: ["id"]; + }, + ]; + }; + estados_plan: { + Row: { + clave: string; + es_final: boolean; + etiqueta: string; + id: string; + orden: number; + }; + Insert: { + clave: string; + es_final?: boolean; + etiqueta: string; + id?: string; + orden?: number; + }; + Update: { + clave?: string; + es_final?: boolean; + etiqueta?: string; + id?: string; + orden?: number; + }; + Relationships: []; + }; + estructuras_asignatura: { + Row: { + actualizado_en: string; + creado_en: string; + definicion: Json; + id: string; + nombre: string; + version: string | null; + }; + Insert: { + actualizado_en?: string; + creado_en?: string; + definicion?: Json; + id?: string; + nombre: string; + version?: string | null; + }; + Update: { + actualizado_en?: string; + creado_en?: string; + definicion?: Json; + id?: string; + nombre?: string; + version?: string | null; + }; + Relationships: []; + }; + estructuras_plan: { + Row: { + actualizado_en: string; + creado_en: string; + definicion: Json; + id: string; + nombre: string; + template_id: string | null; + tipo: Database["public"]["Enums"]["tipo_estructura_plan"]; + }; + Insert: { + actualizado_en?: string; + creado_en?: string; + definicion?: Json; + id?: string; + nombre: string; + template_id?: string | null; + tipo: Database["public"]["Enums"]["tipo_estructura_plan"]; + }; + Update: { + actualizado_en?: string; + creado_en?: string; + definicion?: Json; + id?: string; + nombre?: string; + template_id?: string | null; + tipo?: Database["public"]["Enums"]["tipo_estructura_plan"]; + }; + Relationships: []; + }; + facultades: { + Row: { + actualizado_en: string; + color: string | null; + creado_en: string; + icono: string | null; + id: string; + nombre: string; + nombre_corto: string | null; + }; + Insert: { + actualizado_en?: string; + color?: string | null; + creado_en?: string; + icono?: string | null; + id?: string; + nombre: string; + nombre_corto?: string | null; + }; + Update: { + actualizado_en?: string; + color?: string | null; + creado_en?: string; + icono?: string | null; + id?: string; + nombre?: string; + nombre_corto?: string | null; + }; + Relationships: []; + }; + interacciones_ia: { + Row: { + aceptada: boolean; + asignatura_id: string | null; + conversacion_id: string | null; + creado_en: string; + id: string; + ids_archivos: Json; + ids_vector_store: Json; + modelo: string | null; + plan_estudio_id: string | null; + prompt: Json; + respuesta: Json; + rutas_storage: Json; + temperatura: number | null; + tipo: Database["public"]["Enums"]["tipo_interaccion_ia"]; + usuario_id: string | null; + }; + Insert: { + aceptada?: boolean; + asignatura_id?: string | null; + conversacion_id?: string | null; + creado_en?: string; + id?: string; + ids_archivos?: Json; + ids_vector_store?: Json; + modelo?: string | null; + plan_estudio_id?: string | null; + prompt?: Json; + respuesta?: Json; + rutas_storage?: Json; + temperatura?: number | null; + tipo: Database["public"]["Enums"]["tipo_interaccion_ia"]; + usuario_id?: string | null; + }; + Update: { + aceptada?: boolean; + asignatura_id?: string | null; + conversacion_id?: string | null; + creado_en?: string; + id?: string; + ids_archivos?: Json; + ids_vector_store?: Json; + modelo?: string | null; + plan_estudio_id?: string | null; + prompt?: Json; + respuesta?: Json; + rutas_storage?: Json; + temperatura?: number | null; + tipo?: Database["public"]["Enums"]["tipo_interaccion_ia"]; + usuario_id?: string | null; + }; + Relationships: [ + { + foreignKeyName: "interacciones_ia_asignatura_id_fkey"; + columns: ["asignatura_id"]; + isOneToOne: false; + referencedRelation: "asignaturas"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "interacciones_ia_plan_estudio_id_fkey"; + columns: ["plan_estudio_id"]; + isOneToOne: false; + referencedRelation: "planes_estudio"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "interacciones_ia_plan_estudio_id_fkey"; + columns: ["plan_estudio_id"]; + isOneToOne: false; + referencedRelation: "plantilla_plan"; + referencedColumns: ["plan_estudio_id"]; + }, + { + foreignKeyName: "interacciones_ia_usuario_id_fkey"; + columns: ["usuario_id"]; + isOneToOne: false; + referencedRelation: "usuarios_app"; + referencedColumns: ["id"]; + }, + ]; + }; + lineas_plan: { + Row: { + actualizado_en: string; + area: string | null; + creado_en: string; + id: string; + nombre: string; + orden: number; + plan_estudio_id: string; + }; + Insert: { + actualizado_en?: string; + area?: string | null; + creado_en?: string; + id?: string; + nombre: string; + orden?: number; + plan_estudio_id: string; + }; + Update: { + actualizado_en?: string; + area?: string | null; + creado_en?: string; + id?: string; + nombre?: string; + orden?: number; + plan_estudio_id?: string; + }; + Relationships: [ + { + foreignKeyName: "lineas_plan_plan_estudio_id_fkey"; + columns: ["plan_estudio_id"]; + isOneToOne: false; + referencedRelation: "planes_estudio"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "lineas_plan_plan_estudio_id_fkey"; + columns: ["plan_estudio_id"]; + isOneToOne: false; + referencedRelation: "plantilla_plan"; + referencedColumns: ["plan_estudio_id"]; + }, + ]; + }; + notificaciones: { + Row: { + creado_en: string; + id: string; + leida: boolean; + leida_en: string | null; + payload: Json; + tipo: Database["public"]["Enums"]["tipo_notificacion"]; + usuario_id: string; + }; + Insert: { + creado_en?: string; + id?: string; + leida?: boolean; + leida_en?: string | null; + payload?: Json; + tipo: Database["public"]["Enums"]["tipo_notificacion"]; + usuario_id: string; + }; + Update: { + creado_en?: string; + id?: string; + leida?: boolean; + leida_en?: string | null; + payload?: Json; + tipo?: Database["public"]["Enums"]["tipo_notificacion"]; + usuario_id?: string; + }; + Relationships: [ + { + foreignKeyName: "notificaciones_usuario_id_fkey"; + columns: ["usuario_id"]; + isOneToOne: false; + referencedRelation: "usuarios_app"; + referencedColumns: ["id"]; + }, + ]; + }; + planes_estudio: { + Row: { + activo: boolean; + actualizado_en: string; + actualizado_por: string | null; + carrera_id: string; + creado_en: string; + creado_por: string | null; + datos: Json; + estado_actual_id: string | null; + estructura_id: string; + id: string; + meta_origen: Json; + nivel: Database["public"]["Enums"]["nivel_plan_estudio"]; + nombre: string; + nombre_search: string | null; + numero_ciclos: number; + tipo_ciclo: Database["public"]["Enums"]["tipo_ciclo"]; + tipo_origen: + | Database["public"]["Enums"]["tipo_origen"] + | null; + }; + Insert: { + activo?: boolean; + actualizado_en?: string; + actualizado_por?: string | null; + carrera_id: string; + creado_en?: string; + creado_por?: string | null; + datos?: Json; + estado_actual_id?: string | null; + estructura_id: string; + id?: string; + meta_origen?: Json; + nivel: Database["public"]["Enums"]["nivel_plan_estudio"]; + nombre: string; + nombre_search?: string | null; + numero_ciclos: number; + tipo_ciclo: Database["public"]["Enums"]["tipo_ciclo"]; + tipo_origen?: + | Database["public"]["Enums"]["tipo_origen"] + | null; + }; + Update: { + activo?: boolean; + actualizado_en?: string; + actualizado_por?: string | null; + carrera_id?: string; + creado_en?: string; + creado_por?: string | null; + datos?: Json; + estado_actual_id?: string | null; + estructura_id?: string; + id?: string; + meta_origen?: Json; + nivel?: Database["public"]["Enums"]["nivel_plan_estudio"]; + nombre?: string; + nombre_search?: string | null; + numero_ciclos?: number; + tipo_ciclo?: Database["public"]["Enums"]["tipo_ciclo"]; + tipo_origen?: + | Database["public"]["Enums"]["tipo_origen"] + | null; + }; + Relationships: [ + { + foreignKeyName: "planes_estudio_actualizado_por_fkey"; + columns: ["actualizado_por"]; + isOneToOne: false; + referencedRelation: "usuarios_app"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "planes_estudio_carrera_id_fkey"; + columns: ["carrera_id"]; + isOneToOne: false; + referencedRelation: "carreras"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "planes_estudio_creado_por_fkey"; + columns: ["creado_por"]; + isOneToOne: false; + referencedRelation: "usuarios_app"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "planes_estudio_estado_actual_id_fkey"; + columns: ["estado_actual_id"]; + isOneToOne: false; + referencedRelation: "estados_plan"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "planes_estudio_estructura_id_fkey"; + columns: ["estructura_id"]; + isOneToOne: false; + referencedRelation: "estructuras_plan"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "planes_estudio_estructura_id_fkey"; + columns: ["estructura_id"]; + isOneToOne: false; + referencedRelation: "plantilla_plan"; + referencedColumns: ["estructura_id"]; + }, + ]; + }; + responsables_asignatura: { + Row: { + asignatura_id: string; + creado_en: string; + id: string; + rol: Database["public"]["Enums"][ + "rol_responsable_asignatura" + ]; + usuario_id: string; + }; + Insert: { + asignatura_id: string; + creado_en?: string; + id?: string; + rol?: + Database["public"]["Enums"][ + "rol_responsable_asignatura" + ]; + usuario_id: string; + }; + Update: { + asignatura_id?: string; + creado_en?: string; + id?: string; + rol?: + Database["public"]["Enums"][ + "rol_responsable_asignatura" + ]; + usuario_id?: string; + }; + Relationships: [ + { + foreignKeyName: + "responsables_asignatura_asignatura_id_fkey"; + columns: ["asignatura_id"]; + isOneToOne: false; + referencedRelation: "asignaturas"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: + "responsables_asignatura_usuario_id_fkey"; + columns: ["usuario_id"]; + isOneToOne: false; + referencedRelation: "usuarios_app"; + referencedColumns: ["id"]; + }, + ]; + }; + roles: { + Row: { + clave: string; + descripcion: string | null; + id: string; + nombre: string; + }; + Insert: { + clave: string; + descripcion?: string | null; + id?: string; + nombre: string; + }; + Update: { + clave?: string; + descripcion?: string | null; + id?: string; + nombre?: string; + }; + Relationships: []; + }; + tareas_revision: { + Row: { + asignado_a: string; + completado_en: string | null; + creado_en: string; + estado_id: string | null; + estatus: + Database["public"]["Enums"]["estado_tarea_revision"]; + fecha_limite: string | null; + id: string; + plan_estudio_id: string; + rol_id: string | null; + }; + Insert: { + asignado_a: string; + completado_en?: string | null; + creado_en?: string; + estado_id?: string | null; + estatus?: + Database["public"]["Enums"]["estado_tarea_revision"]; + fecha_limite?: string | null; + id?: string; + plan_estudio_id: string; + rol_id?: string | null; + }; + Update: { + asignado_a?: string; + completado_en?: string | null; + creado_en?: string; + estado_id?: string | null; + estatus?: + Database["public"]["Enums"]["estado_tarea_revision"]; + fecha_limite?: string | null; + id?: string; + plan_estudio_id?: string; + rol_id?: string | null; + }; + Relationships: [ + { + foreignKeyName: "tareas_revision_asignado_a_fkey"; + columns: ["asignado_a"]; + isOneToOne: false; + referencedRelation: "usuarios_app"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "tareas_revision_estado_id_fkey"; + columns: ["estado_id"]; + isOneToOne: false; + referencedRelation: "estados_plan"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "tareas_revision_plan_estudio_id_fkey"; + columns: ["plan_estudio_id"]; + isOneToOne: false; + referencedRelation: "planes_estudio"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "tareas_revision_plan_estudio_id_fkey"; + columns: ["plan_estudio_id"]; + isOneToOne: false; + referencedRelation: "plantilla_plan"; + referencedColumns: ["plan_estudio_id"]; + }, + { + foreignKeyName: "tareas_revision_rol_id_fkey"; + columns: ["rol_id"]; + isOneToOne: false; + referencedRelation: "roles"; + referencedColumns: ["id"]; + }, + ]; + }; + transiciones_estado_plan: { + Row: { + creado_en: string; + desde_estado_id: string; + hacia_estado_id: string; + id: string; + rol_permitido_id: string; + }; + Insert: { + creado_en?: string; + desde_estado_id: string; + hacia_estado_id: string; + id?: string; + rol_permitido_id: string; + }; + Update: { + creado_en?: string; + desde_estado_id?: string; + hacia_estado_id?: string; + id?: string; + rol_permitido_id?: string; + }; + Relationships: [ + { + foreignKeyName: + "transiciones_estado_plan_desde_estado_id_fkey"; + columns: ["desde_estado_id"]; + isOneToOne: false; + referencedRelation: "estados_plan"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: + "transiciones_estado_plan_hacia_estado_id_fkey"; + columns: ["hacia_estado_id"]; + isOneToOne: false; + referencedRelation: "estados_plan"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: + "transiciones_estado_plan_rol_permitido_id_fkey"; + columns: ["rol_permitido_id"]; + isOneToOne: false; + referencedRelation: "roles"; + referencedColumns: ["id"]; + }, + ]; + }; + usuarios_app: { + Row: { + actualizado_en: string; + creado_en: string; + email: string | null; + externo: boolean; + id: string; + nombre_completo: string | null; + }; + Insert: { + actualizado_en?: string; + creado_en?: string; + email?: string | null; + externo?: boolean; + id: string; + nombre_completo?: string | null; + }; + Update: { + actualizado_en?: string; + creado_en?: string; + email?: string | null; + externo?: boolean; + id?: string; + nombre_completo?: string | null; + }; + Relationships: []; + }; + usuarios_roles: { + Row: { + carrera_id: string | null; + creado_en: string; + facultad_id: string | null; + id: string; + rol_id: string; + usuario_id: string; + }; + Insert: { + carrera_id?: string | null; + creado_en?: string; + facultad_id?: string | null; + id?: string; + rol_id: string; + usuario_id: string; + }; + Update: { + carrera_id?: string | null; + creado_en?: string; + facultad_id?: string | null; + id?: string; + rol_id?: string; + usuario_id?: string; + }; + Relationships: [ + { + foreignKeyName: "usuarios_roles_carrera_id_fkey"; + columns: ["carrera_id"]; + isOneToOne: false; + referencedRelation: "carreras"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "usuarios_roles_facultad_id_fkey"; + columns: ["facultad_id"]; + isOneToOne: false; + referencedRelation: "facultades"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "usuarios_roles_rol_id_fkey"; + columns: ["rol_id"]; + isOneToOne: false; + referencedRelation: "roles"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "usuarios_roles_usuario_id_fkey"; + columns: ["usuario_id"]; + isOneToOne: false; + referencedRelation: "usuarios_app"; + referencedColumns: ["id"]; + }, + ]; + }; + vector_stores: { + Row: { + creado_en: string; + creado_por: string | null; + id: string; + nombre: string; + openai_vector_id: string | null; + }; + Insert: { + creado_en?: string; + creado_por?: string | null; + id?: string; + nombre: string; + openai_vector_id?: string | null; + }; + Update: { + creado_en?: string; + creado_por?: string | null; + id?: string; + nombre?: string; + openai_vector_id?: string | null; + }; + Relationships: [ + { + foreignKeyName: "vector_stores_creado_por_fkey"; + columns: ["creado_por"]; + isOneToOne: false; + referencedRelation: "usuarios_app"; + referencedColumns: ["id"]; + }, + ]; + }; + }; + Views: { + plantilla_plan: { + Row: { + estructura_id: string | null; + plan_estudio_id: string | null; + template_id: string | null; + }; + Relationships: []; + }; + }; + Functions: { + unaccent: { Args: { "": string }; Returns: string }; + unaccent_immutable: { Args: { "": string }; Returns: string }; + }; + Enums: { + estado_tarea_revision: "PENDIENTE" | "COMPLETADA" | "OMITIDA"; + fuente_cambio: "HUMANO" | "IA"; + nivel_plan_estudio: + | "Licenciatura" + | "Maestr├¡a" + | "Doctorado" + | "Especialidad" + | "Diplomado" + | "Otro"; + puesto_tipo: + | "vicerrector" + | "director_facultad" + | "secretario_academico" + | "jefe_carrera" + | "profesor" + | "lci"; + rol_responsable_asignatura: + | "PROFESOR_RESPONSABLE" + | "COAUTOR" + | "REVISOR"; + tipo_asignatura: "OBLIGATORIA" | "OPTATIVA" | "TRONCAL" | "OTRA"; + tipo_bibliografia: "BASICA" | "COMPLEMENTARIA"; + tipo_cambio: + | "ACTUALIZACION_CAMPO" + | "ACTUALIZACION_MAPA" + | "TRANSICION_ESTADO" + | "OTRO" + | "CREACION"; + tipo_ciclo: "Semestre" | "Cuatrimestre" | "Trimestre" | "Otro"; + tipo_estructura_plan: "CURRICULAR" | "NO_CURRICULAR"; + tipo_fuente_bibliografia: "MANUAL" | "BIBLIOTECA"; + tipo_interaccion_ia: + | "GENERAR" + | "MEJORAR_SECCION" + | "CHAT" + | "OTRA"; + tipo_notificacion: + | "PLAN_ASIGNADO" + | "ESTADO_CAMBIADO" + | "TAREA_ASIGNADA" + | "COMENTARIO" + | "OTRA"; + tipo_origen: + | "MANUAL" + | "IA" + | "CLONADO_INTERNO" + | "CLONADO_TRADICIONAL" + | "OTRO"; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +}; + +type DatabaseWithoutInternals = Omit; + +type DefaultSchema = + DatabaseWithoutInternals[Extract]; + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } ? keyof ( + & DatabaseWithoutInternals[ + DefaultSchemaTableNameOrOptions["schema"] + ]["Tables"] + & DatabaseWithoutInternals[ + DefaultSchemaTableNameOrOptions["schema"] + ]["Views"] + ) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} ? ( + & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]][ + "Tables" + ] + & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]][ + "Views" + ] + )[TableName] extends { + Row: infer R; + } ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof ( + & DefaultSchema["Tables"] + & DefaultSchema["Views"] + ) ? ( + & DefaultSchema["Tables"] + & DefaultSchema["Views"] + )[DefaultSchemaTableNameOrOptions] extends { + Row: infer R; + } ? R + : never + : never; + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[ + DefaultSchemaTableNameOrOptions["schema"] + ]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]][ + "Tables" + ][TableName] extends { + Insert: infer I; + } ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I; + } ? I + : never + : never; + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[ + DefaultSchemaTableNameOrOptions["schema"] + ]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]][ + "Tables" + ][TableName] extends { + Update: infer U; + } ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U; + } ? U + : never + : never; + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[ + DefaultSchemaEnumNameOrOptions["schema"] + ]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]][ + "Enums" + ][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never; + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[ + PublicCompositeTypeNameOrOptions["schema"] + ]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]][ + "CompositeTypes" + ][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends + keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never; + +export const Constants = { + graphql_public: { + Enums: {}, + }, + public: { + Enums: { + estado_tarea_revision: ["PENDIENTE", "COMPLETADA", "OMITIDA"], + fuente_cambio: ["HUMANO", "IA"], + nivel_plan_estudio: [ + "Licenciatura", + "Maestr├¡a", + "Doctorado", + "Especialidad", + "Diplomado", + "Otro", + ], + puesto_tipo: [ + "vicerrector", + "director_facultad", + "secretario_academico", + "jefe_carrera", + "profesor", + "lci", + ], + rol_responsable_asignatura: [ + "PROFESOR_RESPONSABLE", + "COAUTOR", + "REVISOR", + ], + tipo_asignatura: ["OBLIGATORIA", "OPTATIVA", "TRONCAL", "OTRA"], + tipo_bibliografia: ["BASICA", "COMPLEMENTARIA"], + tipo_cambio: [ + "ACTUALIZACION_CAMPO", + "ACTUALIZACION_MAPA", + "TRANSICION_ESTADO", + "OTRO", + "CREACION", + ], + tipo_ciclo: ["Semestre", "Cuatrimestre", "Trimestre", "Otro"], + tipo_estructura_plan: ["CURRICULAR", "NO_CURRICULAR"], + tipo_fuente_bibliografia: ["MANUAL", "BIBLIOTECA"], + tipo_interaccion_ia: ["GENERAR", "MEJORAR_SECCION", "CHAT", "OTRA"], + tipo_notificacion: [ + "PLAN_ASIGNADO", + "ESTADO_CAMBIADO", + "TAREA_ASIGNADA", + "COMENTARIO", + "OTRA", + ], + tipo_origen: [ + "MANUAL", + "IA", + "CLONADO_INTERNO", + "CLONADO_TRADICIONAL", + "OTRO", + ], + }, + }, +} as const; diff --git a/supabase/functions/_shared/openai-service.ts b/supabase/functions/_shared/openai-service.ts new file mode 100644 index 0000000..afa0cd4 --- /dev/null +++ b/supabase/functions/_shared/openai-service.ts @@ -0,0 +1,226 @@ +// supabase/functions/_shared/openai-service.ts +/// +import OpenAI from "npm:openai@6.16.0"; +import type * as OpenAITypes from "npm:openai@6.16.0"; +import { createClient, type SupabaseClient } from "npm:@supabase/supabase-js@2"; + +// Use non-streaming params to ensure `responses.create` returns a typed Response +export type StructuredResponseOptions = + OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming; + +export type StructuredResponseSuccess = { + ok: true; + output?: TOutput; // parsed JSON when available + outputText?: string; // raw text when parsing is not possible + model: string; + usage?: OpenAITypes.OpenAI.Responses.Response["usage"] | null; + responseId: string; + conversationId?: string | null; + references: { + uploadedToStorage: string[]; // supabase storage paths + openaiFileIds: string[]; // file ids in OpenAI + }; + openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers +}; + +export type StructuredResponseFailure = { + ok: false; + code: + | "MissingEnv" + | "StorageUploadFailed" + | "OpenAIFileUploadFailed" + | "OpenAIRequestFailed"; + message: string; + cause?: unknown; +}; + +export type StructuredResponseResult = + | StructuredResponseSuccess + | StructuredResponseFailure; + +export interface OpenAIServiceConfig { + supabaseUrl: string; + serviceRoleKey: string; + openAIApiKey: string; + bucket?: string; // default: ai-storage +} + +export class OpenAIService { + private readonly supabase: SupabaseClient; + private readonly openai: OpenAI; + private readonly bucket: string; + + private constructor( + client: SupabaseClient, + openai: OpenAI, + bucket: string, + ) { + this.supabase = client; + this.openai = openai; + this.bucket = bucket; + } + + static fromEnv(): StructuredResponseFailure | OpenAIService { + const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? ""; + const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; + const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? ""; + const bucket = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage"; + + if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) { + return { + ok: false, + code: "MissingEnv", + message: + "Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY", + }; + } + + const client = createClient(supabaseUrl, serviceRoleKey); + const openai = new OpenAI({ apiKey: openAIApiKey }); + return new OpenAIService(client, openai, bucket); + } + + async createStructuredResponse( + options: StructuredResponseOptions, + files?: File[], + ): Promise> { + try { + const uploadedToStorage = await this.uploadFilesToStorage( + files ?? [], + ); + const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []); + + const newOptions = { ...options }; + + // Attach file references to the request as an extra user message + if (openaiFileIds.length > 0) { + const fileParts: + OpenAITypes.OpenAI.Responses.ResponseInputFile[] = + openaiFileIds.map((id) => ({ + type: "input_file", + file_id: id, + })); + + const arr = Array.isArray(options.input) ? options.input : []; + arr.push({ + role: "user", + content: [ + ...fileParts, + { + type: "input_text", + text: "Usa estos archivos como referencia", + }, + ], + }); + newOptions.input = arr; + } + + // Narrow to non-streaming response + const openaiRaw = (await this.openai.responses.create( + newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming, + )) as OpenAITypes.OpenAI.Responses.Response; + + const { model, id: responseId } = openaiRaw; + const usage = openaiRaw?.usage ?? null; + const conversationId = ( + openaiRaw as OpenAITypes.OpenAI.Responses.Response & { + conversation_id?: string | null; + } + ).conversation_id ?? null; + + // Try to read structured JSON output + let output: TOutput | undefined = undefined; + let outputText: string | undefined = undefined; + + // Prefer `output_text` if present (SDK convenience) + const maybeOutputText = openaiRaw.output_text; + if ( + typeof maybeOutputText === "string" && + maybeOutputText.length > 0 + ) { + outputText = maybeOutputText; + try { + output = JSON.parse(maybeOutputText) as TOutput; + } catch { /* non-JSON text, keep as text only */ } + } else { + // Fallback: attempt to serialize `openaiRaw.output` into text + const maybeOutput = openaiRaw.output as unknown; + if (typeof maybeOutput === "object" && maybeOutput != null) { + try { + outputText = JSON.stringify(maybeOutput); + output = maybeOutput as TOutput; + } catch { /* ignore */ } + } + } + + return { + ok: true, + output, + outputText, + model: String(model), + usage, + responseId: String(responseId), + conversationId: conversationId ? String(conversationId) : null, + references: { uploadedToStorage, openaiFileIds }, + openaiRaw, + }; + } catch (err) { + const e = err as Error; + const message = e?.message ?? "Unknown error"; + const code = message.includes("Supabase upload failed") + ? "StorageUploadFailed" + : message.includes("OpenAI file upload failed") + ? "OpenAIFileUploadFailed" + : "OpenAIRequestFailed"; + + return { ok: false, code, message, cause: err }; + } + } + + private async uploadFilesToStorage(files: File[]): Promise { + const paths: string[] = []; + for (const file of files) { + const safeName = this.sanitizeFilename(file.name); + const path = `${crypto.randomUUID()}-${safeName}`; + const { data, error } = await this.supabase.storage + .from(this.bucket) + .upload(path, file, { + contentType: file.type || "application/octet-stream", + upsert: false, + }); + + if (error) { + throw new Error(`Supabase upload failed: ${error.message}`); + } + paths.push(data.path); + } + return paths; + } + + private async uploadFilesToOpenAI(files: File[]): Promise { + const ids: string[] = []; + for (const file of files) { + try { + const created = await this.openai.files.create({ + file, + purpose: "user_data", + }); + ids.push(created.id); + } catch (e) { + throw new Error( + `OpenAI file upload failed: ${ + (e as Error)?.message ?? String(e) + }`, + ); + } + } + return ids; + } + + private sanitizeFilename(name: string): string { + return name + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-zA-Z0-9.-]/g, "_"); + } +} diff --git a/supabase/functions/ai-generate-plan/index.ts b/supabase/functions/ai-generate-plan/index.ts index a80776b..fd58eda 100644 --- a/supabase/functions/ai-generate-plan/index.ts +++ b/supabase/functions/ai-generate-plan/index.ts @@ -6,10 +6,17 @@ 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 { strict } from "node:assert"; +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) => { if (req.method === "OPTIONS") { @@ -74,30 +81,22 @@ Deno.serve(async (req) => { throw new Error("Supabase environment variables are not set"); } - const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { - global: { - headers: { - Authorization: authHeaderRaw, - }, - }, - }); - - // TODO: quitar hardcode de usuario - const { data: user, error: userError } = await supabaseAnon.auth - .signInWithPassword({ - email: "guillermo.arrieta@lasalle.mx", - password: "admin", - }); - if (userError) { - throw new Error("Error authenticating user: " + userError.message); - } + // 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( + const supabaseService = createClient( SUPABASE_URL, SERVICE_ROLE_KEY, ); @@ -136,7 +135,14 @@ Deno.serve(async (req) => { `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"}`; - const aiStructuredPayload = { + // 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-mini", input: [ { role: "system", content: systemPrompt }, @@ -146,39 +152,34 @@ Deno.serve(async (req) => { format: { type: "json_schema", name: "plan_de_estudios_standard", - schema: estructuraPlan?.definicion, + schema: schemaDef, strict: true, }, }, }; - const aiStructuredFormData = new FormData(); - aiStructuredFormData.append("options", JSON.stringify(aiStructuredPayload)); - for (const file of payload.archivosAdjuntos ?? []) { - aiStructuredFormData.append("files", file); + // 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 { data: aiJson, error } = await supabaseService.functions.invoke( - "ai-structured", - { - headers: { - Authorization: `Bearer ${user.session?.access_token}`, - }, - method: "POST", - body: aiStructuredFormData, - }, + const aiResult = await svc.createStructuredResponse( + aiStructuredPayload, + payload.archivosAdjuntos, ); - - if (error) { + if (!aiResult.ok) { throw new Error( - "ai-structured invocation failed: " + error.message, + `OpenAI call failed [${aiResult.code}]: ${aiResult.message}`, ); } - if (!aiJson || !aiJson.ok) { - throw new Error( - "ai-structured returned an error or no data", - ); + // 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 @@ -200,43 +201,41 @@ Deno.serve(async (req) => { throw new Error("Error fetching carrera: " + carreraError.message); } - const { data: plan, error: planError } = await supabaseService - .from("planes_estudio") - .insert({ - carrera_id: carrera?.id, - estructura_id: estructuraPlan?.id, + 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, - tipo_ciclo: payload.datosBasicos.tipoCiclo, + nivel: payload.datosBasicos.nivel as NivelType, + tipo_ciclo: payload.datosBasicos.tipoCiclo as TipoCicloType, numero_ciclos: payload.datosBasicos.numCiclos, - datos: aiJson.output, - estado_actual_id: estado?.id, + datos: aiOutputJson, + estado_actual_id: estado?.id ?? undefined, activo: true, tipo_origen: "IA", meta_origen: { generado_por: "ai_generate_plan", ai_structured: { - cid: aiJson?.cid ?? null, - responseId: aiJson?.responseId ?? null, - conversationId: aiJson?.conversationId ?? null, - model: aiJson?.model, - usage: aiJson?.usage ?? null, + 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, - // openaiFileIds, - // vectorStoreIds, }, iaConfig: { descripcionEnfoque: payload.iaConfig?.descripcionEnfoque ?? null, notasAdicionales: payload.iaConfig?.notasAdicionales ?? null, usarMCP: Boolean(payload.iaConfig?.usarMCP), }, - }, - // creado_por: user.id, - // actualizado_por: user.id, - }) + } 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", ) @@ -246,7 +245,7 @@ Deno.serve(async (req) => { throw new Error("Error inserting plan: " + planError.message); } - // TODO: update a interaccion_ia y e insert a cambios_plancon id de plan generado + // TODO: update a interaccion_ia y e insert a cambios_plan con id de plan generado const jsonResponse = { ok: true, plan, @@ -283,7 +282,7 @@ const jsonFromString = (schema: T) => z.string().transform((str, ctx) => { try { return JSON.parse(str); - } catch (e) { + } catch (_e) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "El formato no es un JSON válido", diff --git a/supabase/functions/ai-structured/deno.json b/supabase/functions/ai-structured/deno.json index 50133ad..d766bc9 100644 --- a/supabase/functions/ai-structured/deno.json +++ b/supabase/functions/ai-structured/deno.json @@ -1,5 +1,5 @@ { "imports": { - "openai": "npm:openai" + "openai": "npm:openai@6.16.0" } } diff --git a/supabase/functions/ai-structured/index.ts b/supabase/functions/ai-structured/index.ts index e018c0d..3fcbbc6 100644 --- a/supabase/functions/ai-structured/index.ts +++ b/supabase/functions/ai-structured/index.ts @@ -1,10 +1,5 @@ -// supabase/functions/ai-structured/index.ts -/// -import OpenAI from "openai"; -import type * as OpenAITypes from "openai"; - +// Function deprecated. This endpoint was replaced by shared module `_shared/openai-service.ts`. import "jsr:@supabase/functions-js/edge-runtime.d.ts"; -import { createClient } from "npm:@supabase/supabase-js@2"; const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { @@ -16,151 +11,14 @@ const json = (body: unknown, status = 200) => }, }); -Deno.serve(async (req) => { +Deno.serve((req) => { if (req.method === "OPTIONS") return json({ ok: true }); - if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); - - console.log("Received request"); - - try { - const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; - const SUPABASE_SERVICE_ROLE_KEY = - Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; - const SUPABASE_BUCKET = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage"; - const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY") ?? ""; - - if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY || !OPENAI_API_KEY) { - return json({ error: "Missing env vars" }, 500); - } - - console.log("Env vars loaded"); - - type InputForm = { - options: OpenAITypes.OpenAI.Responses.ResponseCreateParams; - files: File[]; - }; - const fd = await req.formData(); - - // Asume siempre vienen: options, files[] - const optionsRaw = fd.get("options"); - - const inputForm: InputForm = { - options: typeof optionsRaw === "string" - ? JSON.parse(optionsRaw) - : optionsRaw, - - files: fd.getAll("files").filter((x): x is File => x instanceof File), - }; - console.log(`Parsed form data: ${inputForm.files.length} files`); - - const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); - const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); - - console.log("Parsed options"); - - // 1) Upload a Supabase Storage - const supabase_paths: string[] = []; - for (const file of inputForm.files ?? []) { - 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, { - contentType: file.type || "application/octet-stream", - upsert: false, - /* metadata */ - }); - - if (error) throw new Error(`Supabase upload failed: ${error.message}`); - supabase_paths.push(data.path); - } - - console.log("Uploaded files to Supabase Storage"); - - // 2) Upload a OpenAI Files - const openai_file_ids: string[] = []; - for (const file of inputForm.files ?? []) { - const created = await openai.files.create({ - file, - purpose: "user_data", - }); - openai_file_ids.push(created.id); - } - - console.log("Uploaded files to OpenAI Files"); - - // 3) Inject file_ids into options.input by appending a new user message - const fileParts: OpenAITypes.OpenAI.Responses.ResponseInputFile[] = - openai_file_ids.map((id) => ({ - type: "input_file", - file_id: id, - })); - - // Asume que SIEMPRE hay input; de todas formas, cae bien si no. - const inputArr = Array.isArray(inputForm.options?.input) - ? inputForm.options.input - : []; - - inputArr.push({ - role: "user", - content: [ - ...fileParts, - { type: "input_text", text: "usa estos archivos como referencia" }, - ], - }); - - inputForm.options.input = inputArr; - - console.log("Prepared OpenAI options with file references"); - - // 4) Call Responses API - const openai_raw = await openai.responses.create(inputForm.options); - - console.log("Received response from OpenAI Responses API"); - - type SuccessResponse = { - ok: true; - openai_raw: OpenAITypes.OpenAI.Responses.Response; - supabase_paths: string[]; - openai_file_ids: string[]; - options: unknown; - }; - - const response: SuccessResponse = { - ok: true, - openai_raw, // cruda - supabase_paths, // rutas en el bucket - openai_file_ids, // IDs en OpenAI - options: inputForm.options, // opciones usadas - }; - response.openai_raw.output. - - return json(response); - } catch (e) { - if (e instanceof Error) { - console.error("Error occurred:", e.message); - return json( - { - ok: false, - - error: e?.message ?? String(e), - // si fue Zod, normalmente trae detalles en `issues` - issues: (e as any)?.issues, - }, - 400, - ); - } else { - console.error("Unknown error occurred:", e); - return json( - { - ok: false, - error: "An unknown error occurred", - }, - 500, - ); - } - } + return json( + { + ok: false, + error: + "This endpoint is deprecated. Use the shared module _shared/openai-service.ts from your functions.", + }, + 410, + ); }); diff --git a/supabase/functions/tests/ai-structured-test.ts b/supabase/functions/tests/ai-structured-test.ts index 423e9bf..5a52e1e 100644 --- a/supabase/functions/tests/ai-structured-test.ts +++ b/supabase/functions/tests/ai-structured-test.ts @@ -25,6 +25,9 @@ function mustEnv() { if (!SUPABASE_ANON_KEY) throw new Error("SUPABASE_ANON_KEY is required"); } +async function getAuthedClient(): Promise< + { client: SupabaseClient; accessToken: string } +> { async function getAuthedClient(): Promise< { client: SupabaseClient; accessToken: string } > { @@ -45,51 +48,76 @@ async function getAuthedClient(): Promise< return { client, accessToken }; } -Deno.test("ai-structured (JSON body)", async () => { - const { client, accessToken } = await getAuthedClient(); +Deno.test( + { name: "ai-structured (JSON body) [DEPRECATED]", ignore: true }, + async () => { + const { client, accessToken } = await getAuthedClient(); - const form = new FormData(); - form.append( - "options", - JSON.stringify({ - input: "Genera 3 ideas de negocio innovadoras en formato JSON.", - model: "gpt-5-nano", - text: { - format: { - "type": "json_schema", - "name": "business_ideas", - "schema": { - "type": "array", - "properties": { - "idea": { "type": "string", "description": "The business idea" }, + const { data, error } = await client.functions.invoke("ai-structured", { + headers: { + Authorization: `Bearer ${accessToken}`, // 👈 clave para que tu función pase el authHeader check + }, + body: { + response: { + model: "gpt-5", + input: [ + { role: "system", content: "Responde SIEMPRE en JSON válido." }, + { + role: "user", + content: "Dame 3 ideas de proyecto de IA para educación.", }, - "required": ["idea"], - "additionalProperties": false, + ], + }, + structured: { + type: "json_schema", + name: "ideas", + strict: true, + schema: { + type: "object", + properties: { + ideas: { + type: "array", + items: { + type: "object", + properties: { + titulo: { type: "string" }, + descripcion: { type: "string" }, + }, + required: ["titulo", "descripcion"], + additionalProperties: false, + }, + }, + }, + required: ["ideas"], + additionalProperties: false, }, }, + references: {}, + usarMCP: false, }, - }), - ); + }); - const { data, error } = await client.functions.invoke("ai-structured", { - headers: { - Authorization: `Bearer ${accessToken}`, // 👈 clave para que tu función pase el authHeader check - }, - body: form, - }); + if (error) throw new Error("Invoke failed: " + error.message); + assert(data, "Expected data from function"); - if (error) throw new Error("Invoke failed: " + error.message); - assert(data, "Expected data from function"); - assertEquals(data.ok, true); - assert( - Array.isArray(data.openai_raw.output), - "Expected output to be an array", - ); - assert(data.openai_raw.output.length === 3, "Expected 3 business ideas"); - console.log(data); -}); + // tu función responde { ok, output, outputText, ... } + assertEquals(data.ok, true); + assert( + Array.isArray(data.output?.ideas), + "output.ideas should be an array", + ); + assertEquals(data.output.ideas.length, 3); + for (const it of data.output.ideas) { + assert(typeof it.titulo === "string"); + assert(typeof it.descripcion === "string"); + } + }, +); -Deno.test("ai-structured (multipart + file)", async () => { +Deno.test({ + name: "ai-structured (multipart + file) [DEPRECATED]", + ignore: true, +}, async () => { const { client, accessToken } = await getAuthedClient(); // Lee un PDF local (ajusta path)