From 288278ea6c3adddc3aef83ca22157d60bdb7f89b Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Fri, 27 Feb 2026 11:34:08 -0600 Subject: [PATCH] feat: update AI subject generation and handling responses - Refactor AIGenerateSubjectInput type to include optional fields for updates. - Add handling for "asignaturas" responses in OpenAI webhook. - Implement crear.ts for creating/updating subjects based on AI responses. - Update tests to validate OpenAI file uploads instead of storage uploads. --- notebooks/background-openai.ipynb | 190 +++- supabase/functions/_shared/database.types.ts | 8 + supabase/functions/_shared/openai-service.ts | 56 +- .../functions/ai-generate-subject/index.ts | 867 ++++++------------ .../functions/ai-generate-subject/types.ts | 45 +- .../asignaturas/crear.ts | 209 +++++ .../asignaturas/index.ts | 25 + .../openai-webhook-responses/index.ts | 4 + .../tests/ai-generate-test-prototipo.ts | 4 +- .../functions/tests/ai-structured-test.ts | 4 +- 10 files changed, 742 insertions(+), 670 deletions(-) create mode 100644 supabase/functions/openai-webhook-responses/asignaturas/crear.ts create mode 100644 supabase/functions/openai-webhook-responses/asignaturas/index.ts diff --git a/notebooks/background-openai.ipynb b/notebooks/background-openai.ipynb index 4f33a78..4450372 100644 --- a/notebooks/background-openai.ipynb +++ b/notebooks/background-openai.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "bb61ec88", "metadata": {}, "outputs": [], @@ -14,12 +14,12 @@ " export: true,\n", "});\n", "\n", - "const openai = new OpenAI();" + "const openai = new OpenAI();\n" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "8baca5f2", "metadata": {}, "outputs": [], @@ -27,7 +27,170 @@ "import { createClient } from \"@supabase/supabase-js\";\n", "const supabaseUrl = Deno.env.get(\"SUPABASE_URL\") || env.SUPABASE_URL;\n", "const supabaseKey = Deno.env.get(\"SUPABASE_ANON_KEY\") || env.SUPABASE_ANON_KEY;\n", - "const supabase = createClient(supabaseUrl, supabaseKey);" + "const supabase = createClient(supabaseUrl, supabaseKey);\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "923b8e08", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Esperando a que la IA termine con la asignatura fe5eb09c-501b-4db6-b6d3-f02bdcfdc4b4...\n", + "Conexión WebSocket establecida. La celda está esperando cambios...\n" + ] + }, + { + "ename": "", + "evalue": "", + "output_type": "error", + "traceback": [ + "\u001b[1;31mThe Kernel crashed while executing code in the current cell or a previous cell. \n", + "\u001b[1;31mPlease review the code in the cell(s) to identify a possible cause of the failure. \n", + "\u001b[1;31mClick here for more info. \n", + "\u001b[1;31mView Jupyter log for further details." + ] + } + ], + "source": [ + "const ID_ASIGNATURA = \"fe5eb09c-501b-4db6-b6d3-f02bdcfdc4b4\";\n", + "console.log(\n", + " `Esperando a que la IA termine con la asignatura ${ID_ASIGNATURA}...`,\n", + ");\n", + "\n", + "if (!supabase) {\n", + " console.log(\"No hay supabase\");\n", + "} else {\n", + " // 2. Armas la \"trampa\" (el listener)\n", + " await new Promise((resolve) => {\n", + " const canal = supabase\n", + " .channel(\"test-asignatura\")\n", + " .on(\n", + " \"postgres_changes\",\n", + " {\n", + " event: \"UPDATE\",\n", + " schema: \"public\",\n", + " table: \"asignaturas\",\n", + " filter: `id=eq.${ID_ASIGNATURA}`,\n", + " },\n", + " (payload) => {\n", + " const nuevoEstado = payload.new.estado;\n", + "\n", + " if (nuevoEstado !== \"generando\") {\n", + " console.log(\n", + " `¡Proceso completado! El nuevo estado es: ${nuevoEstado}`,\n", + " );\n", + "\n", + " supabase.removeChannel(canal);\n", + " resolve(true); // ¡Aquí destrabamos la celda de Jupyter!\n", + " } else {\n", + " console.log(\"Hubo un update, pero sigue generando...\");\n", + " }\n", + " },\n", + " )\n", + " .subscribe((status) => {\n", + " if (status === \"SUBSCRIBED\") {\n", + " console.log(\n", + " \"Conexión WebSocket establecida. La celda está esperando cambios...\",\n", + " );\n", + " }\n", + " });\n", + " });\n", + "\n", + " console.log(\"Script finalizado correctamente.\");\n", + "}\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8a1509d8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Iniciando conexión abierta y sin filtros...\n", + "Conectado. Esperando cualquier cambio en la tabla asignaturas...\n" + ] + }, + { + "ename": "", + "evalue": "", + "output_type": "error", + "traceback": [ + "\u001b[1;31mThe Kernel crashed while executing code in the current cell or a previous cell. \n", + "\u001b[1;31mPlease review the code in the cell(s) to identify a possible cause of the failure. \n", + "\u001b[1;31mClick here for more info. \n", + "\u001b[1;31mView Jupyter log for further details." + ] + } + ], + "source": [ + "console.log(`Iniciando conexión abierta y sin filtros...`);\n", + "\n", + "await new Promise((resolve) => {\n", + " const canal = supabase\n", + " .channel(\"test-global-asignaturas\")\n", + " .on(\n", + " \"postgres_changes\",\n", + " {\n", + " event: \"*\", // Escuchamos absolutamente TODO\n", + " schema: \"public\",\n", + " table: \"asignaturas\",\n", + " },\n", + " (payload) => {\n", + " // Imprimimos todo el objeto tal como llega del servidor\n", + " console.log(\"🚨 ¡EVENTO RECIBIDO EN CRUDO!\", payload);\n", + "\n", + " // Cerramos la conexión para liberar la celda\n", + " supabase.removeChannel(canal);\n", + " resolve(true);\n", + " },\n", + " )\n", + " .subscribe((status, err) => {\n", + " if (status === \"SUBSCRIBED\") {\n", + " console.log(\n", + " \"Conectado. Esperando cualquier cambio en la tabla asignaturas...\",\n", + " );\n", + " }\n", + " if (err) {\n", + " console.error(\"Error en el socket:\", err);\n", + " }\n", + " });\n", + "});\n", + "\n", + "console.log(\"Script finalizado.\");\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3602b930", + "metadata": {}, + "outputs": [], + "source": [ + "import(\"https://esm.sh/@supabase/supabase-js\").then(({ createClient }) => {\n", + " const supabase = createClient(\n", + " \"https://bxkskdxwppdlplrkidcz.supabase.co\",\n", + " \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJ4a3NrZHh3cHBkbHBscmtpZGN6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIwMzU2NDMsImV4cCI6MjA4NzYxMTY0M30.RIyvmB8Ij_qrsiMn0f65awiG5VaCUA5dvb117wuU3jo\",\n", + " );\n", + "\n", + " supabase.channel(\"prueba-consola\")\n", + " .on(\"postgres_changes\", {\n", + " event: \"*\",\n", + " schema: \"public\",\n", + " table: \"asignaturas\",\n", + " }, (payload) => {\n", + " console.log(\"🔥 ¡LLEGÓ EL EVENTO DESDE SUPABASE!\", payload);\n", + " })\n", + " .subscribe((status) => console.log(\"Estado de la conexión:\", status));\n", + "});\n" ] }, { @@ -55,7 +218,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": null, "id": "8abb080e", "metadata": {}, "outputs": [ @@ -71,12 +234,12 @@ } ], "source": [ - "plan.id" + "plan.id;\n" ] }, { "cell_type": "code", - "execution_count": 22, + "execution_count": null, "id": "a382bdc6", "metadata": {}, "outputs": [ @@ -144,7 +307,6 @@ } ], "source": [ - "\n", "let response = await openai.responses.create({\n", " model: \"gpt-5-mini\",\n", " input: \"Hola mundo\",\n", @@ -183,7 +345,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": null, "id": "6b2470ed", "metadata": {}, "outputs": [ @@ -293,12 +455,12 @@ " response = await openai.responses.retrieve(response.id);\n", "}\n", "\n", - "response;" + "response;\n" ] }, { "cell_type": "code", - "execution_count": 24, + "execution_count": null, "id": "ebb67bd8", "metadata": {}, "outputs": [ @@ -314,12 +476,12 @@ } ], "source": [ - "response.metadata" + "response.metadata;\n" ] }, { "cell_type": "code", - "execution_count": 27, + "execution_count": null, "id": "cd18fc2d", "metadata": {}, "outputs": [ @@ -365,7 +527,7 @@ "source": [ "await supabase.from(response.metadata.tabla).update({\n", " datos: JSON.parse(response.output_text),\n", - "}).eq(\"id\", response.metadata.id).select(\"*\").single();" + "}).eq(\"id\", response.metadata.id).select(\"*\").single();\n" ] }, { diff --git a/supabase/functions/_shared/database.types.ts b/supabase/functions/_shared/database.types.ts index 3e1d507..e8f664b 100644 --- a/supabase/functions/_shared/database.types.ts +++ b/supabase/functions/_shared/database.types.ts @@ -1209,6 +1209,14 @@ export type Database = { } } Functions: { + append_conversacion_asignatura: { + Args: { p_append: Json; p_id: string } + Returns: undefined + } + append_conversacion_plan: { + Args: { p_append: Json; p_id: string } + Returns: undefined + } unaccent: { Args: { "": string }; Returns: string } unaccent_immutable: { Args: { "": string }; Returns: string } } diff --git a/supabase/functions/_shared/openai-service.ts b/supabase/functions/_shared/openai-service.ts index 733bafd..5ebc4f6 100644 --- a/supabase/functions/_shared/openai-service.ts +++ b/supabase/functions/_shared/openai-service.ts @@ -2,7 +2,6 @@ /// 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; @@ -15,7 +14,6 @@ export type StructuredResponseSuccess = { 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 @@ -24,7 +22,6 @@ export type StructuredResponseFailure = { ok: false; code: | "MissingEnv" - | "StorageUploadFailed" | "OpenAIFileUploadFailed" | "OpenAIRequestFailed"; message: string; @@ -34,40 +31,24 @@ 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; + private constructor(openai: OpenAI) { 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) { + if (!openAIApiKey) { return { ok: false, code: "MissingEnv", - message: - "Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY", + message: "Required env vars missing: OPENAI_API_KEY", }; } - const client = createClient(supabaseUrl, serviceRoleKey); const openai = new OpenAI({ apiKey: openAIApiKey }); - return new OpenAIService(client, openai, bucket); + return new OpenAIService(openai); } async createConversation(metadata?: Record) { const conversation = await this.openai.conversations.create({ @@ -80,9 +61,6 @@ export class OpenAIService { files?: File[], ): Promise> { try { - const uploadedToStorage = await this.uploadFilesToStorage( - files ?? [], - ); const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []); const newOptions = { ...options }; @@ -136,7 +114,7 @@ export class OpenAIService { conversationId: conversationId ? String(conversationId) : null, - references: { uploadedToStorage, openaiFileIds }, + references: { openaiFileIds }, openaiRaw, }; } @@ -175,38 +153,18 @@ export class OpenAIService { usage, responseId: String(responseId), conversationId: conversationId ? String(conversationId) : null, - references: { uploadedToStorage, openaiFileIds }, + references: { 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") + const code = 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) { diff --git a/supabase/functions/ai-generate-subject/index.ts b/supabase/functions/ai-generate-subject/index.ts index b83a63e..1d5c2fc 100644 --- a/supabase/functions/ai-generate-subject/index.ts +++ b/supabase/functions/ai-generate-subject/index.ts @@ -18,38 +18,13 @@ addEventListener("beforeunload", (ev: BeforeUnloadWithDetail) => { ); }); -/** - * JSON input signature (when `Content-Type: application/json`) - * - * Required: - * - `id` (uuid) - corresponde a la columna `id` de `asignaturas` - * - * Optional patch fields (any subset is allowed): - * - `nombre` (string) - * - `codigo` (string | null) - * - `tipo` (string | null) // must match DB enum values - * - `creditos` (number) - * - `horas_academicas` (number | null) - * - `horas_independientes` (number | null) - * - `numero_ciclo` (number | null) - * - `estructura_id` (uuid | null) - * - `plan_estudio_id` (uuid) - * - `linea_plan_id` (uuid | null) - * - `orden_celda` (number | null) - * - * IA config (optional): - * - `descripcionEnfoqueAcademico` (string) - * - * Notes: - * - This JSON flow does NOT accept `instruccionesAdicionalesIA`. - * - Missing optional fields are ignored (the existing DB values remain). - */ - -const JsonUpdateSchema = z +const DatosUpdateSchema = z .object({ - id: z.string().uuid("id debe ser un UUID"), + id: z.string().uuid("id debe ser un UUID").optional(), - // patch fields (all optional) — nombres coinciden con columnas DB + // campos de asignatura (nombres coinciden con columnas DB) + plan_estudio_id: z.string().uuid("plan_estudio_id debe ser un UUID"), + estructura_id: z.string().uuid("estructura_id debe ser un UUID").optional(), nombre: z.string().min(1).optional(), codigo: z.union([z.string().min(1), z.null()]).optional(), tipo: z.union([z.string().min(1), z.null()]).optional(), @@ -57,17 +32,70 @@ const JsonUpdateSchema = z horas_academicas: z.number().int().nonnegative().nullable().optional(), horas_independientes: z.number().int().nonnegative().nullable().optional(), numero_ciclo: z.number().int().positive().nullable().optional(), - estructura_id: z.string().uuid().nullable().optional(), - plan_estudio_id: z.string().uuid().optional(), linea_plan_id: z.string().uuid().nullable().optional(), orden_celda: z.number().int().nonnegative().nullable().optional(), - - // IA config (no instruccionesAdicionalesIA) - descripcionEnfoqueAcademico: z.string().optional(), }) .strict(); -type EdgeAIGenerateSubjectJsonUpdateInput = z.infer; +const IAConfigSchema = z + .object({ + descripcionEnfoqueAcademico: z.string().optional(), + instruccionesAdicionalesIA: z.string().optional(), + archivosAdjuntos: z.array(z.string().min(1)).optional().default([]), + }) + .strict() + .default({}); + +const UnifiedJsonSchema = z + .object({ + datosUpdate: DatosUpdateSchema, + iaConfig: IAConfigSchema, + }) + .strict() + .superRefine((val, ctx) => { + // Si no hay id, es un insert => necesitamos campos mínimos + if (!val.datosUpdate.id) { + if (!val.datosUpdate.estructura_id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["datosUpdate", "estructura_id"], + message: "estructura_id es requerido para crear", + }); + } + if (!val.datosUpdate.nombre) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["datosUpdate", "nombre"], + message: "nombre es requerido para crear", + }); + } + if (val.datosUpdate.creditos === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["datosUpdate", "creditos"], + message: "creditos es requerido para crear", + }); + } + } + }); + +type EdgeAIGenerateSubjectUnifiedInput = z.infer; + +type AsignaturaBaseSeleccionada = { + id: string; + plan_estudio_id: string; + estructura_id: string | null; + nombre: string; + codigo: string | null; + tipo: Database["public"]["Tables"]["asignaturas"]["Row"]["tipo"]; + creditos: number; + horas_academicas: number | null; + horas_independientes: number | null; + numero_ciclo: number | null; + linea_plan_id: string | null; + orden_celda: number | null; + estado: Database["public"]["Enums"]["estado_asignatura"]; +}; function withColumnDefsAndRefs( schemaDef: Record, @@ -96,28 +124,6 @@ function withColumnDefsAndRefs( return nextSchema; } -function splitAiOutputStringsAndColumns( - aiOutput: unknown, -): { aiOutputJson: Json; columnasGeneradas: Record } { - if (!aiOutput || typeof aiOutput !== "object" || Array.isArray(aiOutput)) { - return { aiOutputJson: aiOutput as unknown as Json, columnasGeneradas: {} }; - } - - const record = aiOutput as Record; - const stringsOnly: Record = {}; - const columnasGeneradas: Record = {}; - - for (const [key, value] of Object.entries(record)) { - if (typeof value === "string") { - stringsOnly[key] = value; - } else { - columnasGeneradas[key] = value; - } - } - - return { aiOutputJson: stringsOnly as unknown as Json, columnasGeneradas }; -} - function formatZodIssues(issues: z.ZodIssue[]): string { return issues .map((issue, i) => { @@ -171,9 +177,8 @@ Deno.serve(async (req: Request): Promise => { } const contentType = (req.headers.get("content-type") || "").toLowerCase(); - const isMultipart = contentType.startsWith("multipart/form-data"); const isJson = contentType.includes("application/json"); - if (!isMultipart && !isJson) { + if (!isJson) { console.error( `[${ new Date().toISOString() @@ -183,7 +188,7 @@ Deno.serve(async (req: Request): Promise => { 415, "Content-Type no soportado.", "UNSUPPORTED_MEDIA_TYPE", - { contentType }, + { contentType, expected: "application/json" }, ); } @@ -237,340 +242,224 @@ Deno.serve(async (req: Request): Promise => { ) ?? "gpt-5-nano"; // ----------------------------- - // JSON update flow + // Unified JSON create/update flow (background) // ----------------------------- - if (isJson) { - let rawBody: unknown; - try { - rawBody = await req.json(); - } catch (e) { - throw new HttpError( - 400, - "Body JSON inválido.", - "INVALID_JSON", - { cause: e }, - ); - } + let rawBody: unknown; + try { + rawBody = await req.json(); + } catch (e) { + throw new HttpError( + 400, + "Body JSON inválido.", + "INVALID_JSON", + { cause: e }, + ); + } - const parsedBody = JsonUpdateSchema.safeParse(rawBody); - if (!parsedBody.success) { - throw new HttpError( - 422, - formatZodIssues(parsedBody.error.issues), - "VALIDATION_ERROR", - parsedBody.error, - ); - } + const parsedBody = UnifiedJsonSchema.safeParse(rawBody); + if (!parsedBody.success) { + throw new HttpError( + 422, + formatZodIssues(parsedBody.error.issues), + "VALIDATION_ERROR", + parsedBody.error, + ); + } - const payload: EdgeAIGenerateSubjectJsonUpdateInput = parsedBody.data; + const payload: EdgeAIGenerateSubjectUnifiedInput = parsedBody.data; + const { datosUpdate, iaConfig } = payload; + const isUpdate = Boolean(datosUpdate.id); - const { data: existingAsignatura, error: existingAsignaturaError } = - await supabaseService - .from("asignaturas") - .select( - "id,plan_estudio_id,estructura_id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,numero_ciclo,linea_plan_id,orden_celda", - ) - .eq("id", payload.id) - .single(); + const svc = OpenAIService.fromEnv(); + if (!(svc instanceof OpenAIService)) { + throw new HttpError( + 500, + "Configuración del servidor incompleta.", + "OPENAI_MISCONFIGURED", + svc, + ); + } - if (existingAsignaturaError) { - const maybeCode = (existingAsignaturaError as { code?: string }).code; + // Resolve fields (merge for update) + const selectCols = + "id,plan_estudio_id,estructura_id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,numero_ciclo,linea_plan_id,orden_celda,estado"; + + let asignaturaBase: AsignaturaBaseSeleccionada | null = null; + + if (isUpdate) { + const { data, error } = await supabaseService + .from("asignaturas") + .select(selectCols) + .eq("id", datosUpdate.id as string) + .single(); + if (error) { + const maybeCode = (error as { code?: string }).code; if (maybeCode === "PGRST116") { throw new HttpError( 404, "No se encontró la asignatura.", "NOT_FOUND", - { table: "asignaturas", id: payload.id }, + { table: "asignaturas", id: datosUpdate.id }, ); } throw new HttpError( 500, "No se pudo obtener la asignatura.", "SUPABASE_QUERY_FAILED", - existingAsignaturaError, + error, ); } - - const resolved = { - plan_estudio_id: payload.plan_estudio_id ?? - existingAsignatura.plan_estudio_id, - estructura_id: payload.estructura_id ?? - existingAsignatura.estructura_id, - nombre: payload.nombre ?? existingAsignatura.nombre, - codigo: payload.codigo ?? existingAsignatura.codigo, - tipo: payload.tipo ?? existingAsignatura.tipo, - creditos: payload.creditos ?? existingAsignatura.creditos, - horas_academicas: payload.horas_academicas ?? - existingAsignatura.horas_academicas, - horas_independientes: payload.horas_independientes ?? - existingAsignatura.horas_independientes, - numero_ciclo: payload.numero_ciclo ?? existingAsignatura.numero_ciclo, - linea_plan_id: payload.linea_plan_id ?? - existingAsignatura.linea_plan_id, - orden_celda: payload.orden_celda ?? existingAsignatura.orden_celda, - }; - - if (!resolved.estructura_id) { - throw new HttpError( - 422, - "estructura_id es requerido (no está presente ni en el JSON ni en la asignatura existente).", - "VALIDATION_ERROR", - { - id: payload.id, - existing_estructura_id: existingAsignatura.estructura_id, - }, - ); - } - - const { data: estructura, error: estructuraError } = await supabaseService - .from("estructuras_asignatura") - .select("id,nombre,definicion,version") - .eq("id", resolved.estructura_id) - .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: resolved.estructura_id }, - ); - } - 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: ${resolved.nombre}\n` + - `- Código (clave de la asignatura): ${ - resolved.codigo ?? "(no especificado)" - }\n` + - `- Tipo: ${resolved.tipo ?? "(no especificado)"}\n` + - `- Número de créditos: ${resolved.creditos}\n` + - `- Horas académicas: ${ - resolved.horas_academicas ?? "(no especificado)" - }\n` + - `- Horas independientes: ${ - resolved.horas_independientes ?? "(no especificado)" - }\n` + - `- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${ - payload.descripcionEnfoqueAcademico ?? "(ninguna)" - }\n\n` + - `REGLA ESTRICTA MATEMÁTICA:\n` + - `Si generas el 'contenido_tematico', la suma total de las 'horasEstimadas' de todos los temas en todas las unidades DEBE coincidir exactamente con el total de Horas académicas indicadas arriba (${ - resolved.horas_academicas ?? 0 - }). No te pases ni te falten horas.`; - - const schemaDef: Record = - typeof estructura?.definicion === "object" && - estructura?.definicion !== null - ? (estructura.definicion as Record) - : {}; - - const schemaForAI = withColumnDefsAndRefs(schemaDef); - - const aiStructuredPayload: StructuredResponseOptions = { - model: AI_GENERATE_SUBJECT_UPDATE_MODELO, - input: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - text: { - format: { - type: "json_schema", - name: "asignatura_contenido_tematico", - schema: schemaForAI, - 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); - 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, columnasGeneradas } = - splitAiOutputStringsAndColumns(aiOutput); - - const updatePatch: Database["public"]["Tables"]["asignaturas"]["Update"] = - { - datos: aiOutputJson, - tipo_origen: "IA", - estado: "borrador", - }; - - for (const value of Object.values(columnasGeneradas)) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - continue; - } - const xColumn = (value as Record)["x-column"]; - const xDef = (value as Record)["x-definicion"]; - if (typeof xColumn !== "string" || !xColumn.length) continue; - (updatePatch as unknown as Record)[xColumn] = - xDef as unknown as Json; - } - - // Apply only provided JSON fields (do not overwrite if missing) - if (payload.plan_estudio_id !== undefined) { - updatePatch.plan_estudio_id = payload.plan_estudio_id; - } - if (payload.estructura_id !== undefined) { - updatePatch.estructura_id = payload.estructura_id; - } - if (payload.nombre !== undefined) { - updatePatch.nombre = payload.nombre; - } - if (payload.codigo !== undefined) { - updatePatch.codigo = payload.codigo; - } - if (payload.tipo !== undefined) { - updatePatch.tipo = payload.tipo as Database["public"]["Tables"][ - "asignaturas" - ]["Update"]["tipo"]; - } - if (payload.creditos !== undefined) { - updatePatch.creditos = payload.creditos; - } - if (payload.horas_academicas !== undefined) { - updatePatch.horas_academicas = payload.horas_academicas; - } - if (payload.horas_independientes !== undefined) { - updatePatch.horas_independientes = payload.horas_independientes; - } - if (payload.numero_ciclo !== undefined) { - updatePatch.numero_ciclo = payload.numero_ciclo; - } - if (payload.linea_plan_id !== undefined) { - updatePatch.linea_plan_id = payload.linea_plan_id; - } - if (payload.orden_celda !== undefined) { - updatePatch.orden_celda = payload.orden_celda; - } - - updatePatch.meta_origen = { - generado_por: "ai_generate_subject_update_json", - ai: { - responseId: aiResult.responseId ?? null, - conversationId: aiResult.conversationId ?? null, - model: aiResult.model, - usage: aiResult.usage ?? null, - }, - iaConfig: { - descripcionEnfoqueAcademico: payload.descripcionEnfoqueAcademico ?? - null, - }, - } as unknown as Json; - - const { data: asignatura, error: asignaturaError } = await supabaseService - .from("asignaturas") - .update(updatePatch) - .eq("id", payload.id) - .select( - "id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,estructura_id,plan_estudio_id,contenido_tematico,meta_origen,datos,creado_en,actualizado_en,numero_ciclo,linea_plan_id,orden_celda", - ) - .single(); - - if (asignaturaError) { - const maybeCode = (asignaturaError as { code?: string }).code; - if (maybeCode === "PGRST116") { - throw new HttpError( - 404, - "No se encontró la asignatura.", - "NOT_FOUND", - { table: "asignaturas", id: payload.id }, - ); - } - throw new HttpError( - 500, - "No se pudo actualizar la asignatura.", - "SUPABASE_UPDATE_FAILED", - asignaturaError, - ); - } - - console.log( - `[${ - new Date().toISOString() - }][${functionName}]: JSON update processed successfully`, - ); - return sendSuccess(asignatura); + asignaturaBase = data as unknown as AsignaturaBaseSeleccionada; } - // ----------------------------- - // Multipart create flow (existing) - // ----------------------------- - 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"); + const resolved = { + id: (datosUpdate.id ?? asignaturaBase?.id ?? null) as string | null, + plan_estudio_id: datosUpdate.plan_estudio_id ?? + asignaturaBase?.plan_estudio_id, + estructura_id: datosUpdate.estructura_id ?? + asignaturaBase?.estructura_id ?? null, + nombre: datosUpdate.nombre ?? asignaturaBase?.nombre ?? null, + codigo: datosUpdate.codigo ?? asignaturaBase?.codigo ?? null, + tipo: datosUpdate.tipo ?? asignaturaBase?.tipo ?? null, + creditos: datosUpdate.creditos ?? asignaturaBase?.creditos ?? null, + horas_academicas: datosUpdate.horas_academicas ?? + asignaturaBase?.horas_academicas ?? null, + horas_independientes: datosUpdate.horas_independientes ?? + asignaturaBase?.horas_independientes ?? null, + numero_ciclo: datosUpdate.numero_ciclo ?? asignaturaBase?.numero_ciclo ?? + null, + linea_plan_id: datosUpdate.linea_plan_id ?? + asignaturaBase?.linea_plan_id ?? null, + orden_celda: datosUpdate.orden_celda ?? asignaturaBase?.orden_celda ?? + null, + }; + + if (!resolved.estructura_id) { throw new HttpError( 422, - message, + "estructura_id es requerido.", "VALIDATION_ERROR", - { errors: validation.errors }, + { estructura_id: resolved.estructura_id }, + ); + } + if (!resolved.nombre) { + throw new HttpError( + 422, + "nombre es requerido.", + "VALIDATION_ERROR", + { nombre: resolved.nombre }, + ); + } + if (resolved.creditos == null) { + throw new HttpError( + 422, + "creditos es requerido.", + "VALIDATION_ERROR", + { creditos: resolved.creditos }, ); } - const payload = validation.data; + // Crear/actualizar stub en estado 'generando' + let asignaturaId: string; + if (isUpdate) { + const updatePatch: Database["public"]["Tables"]["asignaturas"]["Update"] = + { + estado: "generando", + tipo_origen: "IA", + plan_estudio_id: resolved.plan_estudio_id, + estructura_id: resolved.estructura_id, + nombre: resolved.nombre, + codigo: resolved.codigo, + creditos: resolved.creditos, + horas_academicas: resolved.horas_academicas, + horas_independientes: resolved.horas_independientes, + numero_ciclo: resolved.numero_ciclo, + linea_plan_id: resolved.linea_plan_id, + orden_celda: resolved.orden_celda, + tipo: resolved + .tipo as Database["public"]["Tables"]["asignaturas"]["Update"][ + "tipo" + ], + meta_origen: { + generado_por: "ai_generate_subject_unified", + iaConfig: { + descripcionEnfoqueAcademico: + iaConfig.descripcionEnfoqueAcademico ?? null, + instruccionesAdicionalesIA: iaConfig.instruccionesAdicionalesIA ?? + null, + archivosAdjuntos: iaConfig.archivosAdjuntos ?? [], + }, + } as unknown as Json, + }; + + const { data, error } = await supabaseService + .from("asignaturas") + .update(updatePatch) + .eq("id", resolved.id as string) + .select("id") + .single(); + if (error) { + throw new HttpError( + 500, + "No se pudo actualizar la asignatura (stub).", + "SUPABASE_UPDATE_FAILED", + error, + ); + } + asignaturaId = data.id; + } else { + const insertPatch: Database["public"]["Tables"]["asignaturas"]["Insert"] = + { + plan_estudio_id: resolved.plan_estudio_id as string, + estructura_id: resolved.estructura_id as string, + nombre: resolved.nombre as string, + codigo: resolved.codigo ?? null, + tipo: + (resolved.tipo ?? undefined) as Database["public"]["Tables"][ + "asignaturas" + ]["Insert"]["tipo"], + creditos: resolved.creditos as number, + horas_academicas: resolved.horas_academicas ?? null, + horas_independientes: resolved.horas_independientes ?? null, + numero_ciclo: resolved.numero_ciclo ?? null, + linea_plan_id: resolved.linea_plan_id ?? null, + orden_celda: resolved.orden_celda ?? null, + estado: "generando", + tipo_origen: "IA", + meta_origen: { + generado_por: "ai_generate_subject_unified", + iaConfig: { + descripcionEnfoqueAcademico: + iaConfig.descripcionEnfoqueAcademico ?? null, + instruccionesAdicionalesIA: iaConfig.instruccionesAdicionalesIA ?? + null, + archivosAdjuntos: iaConfig.archivosAdjuntos ?? [], + }, + } as unknown as Json, + }; + + const { data, error } = await supabaseService + .from("asignaturas") + .insert(insertPatch) + .select("id") + .single(); + if (error) { + const maybeCode = (error as { code?: string }).code; + const status = maybeCode ? 409 : 500; + throw new HttpError( + status, + "No se pudo crear la asignatura (stub).", + "SUPABASE_INSERT_FAILED", + { ...error, code: maybeCode }, + ); + } + asignaturaId = data.id; + } const { data: estructura, error: estructuraError } = await supabaseService .from("estructuras_asignatura") .select("id,nombre,definicion,version") - .eq("id", payload.datosBasicos.estructuraId) + .eq("id", resolved.estructura_id) .single(); if (estructuraError) { const maybeCode = (estructuraError as { code?: string }).code; @@ -579,10 +468,7 @@ Deno.serve(async (req: Request): Promise => { 404, "No se encontró la estructura de la asignatura.", "NOT_FOUND", - { - table: "estructuras_asignatura", - id: payload.datosBasicos.estructuraId, - }, + { table: "estructuras_asignatura", id: resolved.estructura_id }, ); } throw new HttpError( @@ -594,35 +480,39 @@ Deno.serve(async (req: Request): Promise => { } const systemPrompt = - `Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el JSON Schema proporcionado. - # REGLAS DE FORMATO PARA CAMPOS DE TEXTO (STRING) -1. **Prioridad:** Estas reglas aplican por defecto. Si el prompt del usuario solicita explícitamente un formato distinto, esa instrucción tiene prioridad sobre estas reglas. -2. **Estilo Visual:** Redacta el contenido exclusivamente para visualización en texto plano (estilo 'white-space: pre-wrap'). -3. **Estructura Vertical:** Utiliza saltos de línea explícitos para romper líneas y doble salto de línea para separar párrafos. -4. **Indentación Estricta:** Usa exactamente 2 espacios para la indentación jerárquica. No uses tabuladores. -5. **Listas:** Utiliza un guion seguido de un espacio ("- ") para los elementos de lista. -6. **Prohibiciones:** No incluyas etiquetas HTML, sintaxis Markdown ni caracteres de escape literales visibles en el texto final. Asegúrate de que el JSON final contenga saltos de línea válidos ('\n') y no texto escapado.`; + `Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el JSON Schema proporcionado.`; + + const archivosAdjuntosTexto = (iaConfig.archivosAdjuntos ?? []).length + ? `\n- Rutas de archivos adjuntos (referencia): ${ + (iaConfig.archivosAdjuntos ?? []).join(", ") + }` + : ""; const userPrompt = `Genera un borrador completo completo de una ASIGNATURA con base en lo siguiente:\n` + - `- Nombre de la asignatura: ${payload.datosBasicos.nombre}\n` + + `- Nombre de la asignatura: ${resolved.nombre}\n` + `- Código (clave de la asignatura): ${ - payload.datosBasicos.codigo ?? "(no especificado)" + resolved.codigo ?? "(no especificado)" }\n` + - `- Tipo: ${payload.datosBasicos.tipo ?? "(no especificado)"}\n` + - `- Número de créditos: ${payload.datosBasicos.creditos}\n` + + `- Tipo: ${resolved.tipo ?? "(no especificado)"}\n` + + `- Número de créditos: ${resolved.creditos}\n` + `- Horas académicas: ${ - payload.datosBasicos.horasAcademicas ?? "(no especificado)" + resolved.horas_academicas ?? "(no especificado)" }\n` + `- Horas independientes: ${ - payload.datosBasicos.horasIndependientes ?? "(no especificado)" + resolved.horas_independientes ?? "(no especificado)" }\n` + `- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${ - payload.iaConfig?.descripcionEnfoqueAcademico ?? "(ninguna)" + iaConfig.descripcionEnfoqueAcademico ?? "(ninguna)" }\n` + `- Instrucciones adicionales (sobre el formato de la respuesta generada): ${ - payload.iaConfig?.instruccionesAdicionalesIA ?? "(ninguna)" - }`; + iaConfig.instruccionesAdicionalesIA ?? "(ninguna)" + }` + + archivosAdjuntosTexto + + `\n\nREGLA ESTRICTA MATEMÁTICA:\n` + + `Si generas el 'contenido_tematico', la suma total de las 'horasEstimadas' de todos los temas en todas las unidades DEBE coincidir exactamente con el total de Horas académicas indicadas arriba (${ + resolved.horas_academicas ?? 0 + }). No te pases ni te falten horas.`; const schemaDef: Record = typeof estructura?.definicion === "object" && @@ -633,7 +523,15 @@ Deno.serve(async (req: Request): Promise => { const schemaForAI = withColumnDefsAndRefs(schemaDef); const aiStructuredPayload: StructuredResponseOptions = { - model: AI_GENERATE_SUBJECT_INSERT_MODELO, + model: isUpdate + ? AI_GENERATE_SUBJECT_UPDATE_MODELO + : AI_GENERATE_SUBJECT_INSERT_MODELO, + background: true, + metadata: { + tabla: "asignaturas", + accion: isUpdate ? "actualizar" : "crear", + id: asignaturaId, + }, input: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, @@ -648,126 +546,27 @@ Deno.serve(async (req: Request): Promise => { }, }; - 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, - ); + const aiResult = await svc.createStructuredResponse(aiStructuredPayload); if (!aiResult.ok) { const status = aiResult.code === "MissingEnv" ? 500 : 502; throw new HttpError( status, - "No se pudo generar la asignatura con IA.", + "No se pudo iniciar la generación de 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, columnasGeneradas } = splitAiOutputStringsAndColumns( - aiOutput, - ); - - 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, - }; - - for (const value of Object.values(columnasGeneradas)) { - if (!value || typeof value !== "object" || Array.isArray(value)) continue; - const xColumn = (value as Record)["x-column"]; - const xDef = (value as Record)["x-definicion"]; - if (typeof xColumn !== "string" || !xColumn.length) continue; - (asignaturaInsert as unknown as Record)[xColumn] = - xDef 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`, + }][${functionName}]: Subject generation started in background`, ); - return sendSuccess(asignatura); + return sendSuccess({ + id: asignaturaId, + estado: "generando", + openai: { responseId: aiResult.responseId ?? null }, + }); } catch (error) { if (error instanceof HttpError) { console.error( @@ -801,86 +600,6 @@ Deno.serve(async (req: Request): Promise => { } }); -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().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 }; -} - const definicionesDeEstructurasDeColumnas = { contenido_tematico: { "type": "object", diff --git a/supabase/functions/ai-generate-subject/types.ts b/supabase/functions/ai-generate-subject/types.ts index ec9d6e4..a3ee32e 100644 --- a/supabase/functions/ai-generate-subject/types.ts +++ b/supabase/functions/ai-generate-subject/types.ts @@ -1,37 +1,24 @@ 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"]; + datosUpdate: { + id?: Tables<"asignaturas">["id"]; // si viene, es update; si no, es insert + plan_estudio_id: Tables<"asignaturas">["plan_estudio_id"]; + estructura_id?: Tables<"asignaturas">["estructura_id"] | null; + + 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; + tipo?: Tables<"asignaturas">["tipo"] | null; + creditos?: Tables<"asignaturas">["creditos"] | null; + horas_academicas?: Tables<"asignaturas">["horas_academicas"] | null; + horas_independientes?: Tables<"asignaturas">["horas_independientes"] | null; + numero_ciclo?: Tables<"asignaturas">["numero_ciclo"] | null; + linea_plan_id?: Tables<"asignaturas">["linea_plan_id"] | null; + orden_celda?: Tables<"asignaturas">["orden_celda"] | 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; + descripcionEnfoqueAcademico?: string; + instruccionesAdicionalesIA?: string; + archivosAdjuntos?: string[]; // rutas (no File) }; - archivosAdjuntos?: Array; }; diff --git a/supabase/functions/openai-webhook-responses/asignaturas/crear.ts b/supabase/functions/openai-webhook-responses/asignaturas/crear.ts new file mode 100644 index 0000000..faa348d --- /dev/null +++ b/supabase/functions/openai-webhook-responses/asignaturas/crear.ts @@ -0,0 +1,209 @@ +import type { OpenAI } from "openai"; +import { supabase } from "../supabase.ts"; +import type { Json } from "../../_shared/database.types.ts"; +import { ResponseMetadata } from "../../_shared/utils.ts"; + +function extractOutputText(response: OpenAI.Responses.Response): string { + const direct = (response as unknown as { output_text?: unknown }).output_text; + if (typeof direct === "string") return direct; + + const output = (response as unknown as { output?: unknown }).output; + if (!Array.isArray(output)) return ""; + + try { + return output + .filter((item) => (item as { type?: unknown })?.type === "message") + .flatMap((item) => (item as { content?: unknown })?.content ?? []) + .filter((c) => (c as { type?: unknown })?.type === "output_text") + .map((c) => String((c as { text?: unknown })?.text ?? "")) + .join(""); + } catch { + return ""; + } +} + +function splitAiOutputStringsAndColumns( + aiOutput: unknown, +): { aiOutputJson: Json; columnasGeneradas: Record } { + if (!aiOutput || typeof aiOutput !== "object" || Array.isArray(aiOutput)) { + return { aiOutputJson: aiOutput as unknown as Json, columnasGeneradas: {} }; + } + + const record = aiOutput as Record; + const stringsOnly: Record = {}; + const columnasGeneradas: Record = {}; + + for (const [key, value] of Object.entries(record)) { + if (typeof value === "string") { + stringsOnly[key] = value; + } else { + columnasGeneradas[key] = value; + } + } + + return { aiOutputJson: stringsOnly as unknown as Json, columnasGeneradas }; +} + +async function marcarFalloAsignatura( + asignaturaId: string, + reason: string, + extra?: unknown, +): Promise { + try { + const { data: existing, error: existingError } = await supabase + .from("asignaturas") + .select("meta_origen") + .eq("id", asignaturaId) + .maybeSingle(); + if (existingError) { + console.warn("No se pudo leer meta_origen para marcar fallo", { + asignaturaId, + existingError, + }); + } + + const baseMeta = (existing?.meta_origen && + typeof existing.meta_origen === "object" && + !Array.isArray(existing.meta_origen)) + ? (existing.meta_origen as Record) + : {}; + + const nextMeta: Record = { + ...baseMeta, + ai_error: { + reason, + extra: extra ?? null, + at: new Date().toISOString(), + }, + }; + + const { error } = await supabase + .from("asignaturas") + .update({ estado: "borrador", meta_origen: nextMeta as unknown as Json }) + .eq("id", asignaturaId); + if (error) { + console.warn("No se pudo marcar fallo en asignatura", { + asignaturaId, + error, + }); + } + } catch (e) { + console.warn("Fallo inesperado marcando fallo en asignatura", { + asignaturaId, + e, + }); + } +} + +export async function handleCrearAsignaturaResponse( + response: OpenAI.Responses.Response, +): Promise { + const metadata = response.metadata as ResponseMetadata | null; + const asignaturaId = metadata?.id; + if (!asignaturaId) { + console.warn("No se recibió metadata.id para actualizar la asignatura"); + return; + } + + try { + const outputText = extractOutputText(response); + if (!outputText) { + console.warn("La respuesta no contiene output_text"); + await marcarFalloAsignatura(asignaturaId, "MISSING_OUTPUT_TEXT", { + responseId: response.id, + }); + return; + } + + let aiOutput: unknown; + try { + aiOutput = JSON.parse(outputText); + } catch (e) { + console.warn("No se pudo parsear JSON de la respuesta", e); + await marcarFalloAsignatura(asignaturaId, "INVALID_JSON", { + responseId: response.id, + outputText, + }); + return; + } + + const { aiOutputJson, columnasGeneradas } = splitAiOutputStringsAndColumns( + aiOutput, + ); + + const { data: existing, error: existingError } = await supabase + .from("asignaturas") + .select("meta_origen") + .eq("id", asignaturaId) + .maybeSingle(); + + if (existingError) { + console.warn("No se pudo leer meta_origen existente", { + asignaturaId, + existingError, + }); + } + + const baseMeta = (existing?.meta_origen && + typeof existing.meta_origen === "object" && + !Array.isArray(existing.meta_origen)) + ? (existing.meta_origen as Record) + : {}; + + const nextMeta: Record = { + ...baseMeta, + ai: { + ...(typeof baseMeta.ai === "object" && baseMeta.ai && + !Array.isArray(baseMeta.ai) + ? (baseMeta.ai as Record) + : {}), + responseId: response.id, + model: response.model, + }, + }; + + const updatePatch: Record = { + datos: aiOutputJson, + estado: "borrador", + tipo_origen: "IA", + meta_origen: nextMeta as unknown as Json, + }; + + for (const value of Object.values(columnasGeneradas)) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + continue; + } + const xColumn = (value as Record)["x-column"]; + const xDef = (value as Record)["x-definicion"]; + if (typeof xColumn !== "string" || !xColumn.length) continue; + updatePatch[xColumn] = xDef as unknown as Json; + } + + const { error: updateError } = await supabase + .from("asignaturas") + .update( + updatePatch as unknown as { + [k: string]: unknown; + }, + ) + .eq("id", asignaturaId); + + if (updateError) { + console.warn("No se pudo actualizar asignatura con datos", { + asignaturaId, + updateError, + }); + await marcarFalloAsignatura(asignaturaId, "SUPABASE_UPDATE_FAILED", { + updateError, + }); + return; + } + } catch (e) { + console.warn("Fallo inesperado procesando asignatura", { + asignaturaId, + e, + }); + await marcarFalloAsignatura(asignaturaId, "UNEXPECTED", { e }); + return; + } +} diff --git a/supabase/functions/openai-webhook-responses/asignaturas/index.ts b/supabase/functions/openai-webhook-responses/asignaturas/index.ts new file mode 100644 index 0000000..62e5337 --- /dev/null +++ b/supabase/functions/openai-webhook-responses/asignaturas/index.ts @@ -0,0 +1,25 @@ +import type { OpenAI } from "openai"; +import { ResponseMetadata } from "../../_shared/utils.ts"; +import { handleCrearAsignaturaResponse } from "./crear.ts"; + +export async function handleAsignaturasResponse( + response: OpenAI.Responses.Response, +): Promise { + const metadata = response.metadata as ResponseMetadata | null; + const accion = metadata?.accion; + + if (!accion) { + console.warn("No se recibió metadata.accion para asignaturas"); + return; + } + + switch (accion) { + case "crear": + case "actualizar": + await handleCrearAsignaturaResponse(response); + return; + default: + console.warn("Acción no reconocida para asignaturas:", accion); + return; + } +} diff --git a/supabase/functions/openai-webhook-responses/index.ts b/supabase/functions/openai-webhook-responses/index.ts index d004d0f..246fe78 100644 --- a/supabase/functions/openai-webhook-responses/index.ts +++ b/supabase/functions/openai-webhook-responses/index.ts @@ -7,6 +7,7 @@ import "@supabase/functions-js/edge-runtime.d.ts"; import OpenAI from "openai"; import { ResponseMetadata } from "../_shared/utils.ts"; import { handlePlanesEstudioResponse } from "./planes_estudio/index.ts"; +import { handleAsignaturasResponse } from "./asignaturas/index.ts"; console.log("Starting OpenAI webhook responses function"); const client = new OpenAI({ @@ -29,6 +30,9 @@ async function handleCompletedResponse( case "planes_estudio": await handlePlanesEstudioResponse(response); break; + case "asignaturas": + await handleAsignaturasResponse(response); + break; default: console.warn("Tabla no reconocida:", metadata.tabla); } diff --git a/supabase/functions/tests/ai-generate-test-prototipo.ts b/supabase/functions/tests/ai-generate-test-prototipo.ts index 1ab5425..4d68194 100644 --- a/supabase/functions/tests/ai-generate-test-prototipo.ts +++ b/supabase/functions/tests/ai-generate-test-prototipo.ts @@ -141,7 +141,7 @@ Deno.test("ai-structured (multipart + file)", async () => { assert(typeof data.output?.resumen === "string"); assert(data.output.resumen.length > 0); - // opcional: valida que registró upload(s) a storage - assert(Array.isArray(data.references?.uploadedToStorage)); + // opcional: valida que registró upload(s) a OpenAI files + assert(Array.isArray(data.references?.openaiFileIds)); }); */ diff --git a/supabase/functions/tests/ai-structured-test.ts b/supabase/functions/tests/ai-structured-test.ts index 0145f7d..d4d09ca 100644 --- a/supabase/functions/tests/ai-structured-test.ts +++ b/supabase/functions/tests/ai-structured-test.ts @@ -160,6 +160,6 @@ Deno.test({ assert(typeof data.output?.resumen === "string"); assert(data.output.resumen.length > 0); - // opcional: valida que registró upload(s) a storage - assert(Array.isArray(data.references?.uploadedToStorage)); + // opcional: valida que registró upload(s) a OpenAI files + assert(Array.isArray(data.references?.openaiFileIds)); });