feat: opcion de update a asignaturas ya creadas para ponerles la información generada con IA
- Created a new enum type "estado_asignatura" with values 'borrador', 'revisada', 'aprobada', and 'generando'. - Added a new column "estado" to the "asignaturas" table, defaulting to 'generando'. - Implemented a trigger function to log changes in the "planes_estudio" table, capturing inserts, updates, and deletes. - Updated the logging mechanism to track changes in various fields, including JSONB data. - Removed the "asignaturas" table from the supabase_realtime publication. - Added a script for generating TypeScript types from the local Supabase database.
This commit is contained in:
+1318
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
Usar --local para generar tipos de la BDD local, y --linked para generar tipos de la BDD remota.
|
||||||
|
cmd /c "dx supabase gen types typescript --local > database-types.ts"
|
||||||
@@ -4,14 +4,9 @@
|
|||||||
| boolean
|
| boolean
|
||||||
| null
|
| null
|
||||||
| { [key: string]: Json | undefined }
|
| { [key: string]: Json | undefined }
|
||||||
| Array<Json>;
|
| Json[];
|
||||||
|
|
||||||
export type Database = {
|
export type Database = {
|
||||||
// Allows to automatically instantiate createClient with right options
|
|
||||||
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
|
||||||
__InternalSupabase: {
|
|
||||||
PostgrestVersion: "12.2.3 (519615d)";
|
|
||||||
};
|
|
||||||
graphql_public: {
|
graphql_public: {
|
||||||
Tables: {
|
Tables: {
|
||||||
[_ in never]: never;
|
[_ in never]: never;
|
||||||
@@ -98,6 +93,7 @@ export type Database = {
|
|||||||
creado_por: string | null;
|
creado_por: string | null;
|
||||||
creditos: number;
|
creditos: number;
|
||||||
datos: Json;
|
datos: Json;
|
||||||
|
estado: Database["public"]["Enums"]["estado_asignatura"];
|
||||||
estructura_id: string | null;
|
estructura_id: string | null;
|
||||||
horas_academicas: number | null;
|
horas_academicas: number | null;
|
||||||
horas_independientes: number | null;
|
horas_independientes: number | null;
|
||||||
@@ -124,6 +120,7 @@ export type Database = {
|
|||||||
creado_por?: string | null;
|
creado_por?: string | null;
|
||||||
creditos: number;
|
creditos: number;
|
||||||
datos?: Json;
|
datos?: Json;
|
||||||
|
estado?: Database["public"]["Enums"]["estado_asignatura"];
|
||||||
estructura_id?: string | null;
|
estructura_id?: string | null;
|
||||||
horas_academicas?: number | null;
|
horas_academicas?: number | null;
|
||||||
horas_independientes?: number | null;
|
horas_independientes?: number | null;
|
||||||
@@ -150,6 +147,7 @@ export type Database = {
|
|||||||
creado_por?: string | null;
|
creado_por?: string | null;
|
||||||
creditos?: number;
|
creditos?: number;
|
||||||
datos?: Json;
|
datos?: Json;
|
||||||
|
estado?: Database["public"]["Enums"]["estado_asignatura"];
|
||||||
estructura_id?: string | null;
|
estructura_id?: string | null;
|
||||||
horas_academicas?: number | null;
|
horas_academicas?: number | null;
|
||||||
horas_independientes?: number | null;
|
horas_independientes?: number | null;
|
||||||
@@ -815,20 +813,18 @@ export type Database = {
|
|||||||
asignatura_id: string;
|
asignatura_id: string;
|
||||||
creado_en?: string;
|
creado_en?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
rol?:
|
rol?: Database["public"]["Enums"][
|
||||||
Database["public"]["Enums"][
|
"rol_responsable_asignatura"
|
||||||
"rol_responsable_asignatura"
|
];
|
||||||
];
|
|
||||||
usuario_id: string;
|
usuario_id: string;
|
||||||
};
|
};
|
||||||
Update: {
|
Update: {
|
||||||
asignatura_id?: string;
|
asignatura_id?: string;
|
||||||
creado_en?: string;
|
creado_en?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
rol?:
|
rol?: Database["public"]["Enums"][
|
||||||
Database["public"]["Enums"][
|
"rol_responsable_asignatura"
|
||||||
"rol_responsable_asignatura"
|
];
|
||||||
];
|
|
||||||
usuario_id?: string;
|
usuario_id?: string;
|
||||||
};
|
};
|
||||||
Relationships: [
|
Relationships: [
|
||||||
@@ -1126,6 +1122,11 @@ export type Database = {
|
|||||||
unaccent_immutable: { Args: { "": string }; Returns: string };
|
unaccent_immutable: { Args: { "": string }; Returns: string };
|
||||||
};
|
};
|
||||||
Enums: {
|
Enums: {
|
||||||
|
estado_asignatura:
|
||||||
|
| "borrador"
|
||||||
|
| "revisada"
|
||||||
|
| "aprobada"
|
||||||
|
| "generando";
|
||||||
estado_tarea_revision: "PENDIENTE" | "COMPLETADA" | "OMITIDA";
|
estado_tarea_revision: "PENDIENTE" | "COMPLETADA" | "OMITIDA";
|
||||||
fuente_cambio: "HUMANO" | "IA";
|
fuente_cambio: "HUMANO" | "IA";
|
||||||
nivel_plan_estudio:
|
nivel_plan_estudio:
|
||||||
@@ -1233,15 +1234,13 @@ export type TablesInsert<
|
|||||||
| { schema: keyof DatabaseWithoutInternals },
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
schema: keyof DatabaseWithoutInternals;
|
schema: keyof DatabaseWithoutInternals;
|
||||||
}
|
} ? keyof DatabaseWithoutInternals[
|
||||||
? keyof DatabaseWithoutInternals[
|
|
||||||
DefaultSchemaTableNameOrOptions["schema"]
|
DefaultSchemaTableNameOrOptions["schema"]
|
||||||
]["Tables"]
|
]["Tables"]
|
||||||
: never = never,
|
: never = never,
|
||||||
> = DefaultSchemaTableNameOrOptions extends {
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
schema: keyof DatabaseWithoutInternals;
|
schema: keyof DatabaseWithoutInternals;
|
||||||
}
|
} ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]][
|
||||||
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]][
|
|
||||||
"Tables"
|
"Tables"
|
||||||
][TableName] extends {
|
][TableName] extends {
|
||||||
Insert: infer I;
|
Insert: infer I;
|
||||||
@@ -1260,15 +1259,13 @@ export type TablesUpdate<
|
|||||||
| { schema: keyof DatabaseWithoutInternals },
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
schema: keyof DatabaseWithoutInternals;
|
schema: keyof DatabaseWithoutInternals;
|
||||||
}
|
} ? keyof DatabaseWithoutInternals[
|
||||||
? keyof DatabaseWithoutInternals[
|
|
||||||
DefaultSchemaTableNameOrOptions["schema"]
|
DefaultSchemaTableNameOrOptions["schema"]
|
||||||
]["Tables"]
|
]["Tables"]
|
||||||
: never = never,
|
: never = never,
|
||||||
> = DefaultSchemaTableNameOrOptions extends {
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
schema: keyof DatabaseWithoutInternals;
|
schema: keyof DatabaseWithoutInternals;
|
||||||
}
|
} ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]][
|
||||||
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]][
|
|
||||||
"Tables"
|
"Tables"
|
||||||
][TableName] extends {
|
][TableName] extends {
|
||||||
Update: infer U;
|
Update: infer U;
|
||||||
@@ -1287,15 +1284,13 @@ export type Enums<
|
|||||||
| { schema: keyof DatabaseWithoutInternals },
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
||||||
schema: keyof DatabaseWithoutInternals;
|
schema: keyof DatabaseWithoutInternals;
|
||||||
}
|
} ? keyof DatabaseWithoutInternals[
|
||||||
? keyof DatabaseWithoutInternals[
|
|
||||||
DefaultSchemaEnumNameOrOptions["schema"]
|
DefaultSchemaEnumNameOrOptions["schema"]
|
||||||
]["Enums"]
|
]["Enums"]
|
||||||
: never = never,
|
: never = never,
|
||||||
> = DefaultSchemaEnumNameOrOptions extends {
|
> = DefaultSchemaEnumNameOrOptions extends {
|
||||||
schema: keyof DatabaseWithoutInternals;
|
schema: keyof DatabaseWithoutInternals;
|
||||||
}
|
} ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]][
|
||||||
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]][
|
|
||||||
"Enums"
|
"Enums"
|
||||||
][EnumName]
|
][EnumName]
|
||||||
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
|
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
|
||||||
@@ -1308,15 +1303,13 @@ export type CompositeTypes<
|
|||||||
| { schema: keyof DatabaseWithoutInternals },
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||||
schema: keyof DatabaseWithoutInternals;
|
schema: keyof DatabaseWithoutInternals;
|
||||||
}
|
} ? keyof DatabaseWithoutInternals[
|
||||||
? keyof DatabaseWithoutInternals[
|
|
||||||
PublicCompositeTypeNameOrOptions["schema"]
|
PublicCompositeTypeNameOrOptions["schema"]
|
||||||
]["CompositeTypes"]
|
]["CompositeTypes"]
|
||||||
: never = never,
|
: never = never,
|
||||||
> = PublicCompositeTypeNameOrOptions extends {
|
> = PublicCompositeTypeNameOrOptions extends {
|
||||||
schema: keyof DatabaseWithoutInternals;
|
schema: keyof DatabaseWithoutInternals;
|
||||||
}
|
} ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]][
|
||||||
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]][
|
|
||||||
"CompositeTypes"
|
"CompositeTypes"
|
||||||
][CompositeTypeName]
|
][CompositeTypeName]
|
||||||
: PublicCompositeTypeNameOrOptions extends
|
: PublicCompositeTypeNameOrOptions extends
|
||||||
@@ -1330,6 +1323,12 @@ export const Constants = {
|
|||||||
},
|
},
|
||||||
public: {
|
public: {
|
||||||
Enums: {
|
Enums: {
|
||||||
|
estado_asignatura: [
|
||||||
|
"borrador",
|
||||||
|
"revisada",
|
||||||
|
"aprobada",
|
||||||
|
"generando",
|
||||||
|
],
|
||||||
estado_tarea_revision: ["PENDIENTE", "COMPLETADA", "OMITIDA"],
|
estado_tarea_revision: ["PENDIENTE", "COMPLETADA", "OMITIDA"],
|
||||||
fuente_cambio: ["HUMANO", "IA"],
|
fuente_cambio: ["HUMANO", "IA"],
|
||||||
nivel_plan_estudio: [
|
nivel_plan_estudio: [
|
||||||
|
|||||||
@@ -9,6 +9,66 @@ import {
|
|||||||
type StructuredResponseOptions,
|
type StructuredResponseOptions,
|
||||||
} from "../_shared/openai-service.ts";
|
} from "../_shared/openai-service.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON input signature (when `Content-Type: application/json`)
|
||||||
|
*
|
||||||
|
* Required:
|
||||||
|
* - `id` (uuid) - corresponde a la columna `id` de `asignaturas`
|
||||||
|
*
|
||||||
|
* Optional patch fields (any subset is allowed):
|
||||||
|
* - `nombre` (string)
|
||||||
|
* - `codigo` (string | null)
|
||||||
|
* - `tipo` (string | null) // must match DB enum values
|
||||||
|
* - `creditos` (number)
|
||||||
|
* - `horas_academicas` (number | null)
|
||||||
|
* - `horas_independientes` (number | null)
|
||||||
|
* - `numero_ciclo` (number | null)
|
||||||
|
* - `estructura_id` (uuid | null)
|
||||||
|
* - `plan_estudio_id` (uuid)
|
||||||
|
* - `linea_plan_id` (uuid | null)
|
||||||
|
* - `orden_celda` (number | null)
|
||||||
|
*
|
||||||
|
* IA config (optional):
|
||||||
|
* - `descripcionEnfoqueAcademico` (string)
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - This JSON flow does NOT accept `instruccionesAdicionalesIA`.
|
||||||
|
* - Missing optional fields are ignored (the existing DB values remain).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const JsonUpdateSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.string().uuid("id debe ser un UUID"),
|
||||||
|
|
||||||
|
// patch fields (all optional) — nombres coinciden con columnas DB
|
||||||
|
nombre: z.string().min(1).optional(),
|
||||||
|
codigo: z.union([z.string().min(1), z.null()]).optional(),
|
||||||
|
tipo: z.union([z.string().min(1), z.null()]).optional(),
|
||||||
|
creditos: z.number().positive().optional(),
|
||||||
|
horas_academicas: z.number().int().nonnegative().nullable().optional(),
|
||||||
|
horas_independientes: z.number().int().nonnegative().nullable().optional(),
|
||||||
|
numero_ciclo: z.number().int().positive().nullable().optional(),
|
||||||
|
estructura_id: z.string().uuid().nullable().optional(),
|
||||||
|
plan_estudio_id: z.string().uuid().optional(),
|
||||||
|
linea_plan_id: z.string().uuid().nullable().optional(),
|
||||||
|
orden_celda: z.number().int().nonnegative().nullable().optional(),
|
||||||
|
|
||||||
|
// IA config (no instruccionesAdicionalesIA)
|
||||||
|
descripcionEnfoqueAcademico: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
type EdgeAIGenerateSubjectJsonUpdateInput = z.infer<typeof JsonUpdateSchema>;
|
||||||
|
|
||||||
|
function formatZodIssues(issues: z.ZodIssue[]): string {
|
||||||
|
return issues
|
||||||
|
.map((issue, i) => {
|
||||||
|
const path = issue.path.length ? issue.path.join(".") : "(root)";
|
||||||
|
return `${i + 1}. ${path}: ${issue.message}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
Deno.serve(async (req: Request): Promise<Response> => {
|
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();
|
||||||
@@ -53,7 +113,9 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contentType = (req.headers.get("content-type") || "").toLowerCase();
|
const contentType = (req.headers.get("content-type") || "").toLowerCase();
|
||||||
if (!contentType.startsWith("multipart/form-data")) {
|
const isMultipart = contentType.startsWith("multipart/form-data");
|
||||||
|
const isJson = contentType.includes("application/json");
|
||||||
|
if (!isMultipart && !isJson) {
|
||||||
console.error(
|
console.error(
|
||||||
`[${
|
`[${
|
||||||
new Date().toISOString()
|
new Date().toISOString()
|
||||||
@@ -108,6 +170,299 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|||||||
SERVICE_ROLE_KEY,
|
SERVICE_ROLE_KEY,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// JSON update flow
|
||||||
|
// -----------------------------
|
||||||
|
if (isJson) {
|
||||||
|
let rawBody: unknown;
|
||||||
|
try {
|
||||||
|
rawBody = await req.json();
|
||||||
|
} catch (e) {
|
||||||
|
throw new HttpError(
|
||||||
|
400,
|
||||||
|
"Body JSON inválido.",
|
||||||
|
"INVALID_JSON",
|
||||||
|
{ cause: e },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = JsonUpdateSchema.safeParse(rawBody);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
throw new HttpError(
|
||||||
|
422,
|
||||||
|
formatZodIssues(parsedBody.error.issues),
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
parsedBody.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: EdgeAIGenerateSubjectJsonUpdateInput = parsedBody.data;
|
||||||
|
|
||||||
|
const { data: existingAsignatura, error: existingAsignaturaError } =
|
||||||
|
await supabaseService
|
||||||
|
.from("asignaturas")
|
||||||
|
.select(
|
||||||
|
"id,plan_estudio_id,estructura_id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,numero_ciclo,linea_plan_id,orden_celda",
|
||||||
|
)
|
||||||
|
.eq("id", payload.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (existingAsignaturaError) {
|
||||||
|
const maybeCode = (existingAsignaturaError as { code?: string }).code;
|
||||||
|
if (maybeCode === "PGRST116") {
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
"No se encontró la asignatura.",
|
||||||
|
"NOT_FOUND",
|
||||||
|
{ table: "asignaturas", id: payload.id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new HttpError(
|
||||||
|
500,
|
||||||
|
"No se pudo obtener la asignatura.",
|
||||||
|
"SUPABASE_QUERY_FAILED",
|
||||||
|
existingAsignaturaError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = {
|
||||||
|
plan_estudio_id: payload.plan_estudio_id ??
|
||||||
|
existingAsignatura.plan_estudio_id,
|
||||||
|
estructura_id: payload.estructura_id ??
|
||||||
|
existingAsignatura.estructura_id,
|
||||||
|
nombre: payload.nombre ?? existingAsignatura.nombre,
|
||||||
|
codigo: payload.codigo ?? existingAsignatura.codigo,
|
||||||
|
tipo: payload.tipo ?? existingAsignatura.tipo,
|
||||||
|
creditos: payload.creditos ?? existingAsignatura.creditos,
|
||||||
|
horas_academicas: payload.horas_academicas ??
|
||||||
|
existingAsignatura.horas_academicas,
|
||||||
|
horas_independientes: payload.horas_independientes ??
|
||||||
|
existingAsignatura.horas_independientes,
|
||||||
|
numero_ciclo: payload.numero_ciclo ?? existingAsignatura.numero_ciclo,
|
||||||
|
linea_plan_id: payload.linea_plan_id ??
|
||||||
|
existingAsignatura.linea_plan_id,
|
||||||
|
orden_celda: payload.orden_celda ?? existingAsignatura.orden_celda,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!resolved.estructura_id) {
|
||||||
|
throw new HttpError(
|
||||||
|
422,
|
||||||
|
"estructura_id es requerido (no está presente ni en el JSON ni en la asignatura existente).",
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
{
|
||||||
|
id: payload.id,
|
||||||
|
existing_estructura_id: existingAsignatura.estructura_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: estructura, error: estructuraError } = await supabaseService
|
||||||
|
.from("estructuras_asignatura")
|
||||||
|
.select("id,nombre,definicion,version")
|
||||||
|
.eq("id", resolved.estructura_id)
|
||||||
|
.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: resolved.estructura_id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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: ${resolved.nombre}\n` +
|
||||||
|
`- Código (clave de la asignatura): ${
|
||||||
|
resolved.codigo ?? "(no especificado)"
|
||||||
|
}\n` +
|
||||||
|
`- Tipo: ${resolved.tipo ?? "(no especificado)"}\n` +
|
||||||
|
`- Número de créditos: ${resolved.creditos}\n` +
|
||||||
|
`- Horas académicas: ${
|
||||||
|
resolved.horas_academicas ?? "(no especificado)"
|
||||||
|
}\n` +
|
||||||
|
`- Horas independientes: ${
|
||||||
|
resolved.horas_independientes ?? "(no especificado)"
|
||||||
|
}\n` +
|
||||||
|
`- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${
|
||||||
|
payload.descripcionEnfoqueAcademico ?? "(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);
|
||||||
|
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 updatePatch: Database["public"]["Tables"]["asignaturas"]["Update"] =
|
||||||
|
{
|
||||||
|
datos: aiOutputJson,
|
||||||
|
tipo_origen: "IA",
|
||||||
|
estado: "borrador",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply only provided JSON fields (do not overwrite if missing)
|
||||||
|
if (payload.plan_estudio_id !== undefined) {
|
||||||
|
updatePatch.plan_estudio_id = payload.plan_estudio_id;
|
||||||
|
}
|
||||||
|
if (payload.estructura_id !== undefined) {
|
||||||
|
updatePatch.estructura_id = payload.estructura_id;
|
||||||
|
}
|
||||||
|
if (payload.nombre !== undefined) {
|
||||||
|
updatePatch.nombre = payload.nombre;
|
||||||
|
}
|
||||||
|
if (payload.codigo !== undefined) {
|
||||||
|
updatePatch.codigo = payload.codigo;
|
||||||
|
}
|
||||||
|
if (payload.tipo !== undefined) {
|
||||||
|
updatePatch.tipo = payload.tipo as Database["public"]["Tables"][
|
||||||
|
"asignaturas"
|
||||||
|
]["Update"]["tipo"];
|
||||||
|
}
|
||||||
|
if (payload.creditos !== undefined) {
|
||||||
|
updatePatch.creditos = payload.creditos;
|
||||||
|
}
|
||||||
|
if (payload.horas_academicas !== undefined) {
|
||||||
|
updatePatch.horas_academicas = payload.horas_academicas;
|
||||||
|
}
|
||||||
|
if (payload.horas_independientes !== undefined) {
|
||||||
|
updatePatch.horas_independientes = payload.horas_independientes;
|
||||||
|
}
|
||||||
|
if (payload.numero_ciclo !== undefined) {
|
||||||
|
updatePatch.numero_ciclo = payload.numero_ciclo;
|
||||||
|
}
|
||||||
|
if (payload.linea_plan_id !== undefined) {
|
||||||
|
updatePatch.linea_plan_id = payload.linea_plan_id;
|
||||||
|
}
|
||||||
|
if (payload.orden_celda !== undefined) {
|
||||||
|
updatePatch.orden_celda = payload.orden_celda;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePatch.meta_origen = {
|
||||||
|
generado_por: "ai_generate_subject_update_json",
|
||||||
|
ai: {
|
||||||
|
responseId: aiResult.responseId ?? null,
|
||||||
|
conversationId: aiResult.conversationId ?? null,
|
||||||
|
model: aiResult.model,
|
||||||
|
usage: aiResult.usage ?? null,
|
||||||
|
},
|
||||||
|
iaConfig: {
|
||||||
|
descripcionEnfoqueAcademico: payload.descripcionEnfoqueAcademico ??
|
||||||
|
null,
|
||||||
|
},
|
||||||
|
} as unknown as Json;
|
||||||
|
|
||||||
|
const { data: asignatura, error: asignaturaError } = await supabaseService
|
||||||
|
.from("asignaturas")
|
||||||
|
.update(updatePatch)
|
||||||
|
.eq("id", payload.id)
|
||||||
|
.select(
|
||||||
|
"id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,estructura_id,plan_estudio_id,contenido_tematico,meta_origen,datos,creado_en,actualizado_en,numero_ciclo,linea_plan_id,orden_celda",
|
||||||
|
)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (asignaturaError) {
|
||||||
|
const maybeCode = (asignaturaError as { code?: string }).code;
|
||||||
|
if (maybeCode === "PGRST116") {
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
"No se encontró la asignatura.",
|
||||||
|
"NOT_FOUND",
|
||||||
|
{ table: "asignaturas", id: payload.id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new HttpError(
|
||||||
|
500,
|
||||||
|
"No se pudo actualizar la asignatura.",
|
||||||
|
"SUPABASE_UPDATE_FAILED",
|
||||||
|
asignaturaError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[${
|
||||||
|
new Date().toISOString()
|
||||||
|
}][${functionName}]: JSON update processed successfully`,
|
||||||
|
);
|
||||||
|
return sendSuccess(asignatura);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Multipart create flow (existing)
|
||||||
|
// -----------------------------
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
const validation = parseAndValidate(formData);
|
const validation = parseAndValidate(formData);
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
create type "public"."estado_asignatura" as enum ('borrador', 'revisada', 'aprobada', 'generando');
|
||||||
|
|
||||||
|
alter table "public"."asignaturas" add column "estado" public.estado_asignatura not null default 'generando'::public.estado_asignatura;
|
||||||
|
|
||||||
|
set check_function_bodies = off;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.fn_log_cambios_planes_estudio()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $function$declare
|
||||||
|
k text;
|
||||||
|
old_val jsonb;
|
||||||
|
new_val jsonb;
|
||||||
|
|
||||||
|
v_response_id text;
|
||||||
|
begin
|
||||||
|
v_response_id := nullif(new.meta_origen->>'response_id','');
|
||||||
|
|
||||||
|
-- INSERT -> CREACION
|
||||||
|
if tg_op = 'INSERT' then
|
||||||
|
insert into public.cambios_plan (
|
||||||
|
plan_estudio_id,
|
||||||
|
cambiado_por,
|
||||||
|
tipo,
|
||||||
|
campo,
|
||||||
|
valor_anterior,
|
||||||
|
valor_nuevo,
|
||||||
|
response_id
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
new.creado_por,
|
||||||
|
'CREACION'::public.tipo_cambio,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
to_jsonb(new),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- DELETE (opcional): si no lo quieres, bórralo
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
insert into public.cambios_plan (
|
||||||
|
plan_estudio_id,
|
||||||
|
cambiado_por,
|
||||||
|
tipo,
|
||||||
|
campo,
|
||||||
|
valor_anterior,
|
||||||
|
valor_nuevo,
|
||||||
|
response_id
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
old.id,
|
||||||
|
old.actualizado_por,
|
||||||
|
'OTRO'::public.tipo_cambio,
|
||||||
|
'DELETE',
|
||||||
|
to_jsonb(old),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
return old;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- UPDATE ----------------------------------------------------------
|
||||||
|
-- 1) Transición de estado
|
||||||
|
if (new.estado_actual_id is distinct from old.estado_actual_id) then
|
||||||
|
insert into public.cambios_plan (
|
||||||
|
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
new.actualizado_por,
|
||||||
|
'TRANSICION_ESTADO'::public.tipo_cambio,
|
||||||
|
'estado_actual_id',
|
||||||
|
to_jsonb(old.estado_actual_id),
|
||||||
|
to_jsonb(new.estado_actual_id),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- 2) Cambios en JSONB "datos" (diff top-level por llave)
|
||||||
|
if (new.datos is distinct from old.datos) then
|
||||||
|
for k in
|
||||||
|
select distinct key
|
||||||
|
from (
|
||||||
|
select jsonb_object_keys(coalesce(old.datos, '{}'::jsonb)) as key
|
||||||
|
union all
|
||||||
|
select jsonb_object_keys(coalesce(new.datos, '{}'::jsonb)) as key
|
||||||
|
) t
|
||||||
|
loop
|
||||||
|
old_val := coalesce(old.datos, '{}'::jsonb) -> k;
|
||||||
|
new_val := coalesce(new.datos, '{}'::jsonb) -> k;
|
||||||
|
|
||||||
|
if (old_val is distinct from new_val) then
|
||||||
|
insert into public.cambios_plan (
|
||||||
|
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
new.actualizado_por,
|
||||||
|
'ACTUALIZACION_CAMPO'::public.tipo_cambio,
|
||||||
|
k,
|
||||||
|
old_val,
|
||||||
|
new_val,
|
||||||
|
v_response_id
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- 3) Cambios en columnas "normales" (uno por columna)
|
||||||
|
if (new.nombre is distinct from old.nombre) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'nombre', to_jsonb(old.nombre), to_jsonb(new.nombre), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (new.nivel is distinct from old.nivel) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'nivel', to_jsonb(old.nivel), to_jsonb(new.nivel), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (new.tipo_ciclo is distinct from old.tipo_ciclo) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'tipo_ciclo', to_jsonb(old.tipo_ciclo), to_jsonb(new.tipo_ciclo), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (new.numero_ciclos is distinct from old.numero_ciclos) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'numero_ciclos', to_jsonb(old.numero_ciclos), to_jsonb(new.numero_ciclos), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (new.activo is distinct from old.activo) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'activo', to_jsonb(old.activo), to_jsonb(new.activo), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (new.carrera_id is distinct from old.carrera_id) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'carrera_id', to_jsonb(old.carrera_id), to_jsonb(new.carrera_id), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (new.estructura_id is distinct from old.estructura_id) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'estructura_id', to_jsonb(old.estructura_id), to_jsonb(new.estructura_id), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (new.tipo_origen is distinct from old.tipo_origen) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'tipo_origen', to_jsonb(old.tipo_origen), to_jsonb(new.tipo_origen), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
|
||||||
|
if (new.conversation_id is distinct from old.conversation_id) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'conversation_id', to_jsonb(old.conversation_id), to_jsonb(new.conversation_id), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- 🔥 consumirlo para que NO se guarde en planes_estudio
|
||||||
|
if v_response_id is not null then
|
||||||
|
new.meta_origen := new.meta_origen - 'response_id';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;$function$
|
||||||
|
;
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
alter publication supabase_realtime drop table asignaturas;
|
||||||
Reference in New Issue
Block a user