issue/6-generacin-de-materias #16

Merged
Guillermo.Arrieta merged 5 commits from issue/6-generacin-de-materias into main 2026-02-05 22:07:23 +00:00
5 changed files with 217 additions and 65 deletions
Showing only changes of commit 42a437afa3 - Show all commits
BIN
View File
Binary file not shown.
+49
View File
@@ -0,0 +1,49 @@
import { corsHeaders } from "./cors.ts";
// --- CLASE DE ERROR ---
export class HttpError extends Error {
public readonly status: number;
public readonly code: string;
public readonly internalDetails: unknown;
constructor(
status: number,
message: string,
code = "API_ERROR",
internalDetails?: unknown,
) {
super(message);
this.name = "HttpError";
this.status = status;
this.code = code;
this.internalDetails = internalDetails;
}
}
// --- HELPER DE ÉXITO ---
export function sendSuccess<T>(data: T, status = 200): Response {
return new Response(
JSON.stringify(data),
{
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// --- HELPER DE ERROR ---
export function sendError(
status: number,
message: string,
code: string,
): Response {
return new Response(
JSON.stringify({
error: { message, code },
}),
{
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
@@ -1,5 +1,8 @@
{ {
"imports": { "imports": {
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts" "zod": "https://deno.land/x/zod@v3.22.4/mod.ts",
"openai": "npm:openai@6.16.0",
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
"@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts"
} }
} }
+164 -61
View File
@@ -5,6 +5,7 @@
// 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 { HttpError, sendError, sendSuccess } from "../_shared/utils.ts";
import { createClient } from "npm:@supabase/supabase-js@2"; import { createClient } from "npm:@supabase/supabase-js@2";
import type { Database, Json } from "../_shared/database.types.ts"; import type { Database, Json } from "../_shared/database.types.ts";
import type { AIGeneratePlanInput } from "./types.ts"; import type { AIGeneratePlanInput } from "./types.ts";
@@ -18,7 +19,7 @@ type NivelType =
type TipoCicloType = type TipoCicloType =
Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"]; Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"];
Deno.serve(async (req) => { Deno.serve(async (req: Request): Promise<Response> => {
const url = new URL(req.url); const url = new URL(req.url);
const functionName = url.pathname.split("/").pop(); const functionName = url.pathname.split("/").pop();
console.log( console.log(
@@ -37,12 +38,11 @@ Deno.serve(async (req) => {
new Date().toISOString() new Date().toISOString()
}][${functionName}]: Invalid method: ${method}`, }][${functionName}]: Invalid method: ${method}`,
); );
return new Response( throw new HttpError(
JSON.stringify({ error: "Method not allowed" }), 405,
{ "Método no permitido.",
headers: { ...corsHeaders, "Content-Type": "application/json" }, "METHOD_NOT_ALLOWED",
status: 405, { method },
},
); );
} }
@@ -54,12 +54,11 @@ Deno.serve(async (req) => {
new Date().toISOString() new Date().toISOString()
}][${functionName}]: Missing Authorization header`, }][${functionName}]: Missing Authorization header`,
); );
return new Response( throw new HttpError(
JSON.stringify({ error: "Authorization header missing" }), 401,
{ "No autorizado.",
headers: { ...corsHeaders, "Content-Type": "application/json" }, "UNAUTHORIZED",
status: 401, { reason: "missing_authorization_header" },
},
); );
} }
@@ -70,19 +69,28 @@ Deno.serve(async (req) => {
new Date().toISOString() new Date().toISOString()
}][${functionName}]: Unsupported content type: ${contentType}`, }][${functionName}]: Unsupported content type: ${contentType}`,
); );
return new Response( throw new HttpError(
JSON.stringify({ error: "Unsupported content type" }), 415,
{ "Content-Type no soportado.",
headers: { ...corsHeaders, "Content-Type": "application/json" }, "UNSUPPORTED_MEDIA_TYPE",
status: 400, { contentType },
},
); );
} }
const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY"); const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) { if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
throw new Error("Supabase environment variables are not set"); throw new HttpError(
500,
"Configuración del servidor incompleta.",
"MISSING_ENV",
{
missing: [
!SUPABASE_URL ? "SUPABASE_URL" : null,
!SUPABASE_ANON_KEY ? "SUPABASE_ANON_KEY" : null,
].filter(Boolean),
},
);
} }
// If needed for RLS-protected reads, create an anon client with user's JWT // If needed for RLS-protected reads, create an anon client with user's JWT
@@ -97,7 +105,12 @@ Deno.serve(async (req) => {
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!SERVICE_ROLE_KEY) { if (!SERVICE_ROLE_KEY) {
throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set"); throw new HttpError(
500,
"Configuración del servidor incompleta.",
"MISSING_ENV",
{ missing: ["SUPABASE_SERVICE_ROLE_KEY"] },
);
} }
const supabaseService = createClient<Database>( const supabaseService = createClient<Database>(
@@ -112,12 +125,15 @@ Deno.serve(async (req) => {
`[${new Date().toISOString()}][${functionName}]: Validation errors:`, `[${new Date().toISOString()}][${functionName}]: Validation errors:`,
validation.errors, validation.errors,
); );
return new Response( const message = validation.errors
JSON.stringify({ errors: validation.errors }), .map((e, i) => `(${i + 1}) ${e}`)
{ .join("\n");
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 400, throw new HttpError(
}, 422,
message,
"VALIDATION_ERROR",
{ errors: validation.errors },
); );
} }
@@ -130,8 +146,23 @@ Deno.serve(async (req) => {
.eq("id", payload.datosBasicos.estructuraPlanId) .eq("id", payload.datosBasicos.estructuraPlanId)
.single(); .single();
if (estructuraPlanError) { if (estructuraPlanError) {
throw new Error( const maybeCode = (estructuraPlanError as { code?: string }).code;
"Error fetching estructuraPlan: " + estructuraPlanError.message, if (maybeCode === "PGRST116") {
throw new HttpError(
404,
"No se encontró la estructura del plan.",
"NOT_FOUND",
{
table: "estructuras_plan",
id: payload.datosBasicos.estructuraPlanId,
},
);
}
throw new HttpError(
500,
"No se pudo obtener la estructura del plan.",
"SUPABASE_QUERY_FAILED",
estructuraPlanError,
); );
} }
@@ -165,25 +196,64 @@ Deno.serve(async (req) => {
// Use shared OpenAI service directly (no HTTP invoke) // Use shared OpenAI service directly (no HTTP invoke)
const svc = OpenAIService.fromEnv(); const svc = OpenAIService.fromEnv();
if (!(svc instanceof OpenAIService)) { if (!(svc instanceof OpenAIService)) {
throw new Error(`OpenAI service misconfiguration: ${svc.message}`); throw new HttpError(
500,
"Configuración del servidor incompleta.",
"OPENAI_MISCONFIGURED",
svc,
);
} }
// INICIO DE CÓDIGO PARA DEBBUGGING
// console.log(
// `[${
// new Date().toISOString()
// }][${functionName}]: Request processed successfully`,
// );
// const { data: plan_debug } = await supabaseService
// .from("planes_estudio")
// .select("*")
// .eq("id", "7ce657b1-1abf-4972-858d-5fffe1d51499")
// .maybeSingle();
// return sendSuccess(plan_debug);
// FIN DE CÓDIGO PARA DEBBUGGING
const aiResult = await svc.createStructuredResponse( const aiResult = await svc.createStructuredResponse(
aiStructuredPayload, aiStructuredPayload,
payload.archivosAdjuntos, payload.archivosAdjuntos,
); );
if (!aiResult.ok) { if (!aiResult.ok) {
throw new Error( const status = aiResult.code === "MissingEnv" ? 500 : 502;
`OpenAI call failed [${aiResult.code}]: ${aiResult.message}`, throw new HttpError(
status,
"No se pudo generar el plan con IA.",
"OPENAI_REQUEST_FAILED",
aiResult,
); );
} }
// Prefer parsed output; fallback to parse outputText // Prefer parsed output; fallback to parse outputText
const aiOutput = aiResult.output ?? let aiOutput = aiResult.output ?? null;
(aiResult.outputText ? JSON.parse(aiResult.outputText) : null); if (aiOutput == null && aiResult.outputText) {
try {
aiOutput = JSON.parse(aiResult.outputText);
} catch {
throw new HttpError(
502,
"La respuesta de la IA no es JSON válido.",
"OPENAI_INVALID_JSON",
{ outputText: aiResult.outputText },
);
}
}
const aiOutputJson: Json = aiOutput as unknown as Json; const aiOutputJson: Json = aiOutput as unknown as Json;
if (!aiOutput) { if (!aiOutput) {
throw new Error("OpenAI response did not contain structured output"); throw new HttpError(
502,
"La respuesta de la IA no contiene salida estructurada.",
"OPENAI_MISSING_STRUCTURED_OUTPUT",
{ outputText: aiResult.outputText ?? null },
);
} }
// TODO: Insertar interacciones con IA y quizas forzar a que la informacion de datosBasicos sea la misma que la recibida // TODO: Insertar interacciones con IA y quizas forzar a que la informacion de datosBasicos sea la misma que la recibida
@@ -202,7 +272,20 @@ Deno.serve(async (req) => {
.eq("id", payload.datosBasicos.carreraId) .eq("id", payload.datosBasicos.carreraId)
.maybeSingle(); .maybeSingle();
if (carreraError) { if (carreraError) {
throw new Error("Error fetching carrera: " + carreraError.message); throw new HttpError(
500,
"No se pudo obtener la carrera.",
"SUPABASE_QUERY_FAILED",
carreraError,
);
}
if (!carrera) {
throw new HttpError(
404,
"No se encontró la carrera.",
"NOT_FOUND",
{ table: "carreras", id: payload.datosBasicos.carreraId },
);
} }
const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] = const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] =
@@ -248,42 +331,62 @@ Deno.serve(async (req) => {
// TODO: interaccion con IA y cambio de estado // TODO: interaccion con IA y cambio de estado
if (planError) { if (planError) {
throw new Error("Error inserting plan: " + planError.message); const maybeCode = (planError as { code?: string }).code;
// Common cases:
// - foreign key / constraint violations -> 409
// - others -> 500
const status = maybeCode ? 409 : 500;
throw new HttpError(
status,
"No se pudo guardar el plan de estudios.",
"SUPABASE_INSERT_FAILED",
{ ...planError, code: maybeCode },
);
} }
// TODO: update a interaccion_ia y e insert a cambios_plan con id de plan generado // TODO: update a interaccion_ia y e insert a cambios_plan con id de plan generado
const jsonResponse = {
ok: true,
plan,
};
console.log( console.log(
`[${ `[${
new Date().toISOString() new Date().toISOString()
}][${functionName}]: Request processed successfully`, }][${functionName}]: Request processed successfully`,
); );
return new Response( return sendSuccess(plan);
JSON.stringify(jsonResponse),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 200,
},
);
} catch (error) { } catch (error) {
// Log full error server-side for diagnostics if (error instanceof HttpError) {
if (error instanceof Error) {
console.error( console.error(
`[${ `[${new Date().toISOString()}][${functionName}] ⚠️ Handled Error:`,
new Date().toISOString() {
}][${functionName}]: Request handler error:`, message: error.message,
error.message, code: error.code,
internalDetails: error.internalDetails || "N/A",
},
); );
// RESPONSE: Solo enviamos el mensaje limpio y el código
return sendError(error.status, error.message, error.code);
} }
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { // CASO B: Error Inesperado (Crash, Bug, Syntax Error, etc.)
headers: { ...corsHeaders, "Content-Type": "application/json" }, // El usuario NO debe ver esto.
status: 400, const unexpectedError = error instanceof Error
}); ? error
: new Error(String(error));
// LOG: Full stack trace y mensaje real
console.error(
`[${
new Date().toISOString()
}][${functionName}] 💥 CRITICAL UNHANDLED ERROR:`,
unexpectedError.stack || unexpectedError.message, // Esto es lo que necesitas para debuguear
);
// RESPONSE: Mensaje genérico y seguro
return sendError(
500,
"Ocurrió un error inesperado en el servidor.",
"INTERNAL_SERVER_ERROR",
);
} }
}); });
@@ -315,8 +418,8 @@ const DatosBasicosSchema: z.ZodType<AIGeneratePlanInput["datosBasicos"]> = z
}); });
const IAConfigSchema: z.ZodType<AIGeneratePlanInput["iaConfig"]> = z.object({ const IAConfigSchema: z.ZodType<AIGeneratePlanInput["iaConfig"]> = z.object({
descripcionEnfoque: z.string().min(10, "La descripción es muy corta"), descripcionEnfoqueAcademico: z.string(),
notasAdicionales: z.string().optional(), instruccionesAdicionalesIA: z.string().optional(),
archivosReferencia: z.array(z.string().uuid()).optional(), archivosReferencia: z.array(z.string().uuid()).optional(),
repositoriosIds: z.array(z.string().uuid()).optional(), repositoriosIds: z.array(z.string().uuid()).optional(),
usarMCP: z.boolean().optional(), usarMCP: z.boolean().optional(),
@@ -25,9 +25,6 @@ function mustEnv() {
if (!SUPABASE_ANON_KEY) throw new Error("SUPABASE_ANON_KEY is required"); if (!SUPABASE_ANON_KEY) throw new Error("SUPABASE_ANON_KEY is required");
} }
async function getAuthedClient(): Promise<
{ client: SupabaseClient; accessToken: string }
> {
async function getAuthedClient(): Promise< async function getAuthedClient(): Promise<
{ client: SupabaseClient; accessToken: string } { client: SupabaseClient; accessToken: string }
> { > {