Se inserta el plan de estudios y se mandan crear los datos generales en segundo plano, y la edge funcion de openai-webhook-responses recibe la respuesta para actualizar el plan de estudio con los datos generales
This commit is contained in:
@@ -112,6 +112,11 @@ export class OpenAIService {
|
||||
const openaiRaw = (await this.openai.responses.create(
|
||||
newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming,
|
||||
)) as OpenAITypes.OpenAI.Responses.Response;
|
||||
|
||||
const isBackground =
|
||||
(newOptions as unknown as { background?: boolean })
|
||||
.background ===
|
||||
true;
|
||||
const { model, id: responseId } = openaiRaw;
|
||||
const usage = openaiRaw?.usage ?? null;
|
||||
const conversationId = (
|
||||
@@ -119,6 +124,22 @@ export class OpenAIService {
|
||||
conversation_id?: string | null;
|
||||
}
|
||||
).conversation_id ?? null;
|
||||
|
||||
if (isBackground) {
|
||||
return {
|
||||
ok: true,
|
||||
output: undefined,
|
||||
outputText: undefined,
|
||||
model: String(model),
|
||||
usage,
|
||||
responseId: String(responseId),
|
||||
conversationId: conversationId
|
||||
? String(conversationId)
|
||||
: null,
|
||||
references: { uploadedToStorage, openaiFileIds },
|
||||
openaiRaw,
|
||||
};
|
||||
}
|
||||
// Try to read structured JSON output
|
||||
let output: TOutput | undefined = undefined;
|
||||
let outputText: string | undefined = undefined;
|
||||
|
||||
@@ -51,4 +51,5 @@ export function sendError(
|
||||
export interface ResponseMetadata extends Record<string, string | undefined> {
|
||||
tabla?: string;
|
||||
accion?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@ type NivelType =
|
||||
type TipoCicloType =
|
||||
Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"];
|
||||
|
||||
addEventListener("beforeunload", (ev: any) => {
|
||||
// ev.detail.reason te dirá si es "timeout", "memory_limit" o "idle"
|
||||
console.error("ALERTA: La función se va a apagar. Razón:", ev.detail?.reason);
|
||||
type BeforeUnloadWithDetail = Event & { detail?: { reason?: unknown } };
|
||||
|
||||
// Aquí puedes intentar un último log antes de que todo muera
|
||||
// Re-registramos con tipo estricto (evita `any` en análisis)
|
||||
addEventListener("beforeunload", (ev: BeforeUnloadWithDetail) => {
|
||||
console.error("ALERTA: La función se va a apagar. Razón:", ev.detail?.reason);
|
||||
});
|
||||
|
||||
Deno.serve(async (req: Request): Promise<Response> => {
|
||||
@@ -195,8 +195,98 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
||||
? estructuraPlan.definicion as Record<string, unknown>
|
||||
: {};
|
||||
|
||||
const { data: estado } = await supabaseService
|
||||
.from("estados_plan")
|
||||
.select("id,clave,orden")
|
||||
.eq("clave", "GENERANDO")
|
||||
.maybeSingle();
|
||||
|
||||
if (!estado?.id) {
|
||||
throw new HttpError(
|
||||
500,
|
||||
"No se encontró el estado GENERANDO.",
|
||||
"MISSING_STATE",
|
||||
{ clave: "GENERANDO" },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: carrera, error: carreraError } = await supabaseService
|
||||
.from("carreras")
|
||||
.select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)")
|
||||
.eq("id", payload.datosBasicos.carreraId)
|
||||
.maybeSingle();
|
||||
if (carreraError) {
|
||||
throw new HttpError(
|
||||
500,
|
||||
"No se pudo obtener la carrera.",
|
||||
"SUPABASE_QUERY_FAILED",
|
||||
carreraError,
|
||||
);
|
||||
}
|
||||
if (!carrera) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
"No se encontró la carrera.",
|
||||
"NOT_FOUND",
|
||||
{ table: "carreras", id: payload.datosBasicos.carreraId },
|
||||
);
|
||||
}
|
||||
|
||||
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 as NivelType,
|
||||
tipo_ciclo: payload.datosBasicos.tipoCiclo as TipoCicloType,
|
||||
numero_ciclos: payload.datosBasicos.numCiclos,
|
||||
// IMPORTANTE: se inserta SIN `datos` (se actualiza vía webhook)
|
||||
estado_actual_id: estado.id,
|
||||
activo: true,
|
||||
tipo_origen: "IA",
|
||||
meta_origen: {
|
||||
generado_por: "ai-generate-plan",
|
||||
referencias: {
|
||||
archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null,
|
||||
repositoriosIds: payload.iaConfig?.repositoriosIds ?? null,
|
||||
},
|
||||
iaConfig: {
|
||||
descripcionEnfoqueAcademico:
|
||||
payload.iaConfig?.descripcionEnfoqueAcademico ?? null,
|
||||
instruccionesAdicionalesIA:
|
||||
payload.iaConfig?.instruccionesAdicionalesIA ?? null,
|
||||
usarMCP: Boolean(payload.iaConfig?.usarMCP),
|
||||
},
|
||||
} 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",
|
||||
)
|
||||
.single();
|
||||
|
||||
if (planError) {
|
||||
const maybeCode = (planError as { code?: string }).code;
|
||||
const status = maybeCode ? 409 : 500;
|
||||
throw new HttpError(
|
||||
status,
|
||||
"No se pudo guardar el plan de estudios.",
|
||||
"SUPABASE_INSERT_FAILED",
|
||||
{ ...planError, code: maybeCode },
|
||||
);
|
||||
}
|
||||
|
||||
const aiStructuredPayload: StructuredResponseOptions = {
|
||||
model: AI_GENERATE_PLAN_MODELO,
|
||||
background: true,
|
||||
metadata: {
|
||||
tabla: "planes_estudio",
|
||||
accion: "crear",
|
||||
id: String(plan.id),
|
||||
},
|
||||
input: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
@@ -244,126 +334,12 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
||||
const status = aiResult.code === "MissingEnv" ? 500 : 502;
|
||||
throw new HttpError(
|
||||
status,
|
||||
"No se pudo generar el plan con IA.",
|
||||
"No se pudo iniciar la generación del plan con IA.",
|
||||
"OPENAI_REQUEST_FAILED",
|
||||
aiResult,
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer parsed output; fallback to parse outputText
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
const aiOutputJson: Json = aiOutput as unknown as Json;
|
||||
if (!aiOutput) {
|
||||
throw new HttpError(
|
||||
502,
|
||||
"La respuesta de la IA no contiene salida estructurada.",
|
||||
"OPENAI_MISSING_STRUCTURED_OUTPUT",
|
||||
{ outputText: aiResult.outputText ?? null },
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Insertar interacciones con IA y quizas forzar a que la informacion de datosBasicos sea la misma que la recibida
|
||||
|
||||
const { data: estado } = await supabaseService
|
||||
.from("estados_plan")
|
||||
.select("id,clave,orden")
|
||||
.ilike("clave", "BORRADOR%")
|
||||
.order("orden", { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
const { data: carrera, error: carreraError } = await supabaseService
|
||||
.from("carreras")
|
||||
.select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)")
|
||||
.eq("id", payload.datosBasicos.carreraId)
|
||||
.maybeSingle();
|
||||
if (carreraError) {
|
||||
throw new HttpError(
|
||||
500,
|
||||
"No se pudo obtener la carrera.",
|
||||
"SUPABASE_QUERY_FAILED",
|
||||
carreraError,
|
||||
);
|
||||
}
|
||||
if (!carrera) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
"No se encontró la carrera.",
|
||||
"NOT_FOUND",
|
||||
{ table: "carreras", id: payload.datosBasicos.carreraId },
|
||||
);
|
||||
}
|
||||
|
||||
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 as NivelType,
|
||||
tipo_ciclo: payload.datosBasicos.tipoCiclo as TipoCicloType,
|
||||
numero_ciclos: payload.datosBasicos.numCiclos,
|
||||
datos: aiOutputJson,
|
||||
estado_actual_id: estado?.id ?? undefined,
|
||||
activo: true,
|
||||
tipo_origen: "IA",
|
||||
meta_origen: {
|
||||
generado_por: "ai_generate_plan",
|
||||
ai_structured: {
|
||||
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,
|
||||
},
|
||||
iaConfig: {
|
||||
descripcionEnfoqueAcademico:
|
||||
payload.iaConfig?.descripcionEnfoqueAcademico ?? null,
|
||||
instruccionesAdicionalesIA:
|
||||
payload.iaConfig?.instruccionesAdicionalesIA ?? null,
|
||||
usarMCP: Boolean(payload.iaConfig?.usarMCP),
|
||||
},
|
||||
} 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",
|
||||
)
|
||||
.single();
|
||||
|
||||
// TODO: interaccion con IA y cambio de estado
|
||||
|
||||
if (planError) {
|
||||
const maybeCode = (planError as { code?: string }).code;
|
||||
// Common cases:
|
||||
// - foreign key / constraint violations -> 409
|
||||
// - others -> 500
|
||||
const status = maybeCode ? 409 : 500;
|
||||
throw new HttpError(
|
||||
status,
|
||||
"No se pudo guardar el plan de estudios.",
|
||||
"SUPABASE_INSERT_FAILED",
|
||||
{ ...planError, code: maybeCode },
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: update a interaccion_ia y e insert a cambios_plan con id de plan generado
|
||||
|
||||
console.log(
|
||||
|
||||
@@ -27,20 +27,32 @@ async function handleCompletedResponse(
|
||||
|
||||
switch (metadata.tabla) {
|
||||
case "planes_estudio":
|
||||
handlePlanesEstudioResponse(response);
|
||||
await handlePlanesEstudioResponse(response);
|
||||
break;
|
||||
default:
|
||||
console.warn("Tabla no reconocida:", metadata.tabla);
|
||||
}
|
||||
|
||||
const output_text = response.output
|
||||
.filter((item: any) => item.type === "message")
|
||||
.flatMap((item: any) => item.content)
|
||||
.filter((contentItem: any) => contentItem.type === "output_text")
|
||||
.map((contentItem: any) => contentItem.text)
|
||||
const direct = (response as unknown as { output_text?: unknown }).output_text;
|
||||
if (typeof direct === "string" && direct.length) {
|
||||
console.log("Response output:", direct);
|
||||
return;
|
||||
}
|
||||
|
||||
const output = (response as unknown as { output?: unknown }).output;
|
||||
if (!Array.isArray(output)) {
|
||||
console.log("Response output: (no output)");
|
||||
return;
|
||||
}
|
||||
|
||||
const outputText = output
|
||||
.filter((item) => (item as { type?: unknown })?.type === "message")
|
||||
.flatMap((item) => (item as { content?: unknown })?.content ?? [])
|
||||
.filter((c) => (c as { type?: unknown })?.type === "output_text")
|
||||
.map((c) => String((c as { text?: unknown })?.text ?? ""))
|
||||
.join("");
|
||||
|
||||
console.log("Response output:", output_text);
|
||||
console.log("Response output:", outputText);
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
|
||||
@@ -1,6 +1,108 @@
|
||||
import type { OpenAI } from "openai";
|
||||
import { supabase } from "../supabase.ts";
|
||||
import type { TablesUpdate } from "../../_shared/database.types.ts";
|
||||
import type { Json } from "../../_shared/database.types.ts";
|
||||
import { ResponseMetadata } from "../../_shared/utils.ts";
|
||||
|
||||
export function handleCrearPlanEstudio(response: OpenAI.Responses.Response) {
|
||||
async function getEstadoId(clave: string): Promise<string | null> {
|
||||
const { data, error } = await supabase
|
||||
.from("estados_plan")
|
||||
.select("id")
|
||||
.eq("clave", clave)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
console.warn("No se pudo obtener estado:", clave, error);
|
||||
return null;
|
||||
}
|
||||
return data?.id ?? null;
|
||||
}
|
||||
|
||||
async function marcarFallido(planId: string): Promise<void> {
|
||||
const fallId = await getEstadoId("FALLDA");
|
||||
if (!fallId) {
|
||||
console.warn("No existe estado FALLDA; no se pudo marcar fallo", {
|
||||
planId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { error } = await supabase
|
||||
.from("planes_estudio")
|
||||
.update({ estado_actual_id: fallId })
|
||||
.eq("id", planId);
|
||||
if (error) {
|
||||
console.warn("No se pudo marcar plan como FALLDA", { planId, error });
|
||||
}
|
||||
}
|
||||
|
||||
function extractOutputText(response: OpenAI.Responses.Response): string {
|
||||
const direct = (response as unknown as { output_text?: unknown }).output_text;
|
||||
if (typeof direct === "string") return direct;
|
||||
|
||||
const output = (response as unknown as { output?: unknown }).output;
|
||||
if (!Array.isArray(output)) return "";
|
||||
|
||||
// Fallback similar al usado en index.ts
|
||||
try {
|
||||
return output
|
||||
.filter((item) => (item as { type?: unknown })?.type === "message")
|
||||
.flatMap((item) => (item as { content?: unknown })?.content ?? [])
|
||||
.filter((c) => (c as { type?: unknown })?.type === "output_text")
|
||||
.map((c) => String((c as { text?: unknown })?.text ?? ""))
|
||||
.join("");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCrearPlanEstudio(
|
||||
response: OpenAI.Responses.Response,
|
||||
): Promise<void> {
|
||||
const metadata = response.metadata as ResponseMetadata | null;
|
||||
const planId = metadata?.id;
|
||||
if (!planId) {
|
||||
console.warn("No se recibió metadata.id para actualizar el plan");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const borradorId = await getEstadoId("BORRADOR");
|
||||
if (!borradorId) {
|
||||
console.warn("No existe estado BORRADOR");
|
||||
await marcarFallido(planId);
|
||||
return;
|
||||
}
|
||||
|
||||
const outputText = extractOutputText(response);
|
||||
if (!outputText) {
|
||||
console.warn("La respuesta no contiene output_text");
|
||||
await marcarFallido(planId);
|
||||
return;
|
||||
}
|
||||
|
||||
let datos: Json;
|
||||
try {
|
||||
datos = JSON.parse(outputText) as Json;
|
||||
} catch (e) {
|
||||
console.warn("No se pudo parsear JSON de la respuesta", e);
|
||||
await marcarFallido(planId);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("planes_estudio")
|
||||
.update({ datos, estado_actual_id: borradorId })
|
||||
.eq("id", planId);
|
||||
|
||||
if (error) {
|
||||
console.warn("No se pudo actualizar el plan con datos", {
|
||||
planId,
|
||||
error,
|
||||
});
|
||||
await marcarFallido(planId);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Fallo inesperado procesando plan", { planId, e });
|
||||
await marcarFallido(planId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ResponseMetadata } from "../../_shared/utils.ts";
|
||||
import type { OpenAI } from "openai";
|
||||
import { handleCrearPlanEstudio } from "./crear.ts";
|
||||
|
||||
export function handlePlanesEstudioResponse(
|
||||
export async function handlePlanesEstudioResponse(
|
||||
response: OpenAI.Responses.Response,
|
||||
) {
|
||||
): Promise<void> {
|
||||
const metadata = response.metadata as ResponseMetadata | null;
|
||||
if (!metadata || !metadata.accion) {
|
||||
console.warn("No se recibió acción en la metadata");
|
||||
@@ -12,14 +13,14 @@ export function handlePlanesEstudioResponse(
|
||||
|
||||
switch (metadata.accion) {
|
||||
case "crear":
|
||||
// Lógica para crear un plan de estudio
|
||||
break;
|
||||
await handleCrearPlanEstudio(response);
|
||||
return;
|
||||
case "actualizar":
|
||||
// Lógica para actualizar un plan de estudio
|
||||
break;
|
||||
return;
|
||||
case "eliminar":
|
||||
// Lógica para eliminar un plan de estudio
|
||||
break;
|
||||
return;
|
||||
default:
|
||||
console.warn("Acción no reconocida:", metadata.accion);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "../_shared/database.types.ts";
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
|
||||
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||
|
||||
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
||||
if (!SUPABASE_URL || (!SUPABASE_SERVICE_ROLE_KEY && !SUPABASE_ANON_KEY)) {
|
||||
console.warn("Missing Supabase environment variables");
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
const supabaseKey = (SUPABASE_SERVICE_ROLE_KEY ?? SUPABASE_ANON_KEY) as string;
|
||||
|
||||
const supabase = createClient<Database>(
|
||||
SUPABASE_URL as string,
|
||||
SUPABASE_ANON_KEY as string,
|
||||
supabaseKey,
|
||||
);
|
||||
|
||||
export { supabase };
|
||||
|
||||
Reference in New Issue
Block a user