378 lines
13 KiB
TypeScript
378 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 { 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:
|
|
- Nombre de la institución: Universidad La Salle México
|
|
- Nombre del plan: ${payload.datosBasicos.nombrePlan}
|
|
- Nivel: ${payload.datosBasicos.nivel}
|
|
- Tipo de ciclo: ${payload.datosBasicos.tipoCiclo}
|
|
- Número de ciclos: ${payload.datosBasicos.numCiclos}
|
|
- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${payload.iaConfig.descripcionEnfoqueAcademico}
|
|
- Notas adicionales (sobre el formato de la respuesta generada): ${
|
|
payload.iaConfig.instruccionesAdicionalesIA ?? "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: {
|
|
descripcionEnfoqueAcademico:
|
|
payload.iaConfig?.descripcionEnfoqueAcademico ?? null,
|
|
instruccionesAdicionalesIA:
|
|
payload.iaConfig?.instruccionesAdicionalesIA ?? 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({
|
|
descripcionEnfoqueAcademico: z.string().min(
|
|
10,
|
|
"La descripción es muy corta",
|
|
),
|
|
instruccionesAdicionalesIA: 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 };
|
|
}
|