407 lines
13 KiB
TypeScript
407 lines
13 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(
|
|
`[${
|
|
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");
|
|
}
|
|
|
|
const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
global: {
|
|
headers: {
|
|
Authorization: authHeaderRaw,
|
|
},
|
|
},
|
|
});
|
|
|
|
// TODO: quitar hardcode de usuario
|
|
const { data: user, error: userError } = await supabaseAnon.auth
|
|
.signInWithPassword({
|
|
email: "guillermo.arrieta@lasalle.mx",
|
|
password: "admin",
|
|
});
|
|
if (userError) {
|
|
throw new Error("Error authenticating user: " + userError.message);
|
|
}
|
|
|
|
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(
|
|
`[${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"}`;
|
|
const aiStructuredPayload = {
|
|
response: {
|
|
input: [
|
|
{ role: "system", content: systemPrompt },
|
|
{ role: "user", content: userPrompt },
|
|
],
|
|
},
|
|
structured: estructuraPlan.definicion,
|
|
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",
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${user.session?.access_token}`,
|
|
},
|
|
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(
|
|
`[${
|
|
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,
|
|
});
|
|
}
|
|
});
|
|
|
|
// --- 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 };
|
|
}
|