39f5b5d338
- Refactor AIGenerateSubjectInput type to include optional fields for updates. - Add handling for "asignaturas" responses in OpenAI webhook. - Implement crear.ts for creating/updating subjects based on AI responses. - Update tests to validate OpenAI file uploads instead of storage uploads.
662 lines
20 KiB
TypeScript
662 lines
20 KiB
TypeScript
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 "@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";
|
|
|
|
type BeforeUnloadWithDetail = Event & { detail?: { reason?: unknown } };
|
|
|
|
addEventListener("beforeunload", (ev: BeforeUnloadWithDetail) => {
|
|
console.error(
|
|
"ALERTA: La función se va a apagar. Razón:",
|
|
ev?.detail?.reason,
|
|
);
|
|
});
|
|
|
|
const DatosUpdateSchema = z
|
|
.object({
|
|
id: z.string().uuid("id debe ser un UUID").optional(),
|
|
|
|
// campos de asignatura (nombres coinciden con columnas DB)
|
|
plan_estudio_id: z.string().uuid("plan_estudio_id debe ser un UUID"),
|
|
estructura_id: z.string().uuid("estructura_id debe ser un UUID").optional(),
|
|
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(),
|
|
linea_plan_id: z.string().uuid().nullable().optional(),
|
|
orden_celda: z.number().int().nonnegative().nullable().optional(),
|
|
})
|
|
.strict();
|
|
|
|
const IAConfigSchema = z
|
|
.object({
|
|
descripcionEnfoqueAcademico: z.string().optional(),
|
|
instruccionesAdicionalesIA: z.string().optional(),
|
|
archivosAdjuntos: z.array(z.string().min(1)).optional().default([]),
|
|
})
|
|
.strict()
|
|
.default({});
|
|
|
|
const UnifiedJsonSchema = z
|
|
.object({
|
|
datosUpdate: DatosUpdateSchema,
|
|
iaConfig: IAConfigSchema,
|
|
})
|
|
.strict()
|
|
.superRefine((val, ctx) => {
|
|
// Si no hay id, es un insert => necesitamos campos mínimos
|
|
if (!val.datosUpdate.id) {
|
|
if (!val.datosUpdate.estructura_id) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["datosUpdate", "estructura_id"],
|
|
message: "estructura_id es requerido para crear",
|
|
});
|
|
}
|
|
if (!val.datosUpdate.nombre) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["datosUpdate", "nombre"],
|
|
message: "nombre es requerido para crear",
|
|
});
|
|
}
|
|
if (val.datosUpdate.creditos === undefined) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["datosUpdate", "creditos"],
|
|
message: "creditos es requerido para crear",
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
type EdgeAIGenerateSubjectUnifiedInput = z.infer<typeof UnifiedJsonSchema>;
|
|
|
|
type AsignaturaBaseSeleccionada = {
|
|
id: string;
|
|
plan_estudio_id: string;
|
|
estructura_id: string | null;
|
|
nombre: string;
|
|
codigo: string | null;
|
|
tipo: Database["public"]["Tables"]["asignaturas"]["Row"]["tipo"];
|
|
creditos: number;
|
|
horas_academicas: number | null;
|
|
horas_independientes: number | null;
|
|
numero_ciclo: number | null;
|
|
linea_plan_id: string | null;
|
|
orden_celda: number | null;
|
|
estado: Database["public"]["Enums"]["estado_asignatura"];
|
|
};
|
|
|
|
function withColumnDefsAndRefs(
|
|
schemaDef: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const nextSchema: Record<string, unknown> = {
|
|
...schemaDef,
|
|
$defs: definicionesDeEstructurasDeColumnas,
|
|
};
|
|
|
|
const props = nextSchema["properties"];
|
|
if (!props || typeof props !== "object" || Array.isArray(props)) {
|
|
return nextSchema;
|
|
}
|
|
|
|
const nextProps: Record<string, unknown> = {
|
|
...props as Record<string, unknown>,
|
|
};
|
|
for (const [key, value] of Object.entries(nextProps)) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
const xColumn = (value as Record<string, unknown>)["x-column"];
|
|
if (typeof xColumn !== "string" || !xColumn.length) continue;
|
|
nextProps[key] = { $ref: `#/$defs/${xColumn}` };
|
|
}
|
|
|
|
nextSchema["properties"] = nextProps;
|
|
return nextSchema;
|
|
}
|
|
|
|
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> => {
|
|
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}`,
|
|
);
|
|
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();
|
|
const isJson = contentType.includes("application/json");
|
|
if (!isJson) {
|
|
console.error(
|
|
`[${
|
|
new Date().toISOString()
|
|
}][${functionName}]: Unsupported content type: ${contentType}`,
|
|
);
|
|
throw new HttpError(
|
|
415,
|
|
"Content-Type no soportado.",
|
|
"UNSUPPORTED_MEDIA_TYPE",
|
|
{ contentType, expected: "application/json" },
|
|
);
|
|
}
|
|
|
|
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 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
|
|
// 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 HttpError(
|
|
500,
|
|
"Configuración del servidor incompleta.",
|
|
"MISSING_ENV",
|
|
{ missing: ["SUPABASE_SERVICE_ROLE_KEY"] },
|
|
);
|
|
}
|
|
|
|
const supabaseService = createClient<Database>(
|
|
SUPABASE_URL,
|
|
SERVICE_ROLE_KEY,
|
|
);
|
|
|
|
// Model names (override via environment variables)
|
|
const AI_GENERATE_SUBJECT_UPDATE_MODELO = Deno.env.get(
|
|
"AI_GENERATE_SUBJECT_UPDATE_MODELO",
|
|
) ?? "gpt-5-nano";
|
|
const AI_GENERATE_SUBJECT_INSERT_MODELO = Deno.env.get(
|
|
"AI_GENERATE_SUBJECT_INSERT_MODELO",
|
|
) ?? "gpt-5-nano";
|
|
|
|
// -----------------------------
|
|
// Unified JSON create/update flow (background)
|
|
// -----------------------------
|
|
let rawBody: unknown;
|
|
try {
|
|
rawBody = await req.json();
|
|
} catch (e) {
|
|
throw new HttpError(
|
|
400,
|
|
"Body JSON inválido.",
|
|
"INVALID_JSON",
|
|
{ cause: e },
|
|
);
|
|
}
|
|
|
|
const parsedBody = UnifiedJsonSchema.safeParse(rawBody);
|
|
if (!parsedBody.success) {
|
|
throw new HttpError(
|
|
422,
|
|
formatZodIssues(parsedBody.error.issues),
|
|
"VALIDATION_ERROR",
|
|
parsedBody.error,
|
|
);
|
|
}
|
|
|
|
const payload: EdgeAIGenerateSubjectUnifiedInput = parsedBody.data;
|
|
const { datosUpdate, iaConfig } = payload;
|
|
const isUpdate = Boolean(datosUpdate.id);
|
|
|
|
const svc = OpenAIService.fromEnv();
|
|
if (!(svc instanceof OpenAIService)) {
|
|
throw new HttpError(
|
|
500,
|
|
"Configuración del servidor incompleta.",
|
|
"OPENAI_MISCONFIGURED",
|
|
svc,
|
|
);
|
|
}
|
|
|
|
// Resolve fields (merge for update)
|
|
const selectCols =
|
|
"id,plan_estudio_id,estructura_id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,numero_ciclo,linea_plan_id,orden_celda,estado";
|
|
|
|
let asignaturaBase: AsignaturaBaseSeleccionada | null = null;
|
|
|
|
if (isUpdate) {
|
|
const { data, error } = await supabaseService
|
|
.from("asignaturas")
|
|
.select(selectCols)
|
|
.eq("id", datosUpdate.id as string)
|
|
.single();
|
|
if (error) {
|
|
const maybeCode = (error as { code?: string }).code;
|
|
if (maybeCode === "PGRST116") {
|
|
throw new HttpError(
|
|
404,
|
|
"No se encontró la asignatura.",
|
|
"NOT_FOUND",
|
|
{ table: "asignaturas", id: datosUpdate.id },
|
|
);
|
|
}
|
|
throw new HttpError(
|
|
500,
|
|
"No se pudo obtener la asignatura.",
|
|
"SUPABASE_QUERY_FAILED",
|
|
error,
|
|
);
|
|
}
|
|
asignaturaBase = data as unknown as AsignaturaBaseSeleccionada;
|
|
}
|
|
|
|
const resolved = {
|
|
id: (datosUpdate.id ?? asignaturaBase?.id ?? null) as string | null,
|
|
plan_estudio_id: datosUpdate.plan_estudio_id ??
|
|
asignaturaBase?.plan_estudio_id,
|
|
estructura_id: datosUpdate.estructura_id ??
|
|
asignaturaBase?.estructura_id ?? null,
|
|
nombre: datosUpdate.nombre ?? asignaturaBase?.nombre ?? null,
|
|
codigo: datosUpdate.codigo ?? asignaturaBase?.codigo ?? null,
|
|
tipo: datosUpdate.tipo ?? asignaturaBase?.tipo ?? null,
|
|
creditos: datosUpdate.creditos ?? asignaturaBase?.creditos ?? null,
|
|
horas_academicas: datosUpdate.horas_academicas ??
|
|
asignaturaBase?.horas_academicas ?? null,
|
|
horas_independientes: datosUpdate.horas_independientes ??
|
|
asignaturaBase?.horas_independientes ?? null,
|
|
numero_ciclo: datosUpdate.numero_ciclo ?? asignaturaBase?.numero_ciclo ??
|
|
null,
|
|
linea_plan_id: datosUpdate.linea_plan_id ??
|
|
asignaturaBase?.linea_plan_id ?? null,
|
|
orden_celda: datosUpdate.orden_celda ?? asignaturaBase?.orden_celda ??
|
|
null,
|
|
};
|
|
|
|
if (!resolved.estructura_id) {
|
|
throw new HttpError(
|
|
422,
|
|
"estructura_id es requerido.",
|
|
"VALIDATION_ERROR",
|
|
{ estructura_id: resolved.estructura_id },
|
|
);
|
|
}
|
|
if (!resolved.nombre) {
|
|
throw new HttpError(
|
|
422,
|
|
"nombre es requerido.",
|
|
"VALIDATION_ERROR",
|
|
{ nombre: resolved.nombre },
|
|
);
|
|
}
|
|
if (resolved.creditos == null) {
|
|
throw new HttpError(
|
|
422,
|
|
"creditos es requerido.",
|
|
"VALIDATION_ERROR",
|
|
{ creditos: resolved.creditos },
|
|
);
|
|
}
|
|
|
|
// Crear/actualizar stub en estado 'generando'
|
|
let asignaturaId: string;
|
|
if (isUpdate) {
|
|
const updatePatch: Database["public"]["Tables"]["asignaturas"]["Update"] =
|
|
{
|
|
estado: "generando",
|
|
tipo_origen: "IA",
|
|
plan_estudio_id: resolved.plan_estudio_id,
|
|
estructura_id: resolved.estructura_id,
|
|
nombre: resolved.nombre,
|
|
codigo: resolved.codigo,
|
|
creditos: resolved.creditos,
|
|
horas_academicas: resolved.horas_academicas,
|
|
horas_independientes: resolved.horas_independientes,
|
|
numero_ciclo: resolved.numero_ciclo,
|
|
linea_plan_id: resolved.linea_plan_id,
|
|
orden_celda: resolved.orden_celda,
|
|
tipo: resolved
|
|
.tipo as Database["public"]["Tables"]["asignaturas"]["Update"][
|
|
"tipo"
|
|
],
|
|
meta_origen: {
|
|
generado_por: "ai_generate_subject_unified",
|
|
iaConfig: {
|
|
descripcionEnfoqueAcademico:
|
|
iaConfig.descripcionEnfoqueAcademico ?? null,
|
|
instruccionesAdicionalesIA: iaConfig.instruccionesAdicionalesIA ??
|
|
null,
|
|
archivosAdjuntos: iaConfig.archivosAdjuntos ?? [],
|
|
},
|
|
} as unknown as Json,
|
|
};
|
|
|
|
const { data, error } = await supabaseService
|
|
.from("asignaturas")
|
|
.update(updatePatch)
|
|
.eq("id", resolved.id as string)
|
|
.select("id")
|
|
.single();
|
|
if (error) {
|
|
throw new HttpError(
|
|
500,
|
|
"No se pudo actualizar la asignatura (stub).",
|
|
"SUPABASE_UPDATE_FAILED",
|
|
error,
|
|
);
|
|
}
|
|
asignaturaId = data.id;
|
|
} else {
|
|
const insertPatch: Database["public"]["Tables"]["asignaturas"]["Insert"] =
|
|
{
|
|
plan_estudio_id: resolved.plan_estudio_id as string,
|
|
estructura_id: resolved.estructura_id as string,
|
|
nombre: resolved.nombre as string,
|
|
codigo: resolved.codigo ?? null,
|
|
tipo:
|
|
(resolved.tipo ?? undefined) as Database["public"]["Tables"][
|
|
"asignaturas"
|
|
]["Insert"]["tipo"],
|
|
creditos: resolved.creditos as number,
|
|
horas_academicas: resolved.horas_academicas ?? null,
|
|
horas_independientes: resolved.horas_independientes ?? null,
|
|
numero_ciclo: resolved.numero_ciclo ?? null,
|
|
linea_plan_id: resolved.linea_plan_id ?? null,
|
|
orden_celda: resolved.orden_celda ?? null,
|
|
estado: "generando",
|
|
tipo_origen: "IA",
|
|
meta_origen: {
|
|
generado_por: "ai_generate_subject_unified",
|
|
iaConfig: {
|
|
descripcionEnfoqueAcademico:
|
|
iaConfig.descripcionEnfoqueAcademico ?? null,
|
|
instruccionesAdicionalesIA: iaConfig.instruccionesAdicionalesIA ??
|
|
null,
|
|
archivosAdjuntos: iaConfig.archivosAdjuntos ?? [],
|
|
},
|
|
} as unknown as Json,
|
|
};
|
|
|
|
const { data, error } = await supabaseService
|
|
.from("asignaturas")
|
|
.insert(insertPatch)
|
|
.select("id")
|
|
.single();
|
|
if (error) {
|
|
const maybeCode = (error as { code?: string }).code;
|
|
const status = maybeCode ? 409 : 500;
|
|
throw new HttpError(
|
|
status,
|
|
"No se pudo crear la asignatura (stub).",
|
|
"SUPABASE_INSERT_FAILED",
|
|
{ ...error, code: maybeCode },
|
|
);
|
|
}
|
|
asignaturaId = data.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 archivosAdjuntosTexto = (iaConfig.archivosAdjuntos ?? []).length
|
|
? `\n- Rutas de archivos adjuntos (referencia): ${
|
|
(iaConfig.archivosAdjuntos ?? []).join(", ")
|
|
}`
|
|
: "";
|
|
|
|
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): ${
|
|
iaConfig.descripcionEnfoqueAcademico ?? "(ninguna)"
|
|
}\n` +
|
|
`- Instrucciones adicionales (sobre el formato de la respuesta generada): ${
|
|
iaConfig.instruccionesAdicionalesIA ?? "(ninguna)"
|
|
}` +
|
|
archivosAdjuntosTexto +
|
|
`\n\nREGLA ESTRICTA MATEMÁTICA:\n` +
|
|
`Si generas el 'contenido_tematico', la suma total de las 'horasEstimadas' de todos los temas en todas las unidades DEBE coincidir exactamente con el total de Horas académicas indicadas arriba (${
|
|
resolved.horas_academicas ?? 0
|
|
}). No te pases ni te falten horas.`;
|
|
|
|
const schemaDef: Record<string, unknown> =
|
|
typeof estructura?.definicion === "object" &&
|
|
estructura?.definicion !== null
|
|
? (estructura.definicion as Record<string, unknown>)
|
|
: {};
|
|
|
|
const schemaForAI = withColumnDefsAndRefs(schemaDef);
|
|
|
|
const aiStructuredPayload: StructuredResponseOptions = {
|
|
model: isUpdate
|
|
? AI_GENERATE_SUBJECT_UPDATE_MODELO
|
|
: AI_GENERATE_SUBJECT_INSERT_MODELO,
|
|
background: true,
|
|
metadata: {
|
|
tabla: "asignaturas",
|
|
accion: isUpdate ? "actualizar" : "crear",
|
|
id: asignaturaId,
|
|
},
|
|
input: [
|
|
{ role: "system", content: systemPrompt },
|
|
{ role: "user", content: userPrompt },
|
|
],
|
|
text: {
|
|
format: {
|
|
type: "json_schema",
|
|
name: "asignatura_contenido_tematico",
|
|
schema: schemaForAI,
|
|
strict: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
const aiResult = await svc.createStructuredResponse(aiStructuredPayload);
|
|
if (!aiResult.ok) {
|
|
const status = aiResult.code === "MissingEnv" ? 500 : 502;
|
|
throw new HttpError(
|
|
status,
|
|
"No se pudo iniciar la generación de la asignatura con IA.",
|
|
"OPENAI_REQUEST_FAILED",
|
|
aiResult,
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`[${
|
|
new Date().toISOString()
|
|
}][${functionName}]: Subject generation started in background`,
|
|
);
|
|
return sendSuccess({
|
|
id: asignaturaId,
|
|
estado: "generando",
|
|
openai: { responseId: aiResult.responseId ?? null },
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof HttpError) {
|
|
console.error(
|
|
`[${new Date().toISOString()}][${functionName}] ⚠️ Handled Error:`,
|
|
{
|
|
message: error.message,
|
|
code: error.code,
|
|
internalDetails: error.internalDetails || "N/A",
|
|
},
|
|
);
|
|
|
|
return sendError(error.status, error.message, error.code);
|
|
}
|
|
|
|
const unexpectedError = error instanceof Error
|
|
? error
|
|
: new Error(String(error));
|
|
|
|
console.error(
|
|
`[${
|
|
new Date().toISOString()
|
|
}][${functionName}] 💥 CRITICAL UNHANDLED ERROR:`,
|
|
unexpectedError.stack || unexpectedError.message,
|
|
);
|
|
|
|
return sendError(
|
|
500,
|
|
"Ocurrió un error inesperado en el servidor.",
|
|
"INTERNAL_SERVER_ERROR",
|
|
);
|
|
}
|
|
});
|
|
|
|
const definicionesDeEstructurasDeColumnas = {
|
|
contenido_tematico: {
|
|
"type": "object",
|
|
"properties": {
|
|
"x-column": {
|
|
"type": "string",
|
|
"enum": [
|
|
"contenido_tematico",
|
|
],
|
|
},
|
|
"x-definicion": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"unidad": {
|
|
"type": "integer",
|
|
},
|
|
"titulo": {
|
|
"type": "string",
|
|
},
|
|
"temas": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"nombre": {
|
|
"type": "string",
|
|
},
|
|
"horasEstimadas": {
|
|
"type": "integer",
|
|
"description":
|
|
"Horas del tema. La suma de todas las horasEstimadas debe ser igual a las horas académicas del prompt.",
|
|
},
|
|
},
|
|
"required": [
|
|
"nombre",
|
|
"horasEstimadas",
|
|
],
|
|
"additionalProperties": false,
|
|
},
|
|
},
|
|
},
|
|
"required": [
|
|
"unidad",
|
|
"titulo",
|
|
"temas",
|
|
],
|
|
"additionalProperties": false,
|
|
},
|
|
},
|
|
},
|
|
"required": [
|
|
"x-column",
|
|
"x-definicion",
|
|
],
|
|
"additionalProperties": false,
|
|
},
|
|
};
|