wip
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
{
|
{
|
||||||
"imports": {}
|
"imports": {
|
||||||
|
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,35 +5,52 @@
|
|||||||
// Setup type definitions for built-in Supabase Runtime APIs
|
// Setup type definitions for built-in Supabase Runtime APIs
|
||||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||||
import { corsHeaders } from "../_shared/cors.ts";
|
import { corsHeaders } from "../_shared/cors.ts";
|
||||||
|
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||||
console.log("Hello from Functions!");
|
import type { AIGeneratePlanInput } from "./types.ts";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
Deno.serve(async (req) => {
|
Deno.serve(async (req) => {
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
return new Response(null, { status: 204, headers: corsHeaders });
|
return new Response(null, { status: 204, headers: corsHeaders });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const functionName = url.pathname.split("/").pop();
|
||||||
try {
|
try {
|
||||||
const contentType = (req.headers.get("content-type") || "").toLowerCase();
|
const method = req.method;
|
||||||
|
if (method !== "POST") {
|
||||||
if (contentType.startsWith("multipart/form-data")) {
|
console.error(
|
||||||
const formData = await req.formData();
|
`[${Date.now()}][${functionName}]: Invalid method: ${method}`,
|
||||||
console.log("Received multipart/form-data:", formData);
|
);
|
||||||
// 1. Usa .getAll() para sacar TODOS los valores de esa llave
|
|
||||||
const archivos = formData.getAll("archivosAdjuntos");
|
|
||||||
|
|
||||||
// 2. Imprime la longitud y el arreglo explícito
|
|
||||||
console.log(`Total archivos recibidos: ${archivos.length}`);
|
|
||||||
console.log("Lista de archivos:", archivos);
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ message: "Multipart/form-data received" }),
|
JSON.stringify({ error: "Method not allowed" }),
|
||||||
{
|
{
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
status: 200,
|
status: 405,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
}
|
||||||
console.error("Unsupported content type:", contentType);
|
|
||||||
|
const authHeaderRaw = req.headers.get("Authorization") ??
|
||||||
|
req.headers.get("authorization");
|
||||||
|
if (!authHeaderRaw) {
|
||||||
|
console.error(
|
||||||
|
`[${Date.now()}][${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(
|
||||||
|
`[${Date.now()}][${functionName}]: Unsupported content type: ${contentType}`,
|
||||||
|
);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "Unsupported content type" }),
|
JSON.stringify({ error: "Unsupported content type" }),
|
||||||
{
|
{
|
||||||
@@ -42,10 +59,93 @@ Deno.serve(async (req) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||||
|
global: {
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeaderRaw,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: iniciar sesion con el usuario
|
||||||
|
|
||||||
|
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(
|
||||||
|
SUPABASE_URL,
|
||||||
|
SERVICE_ROLE_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
const formData = await req.formData();
|
||||||
|
const validation = parseAndValidate(formData);
|
||||||
|
if (!validation.success) {
|
||||||
|
console.error(
|
||||||
|
`[${Date.now()}][${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 respuestaSubida: string[] = subirAStorage(
|
||||||
|
supabaseService,
|
||||||
|
payload.archivosAdjuntos,
|
||||||
|
"ai-storage",
|
||||||
|
"tmp",
|
||||||
|
);
|
||||||
|
|
||||||
|
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 { 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ message: "Multipart/form-data received" }),
|
||||||
|
{
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
status: 200,
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log full error server-side for diagnostics
|
// Log full error server-side for diagnostics
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
console.error("Request handler error:", error.message);
|
console.error(
|
||||||
|
`[${Date.now()}][${functionName}]: Request handler error:`,
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
return new Response(JSON.stringify({ error: message }), {
|
return new Response(JSON.stringify({ error: message }), {
|
||||||
@@ -54,3 +154,136 @@ Deno.serve(async (req) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseAndValidate(
|
||||||
|
formData: FormData,
|
||||||
|
): { success: true; data: AIGeneratePlanInput } | {
|
||||||
|
success: false;
|
||||||
|
errors: string[];
|
||||||
|
} {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// --- A. Extracción de Strings ---
|
||||||
|
const rawBasicos = formData.get("datosBasicos");
|
||||||
|
const rawIA = formData.get("iaConfig");
|
||||||
|
|
||||||
|
if (!rawBasicos) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: ["Falta el campo 'datosBasicos' en el FormData"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!rawIA) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: ["Falta el campo 'iaConfig' en el FormData"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B. Parsing de JSON (Usamos 'unknown' en lugar de 'any') ---
|
||||||
|
let jsonBasicos: unknown;
|
||||||
|
let jsonIA: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
jsonBasicos = JSON.parse(rawBasicos as string);
|
||||||
|
} catch (_error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: ["El JSON de 'datosBasicos' tiene un formato inválido"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
jsonIA = JSON.parse(rawIA as string);
|
||||||
|
} catch (_error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: ["El JSON de 'iaConfig' tiene un formato inválido"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C. Validación con Zod ---
|
||||||
|
const parsedBasicos = DatosBasicosSchema.safeParse(jsonBasicos);
|
||||||
|
const parsedIA = IAConfigSchema.safeParse(jsonIA);
|
||||||
|
|
||||||
|
// Recolectamos errores de Zod si existen
|
||||||
|
if (!parsedBasicos.success) {
|
||||||
|
parsedBasicos.error.errors.forEach((issue) => {
|
||||||
|
errors.push(
|
||||||
|
`Datos Básicos (${issue.path.join(". ")}): ${issue.message}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsedIA.success) {
|
||||||
|
parsedIA.error.errors.forEach((issue) => {
|
||||||
|
errors.push(`IA Config (${issue.path.join(". ")}): ${issue.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- D. Validación Manual de Archivos ---
|
||||||
|
const filesRaw = formData.getAll("archivosAdjuntos");
|
||||||
|
const filesClean: File[] = [];
|
||||||
|
|
||||||
|
filesRaw.forEach((entry, index) => {
|
||||||
|
if (entry instanceof File) {
|
||||||
|
filesClean.push(entry);
|
||||||
|
} else {
|
||||||
|
errors.push(
|
||||||
|
`El adjunto en la posición ${index} no es un archivo válido.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- E. Retorno de Errores ---
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error("❌ Errores de validación:", errors);
|
||||||
|
return { success: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- F. Retorno de Éxito (Narrowing de Tipos) ---
|
||||||
|
|
||||||
|
// TypeScript necesita esta verificación explícita.
|
||||||
|
// Aunque lógicamente sabemos que si errors.length === 0, entonces success es true,
|
||||||
|
// TS no conecta el array de errores con el resultado de Zod automáticamente.
|
||||||
|
if (!parsedBasicos.success || !parsedIA.success) {
|
||||||
|
// Este caso teóricamente es inalcanzable si la lógica de arriba es correcta,
|
||||||
|
// pero satisface al compilador para acceder a .data abajo.
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: ["Error interno de estado inconsistente."],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ahora sí, TS sabe que parsedBasicos.data existe y es del tipo correcto
|
||||||
|
const finalInput: AIGeneratePlanInput = {
|
||||||
|
datosBasicos: parsedBasicos.data,
|
||||||
|
iaConfig: parsedIA.data,
|
||||||
|
archivosAdjuntos: filesClean,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { success: true, data: finalInput };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subirAStorage(supabase: supa) {
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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,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));
|
||||||
|
});
|
||||||
|
*/
|
||||||
Reference in New Issue
Block a user