356 lines
12 KiB
TypeScript
356 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";
|
|
import { strict } from "node:assert";
|
|
|
|
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 = {
|
|
model: "gpt-5-mini",
|
|
input: [
|
|
{ role: "system", content: systemPrompt },
|
|
{ role: "user", content: userPrompt },
|
|
],
|
|
text: {
|
|
format: {
|
|
type: "json_schema",
|
|
name: "plan_de_estudios_standard",
|
|
schema: estructuraPlan?.definicion,
|
|
strict: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
const aiStructuredFormData = new FormData();
|
|
aiStructuredFormData.append("options", JSON.stringify(aiStructuredPayload));
|
|
for (const file of payload.archivosAdjuntos ?? []) {
|
|
aiStructuredFormData.append("files", 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,
|
|
});
|
|
}
|
|
});
|
|
|
|
// 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 };
|
|
}
|