Creación de planes y asignaturas en segundo plano y con webhook de openai #40
@@ -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 <a href='https://aka.ms/vscodeJupyterKernelCrash'>here</a> for more info. \n",
|
||||
"\u001b[1;31mView Jupyter <a href='command:jupyter.viewOutput'>log</a> 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 <a href='https://aka.ms/vscodeJupyterKernelCrash'>here</a> for more info. \n",
|
||||
"\u001b[1;31mView Jupyter <a href='command:jupyter.viewOutput'>log</a> 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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@
|
||||
/// <reference lib="deno.window" />
|
||||
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<TOutput = unknown> = {
|
||||
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<TOutput = unknown> =
|
||||
| StructuredResponseSuccess<TOutput>
|
||||
| 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<string, string>) {
|
||||
const conversation = await this.openai.conversations.create({
|
||||
@@ -80,9 +61,6 @@ export class OpenAIService {
|
||||
files?: File[],
|
||||
): Promise<StructuredResponseResult<TOutput>> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
const ids: string[] = [];
|
||||
for (const file of files) {
|
||||
|
||||
@@ -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<typeof JsonUpdateSchema>;
|
||||
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<typeof UnifiedJsonSchema>;
|
||||
|
||||
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<string, unknown>,
|
||||
@@ -96,28 +124,6 @@ function withColumnDefsAndRefs(
|
||||
return nextSchema;
|
||||
}
|
||||
|
||||
function splitAiOutputStringsAndColumns(
|
||||
aiOutput: unknown,
|
||||
): { aiOutputJson: Json; columnasGeneradas: Record<string, unknown> } {
|
||||
if (!aiOutput || typeof aiOutput !== "object" || Array.isArray(aiOutput)) {
|
||||
return { aiOutputJson: aiOutput as unknown as Json, columnasGeneradas: {} };
|
||||
}
|
||||
|
||||
const record = aiOutput as Record<string, unknown>;
|
||||
const stringsOnly: Record<string, string> = {};
|
||||
const columnasGeneradas: Record<string, unknown> = {};
|
||||
|
||||
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<Response> => {
|
||||
}
|
||||
|
||||
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<Response> => {
|
||||
415,
|
||||
"Content-Type no soportado.",
|
||||
"UNSUPPORTED_MEDIA_TYPE",
|
||||
{ contentType },
|
||||
{ contentType, expected: "application/json" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,340 +242,224 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
||||
) ?? "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<string, unknown> =
|
||||
typeof estructura?.definicion === "object" &&
|
||||
estructura?.definicion !== null
|
||||
? (estructura.definicion as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
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<string, unknown>)["x-column"];
|
||||
const xDef = (value as Record<string, unknown>)["x-definicion"];
|
||||
if (typeof xColumn !== "string" || !xColumn.length) continue;
|
||||
(updatePatch as unknown as Record<string, unknown>)[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<Response> => {
|
||||
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<Response> => {
|
||||
}
|
||||
|
||||
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<string, unknown> =
|
||||
typeof estructura?.definicion === "object" &&
|
||||
@@ -633,7 +523,15 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
||||
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<Response> => {
|
||||
},
|
||||
};
|
||||
|
||||
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<string, unknown>)["x-column"];
|
||||
const xDef = (value as Record<string, unknown>)["x-definicion"];
|
||||
if (typeof xColumn !== "string" || !xColumn.length) continue;
|
||||
(asignaturaInsert as unknown as Record<string, unknown>)[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<Response> => {
|
||||
}
|
||||
});
|
||||
|
||||
const jsonFromString = <T extends z.ZodTypeAny>(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 = <T extends z.ZodTypeAny>(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<typeof SolicitudSchema>;
|
||||
|
||||
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",
|
||||
|
||||
@@ -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<string>
|
||||
// }
|
||||
iaConfig?: {
|
||||
descripcionEnfoqueAcademico: string;
|
||||
instruccionesAdicionalesIA: string;
|
||||
archivosReferencia: Array<string>;
|
||||
repositoriosReferencia?: Array<string>;
|
||||
descripcionEnfoqueAcademico?: string;
|
||||
instruccionesAdicionalesIA?: string;
|
||||
archivosAdjuntos?: string[]; // rutas (no File)
|
||||
};
|
||||
archivosAdjuntos?: Array<UploadedFile>;
|
||||
};
|
||||
|
||||
@@ -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<string, unknown> } {
|
||||
if (!aiOutput || typeof aiOutput !== "object" || Array.isArray(aiOutput)) {
|
||||
return { aiOutputJson: aiOutput as unknown as Json, columnasGeneradas: {} };
|
||||
}
|
||||
|
||||
const record = aiOutput as Record<string, unknown>;
|
||||
const stringsOnly: Record<string, string> = {};
|
||||
const columnasGeneradas: Record<string, unknown> = {};
|
||||
|
||||
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<void> {
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
|
||||
const nextMeta: Record<string, unknown> = {
|
||||
...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<void> {
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
|
||||
const nextMeta: Record<string, unknown> = {
|
||||
...baseMeta,
|
||||
ai: {
|
||||
...(typeof baseMeta.ai === "object" && baseMeta.ai &&
|
||||
!Array.isArray(baseMeta.ai)
|
||||
? (baseMeta.ai as Record<string, unknown>)
|
||||
: {}),
|
||||
responseId: response.id,
|
||||
model: response.model,
|
||||
},
|
||||
};
|
||||
|
||||
const updatePatch: Record<string, unknown> = {
|
||||
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<string, unknown>)["x-column"];
|
||||
const xDef = (value as Record<string, unknown>)["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;
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
*/
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user