Files
genesis-2/supabase/functions/ai-generate-plan/index.ts
T

388 lines
12 KiB
TypeScript

// 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 { AIGeneratePlanInput } from "./types.ts";
import { z } from "zod";
import { systemPrompt } from "./prompts.ts";
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
const url = new URL(req.url);
const functionName = url.pathname.split("/").pop();
try {
const method = req.method;
if (method !== "POST") {
console.error(
`[${Date.now()}][${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(
`[${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(
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");
}
const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: {
headers: {
Authorization: authHeaderRaw,
},
},
});
// TODO: iniciar sesion con el usuario e implementar RLS
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 { 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"}`;
const aiStructuredPayload = {
response: {
input: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
},
estructuraPlan,
references: {
openaiFileIds: payload.iaConfig.archivosReferencia,
vectorStoreIds: payload.iaConfig.repositoriosIds,
},
usarMCP: Boolean(payload.iaConfig?.usarMCP),
};
const aiStructuredFormData = new FormData();
aiStructuredFormData.append("payload", JSON.stringify(aiStructuredPayload));
for (const file of payload.archivosAdjuntos ?? []) {
aiStructuredFormData.append("archivos", file);
}
const { data: aiJson, error } = await supabaseService.functions.invoke(
"ai-structured",
{
method: "POST",
body: aiStructuredFormData,
},
);
if (error) {
throw new Error(
"ai-structured invocation failed: " + error.message,
);
}
if (!aiJson || !aiJson.ok) {
throw new Error(
"ai-structured returned an error or no data",
);
}
// 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 { data: plan, error: planError } = await supabaseService
.from("planes_estudio")
.insert({
carrera_id: carrera?.id,
estructura_id: estructuraPlan?.id,
nombre: payload.datosBasicos.nombrePlan,
nivel: payload.datosBasicos.nivel,
tipo_ciclo: payload.datosBasicos.tipoCiclo,
numero_ciclos: payload.datosBasicos.numCiclos,
datos: aiJson.output,
estado_actual_id: estado?.id,
activo: true,
tipo_origen: "IA",
meta_origen: {
generado_por: "ai_generate_plan",
ai_structured: {
cid: aiJson?.cid ?? null,
responseId: aiJson?.responseId ?? null,
conversationId: aiJson?.conversationId ?? null,
model: aiJson?.model,
usage: aiJson?.usage ?? null,
},
referencias: {
archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null,
repositoriosIds: payload.iaConfig?.repositoriosIds ?? null,
// openaiFileIds,
// vectorStoreIds,
},
iaConfig: {
descripcionEnfoque: payload.iaConfig?.descripcionEnfoque ?? null,
notasAdicionales: payload.iaConfig?.notasAdicionales ?? null,
usarMCP: Boolean(payload.iaConfig?.usarMCP),
},
},
// creado_por: user.id,
// actualizado_por: user.id,
})
.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();
if (planError) {
throw new Error("Error inserting plan: " + planError.message);
}
// TODO: update a interaccion_ia y e insert a cambios_plancon id de plan generado
const jsonResponse = {
ok: true,
plan,
};
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(
`[${Date.now()}][${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,
});
}
});
// --- 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 };
}