Versión funcional de creación de plan de estudios con IA
This commit is contained in:
@@ -6,10 +6,17 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||
import type { Database, Json } from "../_shared/database.types.ts";
|
||||
import type { AIGeneratePlanInput } from "./types.ts";
|
||||
import { z } from "zod";
|
||||
import { systemPrompt } from "./prompts.ts";
|
||||
import { strict } from "node:assert";
|
||||
import { OpenAIService } from "../_shared/openai-service.ts";
|
||||
import type { StructuredResponseOptions } from "../_shared/openai-service.ts";
|
||||
// Typed aliases for strict field unions
|
||||
type NivelType =
|
||||
Database["public"]["Tables"]["planes_estudio"]["Insert"]["nivel"];
|
||||
type TipoCicloType =
|
||||
Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"];
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
@@ -74,30 +81,22 @@ Deno.serve(async (req) => {
|
||||
throw new Error("Supabase environment variables are not set");
|
||||
}
|
||||
|
||||
const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
global: {
|
||||
headers: {
|
||||
Authorization: authHeaderRaw,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: quitar hardcode de usuario
|
||||
const { data: user, error: userError } = await supabaseAnon.auth
|
||||
.signInWithPassword({
|
||||
email: "guillermo.arrieta@lasalle.mx",
|
||||
password: "admin",
|
||||
});
|
||||
if (userError) {
|
||||
throw new Error("Error authenticating user: " + userError.message);
|
||||
}
|
||||
// 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 Error("SUPABASE_SERVICE_ROLE_KEY is not set");
|
||||
}
|
||||
|
||||
const supabaseService = createClient(
|
||||
const supabaseService = createClient<Database>(
|
||||
SUPABASE_URL,
|
||||
SERVICE_ROLE_KEY,
|
||||
);
|
||||
@@ -136,7 +135,14 @@ Deno.serve(async (req) => {
|
||||
`Genera un borrador completo del PLAN DE ESTUDIOS con base en lo siguiente:
|
||||
- Descripción del enfoque: ${payload.iaConfig.descripcionEnfoque}
|
||||
- Notas adicionales: ${payload.iaConfig.notasAdicionales ?? "Ninguna"}`;
|
||||
const aiStructuredPayload = {
|
||||
// Ensure the JSON schema is an object as required by OpenAI types
|
||||
const schemaDef: Record<string, unknown> =
|
||||
typeof estructuraPlan?.definicion === "object" &&
|
||||
estructuraPlan?.definicion !== null
|
||||
? estructuraPlan.definicion as Record<string, unknown>
|
||||
: {};
|
||||
|
||||
const aiStructuredPayload: StructuredResponseOptions = {
|
||||
model: "gpt-5-mini",
|
||||
input: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
@@ -146,39 +152,34 @@ Deno.serve(async (req) => {
|
||||
format: {
|
||||
type: "json_schema",
|
||||
name: "plan_de_estudios_standard",
|
||||
schema: estructuraPlan?.definicion,
|
||||
schema: schemaDef,
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const aiStructuredFormData = new FormData();
|
||||
aiStructuredFormData.append("options", JSON.stringify(aiStructuredPayload));
|
||||
for (const file of payload.archivosAdjuntos ?? []) {
|
||||
aiStructuredFormData.append("files", file);
|
||||
// Use shared OpenAI service directly (no HTTP invoke)
|
||||
const svc = OpenAIService.fromEnv();
|
||||
if (!(svc instanceof OpenAIService)) {
|
||||
throw new Error(`OpenAI service misconfiguration: ${svc.message}`);
|
||||
}
|
||||
|
||||
const { data: aiJson, error } = await supabaseService.functions.invoke(
|
||||
"ai-structured",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${user.session?.access_token}`,
|
||||
},
|
||||
method: "POST",
|
||||
body: aiStructuredFormData,
|
||||
},
|
||||
const aiResult = await svc.createStructuredResponse(
|
||||
aiStructuredPayload,
|
||||
payload.archivosAdjuntos,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
if (!aiResult.ok) {
|
||||
throw new Error(
|
||||
"ai-structured invocation failed: " + error.message,
|
||||
`OpenAI call failed [${aiResult.code}]: ${aiResult.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!aiJson || !aiJson.ok) {
|
||||
throw new Error(
|
||||
"ai-structured returned an error or no data",
|
||||
);
|
||||
// Prefer parsed output; fallback to parse outputText
|
||||
const aiOutput = aiResult.output ??
|
||||
(aiResult.outputText ? JSON.parse(aiResult.outputText) : null);
|
||||
const aiOutputJson: Json = aiOutput as unknown as Json;
|
||||
if (!aiOutput) {
|
||||
throw new Error("OpenAI response did not contain structured output");
|
||||
}
|
||||
|
||||
// TODO: Insertar interacciones con IA y quizas forzar a que la informacion de datosBasicos sea la misma que la recibida
|
||||
@@ -200,43 +201,41 @@ Deno.serve(async (req) => {
|
||||
throw new Error("Error fetching carrera: " + carreraError.message);
|
||||
}
|
||||
|
||||
const { data: plan, error: planError } = await supabaseService
|
||||
.from("planes_estudio")
|
||||
.insert({
|
||||
carrera_id: carrera?.id,
|
||||
estructura_id: estructuraPlan?.id,
|
||||
const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] =
|
||||
{
|
||||
carrera_id: carrera?.id as string,
|
||||
estructura_id: estructuraPlan?.id as string,
|
||||
nombre: payload.datosBasicos.nombrePlan,
|
||||
nivel: payload.datosBasicos.nivel,
|
||||
tipo_ciclo: payload.datosBasicos.tipoCiclo,
|
||||
nivel: payload.datosBasicos.nivel as NivelType,
|
||||
tipo_ciclo: payload.datosBasicos.tipoCiclo as TipoCicloType,
|
||||
numero_ciclos: payload.datosBasicos.numCiclos,
|
||||
datos: aiJson.output,
|
||||
estado_actual_id: estado?.id,
|
||||
datos: aiOutputJson,
|
||||
estado_actual_id: estado?.id ?? undefined,
|
||||
activo: true,
|
||||
tipo_origen: "IA",
|
||||
meta_origen: {
|
||||
generado_por: "ai_generate_plan",
|
||||
ai_structured: {
|
||||
cid: aiJson?.cid ?? null,
|
||||
responseId: aiJson?.responseId ?? null,
|
||||
conversationId: aiJson?.conversationId ?? null,
|
||||
model: aiJson?.model,
|
||||
usage: aiJson?.usage ?? null,
|
||||
responseId: aiResult.responseId ?? null,
|
||||
conversationId: aiResult.conversationId ?? null,
|
||||
model: aiResult.model,
|
||||
usage: aiResult.usage ?? null,
|
||||
},
|
||||
referencias: {
|
||||
archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null,
|
||||
repositoriosIds: payload.iaConfig?.repositoriosIds ?? null,
|
||||
// openaiFileIds,
|
||||
// vectorStoreIds,
|
||||
},
|
||||
iaConfig: {
|
||||
descripcionEnfoque: payload.iaConfig?.descripcionEnfoque ?? null,
|
||||
notasAdicionales: payload.iaConfig?.notasAdicionales ?? null,
|
||||
usarMCP: Boolean(payload.iaConfig?.usarMCP),
|
||||
},
|
||||
},
|
||||
// creado_por: user.id,
|
||||
// actualizado_por: user.id,
|
||||
})
|
||||
} as unknown as Json,
|
||||
};
|
||||
|
||||
const { data: plan, error: planError } = await supabaseService
|
||||
.from("planes_estudio")
|
||||
.insert(planInsert)
|
||||
.select(
|
||||
"id,nombre,nivel,tipo_ciclo,numero_ciclos,carrera_id,estructura_id,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,datos",
|
||||
)
|
||||
@@ -246,7 +245,7 @@ Deno.serve(async (req) => {
|
||||
throw new Error("Error inserting plan: " + planError.message);
|
||||
}
|
||||
|
||||
// TODO: update a interaccion_ia y e insert a cambios_plancon 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,
|
||||
@@ -283,7 +282,7 @@ const jsonFromString = <T extends z.ZodTypeAny>(schema: T) =>
|
||||
z.string().transform((str, ctx) => {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "El formato no es un JSON válido",
|
||||
|
||||
Reference in New Issue
Block a user