feat/ai-generate-plan #59
Vendored
+10
-1
@@ -1,2 +1,11 @@
|
||||
{
|
||||
}
|
||||
"deno.enablePaths": ["supabase/functions"],
|
||||
"deno.lint": true,
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "denoland.vscode-deno"
|
||||
},
|
||||
"notebook.defaultFormatter": "denoland.vscode-deno",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"deno.enable": true,
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"allowScripts": ["npm:deno@2.6.4", "npm:supabase-js@1.0.4", "npm:supabase@2.72.6"]
|
||||
"allowScripts": ["npm:deno@2.6.4", "npm:supabase-js@1.0.4", "npm:supabase@2.72.6"],
|
||||
"imports": {
|
||||
"@david/dax": "jsr:@david/dax@^0.44.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,54 @@
|
||||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"jsr:@david/console-static-text@0.3": "0.3.0",
|
||||
"jsr:@david/dax@~0.44.2": "0.44.2",
|
||||
"jsr:@david/path@0.2": "0.2.0",
|
||||
"jsr:@david/which@~0.4.1": "0.4.1",
|
||||
"jsr:@openai/openai@*": "6.9.0",
|
||||
"jsr:@std/assert@1": "1.0.16",
|
||||
"jsr:@std/bytes@^1.0.5": "1.0.6",
|
||||
"jsr:@std/dotenv@*": "0.225.6",
|
||||
"jsr:@std/fmt@1": "1.0.9",
|
||||
"jsr:@std/fs@1": "1.0.22",
|
||||
"jsr:@std/fs@^1.0.20": "1.0.22",
|
||||
"jsr:@std/internal@^1.0.12": "1.0.12",
|
||||
"jsr:@std/io@0.225": "0.225.2",
|
||||
"jsr:@std/path@1": "1.1.4",
|
||||
"jsr:@std/path@^1.1.4": "1.1.4",
|
||||
"jsr:@supabase/functions-js@*": "2.90.1",
|
||||
"npm:@supabase/supabase-js@^2.90.1": "2.90.1",
|
||||
"npm:@types/bun@^1.3.5": "1.3.5",
|
||||
"npm:deno@^2.6.4": "2.6.4",
|
||||
"npm:openai@^6.16.0": "6.16.0",
|
||||
"npm:supabase-js@^1.0.4": "1.0.4",
|
||||
"npm:supabase@^2.72.6": "2.72.6"
|
||||
},
|
||||
"jsr": {
|
||||
"@david/console-static-text@0.3.0": {
|
||||
"integrity": "2dfb46ecee525755f7989f94ece30bba85bd8ffe3e8666abc1bf926e1ee0698d"
|
||||
},
|
||||
"@david/dax@0.44.2": {
|
||||
"integrity": "26f5985f66a4340d55fb05ca90a0063bb5f0d670a326e14cb33a974aafcbb8d9",
|
||||
"dependencies": [
|
||||
"jsr:@david/console-static-text",
|
||||
"jsr:@david/path",
|
||||
"jsr:@david/which",
|
||||
"jsr:@std/fmt",
|
||||
"jsr:@std/fs@^1.0.20",
|
||||
"jsr:@std/io",
|
||||
"jsr:@std/path@1"
|
||||
]
|
||||
},
|
||||
"@david/path@0.2.0": {
|
||||
"integrity": "f2d7aa7f02ce5a55e27c09f9f1381794acb09d328f8d3c8a2e3ab3ffc294dccd",
|
||||
"dependencies": [
|
||||
"jsr:@std/fs@1",
|
||||
"jsr:@std/path@1"
|
||||
]
|
||||
},
|
||||
"@david/which@0.4.1": {
|
||||
"integrity": "896a682b111f92ab866cc70c5b4afab2f5899d2f9bde31ed00203b9c250f225e"
|
||||
},
|
||||
"@openai/openai@6.9.0": {
|
||||
"integrity": "d1828101f5a782a75c25805c4e06c1316d9c19f422d84755fb908046184c3ef6"
|
||||
},
|
||||
@@ -23,12 +58,37 @@
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@std/bytes@1.0.6": {
|
||||
"integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
|
||||
},
|
||||
"@std/dotenv@0.225.6": {
|
||||
"integrity": "1d6f9db72f565bd26790fa034c26e45ecb260b5245417be76c2279e5734c421b"
|
||||
},
|
||||
"@std/fmt@1.0.9": {
|
||||
"integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0"
|
||||
},
|
||||
"@std/fs@1.0.22": {
|
||||
"integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal",
|
||||
"jsr:@std/path@^1.1.4"
|
||||
]
|
||||
},
|
||||
"@std/internal@1.0.12": {
|
||||
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
|
||||
},
|
||||
"@std/io@0.225.2": {
|
||||
"integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7",
|
||||
"dependencies": [
|
||||
"jsr:@std/bytes"
|
||||
]
|
||||
},
|
||||
"@std/path@1.1.4": {
|
||||
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@supabase/functions-js@2.90.1": {
|
||||
"integrity": "9a077ecf42aa84594ef3aac621d643e15ffaa9506a4c051e35c93f127ffdb035"
|
||||
}
|
||||
@@ -248,10 +308,6 @@
|
||||
"signal-exit@4.1.0": {
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="
|
||||
},
|
||||
"supabase-js@1.0.4": {
|
||||
"integrity": "sha512-b9Pxxl3ACEUhY/qTyF7WG9yUQTQQ/5Qm/0KnqbBOQdMaKi13glxs0SzqB1LSJnk2mZvufm6FF8Pyv5ZMc5YVqw==",
|
||||
"scripts": true
|
||||
},
|
||||
"supabase@2.72.6": {
|
||||
"integrity": "sha512-axO1MkZgwMIxj78vKiGosnuXwzOaQq0Kz0I8jheNMwJH9dKGf3q/arivYT44z8u3t5FjGUxQWOuQoJetzcmm/A==",
|
||||
"dependencies": [
|
||||
@@ -271,7 +327,8 @@
|
||||
"minipass",
|
||||
"minizlib",
|
||||
"yallist"
|
||||
]
|
||||
],
|
||||
"deprecated": true
|
||||
},
|
||||
"tslib@2.8.1": {
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
@@ -297,13 +354,15 @@
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"jsr:@david/dax@~0.44.2"
|
||||
],
|
||||
"packageJson": {
|
||||
"dependencies": [
|
||||
"npm:@supabase/supabase-js@^2.90.1",
|
||||
"npm:@types/bun@^1.3.5",
|
||||
"npm:deno@^2.6.4",
|
||||
"npm:openai@^6.16.0",
|
||||
"npm:supabase-js@^1.0.4",
|
||||
"npm:supabase@^2.72.6"
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
+1
-2
@@ -3,8 +3,7 @@
|
||||
"@supabase/supabase-js": "^2.90.1",
|
||||
"deno": "^2.6.4",
|
||||
"openai": "^6.16.0",
|
||||
"supabase": "^2.72.6",
|
||||
"supabase-js": "^1.0.4"
|
||||
"supabase": "^2.72.6"
|
||||
},
|
||||
"name": "genesis-2",
|
||||
"module": "index.ts",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
|
||||
create table "public"."tabla_prueba" (
|
||||
"id" bigint generated by default as identity not null,
|
||||
"created_at" timestamp with time zone not null default now(),
|
||||
"columna_prueba" text
|
||||
);
|
||||
|
||||
|
||||
alter table "public"."tabla_prueba" enable row level security;
|
||||
|
||||
CREATE UNIQUE INDEX tabla_prueba_pkey ON public.tabla_prueba USING btree (id);
|
||||
|
||||
alter table "public"."tabla_prueba" add constraint "tabla_prueba_pkey" PRIMARY KEY using index "tabla_prueba_pkey";
|
||||
|
||||
set check_function_bodies = off;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.set_actualizado_en()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
begin
|
||||
new.actualizado_en = now();
|
||||
return new;
|
||||
end;
|
||||
$function$
|
||||
;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.unaccent_immutable(text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE PARALLEL SAFE STRICT
|
||||
AS $function$
|
||||
SELECT public.unaccent('public.unaccent', $1);
|
||||
$function$
|
||||
;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.validar_numero_ciclo_asignatura()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
declare
|
||||
v_numero_ciclos int;
|
||||
begin
|
||||
if new.numero_ciclo is null then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
select pe.numero_ciclos into v_numero_ciclos
|
||||
from planes_estudio pe
|
||||
where pe.id = new.plan_estudio_id;
|
||||
|
||||
if v_numero_ciclos is null then
|
||||
raise exception 'plan_estudio_id inválido %, plan no encontrado', new.plan_estudio_id;
|
||||
end if;
|
||||
|
||||
if new.numero_ciclo < 1 then
|
||||
raise exception 'numero_ciclo debe ser >= 1 (recibido %)', new.numero_ciclo;
|
||||
end if;
|
||||
|
||||
if new.numero_ciclo > v_numero_ciclos then
|
||||
raise exception 'numero_ciclo % excede planes_estudio.numero_ciclos % para plan_estudio_id %',
|
||||
new.numero_ciclo, v_numero_ciclos, new.plan_estudio_id;
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$function$
|
||||
;
|
||||
|
||||
grant delete on table "public"."tabla_prueba" to "anon";
|
||||
|
||||
grant insert on table "public"."tabla_prueba" to "anon";
|
||||
|
||||
grant references on table "public"."tabla_prueba" to "anon";
|
||||
|
||||
grant select on table "public"."tabla_prueba" to "anon";
|
||||
|
||||
grant trigger on table "public"."tabla_prueba" to "anon";
|
||||
|
||||
grant truncate on table "public"."tabla_prueba" to "anon";
|
||||
|
||||
grant update on table "public"."tabla_prueba" to "anon";
|
||||
|
||||
grant delete on table "public"."tabla_prueba" to "authenticated";
|
||||
|
||||
grant insert on table "public"."tabla_prueba" to "authenticated";
|
||||
|
||||
grant references on table "public"."tabla_prueba" to "authenticated";
|
||||
|
||||
grant select on table "public"."tabla_prueba" to "authenticated";
|
||||
|
||||
grant trigger on table "public"."tabla_prueba" to "authenticated";
|
||||
|
||||
grant truncate on table "public"."tabla_prueba" to "authenticated";
|
||||
|
||||
grant update on table "public"."tabla_prueba" to "authenticated";
|
||||
|
||||
grant delete on table "public"."tabla_prueba" to "postgres";
|
||||
|
||||
grant insert on table "public"."tabla_prueba" to "postgres";
|
||||
|
||||
grant references on table "public"."tabla_prueba" to "postgres";
|
||||
|
||||
grant select on table "public"."tabla_prueba" to "postgres";
|
||||
|
||||
grant trigger on table "public"."tabla_prueba" to "postgres";
|
||||
|
||||
grant truncate on table "public"."tabla_prueba" to "postgres";
|
||||
|
||||
grant update on table "public"."tabla_prueba" to "postgres";
|
||||
|
||||
grant delete on table "public"."tabla_prueba" to "service_role";
|
||||
|
||||
grant insert on table "public"."tabla_prueba" to "service_role";
|
||||
|
||||
grant references on table "public"."tabla_prueba" to "service_role";
|
||||
|
||||
grant select on table "public"."tabla_prueba" to "service_role";
|
||||
|
||||
grant trigger on table "public"."tabla_prueba" to "service_role";
|
||||
|
||||
grant truncate on table "public"."tabla_prueba" to "service_role";
|
||||
|
||||
grant update on table "public"."tabla_prueba" to "service_role";
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// scripts/db-diff.ts
|
||||
/* Uso:
|
||||
deno run -A scripts/sql_para_local_como_remoto.ts
|
||||
*/
|
||||
import { $ } from "@david/dax";
|
||||
|
||||
const OUT_FILE = "supabase/diffs/sql_para_remoto_como_local.sql";
|
||||
|
||||
console.log("🔍 Comparando base de datos local vs. remota...");
|
||||
|
||||
try {
|
||||
// 1. Ejecutamos el comando y capturamos la salida como texto
|
||||
// Esto evita que la terminal de Windows/Mac corrompa los acentos
|
||||
const output = await $`dx supabase db diff --linked`.text();
|
||||
|
||||
if (!output.trim()) {
|
||||
console.log(
|
||||
"✅ No hay diferencias. Tu base de datos local está sincronizada.",
|
||||
);
|
||||
} else {
|
||||
// 2. Escribimos el archivo usando Deno (UTF-8 garantizado)
|
||||
await Deno.writeTextFile(OUT_FILE, output);
|
||||
|
||||
console.log(`⚠️ Diferencias encontradas.`);
|
||||
console.log(`📄 Guardadas en: ${OUT_FILE}`);
|
||||
console.log(`👉 Abre ese archivo en VS Code para revisar los cambios.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Error al ejecutar db diff:", error);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// scripts/db-diff.ts
|
||||
/* Uso:
|
||||
deno run -A scripts/sql_para_remoto_como_local.ts
|
||||
*/
|
||||
import { $ } from "@david/dax";
|
||||
|
||||
const OUT_FILE = "supabase/diffs/sql_para_remoto_como_local.sql";
|
||||
|
||||
console.log("🔍 Comparando base de datos local vs. remota...");
|
||||
|
||||
try {
|
||||
// 1. Ejecutamos el comando y capturamos la salida como texto
|
||||
// Esto evita que la terminal de Windows/Mac corrompa los acentos
|
||||
const output = await $`dx supabase db diff`.text();
|
||||
|
||||
if (!output.trim()) {
|
||||
console.log(
|
||||
"✅ No hay diferencias. Tu base de datos local está sincronizada.",
|
||||
);
|
||||
} else {
|
||||
// 2. Escribimos el archivo usando Deno (UTF-8 garantizado)
|
||||
await Deno.writeTextFile(OUT_FILE, output);
|
||||
|
||||
console.log(`⚠️ Diferencias encontradas.`);
|
||||
console.log(`📄 Guardadas en: ${OUT_FILE}`);
|
||||
console.log(`👉 Abre ese archivo en VS Code para revisar los cambios.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Error al ejecutar db diff:", error);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
v2.67.1
|
||||
v2.72.7
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
[functions.ai-generate-plan]
|
||||
enabled = true
|
||||
verify_jwt = true
|
||||
import_map = "./functions/ai-generate-plan/deno.json"
|
||||
# Uncomment to specify a custom file path to the entrypoint.
|
||||
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
|
||||
entrypoint = "./functions/ai-generate-plan/index.ts"
|
||||
# Specifies static files to be bundled with the function. Supports glob patterns.
|
||||
# For example, if you want to serve static HTML pages in your function:
|
||||
# static_files = [ "./functions/ai-generate-plan/*.html" ]
|
||||
|
||||
# [functions.ai-structured]
|
||||
# DEPRECATED: replaced by shared module `_shared/openai-service.ts`
|
||||
# enabled = false
|
||||
# verify_jwt = false
|
||||
# import_map = "./functions/ai-structured/deno.json"
|
||||
@@ -0,0 +1,56 @@
|
||||
set check_function_bodies = off;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.set_actualizado_en()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
begin
|
||||
new.actualizado_en = now();
|
||||
return new;
|
||||
end;
|
||||
$function$
|
||||
;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.unaccent_immutable(text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE PARALLEL SAFE STRICT
|
||||
AS $function$
|
||||
SELECT public.unaccent('public.unaccent', $1);
|
||||
$function$
|
||||
;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.validar_numero_ciclo_asignatura()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
declare
|
||||
v_numero_ciclos int;
|
||||
begin
|
||||
if new.numero_ciclo is null then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
select pe.numero_ciclos into v_numero_ciclos
|
||||
from planes_estudio pe
|
||||
where pe.id = new.plan_estudio_id;
|
||||
|
||||
if v_numero_ciclos is null then
|
||||
raise exception 'plan_estudio_id inválido %, plan no encontrado', new.plan_estudio_id;
|
||||
end if;
|
||||
|
||||
if new.numero_ciclo < 1 then
|
||||
raise exception 'numero_ciclo debe ser >= 1 (recibido %)', new.numero_ciclo;
|
||||
end if;
|
||||
|
||||
if new.numero_ciclo > v_numero_ciclos then
|
||||
raise exception 'numero_ciclo % excede planes_estudio.numero_ciclos % para plan_estudio_id %',
|
||||
new.numero_ciclo, v_numero_ciclos, new.plan_estudio_id;
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$function$
|
||||
;
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
// supabase/functions/_shared/openai-service.ts
|
||||
/// <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;
|
||||
|
||||
export type StructuredResponseSuccess<TOutput = unknown> = {
|
||||
ok: true;
|
||||
output?: TOutput; // parsed JSON when available
|
||||
outputText?: string; // raw text when parsing is not possible
|
||||
model: string;
|
||||
usage?: OpenAITypes.OpenAI.Responses.Response["usage"] | null;
|
||||
responseId: string;
|
||||
conversationId?: string | null;
|
||||
references: {
|
||||
uploadedToStorage: string[]; // supabase storage paths
|
||||
openaiFileIds: string[]; // file ids in OpenAI
|
||||
};
|
||||
openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers
|
||||
};
|
||||
|
||||
export type StructuredResponseFailure = {
|
||||
ok: false;
|
||||
code:
|
||||
| "MissingEnv"
|
||||
| "StorageUploadFailed"
|
||||
| "OpenAIFileUploadFailed"
|
||||
| "OpenAIRequestFailed";
|
||||
message: string;
|
||||
cause?: unknown;
|
||||
};
|
||||
|
||||
export type StructuredResponseResult<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;
|
||||
this.openai = openai;
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
static fromEnv(): StructuredResponseFailure | OpenAIService {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? "";
|
||||
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
||||
const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? "";
|
||||
const bucket = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage";
|
||||
|
||||
if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "MissingEnv",
|
||||
message:
|
||||
"Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY",
|
||||
};
|
||||
}
|
||||
|
||||
const client = createClient(supabaseUrl, serviceRoleKey);
|
||||
const openai = new OpenAI({ apiKey: openAIApiKey });
|
||||
return new OpenAIService(client, openai, bucket);
|
||||
}
|
||||
|
||||
async createStructuredResponse<TOutput = unknown>(
|
||||
options: StructuredResponseOptions,
|
||||
files?: File[],
|
||||
): Promise<StructuredResponseResult<TOutput>> {
|
||||
try {
|
||||
const uploadedToStorage = await this.uploadFilesToStorage(
|
||||
files ?? [],
|
||||
);
|
||||
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
|
||||
|
||||
const newOptions = { ...options };
|
||||
|
||||
// Attach file references to the request as an extra user message
|
||||
if (openaiFileIds.length > 0) {
|
||||
const fileParts:
|
||||
OpenAITypes.OpenAI.Responses.ResponseInputFile[] =
|
||||
openaiFileIds.map((id) => ({
|
||||
type: "input_file",
|
||||
file_id: id,
|
||||
}));
|
||||
|
||||
const arr = Array.isArray(options.input) ? options.input : [];
|
||||
arr.push({
|
||||
role: "user",
|
||||
content: [
|
||||
...fileParts,
|
||||
{
|
||||
type: "input_text",
|
||||
text: "Usa estos archivos como referencia",
|
||||
},
|
||||
],
|
||||
});
|
||||
newOptions.input = arr;
|
||||
}
|
||||
|
||||
// Narrow to non-streaming response
|
||||
const openaiRaw = (await this.openai.responses.create(
|
||||
newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming,
|
||||
)) as OpenAITypes.OpenAI.Responses.Response;
|
||||
|
||||
const { model, id: responseId } = openaiRaw;
|
||||
const usage = openaiRaw?.usage ?? null;
|
||||
const conversationId = (
|
||||
openaiRaw as OpenAITypes.OpenAI.Responses.Response & {
|
||||
conversation_id?: string | null;
|
||||
}
|
||||
).conversation_id ?? null;
|
||||
|
||||
// Try to read structured JSON output
|
||||
let output: TOutput | undefined = undefined;
|
||||
let outputText: string | undefined = undefined;
|
||||
|
||||
// Prefer `output_text` if present (SDK convenience)
|
||||
const maybeOutputText = openaiRaw.output_text;
|
||||
if (
|
||||
typeof maybeOutputText === "string" &&
|
||||
maybeOutputText.length > 0
|
||||
) {
|
||||
outputText = maybeOutputText;
|
||||
try {
|
||||
output = JSON.parse(maybeOutputText) as TOutput;
|
||||
} catch { /* non-JSON text, keep as text only */ }
|
||||
} else {
|
||||
// Fallback: attempt to serialize `openaiRaw.output` into text
|
||||
const maybeOutput = openaiRaw.output as unknown;
|
||||
if (typeof maybeOutput === "object" && maybeOutput != null) {
|
||||
try {
|
||||
outputText = JSON.stringify(maybeOutput);
|
||||
output = maybeOutput as TOutput;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
output,
|
||||
outputText,
|
||||
model: String(model),
|
||||
usage,
|
||||
responseId: String(responseId),
|
||||
conversationId: conversationId ? String(conversationId) : null,
|
||||
references: { uploadedToStorage, openaiFileIds },
|
||||
openaiRaw,
|
||||
};
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
const message = e?.message ?? "Unknown error";
|
||||
const code = message.includes("Supabase upload failed")
|
||||
? "StorageUploadFailed"
|
||||
: message.includes("OpenAI file upload failed")
|
||||
? "OpenAIFileUploadFailed"
|
||||
: "OpenAIRequestFailed";
|
||||
|
||||
return { ok: false, code, message, cause: err };
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFilesToStorage(files: File[]): Promise<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) {
|
||||
try {
|
||||
const created = await this.openai.files.create({
|
||||
file,
|
||||
purpose: "user_data",
|
||||
});
|
||||
ids.push(created.id);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`OpenAI file upload failed: ${
|
||||
(e as Error)?.message ?? String(e)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private sanitizeFilename(name: string): string {
|
||||
return name
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-zA-Z0-9.-]/g, "_");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Configuration for private npm package dependencies
|
||||
# For more information on using private registries with Edge Functions, see:
|
||||
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"imports": {
|
||||
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// Follow this setup guide to integrate the Deno language server with your editor:
|
||||
// https://deno.land/manual/getting_started/setup_your_environment
|
||||
// This enables autocomplete, go to definition, etc.
|
||||
|
||||
// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||
import type { Database, Json } from "../_shared/database.types.ts";
|
||||
import type { AIGeneratePlanInput } from "./types.ts";
|
||||
import { z } from "zod";
|
||||
import { systemPrompt } from "./prompts.ts";
|
||||
import { OpenAIService } from "../_shared/openai-service.ts";
|
||||
import type { StructuredResponseOptions } from "../_shared/openai-service.ts";
|
||||
// Typed aliases for strict field unions
|
||||
type NivelType =
|
||||
Database["public"]["Tables"]["planes_estudio"]["Insert"]["nivel"];
|
||||
type TipoCicloType =
|
||||
Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"];
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const url = new URL(req.url);
|
||||
const functionName = url.pathname.split("/").pop();
|
||||
console.log(
|
||||
`[${new Date().toISOString()}][${functionName}]: Request received`,
|
||||
);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const method = req.method;
|
||||
if (method !== "POST") {
|
||||
console.error(
|
||||
`[${
|
||||
new Date().toISOString()
|
||||
}][${functionName}]: Invalid method: ${method}`,
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Method not allowed" }),
|
||||
{
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 405,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const authHeaderRaw = req.headers.get("Authorization") ??
|
||||
req.headers.get("authorization");
|
||||
if (!authHeaderRaw) {
|
||||
console.error(
|
||||
`[${
|
||||
new Date().toISOString()
|
||||
}][${functionName}]: Missing Authorization header`,
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Authorization header missing" }),
|
||||
{
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = (req.headers.get("content-type") || "").toLowerCase();
|
||||
if (!contentType.startsWith("multipart/form-data")) {
|
||||
console.error(
|
||||
`[${
|
||||
new Date().toISOString()
|
||||
}][${functionName}]: Unsupported content type: ${contentType}`,
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Unsupported content type" }),
|
||||
{
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
|
||||
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
|
||||
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
||||
throw new Error("Supabase environment variables are not set");
|
||||
}
|
||||
|
||||
// If needed for RLS-protected reads, create an anon client with user's JWT
|
||||
// Currently not used; kept here for future expansion.
|
||||
// const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
// global: {
|
||||
// headers: {
|
||||
// Authorization: authHeaderRaw,
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||
if (!SERVICE_ROLE_KEY) {
|
||||
throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set");
|
||||
}
|
||||
|
||||
const supabaseService = createClient<Database>(
|
||||
SUPABASE_URL,
|
||||
SERVICE_ROLE_KEY,
|
||||
);
|
||||
|
||||
const formData = await req.formData();
|
||||
const validation = parseAndValidate(formData);
|
||||
if (!validation.success) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}][${functionName}]: Validation errors:`,
|
||||
validation.errors,
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({ errors: validation.errors }),
|
||||
{
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const payload: AIGeneratePlanInput = validation.data;
|
||||
|
||||
const { data: estructuraPlan, error: estructuraPlanError } =
|
||||
await supabaseService
|
||||
.from("estructuras_plan")
|
||||
.select("id,nombre,tipo,template_id,definicion")
|
||||
.eq("id", payload.datosBasicos.estructuraPlanId)
|
||||
.single();
|
||||
if (estructuraPlanError) {
|
||||
throw new Error(
|
||||
"Error fetching estructuraPlan: " + estructuraPlanError.message,
|
||||
);
|
||||
}
|
||||
|
||||
const userPrompt =
|
||||
`Genera un borrador completo del PLAN DE ESTUDIOS con base en lo siguiente:
|
||||
- Descripción del enfoque: ${payload.iaConfig.descripcionEnfoque}
|
||||
- Notas adicionales: ${payload.iaConfig.notasAdicionales ?? "Ninguna"}`;
|
||||
// Ensure the JSON schema is an object as required by OpenAI types
|
||||
const schemaDef: Record<string, unknown> =
|
||||
typeof estructuraPlan?.definicion === "object" &&
|
||||
estructuraPlan?.definicion !== null
|
||||
? estructuraPlan.definicion as Record<string, unknown>
|
||||
: {};
|
||||
|
||||
const aiStructuredPayload: StructuredResponseOptions = {
|
||||
model: "gpt-5-nano",
|
||||
input: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
text: {
|
||||
format: {
|
||||
type: "json_schema",
|
||||
name: "plan_de_estudios_standard",
|
||||
schema: schemaDef,
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Use shared OpenAI service directly (no HTTP invoke)
|
||||
const svc = OpenAIService.fromEnv();
|
||||
if (!(svc instanceof OpenAIService)) {
|
||||
throw new Error(`OpenAI service misconfiguration: ${svc.message}`);
|
||||
}
|
||||
|
||||
const aiResult = await svc.createStructuredResponse(
|
||||
aiStructuredPayload,
|
||||
payload.archivosAdjuntos,
|
||||
);
|
||||
if (!aiResult.ok) {
|
||||
throw new Error(
|
||||
`OpenAI call failed [${aiResult.code}]: ${aiResult.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer parsed output; fallback to parse outputText
|
||||
const aiOutput = aiResult.output ??
|
||||
(aiResult.outputText ? JSON.parse(aiResult.outputText) : null);
|
||||
const aiOutputJson: Json = aiOutput as unknown as Json;
|
||||
if (!aiOutput) {
|
||||
throw new Error("OpenAI response did not contain structured output");
|
||||
}
|
||||
|
||||
// TODO: Insertar interacciones con IA y quizas forzar a que la informacion de datosBasicos sea la misma que la recibida
|
||||
|
||||
const { data: estado } = await supabaseService
|
||||
.from("estados_plan")
|
||||
.select("id,clave,orden")
|
||||
.ilike("clave", "BORRADOR%")
|
||||
.order("orden", { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
const { data: carrera, error: carreraError } = await supabaseService
|
||||
.from("carreras")
|
||||
.select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)")
|
||||
.eq("id", payload.datosBasicos.carreraId)
|
||||
.maybeSingle();
|
||||
if (carreraError) {
|
||||
throw new Error("Error fetching carrera: " + carreraError.message);
|
||||
}
|
||||
|
||||
const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] =
|
||||
{
|
||||
carrera_id: carrera?.id as string,
|
||||
estructura_id: estructuraPlan?.id as string,
|
||||
nombre: payload.datosBasicos.nombrePlan,
|
||||
nivel: payload.datosBasicos.nivel as NivelType,
|
||||
tipo_ciclo: payload.datosBasicos.tipoCiclo as TipoCicloType,
|
||||
numero_ciclos: payload.datosBasicos.numCiclos,
|
||||
datos: aiOutputJson,
|
||||
estado_actual_id: estado?.id ?? undefined,
|
||||
activo: true,
|
||||
tipo_origen: "IA",
|
||||
meta_origen: {
|
||||
generado_por: "ai_generate_plan",
|
||||
ai_structured: {
|
||||
responseId: aiResult.responseId ?? null,
|
||||
conversationId: aiResult.conversationId ?? null,
|
||||
model: aiResult.model,
|
||||
usage: aiResult.usage ?? null,
|
||||
},
|
||||
referencias: {
|
||||
archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null,
|
||||
repositoriosIds: payload.iaConfig?.repositoriosIds ?? null,
|
||||
},
|
||||
iaConfig: {
|
||||
descripcionEnfoque: payload.iaConfig?.descripcionEnfoque ?? null,
|
||||
notasAdicionales: payload.iaConfig?.notasAdicionales ?? null,
|
||||
usarMCP: Boolean(payload.iaConfig?.usarMCP),
|
||||
},
|
||||
} as unknown as Json,
|
||||
};
|
||||
|
||||
const { data: plan, error: planError } = await supabaseService
|
||||
.from("planes_estudio")
|
||||
.insert(planInsert)
|
||||
.select(
|
||||
"id,nombre,nivel,tipo_ciclo,numero_ciclos,carrera_id,estructura_id,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,datos",
|
||||
)
|
||||
.single();
|
||||
|
||||
// TODO: interaccion con IA y cambio de estado
|
||||
|
||||
if (planError) {
|
||||
throw new Error("Error inserting plan: " + planError.message);
|
||||
}
|
||||
|
||||
// TODO: update a interaccion_ia y e insert a cambios_plan con id de plan generado
|
||||
const jsonResponse = {
|
||||
ok: true,
|
||||
plan,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[${
|
||||
new Date().toISOString()
|
||||
}][${functionName}]: Request processed successfully`,
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify(jsonResponse),
|
||||
{
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
// Log full error server-side for diagnostics
|
||||
if (error instanceof Error) {
|
||||
console.error(
|
||||
`[${
|
||||
new Date().toISOString()
|
||||
}][${functionName}]: Request handler error:`,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Este helper recibe un esquema (ej. DatosBasicosSchema) y devuelve un validador
|
||||
// que acepta un string JSON y lo valida contra ese esquema.
|
||||
const jsonFromString = <T extends z.ZodTypeAny>(schema: T) =>
|
||||
z.string().transform((str, ctx) => {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (_e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "El formato no es un JSON válido",
|
||||
});
|
||||
return z.NEVER; // Detiene la ejecución aquí si falla el parseo
|
||||
}
|
||||
}).pipe(schema); // Si el parseo es exitoso, pasa los datos al esquema real
|
||||
|
||||
// --- VALIDACIÓN ESTRICTA DE DATOS BÁSICOS ---
|
||||
const DatosBasicosSchema: z.ZodType<AIGeneratePlanInput["datosBasicos"]> = z
|
||||
.object({
|
||||
nombrePlan: z.string().min(1, "El nombre es requerido"),
|
||||
carreraId: z.string().uuid("carreraId debe ser un UUID"),
|
||||
facultadId: z.string().uuid("facultadId debe ser un UUID").optional(),
|
||||
nivel: z.string().min(1, "Nivel es requerido"),
|
||||
tipoCiclo: z.enum(["Semestre", "Cuatrimestre", "Trimestre", "Otro"]),
|
||||
numCiclos: z.number().int().positive(),
|
||||
estructuraPlanId: z.string().uuid("estructuraPlanId debe ser un UUID"),
|
||||
});
|
||||
|
||||
const IAConfigSchema: z.ZodType<AIGeneratePlanInput["iaConfig"]> = z.object({
|
||||
descripcionEnfoque: z.string().min(10, "La descripción es muy corta"),
|
||||
notasAdicionales: z.string().optional(),
|
||||
archivosReferencia: z.array(z.string().uuid()).optional(),
|
||||
repositoriosIds: z.array(z.string().uuid()).optional(),
|
||||
usarMCP: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const SolicitudSchema = z.object({
|
||||
// Usamos el helper aquí. Zod recibe string -> parsea -> valida estructura
|
||||
datosBasicos: jsonFromString(DatosBasicosSchema),
|
||||
|
||||
iaConfig: jsonFromString(IAConfigSchema),
|
||||
|
||||
// Validamos directamente que sea un array de Archivos
|
||||
// z.instanceof(File) funciona en entornos Web/Deno
|
||||
archivosAdjuntos: z.array(z.instanceof(File)).optional().default([]),
|
||||
});
|
||||
|
||||
function parseAndValidate(
|
||||
formData: FormData,
|
||||
): { success: true; data: AIGeneratePlanInput } | {
|
||||
success: false;
|
||||
errors: string[];
|
||||
} {
|
||||
// 1. Convertimos el FormData a un objeto plano de JS para que Zod lo lea
|
||||
const rawInput = {
|
||||
datosBasicos: formData.get("datosBasicos"),
|
||||
iaConfig: formData.get("iaConfig"),
|
||||
// getAll es clave para obtener el array de archivos
|
||||
archivosAdjuntos: formData.getAll("archivosAdjuntos"),
|
||||
};
|
||||
|
||||
// 2. Dejamos que Zod haga TODO el trabajo sucio (null check, json parse, validación)
|
||||
const result = SolicitudSchema.safeParse(rawInput);
|
||||
|
||||
if (!result.success) {
|
||||
// Aplanamos los errores para devolver un array de strings limpio
|
||||
const errors = result.error.errors.map((issue) => {
|
||||
const path = issue.path.join(".");
|
||||
return `${path}: ${issue.message}`;
|
||||
});
|
||||
|
||||
return { success: false, errors };
|
||||
}
|
||||
|
||||
// 3. Retorno exitoso con tipos inferidos automáticamente
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
@@ -0,0 +1,919 @@
|
||||
// supabase/functions/ai_generate_plan/index.ts
|
||||
/// <reference lib="deno.window" />
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
function json(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body, null, 2), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** ----------------- LOGGING HELPERS (diagnóstico) ----------------- */
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function redactAuthHeader(v: string | null) {
|
||||
if (!v) return null;
|
||||
const parts = v.split(" ");
|
||||
if (parts.length >= 2) return `${parts[0]} [REDACTED]`;
|
||||
return "[REDACTED]";
|
||||
}
|
||||
|
||||
function safePreview(val: unknown, maxLen = 1400) {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(val, (k, v) => {
|
||||
if (typeof v === "string") {
|
||||
if (v.length > 800) return v.slice(0, 800) + "…[TRUNCATED]";
|
||||
if (v.startsWith("data:") && v.includes(";base64,")) {
|
||||
return "[DATA_URL_REDACTED]";
|
||||
}
|
||||
}
|
||||
return v;
|
||||
});
|
||||
if (!jsonStr) return String(val);
|
||||
return jsonStr.length > maxLen
|
||||
? jsonStr.slice(0, maxLen) + "…[TRUNCATED]"
|
||||
: jsonStr;
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
|
||||
function headersSnapshot(req: Request) {
|
||||
const h: Record<string, string> = {};
|
||||
for (const [k, v] of req.headers.entries()) {
|
||||
if (k.toLowerCase() === "authorization") h[k] = redactAuthHeader(v) ?? "";
|
||||
else h[k] = v;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function normalizeNivel(raw: string): string {
|
||||
const s = (raw ?? "").trim().toLowerCase();
|
||||
if (!s) return "Otro";
|
||||
if (s === "licenciatura" || s === "lic") return "Licenciatura";
|
||||
if (s === "maestria" || s === "maestría" || s === "msc") return "Maestría";
|
||||
if (s === "doctorado" || s === "phd") return "Doctorado";
|
||||
if (s === "especialidad") return "Especialidad";
|
||||
if (s === "diplomado") return "Diplomado";
|
||||
return "Otro";
|
||||
}
|
||||
|
||||
function normalizeTipoCiclo(raw: string): string {
|
||||
const s = (raw ?? "").trim().toLowerCase();
|
||||
if (!s) return "Otro";
|
||||
if (
|
||||
s === "semestre" || s === "semestres" || s === "sem" ||
|
||||
s === "semestre(s)" ||
|
||||
s === "semestre" || s === "semestre" || s === "semestre"
|
||||
) return "Semestre";
|
||||
if (s === "cuatrimestre" || s === "cuatrimestres" || s === "cuatri") {
|
||||
return "Cuatrimestre";
|
||||
}
|
||||
if (s === "trimestre" || s === "trimestres" || s === "trim") {
|
||||
return "Trimestre";
|
||||
}
|
||||
// si viene en mayúsculas tipo frontend
|
||||
if (s === "semestre" || s === "semestre".toLowerCase()) return "Semestre";
|
||||
if (s === "cuatrimestre" || s === "cuatrimestre".toLowerCase()) {
|
||||
return "Cuatrimestre";
|
||||
}
|
||||
if (s === "trimestre" || s === "trimestre".toLowerCase()) return "Trimestre";
|
||||
if (s === "otro") return "Otro";
|
||||
// variantes comunes
|
||||
if (s === "semestre".toLowerCase()) return "Semestre";
|
||||
if (s === "cuatrimestre".toLowerCase()) return "Cuatrimestre";
|
||||
if (s === "trimestre".toLowerCase()) return "Trimestre";
|
||||
// si llega como SEMESTRE / CUATRIMESTRE / TRIMESTRE
|
||||
if (s === "semestre".toLowerCase()) return "Semestre";
|
||||
if (s === "cuatrimestre".toLowerCase()) return "Cuatrimestre";
|
||||
if (s === "trimestre".toLowerCase()) return "Trimestre";
|
||||
// fallback
|
||||
return "Otro";
|
||||
}
|
||||
|
||||
function suggestedWeeksFor(tipoCiclo: string) {
|
||||
switch (tipoCiclo) {
|
||||
case "Semestre":
|
||||
return "16";
|
||||
case "Cuatrimestre":
|
||||
return "16";
|
||||
case "Trimestre":
|
||||
return "12";
|
||||
default:
|
||||
return "16";
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrompt(params: {
|
||||
datosBasicos: any;
|
||||
carrera?: any;
|
||||
estructura?: any;
|
||||
iaConfig?: any;
|
||||
}) {
|
||||
const { datosBasicos, carrera, estructura, iaConfig } = params;
|
||||
|
||||
const nombrePlan = String(datosBasicos?.nombrePlan ?? "").trim();
|
||||
const nivel = normalizeNivel(String(datosBasicos?.nivel ?? ""));
|
||||
const tipoCiclo = normalizeTipoCiclo(String(datosBasicos?.tipoCiclo ?? ""));
|
||||
const numCiclos = Number(datosBasicos?.numCiclos ?? 0) || 0;
|
||||
|
||||
const semanas = suggestedWeeksFor(tipoCiclo);
|
||||
|
||||
const carreraTxt = carrera
|
||||
? `Carrera: ${carrera?.nombre ?? ""} (id: ${
|
||||
carrera?.id ?? ""
|
||||
})\nFacultad: ${
|
||||
carrera?.facultades?.nombre ?? carrera?.facultad?.nombre ?? ""
|
||||
}`
|
||||
: "";
|
||||
|
||||
const system = [
|
||||
"Eres un experto en diseño curricular (México) y redacción institucional.",
|
||||
"Debes generar SOLO JSON válido que cumpla EXACTAMENTE el schema (sin campos extra).",
|
||||
"Usa español neutro y redacción formal. Si un campo es opcional y no aplica, usa null.",
|
||||
"Respeta enumeraciones (p.ej. modalidad_educativa, diseno_curricular).",
|
||||
"Asegura consistencia interna entre nivel, fines, perfiles y área de estudio.",
|
||||
"La carga_horaria_a_la_semana NO debe exceder 50.",
|
||||
].join(" ");
|
||||
|
||||
const user = [
|
||||
"Genera un borrador completo del PLAN DE ESTUDIOS con base en lo siguiente:",
|
||||
"",
|
||||
"== Datos básicos (NO los cambies) ==",
|
||||
`- nivel: ${nivel}`,
|
||||
`- nombre: ${nombrePlan}`,
|
||||
`- tipo de ciclo: ${tipoCiclo}`,
|
||||
`- número de ciclos: ${numCiclos}`,
|
||||
`- duración sugerida del ciclo escolar (semanas efectivas): ${semanas}`,
|
||||
"",
|
||||
carreraTxt ? `== Contexto institucional ==\n${carreraTxt}\n` : "",
|
||||
"== Enfoque / Población / Notas ==",
|
||||
`- descripcionEnfoque: ${iaConfig?.descripcionEnfoque ?? ""}`,
|
||||
`- notasAdicionales: ${iaConfig?.notasAdicionales ?? ""}`,
|
||||
"",
|
||||
"== Reglas adicionales ==",
|
||||
"- Alinea: fines_de_aprendizaje_o_formacion ↔ perfil_de_egreso ↔ area_de_estudio.",
|
||||
`- total_de_ciclos_del_plan_de_estudios debe ser \"${
|
||||
String(numCiclos)
|
||||
}\" (string).`,
|
||||
`- duracion_del_ciclo_escolar debe ser \"${semanas}\" (string).`,
|
||||
"- diseno_curricular: elige 'Rígido' o 'Flexible' coherente con la administración.",
|
||||
"- Si modalidad_educativa es 'No escolarizada' o 'Mixta', llena justificacion_de_la_propuesta_curricular; si es 'Escolar', puede ser null.",
|
||||
"- clave_del_plan_de_estudios: genera una clave administrativa verosímil (p.ej. 2026-XXX-YYY).",
|
||||
"",
|
||||
"Entrega únicamente el JSON final (sin Markdown, sin explicación).",
|
||||
].join("\n");
|
||||
|
||||
return {
|
||||
system,
|
||||
user,
|
||||
computed: { nivel, tipoCiclo, numCiclos, semanas, nombrePlan },
|
||||
};
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const cid = crypto.randomUUID();
|
||||
const t0 = performance.now();
|
||||
|
||||
const log = (msg: string, extra?: unknown) => {
|
||||
if (extra !== undefined) {
|
||||
console.log(
|
||||
`[${nowIso()}][ai_generate_plan][${cid}] ${msg}`,
|
||||
safePreview(extra),
|
||||
);
|
||||
} else {
|
||||
console.log(`[${nowIso()}][ai_generate_plan][${cid}] ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const warn = (msg: string, extra?: unknown) => {
|
||||
if (extra !== undefined) {
|
||||
console.warn(
|
||||
`[${nowIso()}][ai_generate_plan][${cid}] ⚠️ ${msg}`,
|
||||
safePreview(extra),
|
||||
);
|
||||
} else {
|
||||
console.warn(`[${nowIso()}][ai_generate_plan][${cid}] ⚠️ ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const errlog = (msg: string, extra?: unknown) => {
|
||||
if (extra !== undefined) {
|
||||
console.error(
|
||||
`[${nowIso()}][ai_generate_plan][${cid}] ❌ ${msg}`,
|
||||
safePreview(extra),
|
||||
);
|
||||
} else {
|
||||
console.error(`[${nowIso()}][ai_generate_plan][${cid}] ❌ ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
log("OPTIONS preflight ✅", {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: headersSnapshot(req),
|
||||
});
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
warn("Method not allowed", { method: req.method });
|
||||
return json({ ok: false, error: "Method not allowed", cid }, 405);
|
||||
}
|
||||
|
||||
log("START 🚀", { method: req.method, url: req.url });
|
||||
log("Headers snapshot 🧾", headersSnapshot(req));
|
||||
|
||||
try {
|
||||
// 1) Auth requerido ✅
|
||||
log("Step 1: Auth check 🔐");
|
||||
const authHeaderRaw = req.headers.get("Authorization") ??
|
||||
req.headers.get("authorization");
|
||||
log("Authorization header present?", {
|
||||
present: Boolean(authHeaderRaw),
|
||||
value: redactAuthHeader(authHeaderRaw),
|
||||
});
|
||||
|
||||
if (!authHeaderRaw) {
|
||||
warn("Missing Authorization header");
|
||||
return json(
|
||||
{ ok: false, error: "Missing Authorization header", cid },
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
|
||||
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
|
||||
log("Env presence (Supabase) 🌍", {
|
||||
hasSUPABASE_URL: Boolean(SUPABASE_URL),
|
||||
hasSUPABASE_ANON_KEY: Boolean(SUPABASE_ANON_KEY),
|
||||
});
|
||||
|
||||
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
||||
errlog("Missing SUPABASE_URL / SUPABASE_ANON_KEY");
|
||||
return json(
|
||||
{ ok: false, error: "Missing SUPABASE_URL / SUPABASE_ANON_KEY", cid },
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
const supabaseUser = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
global: { headers: { Authorization: authHeaderRaw } },
|
||||
});
|
||||
|
||||
const tAuth = performance.now();
|
||||
const { data: userData, error: userErr } = await supabaseUser.auth
|
||||
.getUser();
|
||||
log("Supabase auth.getUser() done ⏱️", {
|
||||
ms: Math.round(performance.now() - tAuth),
|
||||
});
|
||||
|
||||
if (userErr || !userData?.user) {
|
||||
warn("Invalid token", { userErr: userErr?.message ?? userErr ?? null });
|
||||
return json({ ok: false, error: "Invalid token", cid }, 401);
|
||||
}
|
||||
|
||||
const user = userData.user;
|
||||
log("Authenticated user ✅", {
|
||||
userId: user.id,
|
||||
email: user.email ?? null,
|
||||
});
|
||||
|
||||
const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||
log("Env presence (service role) 🗝️", {
|
||||
hasSERVICE_ROLE: Boolean(SERVICE_ROLE),
|
||||
});
|
||||
|
||||
const supabaseAdmin = SERVICE_ROLE
|
||||
? createClient(SUPABASE_URL, SERVICE_ROLE)
|
||||
: supabaseUser;
|
||||
|
||||
log("Supabase client selected 🧩", { adminMode: Boolean(SERVICE_ROLE) });
|
||||
|
||||
// 2) Parse body 🧾
|
||||
log("Step 2: Parse body 🧾");
|
||||
let payload: any = {};
|
||||
const tParse = performance.now();
|
||||
try {
|
||||
payload = await req.json();
|
||||
} catch (e) {
|
||||
errlog("Invalid JSON body", {
|
||||
error: (e as Error)?.message ?? String(e),
|
||||
});
|
||||
return json({ ok: false, error: "Invalid JSON body", cid }, 400);
|
||||
}
|
||||
log("Body parsed ⏱️", { ms: Math.round(performance.now() - tParse) });
|
||||
log("Payload preview", {
|
||||
keys: Object.keys(payload ?? {}),
|
||||
payload: payload ? { ...payload, iaConfig: undefined } : null,
|
||||
});
|
||||
|
||||
const datosBasicos = payload?.datosBasicos ?? payload?.input?.datosBasicos;
|
||||
const iaConfig = payload?.iaConfig ?? payload?.input?.iaConfig ?? {};
|
||||
|
||||
if (
|
||||
!datosBasicos?.nombrePlan || !datosBasicos?.carreraId ||
|
||||
!datosBasicos?.nivel || !datosBasicos?.tipoCiclo ||
|
||||
!datosBasicos?.numCiclos
|
||||
) {
|
||||
warn("Missing required datosBasicos fields", { datosBasicos });
|
||||
return json({
|
||||
ok: false,
|
||||
error:
|
||||
"Missing required fields in datosBasicos (nombrePlan,carreraId,nivel,tipoCiclo,numCiclos).",
|
||||
cid,
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// 3) Asegura usuarios_app (por FK en logs) 👤
|
||||
log("Step 3: Ensure usuarios_app row 👤");
|
||||
const tUserUpsert = performance.now();
|
||||
const upsertUser = {
|
||||
id: user.id,
|
||||
email: user.email ?? null,
|
||||
nombre_completo: (user.user_metadata as any)?.full_name ??
|
||||
(user.user_metadata as any)?.name ?? null,
|
||||
};
|
||||
const { error: upsertErr } = await supabaseAdmin
|
||||
.from("usuarios_app")
|
||||
.upsert(upsertUser, { onConflict: "id" });
|
||||
log("usuarios_app upsert done ⏱️", {
|
||||
ms: Math.round(performance.now() - tUserUpsert),
|
||||
ok: !upsertErr,
|
||||
});
|
||||
if (upsertErr) {
|
||||
errlog("usuarios_app upsert failed", { message: upsertErr.message });
|
||||
return json({
|
||||
ok: false,
|
||||
error: "usuarios_app upsert failed",
|
||||
details: upsertErr.message,
|
||||
cid,
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// 4) Trae carrera + facultad (contexto prompt) 🎓
|
||||
log("Step 4: Fetch carrera/facultad 🎓");
|
||||
const carreraId = datosBasicos.carreraId;
|
||||
const tCarr = performance.now();
|
||||
const { data: carrera, error: carreraErr } = await supabaseAdmin
|
||||
.from("carreras")
|
||||
.select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)")
|
||||
.eq("id", carreraId)
|
||||
.maybeSingle();
|
||||
log("Carrera fetch done ⏱️", {
|
||||
ms: Math.round(performance.now() - tCarr),
|
||||
found: Boolean(carrera),
|
||||
carreraId,
|
||||
});
|
||||
if (carreraErr) {
|
||||
warn("Carrera fetch error (continuing)", { message: carreraErr.message });
|
||||
}
|
||||
|
||||
// 5) Resolver estructura (schema) desde estructuras_plan.definicion 🧩
|
||||
log("Step 5: Resolve estructura_plan 🧩");
|
||||
const estructuraIdFromPayload = datosBasicos?.estructuraId ??
|
||||
payload?.estructuraId ?? null;
|
||||
|
||||
let estructura: any = null;
|
||||
const tEst = performance.now();
|
||||
|
||||
if (estructuraIdFromPayload) {
|
||||
log("Using estructuraId from payload", { estructuraIdFromPayload });
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from("estructuras_plan")
|
||||
.select("id,nombre,tipo,template_id,definicion")
|
||||
.eq("id", estructuraIdFromPayload)
|
||||
.single();
|
||||
if (error) {
|
||||
errlog("estructuraId provided but not found", {
|
||||
message: error.message,
|
||||
estructuraIdFromPayload,
|
||||
});
|
||||
return json({
|
||||
ok: false,
|
||||
error: "estructuraId not found",
|
||||
details: error.message,
|
||||
cid,
|
||||
}, 400);
|
||||
}
|
||||
estructura = data;
|
||||
} else {
|
||||
// default: la más reciente tipo CURRICULAR, si no existe, la más reciente en general
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from("estructuras_plan")
|
||||
.select("id,nombre,tipo,template_id,definicion")
|
||||
.eq("tipo", "CURRICULAR")
|
||||
.order("actualizado_en", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
warn("Error fetching CURRICULAR estructura (fallback to any)", {
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
if (data) estructura = data;
|
||||
|
||||
if (!estructura) {
|
||||
const { data: anyE, error: anyErr } = await supabaseAdmin
|
||||
.from("estructuras_plan")
|
||||
.select("id,nombre,tipo,template_id,definicion")
|
||||
.order("actualizado_en", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (anyErr) {
|
||||
errlog("No estructuras_plan found", { message: anyErr.message });
|
||||
return json({
|
||||
ok: false,
|
||||
error: "No estructuras_plan found",
|
||||
details: anyErr.message,
|
||||
cid,
|
||||
}, 500);
|
||||
}
|
||||
estructura = anyE;
|
||||
}
|
||||
}
|
||||
|
||||
log("Estructura resolved ⏱️", {
|
||||
ms: Math.round(performance.now() - tEst),
|
||||
estructuraId: estructura?.id ?? null,
|
||||
nombre: estructura?.nombre ?? null,
|
||||
tipo: estructura?.tipo ?? null,
|
||||
template_id: estructura?.template_id ?? null,
|
||||
definicionKeys: estructura?.definicion
|
||||
? Object.keys(estructura.definicion)
|
||||
: null,
|
||||
});
|
||||
|
||||
if (!estructura?.definicion) {
|
||||
errlog("estructuras_plan.definicion missing", {
|
||||
estructuraId: estructura?.id ?? null,
|
||||
});
|
||||
return json({
|
||||
ok: false,
|
||||
error: "estructuras_plan.definicion missing",
|
||||
cid,
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// 6) Resolver referencias (archivos / repositorios) 📎
|
||||
log("Step 6: Resolve references 📎");
|
||||
const archivosReferenciaIds: string[] =
|
||||
Array.isArray(iaConfig?.archivosReferencia)
|
||||
? iaConfig.archivosReferencia
|
||||
: [];
|
||||
const repositoriosIds: string[] = Array.isArray(iaConfig?.repositoriosIds)
|
||||
? iaConfig.repositoriosIds
|
||||
: [];
|
||||
|
||||
log("Reference IDs received", {
|
||||
archivosReferenciaIdsCount: archivosReferenciaIds.length,
|
||||
repositoriosIdsCount: repositoriosIds.length,
|
||||
usarMCP: Boolean(iaConfig?.usarMCP),
|
||||
});
|
||||
|
||||
const archivosInfo: any[] = [];
|
||||
const vectorInfo: any[] = [];
|
||||
|
||||
if (archivosReferenciaIds.length) {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from("archivos")
|
||||
.select("id,nombre,mime_type,openai_file_id,ruta_storage")
|
||||
.in("id", archivosReferenciaIds);
|
||||
if (error) {
|
||||
warn("archivos lookup failed (continuing)", { message: error.message });
|
||||
}
|
||||
for (const a of data ?? []) archivosInfo.push(a);
|
||||
}
|
||||
|
||||
if (repositoriosIds.length) {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from("vector_stores")
|
||||
.select("id,nombre,openai_vector_id")
|
||||
.in("id", repositoriosIds);
|
||||
if (error) {
|
||||
warn("vector_stores lookup failed (continuing)", {
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
for (const v of data ?? []) vectorInfo.push(v);
|
||||
}
|
||||
|
||||
const openaiFileIds: string[] = archivosInfo
|
||||
.map((a) => a?.openai_file_id)
|
||||
.filter((x) => typeof x === "string" && x.length > 0);
|
||||
const vectorStoreIds: string[] = vectorInfo
|
||||
.map((v) => v?.openai_vector_id)
|
||||
.filter((x) => typeof x === "string" && x.length > 0);
|
||||
|
||||
const rutasStorage: string[] = archivosInfo
|
||||
.map((a) => a?.ruta_storage)
|
||||
.filter((x) => typeof x === "string" && x.length > 0);
|
||||
|
||||
log("Resolved reference OpenAI IDs", {
|
||||
openaiFileIdsCount: openaiFileIds.length,
|
||||
vectorStoreIdsCount: vectorStoreIds.length,
|
||||
rutasStorageCount: rutasStorage.length,
|
||||
});
|
||||
|
||||
// 7) Construye prompt + structured wrapper 🧠
|
||||
log("Step 7: Build prompt + structured schema 🧠");
|
||||
const { system, user: userPrompt, computed } = buildPrompt({
|
||||
datosBasicos,
|
||||
carrera,
|
||||
estructura,
|
||||
iaConfig,
|
||||
});
|
||||
|
||||
// definicion en DB puede venir como (schema) o como (format). Normalizamos.
|
||||
let structured: any = null;
|
||||
const definicion = estructura.definicion;
|
||||
|
||||
if (definicion?.type === "json_schema" && definicion?.schema) {
|
||||
structured = definicion;
|
||||
} else if (
|
||||
definicion?.format?.type === "json_schema" && definicion?.format?.schema
|
||||
) {
|
||||
structured = definicion;
|
||||
} else {
|
||||
structured = {
|
||||
type: "json_schema",
|
||||
name: "plan_datos",
|
||||
strict: true,
|
||||
schema: definicion,
|
||||
};
|
||||
}
|
||||
|
||||
log("Structured wrapper summary", {
|
||||
type: structured?.type ?? null,
|
||||
name: structured?.name ?? null,
|
||||
strict: structured?.strict ?? null,
|
||||
hasSchema: Boolean(structured?.schema),
|
||||
schemaTopKeys: structured?.schema
|
||||
? Object.keys(structured.schema).slice(0, 20)
|
||||
: null,
|
||||
});
|
||||
|
||||
// 8) Llamar ai-structured (otra Edge Function) 🔁
|
||||
log("Step 8: Call ai-structured 🔁");
|
||||
const aiStructuredUrl = `${SUPABASE_URL}/functions/v1/ai-structured`;
|
||||
|
||||
const model = payload?.model ?? payload?.openai?.model ??
|
||||
payload?.response?.model ?? "gpt-5";
|
||||
|
||||
const aiStructuredBody = {
|
||||
response: {
|
||||
model,
|
||||
input: [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
},
|
||||
structured,
|
||||
references: {
|
||||
openaiFileIds,
|
||||
vectorStoreIds,
|
||||
archivosReferenciaIds,
|
||||
},
|
||||
usarMCP: Boolean(iaConfig?.usarMCP),
|
||||
};
|
||||
|
||||
log("ai-structured request snapshot 🧪", {
|
||||
url: aiStructuredUrl,
|
||||
model,
|
||||
inputChars: { system: system.length, user: userPrompt.length },
|
||||
openaiFileIdsCount: openaiFileIds.length,
|
||||
vectorStoreIdsCount: vectorStoreIds.length,
|
||||
usarMCP: Boolean(iaConfig?.usarMCP),
|
||||
});
|
||||
|
||||
const tAi = performance.now();
|
||||
const aiResp = await fetch(aiStructuredUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: authHeaderRaw,
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
},
|
||||
body: JSON.stringify(aiStructuredBody),
|
||||
});
|
||||
|
||||
const aiJson = await aiResp.json().catch(() => null);
|
||||
|
||||
log("ai-structured response received ⏱️", {
|
||||
ms: Math.round(performance.now() - tAi),
|
||||
status: aiResp.status,
|
||||
okHttp: aiResp.ok,
|
||||
ok: aiJson?.ok ?? null,
|
||||
aiCid: aiJson?.cid ?? null,
|
||||
responseId: aiJson?.responseId ?? null,
|
||||
conversationId: aiJson?.conversationId ?? null,
|
||||
outputParseError: aiJson?.outputParseError ?? null,
|
||||
});
|
||||
|
||||
if (!aiResp.ok || !aiJson) {
|
||||
errlog("ai-structured HTTP failed", {
|
||||
status: aiResp.status,
|
||||
body: aiJson,
|
||||
});
|
||||
return json({
|
||||
ok: false,
|
||||
error: "ai-structured HTTP failed",
|
||||
status: aiResp.status,
|
||||
details: aiJson,
|
||||
cid,
|
||||
}, 500);
|
||||
}
|
||||
|
||||
if (!aiJson.ok || !aiJson.output) {
|
||||
errlog("ai-structured returned ok=false or missing output", { aiJson });
|
||||
// Aun así, registra interaccion_ia (sin plan) para auditoría
|
||||
const { data: inter, error: interErr } = await supabaseAdmin
|
||||
.from("interacciones_ia")
|
||||
.insert({
|
||||
usuario_id: user.id,
|
||||
plan_estudio_id: null,
|
||||
tipo: "GENERAR",
|
||||
modelo: aiJson?.model ?? model,
|
||||
temperatura: null,
|
||||
prompt: {
|
||||
datosBasicos,
|
||||
iaConfig,
|
||||
estructura: {
|
||||
id: estructura.id,
|
||||
nombre: estructura.nombre,
|
||||
template_id: estructura.template_id,
|
||||
tipo: estructura.tipo,
|
||||
},
|
||||
request: aiStructuredBody,
|
||||
computed,
|
||||
},
|
||||
respuesta: aiJson,
|
||||
aceptada: false,
|
||||
conversacion_id: aiJson?.conversationId ?? null,
|
||||
ids_archivos: archivosReferenciaIds,
|
||||
ids_vector_store: repositoriosIds,
|
||||
rutas_storage: rutasStorage,
|
||||
})
|
||||
.select("id")
|
||||
.maybeSingle();
|
||||
|
||||
if (interErr) {
|
||||
warn("interacciones_ia insert failed (error path)", {
|
||||
message: interErr.message,
|
||||
});
|
||||
}
|
||||
|
||||
return json({
|
||||
ok: false,
|
||||
error: "AI generation failed (structured output invalid).",
|
||||
cid,
|
||||
ai: aiJson,
|
||||
interaccion_ia_id: inter?.id ?? null,
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// 9) Normaliza/asegura campos clave del output ✅
|
||||
log("Step 9: Normalize output ✅");
|
||||
const output = aiJson.output as Record<string, any>;
|
||||
|
||||
// fuerza consistencia con datosBasicos
|
||||
output.nombre = computed.nombrePlan;
|
||||
output.nivel = computed.nivel;
|
||||
output.total_de_ciclos_del_plan_de_estudios = String(computed.numCiclos);
|
||||
output.duracion_del_ciclo_escolar = String(computed.semanas);
|
||||
|
||||
// 10) Inserta log de IA (interacciones_ia) 🧾
|
||||
log("Step 10: Insert interacciones_ia 🧾");
|
||||
const tInter = performance.now();
|
||||
const { data: interaccion, error: interErr } = await supabaseAdmin
|
||||
.from("interacciones_ia")
|
||||
.insert({
|
||||
usuario_id: user.id,
|
||||
plan_estudio_id: null, // se linkea luego (cuando exista el plan)
|
||||
tipo: "GENERAR",
|
||||
modelo: aiJson?.model ?? model,
|
||||
temperatura: null,
|
||||
prompt: {
|
||||
datosBasicos,
|
||||
iaConfig,
|
||||
estructura: {
|
||||
id: estructura.id,
|
||||
nombre: estructura.nombre,
|
||||
template_id: estructura.template_id,
|
||||
tipo: estructura.tipo,
|
||||
},
|
||||
request: aiStructuredBody,
|
||||
computed,
|
||||
},
|
||||
respuesta: aiJson,
|
||||
aceptada: true, // se está usando para crear el plan
|
||||
conversacion_id: aiJson?.conversationId ?? null,
|
||||
ids_archivos: archivosReferenciaIds,
|
||||
ids_vector_store: repositoriosIds,
|
||||
rutas_storage: rutasStorage,
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
log("interacciones_ia insert done ⏱️", {
|
||||
ms: Math.round(performance.now() - tInter),
|
||||
interaccionId: interaccion?.id ?? null,
|
||||
ok: !interErr,
|
||||
});
|
||||
|
||||
if (interErr) {
|
||||
errlog("interacciones_ia insert failed", { message: interErr.message });
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: "interacciones_ia insert failed",
|
||||
details: interErr.message,
|
||||
cid,
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
// 11) Crear plan (planes_estudio) 🏗️
|
||||
log("Step 11: Insert planes_estudio 🏗️");
|
||||
const nivelDB = normalizeNivel(String(datosBasicos?.nivel ?? ""));
|
||||
const tipoCicloDB = normalizeTipoCiclo(
|
||||
String(datosBasicos?.tipoCiclo ?? ""),
|
||||
);
|
||||
const numeroCiclosDB = Number(datosBasicos?.numCiclos ?? 0);
|
||||
|
||||
// intenta setear estado BORRADOR si existe
|
||||
let estadoBorradorId: string | null = null;
|
||||
try {
|
||||
const { data: estado } = await supabaseAdmin
|
||||
.from("estados_plan")
|
||||
.select("id,clave,orden")
|
||||
.ilike("clave", "BORRADOR%")
|
||||
.order("orden", { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
estadoBorradorId = estado?.id ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const tPlan = performance.now();
|
||||
const { data: plan, error: planErr } = await supabaseAdmin
|
||||
.from("planes_estudio")
|
||||
.insert({
|
||||
carrera_id: carreraId,
|
||||
estructura_id: estructura.id,
|
||||
nombre: computed.nombrePlan,
|
||||
nivel: nivelDB,
|
||||
tipo_ciclo: tipoCicloDB,
|
||||
numero_ciclos: numeroCiclosDB,
|
||||
datos: output,
|
||||
estado_actual_id: estadoBorradorId,
|
||||
activo: true,
|
||||
tipo_origen: "IA",
|
||||
meta_origen: {
|
||||
generado_por: "ai_generate_plan",
|
||||
cid,
|
||||
ai_structured: {
|
||||
cid: aiJson?.cid ?? null,
|
||||
responseId: aiJson?.responseId ?? null,
|
||||
conversationId: aiJson?.conversationId ?? null,
|
||||
model: aiJson?.model ?? model,
|
||||
usage: aiJson?.usage ?? null,
|
||||
},
|
||||
referencias: {
|
||||
archivosReferenciaIds,
|
||||
repositoriosIds,
|
||||
openaiFileIds,
|
||||
vectorStoreIds,
|
||||
},
|
||||
iaConfig: {
|
||||
descripcionEnfoque: iaConfig?.descripcionEnfoque ?? null,
|
||||
poblacionObjetivo: iaConfig?.poblacionObjetivo ?? null,
|
||||
notasAdicionales: iaConfig?.notasAdicionales ?? null,
|
||||
usarMCP: Boolean(iaConfig?.usarMCP),
|
||||
},
|
||||
},
|
||||
creado_por: user.id,
|
||||
actualizado_por: user.id,
|
||||
})
|
||||
.select(
|
||||
"id,nombre,nivel,tipo_ciclo,numero_ciclos,carrera_id,estructura_id,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,datos",
|
||||
)
|
||||
.single();
|
||||
|
||||
log("planes_estudio insert done ⏱️", {
|
||||
ms: Math.round(performance.now() - tPlan),
|
||||
planId: plan?.id ?? null,
|
||||
ok: !planErr,
|
||||
});
|
||||
|
||||
if (planErr) {
|
||||
errlog("planes_estudio insert failed", { message: planErr.message });
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: "planes_estudio insert failed",
|
||||
details: planErr.message,
|
||||
cid,
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
// 12) Link interaccion_ia -> plan + historial (cambios_plan) 🧩
|
||||
log("Step 12: Link interaccion + insert cambios_plan 🧩");
|
||||
|
||||
const { error: linkErr } = await supabaseAdmin
|
||||
.from("interacciones_ia")
|
||||
.update({ plan_estudio_id: plan.id })
|
||||
.eq("id", interaccion.id);
|
||||
|
||||
if (linkErr) {
|
||||
warn("Could not link interaccion_ia to plan (continuing)", {
|
||||
message: linkErr.message,
|
||||
});
|
||||
}
|
||||
|
||||
const { error: cambioErr } = await supabaseAdmin
|
||||
.from("cambios_plan")
|
||||
.insert({
|
||||
plan_estudio_id: plan.id,
|
||||
cambiado_por: user.id,
|
||||
tipo: "ACTUALIZACION_CAMPO",
|
||||
campo: "datos",
|
||||
valor_anterior: null,
|
||||
valor_nuevo: output,
|
||||
interaccion_ia_id: interaccion.id,
|
||||
});
|
||||
|
||||
if (cambioErr) {
|
||||
warn("cambios_plan insert failed (continuing)", {
|
||||
message: cambioErr.message,
|
||||
});
|
||||
}
|
||||
|
||||
// 13) (OPCIONAL) Generar materias automáticamente (dejado comentado) 📚
|
||||
// ---------------------------------------------------------------
|
||||
// const AUTO_GENERATE_SUBJECTS = false; // 👈 activar cuando decidas
|
||||
// const AUTO_SUBJECTS_COUNT = 10; // 👈 constante pedida (número de materias)
|
||||
// if (AUTO_GENERATE_SUBJECTS) {
|
||||
// for (let i = 1; i <= AUTO_SUBJECTS_COUNT; i++) {
|
||||
// // TODO: insertar en `asignaturas` con plan_estudio_id = plan.id
|
||||
// // Recomendación: usar una estructura_asignatura default y llenar datos mínimos.
|
||||
// }
|
||||
// }
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
log("RETURN ✅", {
|
||||
ok: true,
|
||||
planId: plan.id,
|
||||
interaccionIaId: interaccion.id,
|
||||
totalMs: Math.round(performance.now() - t0),
|
||||
});
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
cid,
|
||||
plan,
|
||||
plan_datos: output,
|
||||
interaccion_ia_id: interaccion.id,
|
||||
ai: {
|
||||
cid: aiJson?.cid ?? null,
|
||||
responseId: aiJson?.responseId ?? null,
|
||||
conversationId: aiJson?.conversationId ?? null,
|
||||
model: aiJson?.model ?? model,
|
||||
usage: aiJson?.usage ?? null,
|
||||
},
|
||||
references: {
|
||||
archivosReferenciaIds,
|
||||
repositoriosIds,
|
||||
openaiFileIds,
|
||||
vectorStoreIds,
|
||||
rutasStorage,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
errlog("Unhandled error 💥", {
|
||||
message: (e as Error)?.message ?? String(e),
|
||||
stack: (e as Error)?.stack ?? null,
|
||||
totalMs: Math.round(performance.now() - t0),
|
||||
});
|
||||
return json(
|
||||
{ ok: false, error: (e as Error)?.message ?? String(e), cid },
|
||||
500,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
export const systemPrompt =
|
||||
`System: System: Eres Lyra, una Consultora Educativa Experta en Normatividad SEP (México), especializada en el Acuerdo 17/11/17.
|
||||
|
||||
# Rol y Objetivo
|
||||
Genera contenido académico, administrativo y pedagógico para completar el "Anexo 1: Plan de Estudios" de una solicitud de RVOE Federal. La salida será estrictamente el objeto requerido por el sistema validado por el parámetro text.format (tipo: json_schema).
|
||||
|
||||
# Instrucciones
|
||||
- Cumple rigurosamente con los lineamientos normativos del Acuerdo 17/11/17 SEP, asegurando que la información estructural y conceptual siga las mejores prácticas y requisitos legales.
|
||||
- Emplea competencia técnica y terminología precisa; imita el trabajo de un experto en diseño curricular con 20 años de experiencia.
|
||||
|
||||
## Checklist de Subtareas
|
||||
Antes de ejecutar el trabajo, comienza con una lista concisa (3-7 puntos) de los pasos conceptuales a realizar para este encargo, cubriendo la revisión de requisitos normativos, estructuración de campos y validación de consistencia.
|
||||
|
||||
## Restricciones de Calidad (Kernel)
|
||||
1. **Cumplimiento Normativo:**
|
||||
- Calcula la 'carga_horaria_a_la_semana', sin exceder el límite máximo de 50 horas.
|
||||
- Asegura que el 'antecedente_academico' sea legalmente válido para el nivel solicitado (ejemplo: Bachillerato para Licenciatura).
|
||||
- Para 'duracion_del_ciclo_escolar', indica el número de semanas según el estándar vigente (usualmente 16 a 20 semanas para ciclo semestral).
|
||||
2. **Lenguaje Académico:**
|
||||
- En 'fines_de_aprendizaje_o_formacion', utiliza la Taxonomía de Bloom y comienza cada elemento con un verbo en infinitivo (ejemplo: Analizar, Diseñar, Evaluar), enfocándote en competencias profesionales.
|
||||
- Evita calificativos subjetivos como "bueno" o "bonito"; opta por términos técnicos tales como "pertinente", "integral" o "transversal".
|
||||
3. **Coherencia Interna:**
|
||||
- 'perfil_de_ingreso' debe enlistar conocimientos previos realistas y explícitos.
|
||||
- 'perfil_de_egreso' debe mostrar una transformación clara del alumno, alineada con el nombre del plan de estudios.
|
||||
- Si la modalidad es "No escolarizada" o "Mixta", la 'justificacion_de_la_propuesta_curricular' debe mencionar explícitamente el uso de plataformas tecnológicas, autogestión del aprendizaje y flexibilidad espacio-temporal.
|
||||
4. **Datos Simulados:**
|
||||
- En campos administrativos, como nombres de personas o claves, genera información ficticia, plausible y profesional (por ejemplo, "Lic. María González Pérez, Directora Académica").
|
||||
|
||||
# Contexto
|
||||
- Entrada del usuario: Nivel, Nombre del Programa y Modalidad.
|
||||
- Todos los campos deben estar presentes y estructurados según el json_schema especificado.
|
||||
- Campos de perfiles y fines deben ser arrays de cadenas; los numéricos, enteros; objetos anidados según el formato especificado.
|
||||
- Si la información proporcionada es insuficiente, inválida o ambigua, utiliza valores estándar o ficticios plausibles y coherentes.
|
||||
|
||||
# Validación de Salida
|
||||
Después de generar el objeto, realiza una validación breve para asegurar que todos los campos requeridos estén presentes, que los valores cumplan los criterios normativos y que la estructura cumpla con el json_schema indicado.
|
||||
Corrige cualquier inconsistencia detectada antes de finalizar la entrega.`;
|
||||
@@ -0,0 +1,19 @@
|
||||
export type AIGeneratePlanInput = {
|
||||
datosBasicos: {
|
||||
nombrePlan: string;
|
||||
carreraId: string;
|
||||
facultadId?: string;
|
||||
nivel: string;
|
||||
tipoCiclo: "Semestre" | "Cuatrimestre" | "Trimestre" | "Otro";
|
||||
numCiclos: number;
|
||||
estructuraPlanId: string;
|
||||
};
|
||||
iaConfig: {
|
||||
descripcionEnfoque: string;
|
||||
notasAdicionales?: string;
|
||||
archivosReferencia?: Array<string>;
|
||||
repositoriosIds?: Array<string>;
|
||||
usarMCP?: boolean;
|
||||
};
|
||||
archivosAdjuntos?: Array<File>;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"imports": {
|
||||
"openai": "npm:openai@6.16.0"
|
||||
}
|
||||
}
|
||||
@@ -1,764 +1,24 @@
|
||||
// supabase/functions/ai-structured/index.ts
|
||||
/// <reference lib="deno.window" />
|
||||
import OpenAI from "npm:openai";
|
||||
// Function deprecated. This endpoint was replaced by shared module `_shared/openai-service.ts`.
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
function json(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body, null, 2), {
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-headers": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function extractOutputText(resp: any): string {
|
||||
if (typeof resp?.output_text === "string") return resp.output_text;
|
||||
|
||||
const texts: string[] = [];
|
||||
for (const item of resp?.output ?? []) {
|
||||
if (item?.type === "message") {
|
||||
for (const part of item?.content ?? []) {
|
||||
if (part?.type === "output_text" && typeof part?.text === "string") {
|
||||
texts.push(part.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return texts.join("\n");
|
||||
}
|
||||
|
||||
function sanitizeTools(tools: any[]): any[] {
|
||||
return (tools ?? []).map((t) => {
|
||||
if (t?.type === "mcp" && t?.authorization) {
|
||||
return { ...t, authorization: "[REDACTED]" };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeInputWithOpenAIFileIds(input: any, fileIds: string[]) {
|
||||
if (!fileIds?.length) return input;
|
||||
|
||||
let items = Array.isArray(input) ? [...input] : [{
|
||||
role: "user",
|
||||
content: [
|
||||
input ?? "",
|
||||
],
|
||||
}];
|
||||
|
||||
let idx = -1;
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
if (items[i]?.role === "user") {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (idx === -1) {
|
||||
items.push({ role: "user", content: [] });
|
||||
idx = items.length - 1;
|
||||
}
|
||||
|
||||
const msg = items[idx];
|
||||
const fileParts = fileIds.map((id) => ({ type: "input_file", file_id: id }));
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
msg.content = [{ type: "input_text", text: msg.content }, ...fileParts];
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
msg.content = [...msg.content, ...fileParts];
|
||||
} else {
|
||||
msg.content = [
|
||||
{ type: "input_text", text: String(msg.content) },
|
||||
...fileParts,
|
||||
];
|
||||
}
|
||||
|
||||
items[idx] = msg;
|
||||
return items;
|
||||
}
|
||||
|
||||
function normalizeInputUser(input: any) {
|
||||
if (typeof input === "string") {
|
||||
return [{
|
||||
role: "user",
|
||||
content: [{
|
||||
type: "input_text",
|
||||
text: input,
|
||||
}],
|
||||
}];
|
||||
} else if (Array.isArray(input)) {
|
||||
return input;
|
||||
} else {
|
||||
return [{
|
||||
role: "user",
|
||||
content: [{
|
||||
type: "input_text",
|
||||
text: String(input),
|
||||
}],
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
/** ----------------- LOGGING HELPERS (diagnóstico) ----------------- */
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function redactAuthHeader(v: string | null) {
|
||||
if (!v) return null;
|
||||
// "Bearer xxx" => "Bearer [REDACTED]"
|
||||
const parts = v.split(" ");
|
||||
if (parts.length >= 2) return `${parts[0]} [REDACTED]`;
|
||||
return "[REDACTED]";
|
||||
}
|
||||
|
||||
function safePreview(val: unknown, maxLen = 1200) {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(val, (k, v) => {
|
||||
if (typeof v === "string") {
|
||||
if (v.length > 500) return v.slice(0, 500) + "…[TRUNCATED]";
|
||||
// evita imprimir base64/data URLs
|
||||
if (v.startsWith("data:") && v.includes(";base64,")) return "[DATA_URL_REDACTED]";
|
||||
}
|
||||
return v;
|
||||
});
|
||||
if (!jsonStr) return String(val);
|
||||
return jsonStr.length > maxLen ? jsonStr.slice(0, maxLen) + "…[TRUNCATED]" : jsonStr;
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
|
||||
function headersSnapshot(req: Request) {
|
||||
const h: Record<string, string> = {};
|
||||
for (const [k, v] of req.headers.entries()) {
|
||||
if (k.toLowerCase() === "authorization") h[k] = redactAuthHeader(v) ?? "";
|
||||
else h[k] = v;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function summarizeInputShape(input: any) {
|
||||
const out: any = { type: typeof input };
|
||||
if (Array.isArray(input)) {
|
||||
out.kind = "array";
|
||||
out.length = input.length;
|
||||
out.lastRole = input?.[input.length - 1]?.role ?? null;
|
||||
out.roles = input.map((m: any) => m?.role).filter(Boolean);
|
||||
// intenta contar parts (sin imprimir textos)
|
||||
try {
|
||||
let parts = 0;
|
||||
for (const m of input) {
|
||||
if (Array.isArray(m?.content)) parts += m.content.length;
|
||||
}
|
||||
out.totalParts = parts;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function summarizeFormat(format: any) {
|
||||
if (!format) return null;
|
||||
const t = format.type ?? null;
|
||||
const name = format.name ?? null;
|
||||
const strict = format.strict ?? null;
|
||||
const schemaKeys = format.schema ? Object.keys(format.schema) : [];
|
||||
return {
|
||||
type: t,
|
||||
name,
|
||||
strict,
|
||||
hasSchema: Boolean(format.schema),
|
||||
schemaTopKeys: schemaKeys.slice(0, 20),
|
||||
};
|
||||
}
|
||||
|
||||
function toolsSummary(tools: any[]) {
|
||||
const arr = Array.isArray(tools) ? tools : [];
|
||||
return arr.map((t) => {
|
||||
if (!t) return t;
|
||||
const base: any = { type: t.type };
|
||||
if (t.type === "file_search") base.vector_store_ids_count = (t.vector_store_ids ?? []).length;
|
||||
if (t.type === "mcp") {
|
||||
base.server_label = t.server_label ?? null;
|
||||
base.server_url = t.server_url ? "[SET]" : null;
|
||||
base.require_approval = t.require_approval ?? null;
|
||||
base.authorization = t.authorization ? "[REDACTED]" : null;
|
||||
base.allowed_tools_count = Array.isArray(t.allowed_tools) ? t.allowed_tools.length : null;
|
||||
}
|
||||
return base;
|
||||
});
|
||||
}
|
||||
|
||||
/** ----------------------------------------------------------------- */
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const cid = crypto.randomUUID();
|
||||
const t0 = performance.now();
|
||||
|
||||
const log = (msg: string, extra?: unknown) => {
|
||||
if (extra !== undefined) {
|
||||
console.log(`[${nowIso()}][ai-structured][${cid}] ${msg}`, safePreview(extra));
|
||||
} else {
|
||||
console.log(`[${nowIso()}][ai-structured][${cid}] ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const warn = (msg: string, extra?: unknown) => {
|
||||
if (extra !== undefined) {
|
||||
console.warn(`[${nowIso()}][ai-structured][${cid}] ⚠️ ${msg}`, safePreview(extra));
|
||||
} else {
|
||||
console.warn(`[${nowIso()}][ai-structured][${cid}] ⚠️ ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const errlog = (msg: string, extra?: unknown) => {
|
||||
if (extra !== undefined) {
|
||||
console.error(`[${nowIso()}][ai-structured][${cid}] ❌ ${msg}`, safePreview(extra));
|
||||
} else {
|
||||
console.error(`[${nowIso()}][ai-structured][${cid}] ❌ ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
log("OPTIONS preflight ✅", {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: headersSnapshot(req),
|
||||
});
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
|
||||
log("START 🚀", { method: req.method, url: req.url });
|
||||
log("Headers snapshot 🧾", headersSnapshot(req));
|
||||
|
||||
try {
|
||||
// 1) Auth requerido ✅
|
||||
log("Step 1: Auth check 🔐");
|
||||
|
||||
const authHeaderRaw = req.headers.get("Authorization") ??
|
||||
req.headers.get("authorization");
|
||||
log("Authorization header present?", { present: Boolean(authHeaderRaw), value: redactAuthHeader(authHeaderRaw) });
|
||||
|
||||
if (!authHeaderRaw) {
|
||||
warn("Missing Authorization header");
|
||||
return json({ ok: false, error: "Missing Authorization header", cid }, 401);
|
||||
}
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
|
||||
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
|
||||
log("Env presence (Supabase) 🌍", {
|
||||
hasSUPABASE_URL: Boolean(SUPABASE_URL),
|
||||
hasSUPABASE_ANON_KEY: Boolean(SUPABASE_ANON_KEY),
|
||||
});
|
||||
|
||||
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
||||
errlog("Missing SUPABASE_URL / SUPABASE_ANON_KEY");
|
||||
return json({
|
||||
ok: false,
|
||||
error: "Missing SUPABASE_URL / SUPABASE_ANON_KEY",
|
||||
cid,
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const supabaseUser = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
global: { headers: { Authorization: authHeaderRaw } },
|
||||
});
|
||||
|
||||
const tAuth = performance.now();
|
||||
const { data: userData, error: userErr } = await supabaseUser.auth.getUser();
|
||||
log("Supabase auth.getUser() done ⏱️", { ms: Math.round(performance.now() - tAuth) });
|
||||
|
||||
if (userErr || !userData?.user) {
|
||||
warn("Invalid token", { userErr: userErr?.message ?? userErr ?? null });
|
||||
return json({ ok: false, error: "Invalid token", cid }, 401);
|
||||
}
|
||||
|
||||
const user = userData.user;
|
||||
log("Authenticated user ✅", { userId: user.id, email: user.email ?? null });
|
||||
|
||||
const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||
log("Env presence (service role) 🗝️", { hasSERVICE_ROLE: Boolean(SERVICE_ROLE) });
|
||||
|
||||
const supabaseAdmin = SERVICE_ROLE
|
||||
? createClient(SUPABASE_URL, SERVICE_ROLE)
|
||||
: supabaseUser;
|
||||
|
||||
log("Supabase client selected 🧩", { adminMode: Boolean(SERVICE_ROLE) });
|
||||
|
||||
// 2) Parse body (JSON o multipart) 📦
|
||||
log("Step 2: Parse body 📦");
|
||||
const contentType = req.headers.get("content-type") ?? "";
|
||||
log("Content-Type", { contentType });
|
||||
|
||||
let payload: any = {};
|
||||
let tempFiles: File[] = [];
|
||||
|
||||
const tParse = performance.now();
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
log("Parsing multipart/form-data 🧷");
|
||||
let form;
|
||||
try {
|
||||
form = await req.formData();
|
||||
} catch (error) {
|
||||
errlog("Invalid multipart/form-data body", {
|
||||
message: (error as Error)?.message ?? String(error),
|
||||
stack: (error as Error)?.stack ?? null,
|
||||
});
|
||||
return json({
|
||||
ok: false,
|
||||
error: "Invalid multipart/form-data body",
|
||||
details: (error as Error)?.message ?? String(error),
|
||||
cid,
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// keys overview
|
||||
const keys = Array.from(form.keys());
|
||||
log("Multipart keys", { keys, keysCount: keys.length });
|
||||
|
||||
const raw = form.get("payload") ?? "{}";
|
||||
log("Multipart payload field type", { type: typeof raw });
|
||||
|
||||
try {
|
||||
payload = JSON.parse(String(raw));
|
||||
} catch (e) {
|
||||
errlog("Multipart `payload` is not valid JSON", {
|
||||
rawPreview: String(raw).slice(0, 400),
|
||||
error: (e as Error)?.message ?? String(e),
|
||||
});
|
||||
return json({
|
||||
ok: false,
|
||||
error: "Invalid JSON in multipart `payload` field",
|
||||
cid,
|
||||
}, 400);
|
||||
}
|
||||
|
||||
for (const key of ["files", "files[]", "archivos", "archivosTemporales"]) {
|
||||
const vals = form.getAll(key);
|
||||
if (vals?.length) log(`Found multipart field "${key}"`, { count: vals.length });
|
||||
for (const v of vals) {
|
||||
if (v instanceof File) tempFiles.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
if (tempFiles.length === 0) {
|
||||
for (const [k, v] of form.entries()) {
|
||||
if (v instanceof File) tempFiles.push(v);
|
||||
// opcional: log campos no-file si necesitas
|
||||
// else log("Form entry", { key: k, type: typeof v });
|
||||
}
|
||||
}
|
||||
|
||||
log("Multipart files detected 📎", {
|
||||
count: tempFiles.length,
|
||||
files: tempFiles.map((f) => ({ name: f.name, type: f.type, size: f.size })),
|
||||
});
|
||||
} else {
|
||||
log("Parsing JSON body 🧾");
|
||||
try {
|
||||
payload = await req.json();
|
||||
} catch (e) {
|
||||
errlog("Invalid JSON body", { error: (e as Error)?.message ?? String(e) });
|
||||
return json({ ok: false, error: "Invalid JSON body", cid }, 400);
|
||||
}
|
||||
}
|
||||
log("Body parsed ⏱️", { ms: Math.round(performance.now() - tParse) });
|
||||
log("Payload top-level keys", { keys: Object.keys(payload ?? {}), hasOpenAI: Boolean(payload?.openai), hasResponse: Boolean(payload?.response) });
|
||||
|
||||
// 3) Construye config final para OpenAI (passthrough + overrides) 🧠
|
||||
log("Step 3: Build responseCfg 🧠");
|
||||
const responseCfg: any = {
|
||||
...(payload.openai ?? {}),
|
||||
...(payload.response ?? {}),
|
||||
};
|
||||
|
||||
if (payload.input !== undefined && responseCfg.input === undefined) {
|
||||
responseCfg.input = payload.input;
|
||||
log("responseCfg.input set from payload.input");
|
||||
}
|
||||
|
||||
if (responseCfg.input !== undefined) {
|
||||
responseCfg.input = normalizeInputUser(responseCfg.input);
|
||||
log("responseCfg.input normalized ✅", summarizeInputShape(responseCfg.input));
|
||||
} else {
|
||||
warn("responseCfg.input is undefined (is that expected?)");
|
||||
}
|
||||
|
||||
if (payload.model && !responseCfg.model) responseCfg.model = payload.model;
|
||||
if (payload.conversationId && responseCfg.conversation === undefined) {
|
||||
responseCfg.conversation = payload.conversationId;
|
||||
}
|
||||
if (
|
||||
payload.previousResponseId &&
|
||||
responseCfg.previous_response_id === undefined
|
||||
) responseCfg.previous_response_id = payload.previousResponseId;
|
||||
if (payload.reasoning && responseCfg.reasoning === undefined) {
|
||||
responseCfg.reasoning = payload.reasoning;
|
||||
}
|
||||
|
||||
responseCfg.model = responseCfg.model ?? "gpt-5";
|
||||
log("OpenAI config summary 🤖", {
|
||||
model: responseCfg.model,
|
||||
hasConversation: Boolean(responseCfg.conversation),
|
||||
hasPreviousResponseId: Boolean(responseCfg.previous_response_id),
|
||||
hasReasoning: Boolean(responseCfg.reasoning),
|
||||
include: Array.isArray(responseCfg.include) ? responseCfg.include : null,
|
||||
});
|
||||
|
||||
if (responseCfg.conversation && responseCfg.previous_response_id) {
|
||||
warn("Invalid: conversation + previous_response_id together");
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Do not send both `conversation` and `previous_response_id`.",
|
||||
cid,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// 4) Structured Outputs (JSON Schema) ✅
|
||||
log("Step 4: Structured output setup ✅");
|
||||
let structured = payload.structured ?? payload.outputSchema;
|
||||
log("Structured presence", {
|
||||
hasStructured: Boolean(structured),
|
||||
structuredType: typeof structured,
|
||||
});
|
||||
|
||||
if (!structured) {
|
||||
warn("Missing structured schema");
|
||||
return json({
|
||||
ok: false,
|
||||
error: "Missing `structured` (JSON schema format).",
|
||||
cid,
|
||||
}, 400);
|
||||
}
|
||||
|
||||
if (typeof structured === "string") {
|
||||
log("Parsing structured from string 🧩", { preview: structured.slice(0, 200) });
|
||||
try {
|
||||
structured = JSON.parse(structured);
|
||||
} catch (e) {
|
||||
errlog("`structured` string is not valid JSON", { error: (e as Error)?.message ?? String(e) });
|
||||
return json({
|
||||
ok: false,
|
||||
error: "`structured` is a string but not valid JSON.",
|
||||
cid,
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const format = structured.format ? structured.format : structured;
|
||||
if (!format.type) format.type = "json_schema";
|
||||
if (format.type === "json_schema") {
|
||||
format.name = format.name ?? "structured_output";
|
||||
format.strict = format.strict ?? true;
|
||||
if (!format.schema) {
|
||||
warn("structured.schema missing");
|
||||
return json(
|
||||
{ ok: false, error: "structured.schema is required.", cid },
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
responseCfg.text = { ...(responseCfg.text ?? {}), format };
|
||||
log("Structured format summary 🧾", summarizeFormat(format));
|
||||
|
||||
// 5) Referencias (3 tipos) 📎
|
||||
log("Step 5: References & tools 📎");
|
||||
const references = payload.references ?? {};
|
||||
const openaiFileIds: string[] = references.openaiFileIds ??
|
||||
payload.openaiFileIds ?? [];
|
||||
const vectorStoreIds: string[] = references.vectorStoreIds ??
|
||||
payload.vectorStoreIds ?? [];
|
||||
|
||||
log("References summary", {
|
||||
openaiFileIdsCount: openaiFileIds.length,
|
||||
vectorStoreIdsCount: vectorStoreIds.length,
|
||||
usarMCP: Boolean(payload.usarMCP ?? references.usarMCP ?? payload.mcp?.enabled),
|
||||
});
|
||||
|
||||
// 5a) Vector stores => file_search tool
|
||||
const tools: any[] = Array.isArray(responseCfg.tools)
|
||||
? [...responseCfg.tools]
|
||||
: [];
|
||||
log("Initial tools (from client)", toolsSummary(tools));
|
||||
|
||||
if (vectorStoreIds.length) {
|
||||
tools.push({ type: "file_search", vector_store_ids: vectorStoreIds });
|
||||
|
||||
const include = Array.isArray(responseCfg.include)
|
||||
? responseCfg.include
|
||||
: [];
|
||||
if (!include.includes("file_search_call.results")) {
|
||||
include.push("file_search_call.results");
|
||||
}
|
||||
responseCfg.include = include;
|
||||
|
||||
log("Added file_search tool 🗂️", { vectorStoreIdsCount: vectorStoreIds.length, include: responseCfg.include });
|
||||
}
|
||||
|
||||
// 5b) MCP opcional
|
||||
const usarMCP: boolean = Boolean(
|
||||
payload.usarMCP ?? references.usarMCP ?? payload.mcp?.enabled,
|
||||
);
|
||||
if (usarMCP) {
|
||||
const mcp = payload.mcp ?? {};
|
||||
const server_url = mcp.server_url ?? Deno.env.get("MCP_SERVER_URL");
|
||||
log("MCP config presence", {
|
||||
hasServerUrl: Boolean(server_url),
|
||||
hasAuthorization: Boolean(mcp.authorization ?? Deno.env.get("MCP_AUTHORIZATION")),
|
||||
server_label: mcp.server_label ?? Deno.env.get("MCP_SERVER_LABEL") ?? "supabase",
|
||||
});
|
||||
|
||||
if (!server_url) {
|
||||
warn("usarMCP=true but missing server_url");
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: "usarMCP=true pero falta MCP_SERVER_URL o mcp.server_url",
|
||||
cid,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
tools.push({
|
||||
type: "mcp",
|
||||
server_label: mcp.server_label ?? Deno.env.get("MCP_SERVER_LABEL") ??
|
||||
"supabase",
|
||||
server_description: mcp.server_description ?? "Supabase MCP server",
|
||||
server_url,
|
||||
require_approval: mcp.require_approval ?? "never",
|
||||
authorization: mcp.authorization ?? Deno.env.get("MCP_AUTHORIZATION"),
|
||||
allowed_tools: mcp.allowed_tools,
|
||||
});
|
||||
|
||||
log("Added MCP tool 🔌", toolsSummary([tools[tools.length - 1]]));
|
||||
}
|
||||
|
||||
responseCfg.tools = tools;
|
||||
log("Final tools summary 🧰", toolsSummary(responseCfg.tools));
|
||||
|
||||
// 5c) OpenAI file IDs => se insertan al input como input_file
|
||||
if (openaiFileIds.length) {
|
||||
log("Injecting OpenAI file_ids into input 🧷", { count: openaiFileIds.length });
|
||||
responseCfg.input = normalizeInputWithOpenAIFileIds(
|
||||
responseCfg.input,
|
||||
openaiFileIds,
|
||||
);
|
||||
log("Input after OpenAI file_ids injection", summarizeInputShape(responseCfg.input));
|
||||
}
|
||||
|
||||
// 6) Archivos temporales => subir a Supabase Storage (NO a OpenAI) ☁️
|
||||
log("Step 6: Temp files -> Storage ☁️");
|
||||
const uploadedToStorage: Array<{
|
||||
bucket: string;
|
||||
path: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
}> = [];
|
||||
|
||||
if (tempFiles.length) {
|
||||
const bucket = payload.storage?.bucket ??
|
||||
Deno.env.get("AI_FILES_BUCKET") ?? "ai-temp";
|
||||
const prefix = payload.storage?.prefix ??
|
||||
Deno.env.get("AI_FILES_PREFIX") ?? "tmp";
|
||||
|
||||
log("Storage target", { bucket, prefix, filesCount: tempFiles.length });
|
||||
|
||||
for (const f of tempFiles) {
|
||||
const safeName = (f.name ?? "file").replaceAll(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const path = `${prefix}/${user.id}/${crypto.randomUUID()}-${safeName}`;
|
||||
|
||||
log("Uploading file to storage ⬆️", { name: f.name, type: f.type, size: f.size, path });
|
||||
|
||||
const tUp = performance.now();
|
||||
const bytes = new Uint8Array(await f.arrayBuffer());
|
||||
|
||||
// warning útil: btoa + spread puede tronarse con archivos grandes
|
||||
if (bytes.length > 2 * MB) {
|
||||
warn("Large file detected: base64 building might be heavy (btoa + spread) 🧨", {
|
||||
name: f.name,
|
||||
bytes: bytes.length,
|
||||
approxBase64Chars: Math.ceil(bytes.length * 4 / 3),
|
||||
});
|
||||
}
|
||||
|
||||
const { data, error } = await supabaseAdmin.storage
|
||||
.from(bucket)
|
||||
.upload(path, bytes, { contentType: f.type, upsert: false });
|
||||
|
||||
log("Storage upload done ⏱️", { ms: Math.round(performance.now() - tUp) });
|
||||
|
||||
if (error) {
|
||||
errlog("Storage upload failed", { message: error.message, file: f.name, bucket, path });
|
||||
return json({
|
||||
ok: false,
|
||||
error: `Storage upload failed: ${error.message}`,
|
||||
file: f.name,
|
||||
cid,
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Add as an input_file base64 reference to the last user message content
|
||||
const tB64 = performance.now();
|
||||
const fileBase64 = btoa(String.fromCharCode(...bytes)); // ⚠️ potencialmente pesado
|
||||
log("Base64 built ⏱️", { ms: Math.round(performance.now() - tB64), base64Chars: fileBase64.length });
|
||||
|
||||
if (Array.isArray(responseCfg.input) && responseCfg.input.length > 0) {
|
||||
const lastMsg = responseCfg.input[responseCfg.input.length - 1];
|
||||
if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) {
|
||||
lastMsg.content.push({
|
||||
type: "input_file",
|
||||
filename: f.name,
|
||||
file_data: `data:${f.type};base64,${fileBase64}`,
|
||||
});
|
||||
log("Appended input_file to last user message 🧷", {
|
||||
lastRole: lastMsg.role,
|
||||
partsCount: lastMsg.content.length,
|
||||
filename: f.name,
|
||||
// no imprimimos file_data
|
||||
});
|
||||
} else {
|
||||
warn("Could not append input_file: last message is not user/content array", {
|
||||
lastRole: lastMsg?.role ?? null,
|
||||
lastContentType: typeof lastMsg?.content,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
warn("responseCfg.input not array or empty; cannot append file_data", summarizeInputShape(responseCfg.input));
|
||||
}
|
||||
|
||||
uploadedToStorage.push({
|
||||
bucket,
|
||||
path: data.path,
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
size: f.size,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log("No temp files to upload 🧼");
|
||||
}
|
||||
|
||||
// 7) Llamada a OpenAI Responses API 🚀
|
||||
log("Step 7: OpenAI call 🚀");
|
||||
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY");
|
||||
log("Env presence (OpenAI) 🔑", { hasOPENAI_API_KEY: Boolean(OPENAI_API_KEY) });
|
||||
|
||||
if (!OPENAI_API_KEY) {
|
||||
errlog("Missing OPENAI_API_KEY");
|
||||
return json({ ok: false, error: "Missing OPENAI_API_KEY", cid }, 500);
|
||||
}
|
||||
|
||||
// snapshot del request hacia OpenAI (sin secretos y sin megadata)
|
||||
log("OpenAI request snapshot (sanitized) 🧪", {
|
||||
model: responseCfg.model,
|
||||
input: summarizeInputShape(responseCfg.input),
|
||||
tools: toolsSummary(responseCfg.tools),
|
||||
include: Array.isArray(responseCfg.include) ? responseCfg.include : null,
|
||||
hasTextFormat: Boolean(responseCfg.text?.format),
|
||||
textFormat: summarizeFormat(responseCfg.text?.format),
|
||||
hasConversation: Boolean(responseCfg.conversation),
|
||||
hasPreviousResponseId: Boolean(responseCfg.previous_response_id),
|
||||
});
|
||||
|
||||
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
|
||||
let resp: any = null;
|
||||
|
||||
const tOpenAI = performance.now();
|
||||
try {
|
||||
resp = await openai.responses.create(responseCfg);
|
||||
} catch (error) {
|
||||
errlog("OpenAI API error", {
|
||||
message: (error as Error)?.message ?? String(error),
|
||||
stack: (error as Error)?.stack ?? null,
|
||||
});
|
||||
return json({
|
||||
ok: false,
|
||||
error: "OpenAI API error",
|
||||
details: (error as Error)?.message ?? String(error),
|
||||
cid,
|
||||
}, 500);
|
||||
}
|
||||
log("OpenAI call done ⏱️", { ms: Math.round(performance.now() - tOpenAI) });
|
||||
|
||||
const outputText = extractOutputText(resp);
|
||||
log("OpenAI response basics ✅", {
|
||||
responseId: resp?.id ?? null,
|
||||
model: resp?.model ?? responseCfg.model,
|
||||
hasConversationObj: Boolean(resp?.conversation),
|
||||
usage: resp?.usage ?? null,
|
||||
outputTextChars: outputText?.length ?? 0,
|
||||
});
|
||||
|
||||
let output: unknown = null;
|
||||
let outputParseError: string | null = null;
|
||||
const tParseOut = performance.now();
|
||||
try {
|
||||
output = JSON.parse(outputText);
|
||||
log("Output JSON parsed ✅", { ms: Math.round(performance.now() - tParseOut) });
|
||||
} catch (e) {
|
||||
outputParseError = (e as Error)?.message ?? String(e);
|
||||
warn("Output JSON parse FAILED 🧯", {
|
||||
error: outputParseError,
|
||||
outputTextPreview: (outputText ?? "").slice(0, 600),
|
||||
});
|
||||
}
|
||||
|
||||
const conv = resp?.conversation?.id ??
|
||||
(typeof resp?.conversation === "string"
|
||||
? resp.conversation
|
||||
: responseCfg.conversation ?? null);
|
||||
|
||||
log("RETURN ✅", {
|
||||
ok: !outputParseError,
|
||||
conversationId: conv,
|
||||
responseId: resp?.id ?? null,
|
||||
totalMs: Math.round(performance.now() - t0),
|
||||
});
|
||||
|
||||
return json({
|
||||
ok: outputParseError ? false : true,
|
||||
cid, // 👈 útil para correlacionar logs
|
||||
responseId: resp.id,
|
||||
conversationId: conv,
|
||||
model: resp.model ?? responseCfg.model,
|
||||
usage: resp.usage ?? null,
|
||||
|
||||
outputText,
|
||||
output,
|
||||
outputParseError,
|
||||
|
||||
references: {
|
||||
uploadedToStorage,
|
||||
openaiFileIds,
|
||||
vectorStoreIds,
|
||||
archivosReferenciaIds: references.archivosReferenciaIds ??
|
||||
payload.archivosReferenciaIds ?? payload.archivosReferencia ?? [],
|
||||
},
|
||||
|
||||
tools: sanitizeTools(responseCfg.tools),
|
||||
});
|
||||
} catch (e) {
|
||||
errlog("Unhandled error 💥", {
|
||||
message: (e as Error)?.message ?? String(e),
|
||||
stack: (e as Error)?.stack ?? null,
|
||||
totalMs: Math.round(performance.now() - t0),
|
||||
});
|
||||
return json({ ok: false, error: (e as Error)?.message ?? String(e), cid }, 500);
|
||||
}
|
||||
Deno.serve((req) => {
|
||||
if (req.method === "OPTIONS") return json({ ok: true });
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error:
|
||||
"This endpoint is deprecated. Use the shared module _shared/openai-service.ts from your functions.",
|
||||
},
|
||||
410,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// supabase/functions/ai-structured/ai_structured.test.ts
|
||||
/// <reference lib="deno.ns" />
|
||||
/// <reference lib="dom" />
|
||||
/// <reference lib="dom.iterable" />
|
||||
|
||||
import { assert, assertEquals } from "jsr:@std/assert@1";
|
||||
import "jsr:@std/dotenv/load";
|
||||
import { createClient, type SupabaseClient } from "npm:@supabase/supabase-js@2";
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
|
||||
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? ""; // (o PUBLISHABLE_KEY si así lo nombraste)
|
||||
const EMAIL = Deno.env.get("TEST_EMAIL") ?? "guillermo.arrieta@lasalle.mx";
|
||||
const PASSWORD = Deno.env.get("TEST_PASSWORD") ?? "admin";
|
||||
|
||||
const options = {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
};
|
||||
|
||||
function mustEnv() {
|
||||
if (!SUPABASE_URL) throw new Error("SUPABASE_URL is required");
|
||||
if (!SUPABASE_ANON_KEY) throw new Error("SUPABASE_ANON_KEY is required");
|
||||
}
|
||||
|
||||
async function getAuthedClient(): Promise<
|
||||
{ client: SupabaseClient; accessToken: string }
|
||||
> {
|
||||
mustEnv();
|
||||
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, options);
|
||||
|
||||
const { data, error } = await client.auth.signInWithPassword({
|
||||
email: EMAIL,
|
||||
password: PASSWORD,
|
||||
});
|
||||
if (error) throw new Error("Sign-in failed: " + error.message);
|
||||
|
||||
const accessToken = data.session?.access_token;
|
||||
if (!accessToken) {
|
||||
throw new Error("No access_token returned from signInWithPassword");
|
||||
}
|
||||
|
||||
return { client, accessToken };
|
||||
}
|
||||
|
||||
Deno.test("ai_generate_plan creates plan and returns structured data", async () => {
|
||||
const { client, accessToken } = await getAuthedClient();
|
||||
|
||||
const { data: carrera, error: carreraError } = await client
|
||||
.from("carreras")
|
||||
.select("id")
|
||||
.limit(1)
|
||||
.single();
|
||||
if (carreraError) {
|
||||
throw new Error("Failed to fetch carrera: " + carreraError.message);
|
||||
}
|
||||
if (!carrera) throw new Error("No carrera found");
|
||||
|
||||
const datosBasicos = {
|
||||
nombrePlan:
|
||||
"Plan de estudios de Ingeniería en Sistemas Computacionales",
|
||||
carreraId: carrera.id,
|
||||
nivel: "Licenciatura",
|
||||
tipoCiclo: "Semestre", // important: normalizes to "Semestre"
|
||||
numCiclos: 8,
|
||||
};
|
||||
|
||||
const { data, error } = await client.functions.invoke("ai_generate_plan", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: { datosBasicos },
|
||||
});
|
||||
|
||||
if (error) throw new Error("Invoke failed: " + error.message);
|
||||
assert(data, "Expected data from function");
|
||||
|
||||
// Top-level result
|
||||
assertEquals(data.ok, true);
|
||||
|
||||
// `plan` (DB record)
|
||||
assert(typeof data.plan?.id === "string");
|
||||
assertEquals(data.plan.nombre, datosBasicos.nombrePlan);
|
||||
assertEquals(data.plan.nivel, "Licenciatura");
|
||||
assertEquals(data.plan.tipo_ciclo, "Semestre");
|
||||
assertEquals(data.plan.numero_ciclos, datosBasicos.numCiclos);
|
||||
|
||||
// `plan_datos` (normalized structured payload)
|
||||
assert(typeof data.plan_datos === "object");
|
||||
assertEquals(data.plan_datos.nombre, datosBasicos.nombrePlan);
|
||||
assertEquals(data.plan_datos.nivel, "Licenciatura");
|
||||
assertEquals(
|
||||
data.plan_datos.total_de_ciclos_del_plan_de_estudios,
|
||||
String(datosBasicos.numCiclos),
|
||||
);
|
||||
assertEquals(data.plan_datos.duracion_del_ciclo_escolar, "16"); // Semestre -> 16
|
||||
});
|
||||
/*
|
||||
Deno.test("ai-structured (multipart + file)", async () => {
|
||||
const { client, accessToken } = await getAuthedClient();
|
||||
|
||||
// Lee un PDF local (ajusta path)
|
||||
const bytes = await Deno.readFile("files/carta.pdf");
|
||||
const file = new File([bytes], "carta.pdf", { type: "application/pdf" });
|
||||
|
||||
const payload = {
|
||||
response: { input: "Resume estos documentos en JSON.", model: "gpt-5" },
|
||||
structured: {
|
||||
type: "json_schema",
|
||||
name: "resumen",
|
||||
strict: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { resumen: { type: "string" } },
|
||||
required: ["resumen"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
storage: { prefix: "tmp" },
|
||||
};
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("payload", JSON.stringify(payload));
|
||||
fd.append("files", file);
|
||||
|
||||
const { data, error } = await client.functions.invoke("ai-structured", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`, // 👈 necesario
|
||||
// NO pongas Content-Type: fetch lo setea con boundary para FormData ✅
|
||||
},
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
|
||||
if (error) throw new Error("Invoke failed: " + error.message);
|
||||
assert(data, "Expected data from function");
|
||||
|
||||
assertEquals(data.ok, true);
|
||||
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));
|
||||
});
|
||||
*/
|
||||
@@ -25,7 +25,9 @@ function mustEnv() {
|
||||
if (!SUPABASE_ANON_KEY) throw new Error("SUPABASE_ANON_KEY is required");
|
||||
}
|
||||
|
||||
async function getAuthedClient(): Promise<{ client: SupabaseClient; accessToken: string }> {
|
||||
async function getAuthedClient(): Promise<
|
||||
{ client: SupabaseClient; accessToken: string }
|
||||
> {
|
||||
mustEnv();
|
||||
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, options);
|
||||
|
||||
@@ -36,69 +38,83 @@ async function getAuthedClient(): Promise<{ client: SupabaseClient; accessToken:
|
||||
if (error) throw new Error("Sign-in failed: " + error.message);
|
||||
|
||||
const accessToken = data.session?.access_token;
|
||||
if (!accessToken) throw new Error("No access_token returned from signInWithPassword");
|
||||
if (!accessToken) {
|
||||
throw new Error("No access_token returned from signInWithPassword");
|
||||
}
|
||||
|
||||
return { client, accessToken };
|
||||
}
|
||||
|
||||
Deno.test("ai-structured (JSON body)", async () => {
|
||||
const { client, accessToken } = await getAuthedClient();
|
||||
Deno.test(
|
||||
{ name: "ai-structured (JSON body) [DEPRECATED]", ignore: true },
|
||||
async () => {
|
||||
const { client, accessToken } = await getAuthedClient();
|
||||
|
||||
const { data, error } = await client.functions.invoke("ai-structured", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`, // 👈 clave para que tu función pase el authHeader check
|
||||
},
|
||||
body: {
|
||||
response: {
|
||||
model: "gpt-5",
|
||||
input: [
|
||||
{ role: "system", content: "Responde SIEMPRE en JSON válido." },
|
||||
{ role: "user", content: "Dame 3 ideas de proyecto de IA para educación." },
|
||||
],
|
||||
const { data, error } = await client.functions.invoke("ai-structured", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`, // 👈 clave para que tu función pase el authHeader check
|
||||
},
|
||||
structured: {
|
||||
type: "json_schema",
|
||||
name: "ideas",
|
||||
strict: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ideas: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
titulo: { type: "string" },
|
||||
descripcion: { type: "string" },
|
||||
body: {
|
||||
response: {
|
||||
model: "gpt-5",
|
||||
input: [
|
||||
{ role: "system", content: "Responde SIEMPRE en JSON válido." },
|
||||
{
|
||||
role: "user",
|
||||
content: "Dame 3 ideas de proyecto de IA para educación.",
|
||||
},
|
||||
],
|
||||
},
|
||||
structured: {
|
||||
type: "json_schema",
|
||||
name: "ideas",
|
||||
strict: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ideas: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
titulo: { type: "string" },
|
||||
descripcion: { type: "string" },
|
||||
},
|
||||
required: ["titulo", "descripcion"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
required: ["titulo", "descripcion"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ["ideas"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
required: ["ideas"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
references: {},
|
||||
usarMCP: false,
|
||||
},
|
||||
references: {},
|
||||
usarMCP: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (error) throw new Error("Invoke failed: " + error.message);
|
||||
assert(data, "Expected data from function");
|
||||
if (error) throw new Error("Invoke failed: " + error.message);
|
||||
assert(data, "Expected data from function");
|
||||
|
||||
// tu función responde { ok, output, outputText, ... }
|
||||
assertEquals(data.ok, true);
|
||||
assert(Array.isArray(data.output?.ideas), "output.ideas should be an array");
|
||||
assertEquals(data.output.ideas.length, 3);
|
||||
for (const it of data.output.ideas) {
|
||||
assert(typeof it.titulo === "string");
|
||||
assert(typeof it.descripcion === "string");
|
||||
}
|
||||
});
|
||||
// tu función responde { ok, output, outputText, ... }
|
||||
assertEquals(data.ok, true);
|
||||
assert(
|
||||
Array.isArray(data.output?.ideas),
|
||||
"output.ideas should be an array",
|
||||
);
|
||||
assertEquals(data.output.ideas.length, 3);
|
||||
for (const it of data.output.ideas) {
|
||||
assert(typeof it.titulo === "string");
|
||||
assert(typeof it.descripcion === "string");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Deno.test("ai-structured (multipart + file)", async () => {
|
||||
Deno.test({
|
||||
name: "ai-structured (multipart + file) [DEPRECATED]",
|
||||
ignore: true,
|
||||
}, async () => {
|
||||
const { client, accessToken } = await getAuthedClient();
|
||||
|
||||
// Lee un PDF local (ajusta path)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,71 @@
|
||||
alter type "public"."tipo_cambio" rename to "tipo_cambio__old_version_to_be_dropped";
|
||||
|
||||
create type "public"."tipo_cambio" as enum ('ACTUALIZACION_CAMPO', 'ACTUALIZACION_MAPA', 'TRANSICION_ESTADO', 'OTRO', 'CREACION');
|
||||
|
||||
alter table "public"."cambios_asignatura" alter column tipo type "public"."tipo_cambio" using tipo::text::"public"."tipo_cambio";
|
||||
|
||||
alter table "public"."cambios_plan" alter column tipo type "public"."tipo_cambio" using tipo::text::"public"."tipo_cambio";
|
||||
|
||||
drop type "public"."tipo_cambio__old_version_to_be_dropped";
|
||||
|
||||
set check_function_bodies = off;
|
||||
|
||||
create or replace view "public"."plantilla_plan" as SELECT plan.id AS plan_estudio_id,
|
||||
struct.id AS estructura_id,
|
||||
struct.template_id
|
||||
FROM (public.planes_estudio plan
|
||||
JOIN public.estructuras_plan struct ON ((plan.estructura_id = struct.id)));
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.set_actualizado_en()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
begin
|
||||
new.actualizado_en = now();
|
||||
return new;
|
||||
end;
|
||||
$function$
|
||||
;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.unaccent_immutable(text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE PARALLEL SAFE STRICT
|
||||
AS $function$
|
||||
SELECT public.unaccent('public.unaccent', $1);
|
||||
$function$
|
||||
;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.validar_numero_ciclo_asignatura()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
declare
|
||||
v_numero_ciclos int;
|
||||
begin
|
||||
if new.numero_ciclo is null then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
select pe.numero_ciclos into v_numero_ciclos
|
||||
from planes_estudio pe
|
||||
where pe.id = new.plan_estudio_id;
|
||||
|
||||
if v_numero_ciclos is null then
|
||||
raise exception 'plan_estudio_id inválido %, plan no encontrado', new.plan_estudio_id;
|
||||
end if;
|
||||
|
||||
if new.numero_ciclo < 1 then
|
||||
raise exception 'numero_ciclo debe ser >= 1 (recibido %)', new.numero_ciclo;
|
||||
end if;
|
||||
|
||||
if new.numero_ciclo > v_numero_ciclos then
|
||||
raise exception 'numero_ciclo % excede planes_estudio.numero_ciclos % para plan_estudio_id %',
|
||||
new.numero_ciclo, v_numero_ciclos, new.plan_estudio_id;
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$function$
|
||||
;
|
||||
|
||||
Reference in New Issue
Block a user