Files
genesis-2/diff.txt
T
Guillermo.Arrieta 5c9fde1a9e
Deploy Function / deploy (push) Successful in 24s
Generación de datos básicos de asignaturas funcional
close #6:
- Ya se pueden generar asignaturas con IA. Específicamente los datos generales.
- Se actualizaron los tipos de supabase
- Se arreglaron los imports
- TODO: generar contenido temático de la asignatura
2026-02-05 19:42:12 -06:00

650 lines
50 KiB
Plaintext

diff --git a/deno.json b/deno.json
index 60f816d..2128fe4 100644
--- a/deno.json
+++ b/deno.json
@@ -7,6 +7,10 @@
"imports": {
"@openai/openai": "jsr:@openai/openai@^6.16.0",
"@zod/zod": "jsr:@zod/zod@^4.3.5",
- "@david/dax": "jsr:@david/dax@^0.44.2"
+ "@david/dax": "jsr:@david/dax@^0.44.2",
+ "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"
}
}
diff --git a/diff.txt b/diff.txt
index b2704fc..e69de29 100644
Binary files a/diff.txt and b/diff.txt differ
diff --git a/supabase/functions/_shared/database.types.ts b/supabase/functions/_shared/database.types.ts
index fa3ef8e..bf4b960 100644
--- a/supabase/functions/_shared/database.types.ts
+++ b/supabase/functions/_shared/database.types.ts
@@ -4,7 +4,7 @@
| boolean
| null
| { [key: string]: Json | undefined }
- | Json[];
+ | Array<Json>;
export type Database = {
// Allows to automatically instantiate createClient with right options
@@ -90,6 +90,7 @@ export type Database = {
Row: {
actualizado_en: string;
actualizado_por: string | null;
+ asignatura_hash: string | null;
codigo: string | null;
contenido_tematico: Json;
creado_en: string;
@@ -97,8 +98,8 @@ export type Database = {
creditos: number;
datos: Json;
estructura_id: string | null;
- facultad_propietaria_id: string | null;
- horas_semana: number | null;
+ horas_academicas: number | null;
+ horas_independientes: number | null;
id: string;
linea_plan_id: string | null;
meta_origen: Json;
@@ -114,6 +115,7 @@ export type Database = {
Insert: {
actualizado_en?: string;
actualizado_por?: string | null;
+ asignatura_hash?: string | null;
codigo?: string | null;
contenido_tematico?: Json;
creado_en?: string;
@@ -121,8 +123,8 @@ export type Database = {
creditos: number;
datos?: Json;
estructura_id?: string | null;
- facultad_propietaria_id?: string | null;
- horas_semana?: number | null;
+ horas_academicas?: number | null;
+ horas_independientes?: number | null;
id?: string;
linea_plan_id?: string | null;
meta_origen?: Json;
@@ -138,6 +140,7 @@ export type Database = {
Update: {
actualizado_en?: string;
actualizado_por?: string | null;
+ asignatura_hash?: string | null;
codigo?: string | null;
contenido_tematico?: Json;
creado_en?: string;
@@ -145,8 +148,8 @@ export type Database = {
creditos?: number;
datos?: Json;
estructura_id?: string | null;
- facultad_propietaria_id?: string | null;
- horas_semana?: number | null;
+ horas_academicas?: number | null;
+ horas_independientes?: number | null;
id?: string;
linea_plan_id?: string | null;
meta_origen?: Json;
@@ -181,14 +184,6 @@ export type Database = {
referencedRelation: "estructuras_asignatura";
referencedColumns: ["id"];
},
- {
- foreignKeyName:
- "asignaturas_facultad_propietaria_id_fkey";
- columns: ["facultad_propietaria_id"];
- isOneToOne: false;
- referencedRelation: "facultades";
- referencedColumns: ["id"];
- },
{
foreignKeyName: "asignaturas_linea_plan_fk_compuesta";
columns: ["linea_plan_id", "plan_estudio_id"];
@@ -332,8 +327,8 @@ export type Database = {
cambiado_por: string | null;
campo: string | null;
id: string;
- interaccion_ia_id: string | null;
plan_estudio_id: string;
+ response_id: string | null;
tipo: Database["public"]["Enums"]["tipo_cambio"];
valor_anterior: Json | null;
valor_nuevo: Json | null;
@@ -343,8 +338,8 @@ export type Database = {
cambiado_por?: string | null;
campo?: string | null;
id?: string;
- interaccion_ia_id?: string | null;
plan_estudio_id: string;
+ response_id?: string | null;
tipo: Database["public"]["Enums"]["tipo_cambio"];
valor_anterior?: Json | null;
valor_nuevo?: Json | null;
@@ -354,8 +349,8 @@ export type Database = {
cambiado_por?: string | null;
campo?: string | null;
id?: string;
- interaccion_ia_id?: string | null;
plan_estudio_id?: string;
+ response_id?: string | null;
tipo?: Database["public"]["Enums"]["tipo_cambio"];
valor_anterior?: Json | null;
valor_nuevo?: Json | null;
@@ -368,20 +363,6 @@ export type Database = {
referencedRelation: "usuarios_app";
referencedColumns: ["id"];
},
- {
- foreignKeyName: "cambios_plan_plan_estudio_id_fkey";
- columns: ["plan_estudio_id"];
- isOneToOne: false;
- referencedRelation: "planes_estudio";
- referencedColumns: ["id"];
- },
- {
- foreignKeyName: "cambios_plan_plan_estudio_id_fkey";
- columns: ["plan_estudio_id"];
- isOneToOne: false;
- referencedRelation: "plantilla_plan";
- referencedColumns: ["plan_estudio_id"];
- },
];
};
carreras: {
@@ -708,6 +689,7 @@ export type Database = {
actualizado_en: string;
actualizado_por: string | null;
carrera_id: string;
+ conversation_id: string | null;
creado_en: string;
creado_por: string | null;
datos: Json;
@@ -719,6 +701,7 @@ export type Database = {
nombre: string;
nombre_search: string | null;
numero_ciclos: number;
+ plan_hash: string | null;
tipo_ciclo: Database["public"]["Enums"]["tipo_ciclo"];
tipo_origen:
| Database["public"]["Enums"]["tipo_origen"]
@@ -729,6 +712,7 @@ export type Database = {
actualizado_en?: string;
actualizado_por?: string | null;
carrera_id: string;
+ conversation_id?: string | null;
creado_en?: string;
creado_por?: string | null;
datos?: Json;
@@ -740,6 +724,7 @@ export type Database = {
nombre: string;
nombre_search?: string | null;
numero_ciclos: number;
+ plan_hash?: string | null;
tipo_ciclo: Database["public"]["Enums"]["tipo_ciclo"];
tipo_origen?:
| Database["public"]["Enums"]["tipo_origen"]
@@ -750,6 +735,7 @@ export type Database = {
actualizado_en?: string;
actualizado_por?: string | null;
carrera_id?: string;
+ conversation_id?: string | null;
creado_en?: string;
creado_por?: string | null;
datos?: Json;
@@ -761,6 +747,7 @@ export type Database = {
nombre?: string;
nombre_search?: string | null;
numero_ciclos?: number;
+ plan_hash?: string | null;
tipo_ciclo?: Database["public"]["Enums"]["tipo_ciclo"];
tipo_origen?:
| Database["public"]["Enums"]["tipo_origen"]
@@ -1140,7 +1127,7 @@ export type Database = {
fuente_cambio: "HUMANO" | "IA";
nivel_plan_estudio:
| "Licenciatura"
- | "MaestrÔö£┬ía"
+ | "Maestría"
| "Doctorado"
| "Especialidad"
| "Diplomado"
@@ -1163,7 +1150,8 @@ export type Database = {
| "ACTUALIZACION_MAPA"
| "TRANSICION_ESTADO"
| "OTRO"
- | "CREACION";
+ | "CREACION"
+ | "ACTUALIZACION";
tipo_ciclo: "Semestre" | "Cuatrimestre" | "Trimestre" | "Otro";
tipo_estructura_plan: "CURRICULAR" | "NO_CURRICULAR";
tipo_fuente_bibliografia: "MANUAL" | "BIBLIOTECA";
@@ -1343,7 +1331,7 @@ export const Constants = {
fuente_cambio: ["HUMANO", "IA"],
nivel_plan_estudio: [
"Licenciatura",
- "MaestrÔö£┬ía",
+ "Maestría",
"Doctorado",
"Especialidad",
"Diplomado",
@@ -1370,6 +1358,7 @@ export const Constants = {
"TRANSICION_ESTADO",
"OTRO",
"CREACION",
+ "ACTUALIZACION",
],
tipo_ciclo: ["Semestre", "Cuatrimestre", "Trimestre", "Otro"],
tipo_estructura_plan: ["CURRICULAR", "NO_CURRICULAR"],
diff --git a/supabase/functions/ai-generate-plan/index.ts b/supabase/functions/ai-generate-plan/index.ts
index 0f3185b..1db2eef 100644
--- a/supabase/functions/ai-generate-plan/index.ts
+++ b/supabase/functions/ai-generate-plan/index.ts
@@ -3,10 +3,10 @@
// 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 "@supabase/functions-js/edge-runtime.d.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 "@supabase/supabase-js";
import type { Database, Json } from "../_shared/database.types.ts";
import type { AIGeneratePlanInput } from "./types.ts";
import { z } from "zod";
diff --git a/supabase/functions/ai-generate-subject/deno.json b/supabase/functions/ai-generate-subject/deno.json
index f6ca845..8b204ca 100644
--- a/supabase/functions/ai-generate-subject/deno.json
+++ b/supabase/functions/ai-generate-subject/deno.json
@@ -1,3 +1,8 @@
{
- "imports": {}
+ "imports": {
+ "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"
+ }
}
diff --git a/supabase/functions/ai-generate-subject/index.ts b/supabase/functions/ai-generate-subject/index.ts
index e974ea3..9045854 100644
--- a/supabase/functions/ai-generate-subject/index.ts
+++ b/supabase/functions/ai-generate-subject/index.ts
@@ -1,12 +1,15 @@
-import "jsr:@supabase/functions-js/edge-runtime.d.ts";
+import "@supabase/functions-js/edge-runtime.d.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 "@supabase/supabase-js";
import type { Database, Json } from "../_shared/database.types.ts";
+import { z } from "zod";
+import {
+ OpenAIService,
+ type StructuredResponseOptions,
+} from "../_shared/openai-service.ts";
-console.log("Hello from Functions!");
-
-Deno.serve(async (req) => {
+Deno.serve(async (req: Request): Promise<Response> => {
const url = new URL(req.url);
const functionName = url.pathname.split("/").pop();
console.log(
@@ -18,6 +21,52 @@ Deno.serve(async (req) => {
}
try {
+ const method = req.method;
+ if (method !== "POST") {
+ console.error(
+ `[${
+ new Date().toISOString()
+ }][${functionName}]: Invalid method: ${method}`,
+ );
+ throw new HttpError(
+ 405,
+ "M├®todo no permitido.",
+ "METHOD_NOT_ALLOWED",
+ { method },
+ );
+ }
+
+ const authHeaderRaw = req.headers.get("Authorization") ??
+ req.headers.get("authorization");
+ if (!authHeaderRaw) {
+ console.error(
+ `[${
+ new Date().toISOString()
+ }][${functionName}]: Missing Authorization header`,
+ );
+ throw new HttpError(
+ 401,
+ "No autorizado.",
+ "UNAUTHORIZED",
+ { reason: "missing_authorization_header" },
+ );
+ }
+
+ const contentType = (req.headers.get("content-type") || "").toLowerCase();
+ if (!contentType.startsWith("multipart/form-data")) {
+ console.error(
+ `[${
+ new Date().toISOString()
+ }][${functionName}]: Unsupported content type: ${contentType}`,
+ );
+ throw new HttpError(
+ 415,
+ "Content-Type no soportado.",
+ "UNSUPPORTED_MEDIA_TYPE",
+ { contentType },
+ );
+ }
+
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
@@ -59,20 +108,207 @@ Deno.serve(async (req) => {
SERVICE_ROLE_KEY,
);
- // INICIO DE CODIGO PARA DEBBUGGING
+ const formData = await req.formData();
+ const validation = parseAndValidate(formData);
+ if (!validation.success) {
+ console.error(
+ `[${new Date().toISOString()}][${functionName}]: Validation errors:`,
+ validation.errors,
+ );
+ const message = validation.errors
+ .map((e, i) => `${i + 1}. ${e}`)
+ .join("\n");
+ throw new HttpError(
+ 422,
+ message,
+ "VALIDATION_ERROR",
+ { errors: validation.errors },
+ );
+ }
+
+ const payload = validation.data;
+
+ const { data: estructura, error: estructuraError } = await supabaseService
+ .from("estructuras_asignatura")
+ .select("id,nombre,definicion,version")
+ .eq("id", payload.datosBasicos.estructuraId)
+ .single();
+ if (estructuraError) {
+ const maybeCode = (estructuraError as { code?: string }).code;
+ if (maybeCode === "PGRST116") {
+ throw new HttpError(
+ 404,
+ "No se encontr├│ la estructura de la asignatura.",
+ "NOT_FOUND",
+ {
+ table: "estructuras_asignatura",
+ id: payload.datosBasicos.estructuraId,
+ },
+ );
+ }
+ throw new HttpError(
+ 500,
+ "No se pudo obtener la estructura de la asignatura.",
+ "SUPABASE_QUERY_FAILED",
+ estructuraError,
+ );
+ }
+
+ const systemPrompt =
+ "Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el JSON Schema proporcionado.";
+
+ const userPrompt =
+ `Genera un borrador completo completo de una ASIGNATURA con base en lo siguiente:\n` +
+ `- Nombre de la asignatura: ${payload.datosBasicos.nombre}\n` +
+ `- C├│digo (clave de la asignatura): ${
+ payload.datosBasicos.codigo ?? "(no especificado)"
+ }\n` +
+ `- Tipo: ${payload.datosBasicos.tipo ?? "(no especificado)"}\n` +
+ `- N├║mero de cr├®ditos: ${payload.datosBasicos.creditos}\n` +
+ `- Horas acad├®micas: ${
+ payload.datosBasicos.horasAcademicas ?? "(no especificado)"
+ }\n` +
+ `- Horas independientes: ${
+ payload.datosBasicos.horasIndependientes ?? "(no especificado)"
+ }\n` +
+ `- Descripci├│n del enfoque acad├®mico (sobre el contenido de la respuesta generada): ${
+ payload.iaConfig?.descripcionEnfoqueAcademico ?? "(ninguna)"
+ }\n` +
+ `- Instrucciones adicionales (sobre el formato de la respuesta generada): ${
+ payload.iaConfig?.instruccionesAdicionalesIA ?? "(ninguna)"
+ }`;
+
+ const schemaDef: Record<string, unknown> =
+ typeof estructura?.definicion === "object" &&
+ estructura?.definicion !== null
+ ? (estructura.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: "asignatura_contenido_tematico",
+ schema: schemaDef,
+ strict: true,
+ },
+ },
+ };
+
+ const svc = OpenAIService.fromEnv();
+ if (!(svc instanceof OpenAIService)) {
+ throw new HttpError(
+ 500,
+ "Configuraci├│n del servidor incompleta.",
+ "OPENAI_MISCONFIGURED",
+ svc,
+ );
+ }
+
+ const aiResult = await svc.createStructuredResponse(
+ aiStructuredPayload,
+ payload.archivosAdjuntos,
+ );
+ if (!aiResult.ok) {
+ const status = aiResult.code === "MissingEnv" ? 500 : 502;
+ throw new HttpError(
+ status,
+ "No se pudo generar la asignatura con IA.",
+ "OPENAI_REQUEST_FAILED",
+ aiResult,
+ );
+ }
+
+ let aiOutput = aiResult.output ?? 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 },
+ );
+ }
+ }
+ if (!aiOutput) {
+ throw new HttpError(
+ 502,
+ "La respuesta de la IA no contiene salida estructurada.",
+ "OPENAI_MISSING_STRUCTURED_OUTPUT",
+ { outputText: aiResult.outputText ?? null },
+ );
+ }
+
+ const aiOutputJson: Json = aiOutput as unknown as Json;
+
+ const asignaturaInsert:
+ Database["public"]["Tables"]["asignaturas"]["Insert"] = {
+ plan_estudio_id: payload.plan_estudio_id,
+ estructura_id: payload.datosBasicos.estructuraId,
+ nombre: payload.datosBasicos.nombre,
+ codigo: payload.datosBasicos.codigo ?? null,
+ creditos: payload.datosBasicos.creditos,
+ horas_academicas: payload.datosBasicos.horasAcademicas ?? null,
+ horas_independientes: payload.datosBasicos.horasIndependientes ?? null,
+ tipo: (payload.datosBasicos.tipo ?? undefined) as Database["public"][
+ "Tables"
+ ]["asignaturas"]["Insert"]["tipo"],
+ tipo_origen: "IA",
+ datos: aiOutputJson,
+ meta_origen: {
+ generado_por: "ai_generate_subject",
+ ai: {
+ responseId: aiResult.responseId ?? null,
+ conversationId: aiResult.conversationId ?? null,
+ model: aiResult.model,
+ usage: aiResult.usage ?? null,
+ },
+ referencias: {
+ archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null,
+ repositoriosReferencia: payload.iaConfig?.repositoriosReferencia ??
+ null,
+ },
+ iaConfig: {
+ descripcionEnfoqueAcademico:
+ payload.iaConfig?.descripcionEnfoqueAcademico ?? null,
+ instruccionesAdicionalesIA:
+ payload.iaConfig?.instruccionesAdicionalesIA ?? null,
+ },
+ } as unknown as Json,
+ };
+
+ const { data: asignatura, error: asignaturaError } = await supabaseService
+ .from("asignaturas")
+ .insert(asignaturaInsert)
+ .select(
+ "id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,estructura_id,plan_estudio_id,contenido_tematico,meta_origen,creado_en,actualizado_en",
+ )
+ .single();
+
+ if (asignaturaError) {
+ const maybeCode = (asignaturaError as { code?: string }).code;
+ const status = maybeCode ? 409 : 500;
+ throw new HttpError(
+ status,
+ "No se pudo guardar la asignatura.",
+ "SUPABASE_INSERT_FAILED",
+ { ...asignaturaError, code: maybeCode },
+ );
+ }
+
console.log(
`[${
new Date().toISOString()
}][${functionName}]: Request processed successfully`,
);
- const { data: asignatura_debug, error: asignatura_error } =
- await supabaseService
- .from("asignaturas")
- .select("*")
- .eq("id", "9d4dda6a-488f-428a-8a07-38081592a641")
- .single();
- return sendSuccess(asignatura_debug);
- // FIN DE CODIGO PARA DEBBUGGING
+ return sendSuccess(asignatura);
} catch (error) {
if (error instanceof HttpError) {
console.error(
@@ -105,3 +341,83 @@ Deno.serve(async (req) => {
);
}
});
+
+const jsonFromString = <T extends z.ZodTypeAny>(schema: T) =>
+ z.string().transform((str, ctx) => {
+ try {
+ return JSON.parse(str);
+ } catch {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "El formato no es un JSON válido",
+ });
+ return z.NEVER;
+ }
+ }).pipe(schema);
+
+const jsonFromStringOptional = <T extends z.ZodTypeAny>(schema: T) =>
+ z.string().optional().default("{}").transform((str, ctx) => {
+ try {
+ return JSON.parse(str);
+ } catch {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "El formato no es un JSON válido",
+ });
+ return z.NEVER;
+ }
+ }).pipe(schema);
+
+const DatosBasicosSchema = z.object({
+ nombre: z.string().min(1, "El nombre es requerido"),
+ codigo: z.string().min(1).optional().nullable(),
+ tipo: z.union([z.string().min(1), z.null()]).optional().transform((v) =>
+ v ?? null
+ ),
+ creditos: z.number({ invalid_type_error: "Cr├®ditos debe ser num├®rico" })
+ .positive("Cr├®ditos debe ser >= 0"),
+ horasAcademicas: z.number().int().nonnegative().optional().nullable(),
+ horasIndependientes: z.number().int().nonnegative().optional().nullable(),
+ estructuraId: z.string().uuid("estructuraId debe ser un UUID"),
+});
+
+const IAConfigSchema = z.object({
+ descripcionEnfoqueAcademico: z.string().optional(),
+ instruccionesAdicionalesIA: z.string().optional(),
+ archivosReferencia: z.array(z.string().uuid()).optional(),
+ repositoriosReferencia: z.array(z.string().uuid()).optional(),
+}).partial();
+
+const SolicitudSchema = z.object({
+ plan_estudio_id: z.string().uuid("plan_estudio_id debe ser un UUID"),
+ datosBasicos: jsonFromString(DatosBasicosSchema),
+ iaConfig: jsonFromStringOptional(IAConfigSchema),
+ archivosAdjuntos: z.array(z.instanceof(File)).optional().default([]),
+});
+
+type EdgeAIGenerateSubjectInput = z.infer<typeof SolicitudSchema>;
+
+function parseAndValidate(
+ formData: FormData,
+): { success: true; data: EdgeAIGenerateSubjectInput } | {
+ success: false;
+ errors: string[];
+} {
+ const rawInput = {
+ plan_estudio_id: formData.get("plan_estudio_id"),
+ datosBasicos: formData.get("datosBasicos"),
+ iaConfig: formData.get("iaConfig"),
+ archivosAdjuntos: formData.getAll("archivosAdjuntos"),
+ };
+
+ const result = SolicitudSchema.safeParse(rawInput);
+ if (!result.success) {
+ const errors = result.error.errors.map((issue) => {
+ const path = issue.path.length ? `${issue.path.join(".")}: ` : "";
+ return `${path}${issue.message}`;
+ });
+ return { success: false, errors };
+ }
+
+ return { success: true, data: result.data };
+}