Generación de datos básicos de asignaturas funcional
Deploy Function / deploy (push) Successful in 24s

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
This commit was merged in pull request #17.
This commit is contained in:
2026-02-05 19:42:12 -06:00
parent 7f9226ce38
commit 5c9fde1a9e
7 changed files with 404 additions and 53 deletions
+5 -1
View File
@@ -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"
}
}
BIN
View File
Binary file not shown.
+24 -35
View File
@@ -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"],
+2 -2
View File
@@ -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";
@@ -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"
}
}
+330 -14
View File
@@ -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 };
}
@@ -0,0 +1,37 @@
import { Tables } from "../_shared/database.types.ts";
export interface UploadedFile {
id: string; // Necesario para React (key)
file: File; // La fuente de verdad (contiene name, size, type)
preview?: string; // Opcional: si fueran imágenes
}
export type AIGenerateSubjectInput = {
plan_estudio_id: Tables<"asignaturas">["plan_estudio_id"];
datosBasicos: {
nombre: Tables<"asignaturas">["nombre"];
codigo?: Tables<"asignaturas">["codigo"];
tipo: Tables<"asignaturas">["tipo"] | null;
creditos: Tables<"asignaturas">["creditos"] | null;
horasAcademicas?: Tables<"asignaturas">["horas_academicas"] | null;
horasIndependientes?: Tables<"asignaturas">["horas_independientes"] | null;
estructuraId: Tables<"asignaturas">["estructura_id"] | null;
};
// clonInterno?: {
// facultadId?: string
// carreraId?: string
// planOrigenId?: string
// asignaturaOrigenId?: string | null
// }
// clonTradicional?: {
// archivoWordAsignaturaId: string | null
// archivosAdicionalesIds: Array<string>
// }
iaConfig?: {
descripcionEnfoqueAcademico: string;
instruccionesAdicionalesIA: string;
archivosReferencia: Array<string>;
repositoriosReferencia?: Array<string>;
};
archivosAdjuntos?: Array<UploadedFile>;
};