Creación de planes y asignaturas en segundo plano y con webhook de openai #40

Merged
Guillermo.Arrieta merged 9 commits from issue/35-crear-webhook-para-responses into main 2026-02-27 18:32:56 +00:00
9 changed files with 753 additions and 799 deletions
Showing only changes of commit c12e2895a8 - Show all commits
Generated
+6 -2
View File
@@ -21,6 +21,7 @@
"jsr:@zod/zod@^4.3.5": "4.3.5", "jsr:@zod/zod@^4.3.5": "4.3.5",
"npm:@supabase/supabase-js@2": "2.90.1", "npm:@supabase/supabase-js@2": "2.90.1",
"npm:@supabase/supabase-js@^2.90.1": "2.90.1", "npm:@supabase/supabase-js@^2.90.1": "2.90.1",
"npm:@toon-format/toon@^2.1.0": "2.1.0",
"npm:@types/bun@^1.3.5": "1.3.5", "npm:@types/bun@^1.3.5": "1.3.5",
"npm:deno@^2.6.4": "2.6.4", "npm:deno@^2.6.4": "2.6.4",
"npm:openai@6.16.0": "6.16.0_zod@3.25.76", "npm:openai@6.16.0": "6.16.0_zod@3.25.76",
@@ -186,6 +187,9 @@
"@supabase/storage-js" "@supabase/storage-js"
] ]
}, },
"@toon-format/toon@2.1.0": {
"integrity": "sha512-JwWptdF5eOA0HaQxbKAzkpQtR4wSWTEfDlEy/y3/4okmOAX1qwnpLZMmtEWr+ncAhTTY1raCKH0kteHhSXnQqg=="
},
"@types/bun@1.3.5": { "@types/bun@1.3.5": {
"integrity": "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w==", "integrity": "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w==",
"dependencies": [ "dependencies": [
@@ -519,8 +523,7 @@
"minipass", "minipass",
"minizlib", "minizlib",
"yallist" "yallist"
], ]
"deprecated": true
}, },
"tr46@0.0.3": { "tr46@0.0.3": {
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
@@ -591,6 +594,7 @@
"packageJson": { "packageJson": {
"dependencies": [ "dependencies": [
"npm:@supabase/supabase-js@^2.90.1", "npm:@supabase/supabase-js@^2.90.1",
"npm:@toon-format/toon@^2.1.0",
"npm:@types/bun@^1.3.5", "npm:@types/bun@^1.3.5",
"npm:deno@^2.6.4", "npm:deno@^2.6.4",
"npm:openai@^6.16.0", "npm:openai@^6.16.0",
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -83,11 +83,13 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 26, "execution_count": null,
"id": "1403c789", "id": "1403c789",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": [
"import { title } from \"node:process\";\n",
"\n",
"const combinedSchema = {\n", "const combinedSchema = {\n",
" type: \"object\",\n", " type: \"object\",\n",
" $defs: {\n", " $defs: {\n",
@@ -126,10 +128,12 @@
" fabricante: { type: \"string\", maxLength: 100 },\n", " fabricante: { type: \"string\", maxLength: 100 },\n",
"\n", "\n",
" // Agregar campo del preset\n", " // Agregar campo del preset\n",
" perishables: { $ref: \"#/$defs/perishables\" },\n", " perishables: {\n",
" origen: { $ref: \"#/$defs/origen\" },\n", " $ref: \"#/$defs/perishables\",\n",
" \"x-column\": \"perishables\",\n",
" },\n",
"\n", "\n",
" perecedero: { type: \"null\", \"x-column\": \"perishable\", const: null },\n", " origen: { $ref: \"#/$defs/origen\" },\n",
" },\n", " },\n",
" required: [\"nombre\", \"fabricante\", \"perishables\", \"origen\", \"perecedero\"],\n", " required: [\"nombre\", \"fabricante\", \"perishables\", \"origen\", \"perecedero\"],\n",
" additionalProperties: false,\n", " additionalProperties: false,\n",
+54 -73
View File
@@ -2,7 +2,7 @@
"cells": [ "cells": [
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 3, "execution_count": 2,
"id": "902bff53", "id": "902bff53",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
@@ -19,7 +19,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 4, "execution_count": 3,
"id": "3b0acbc3", "id": "3b0acbc3",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
@@ -32,63 +32,18 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 8, "execution_count": 1,
"id": "dae842ad", "id": "dae842ad",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
{ {
"name": "stdout", "ename": "ReferenceError",
"output_type": "stream", "evalue": "openai is not defined",
"text": [ "output_type": "error",
"{\n", "traceback": [
" object: \"list\",\n", "Stack trace:",
" data: [\n", "ReferenceError: openai is not defined",
" {\n", " at <anonymous>:5:15"
" object: \"embedding\",\n",
" index: 0,\n",
" embedding: [\n",
" 0.017360063269734383, 0.022553985938429832, 0.025286488234996796,\n",
" -0.009742671623826027, -0.016438385471701622, -0.014725150540471077,\n",
" -0.020840751007199287, 0.02215278521180153, -0.02024437114596367,\n",
" 0.007834257557988167, 0.0029981620609760284, -0.04109596461057663,\n",
" 0.030165957286953926, -0.03402615711092949, 0.0008369643473997712,\n",
" 0.00044016868923790753, -0.027129843831062317, -0.04248390346765518,\n",
" -0.058683738112449646, 0.034655068069696426, 0.013738414272665977,\n",
" 0.02060219831764698, 0.016687780618667603, 0.0059637944214046,\n",
" -0.010306521318852901, 0.014898642897605896, -0.008826415985822678,\n",
" 0.042136918753385544, 0.009477011859416962, -0.03953453525900841,\n",
" -0.012198670767247677, -0.040055014193058014, -0.019409440457820892,\n",
" -0.0210576169192791, -0.0392959825694561, -0.005996324121952057,\n",
" -0.0022486215457320213, 0.05209103599190712, 0.0009325206046923995,\n",
" 0.013163721188902855, 0.0505296029150486, 0.014540815725922585,\n",
" 0.0027921402361243963, 0.03190087899565697, 0.00004917589103570208,\n",
" 0.03209605813026428, -0.038363464176654816, -0.02424553595483303,\n",
" 0.014096241444349289, 0.02559009939432144, -0.06089576333761215,\n",
" -0.011016755364835262, -0.026696112006902695, 0.050659723579883575,\n",
" 0.019561246037483215, 0.050833214074373245, 0.014529972337186337,\n",
" -0.023204581812024117, -0.014399852603673935, -0.009292676113545895,\n",
" 0.003149967873468995, 0.02007087878882885, 0.025503354147076607,\n",
" 0.0230094026774168, 0.04471761733293533, 0.01479020994156599,\n",
" -0.06692461669445038, 0.03591288626194, -0.037127330899238586,\n",
" 0.029558734968304634, -0.017576929181814194, 0.01953955926001072,\n",
" -0.027888871729373932, -0.0041095963679254055, 0.0004940461367368698,\n",
" -0.011266149580478668, -0.04200679808855057, 0.03537072241306305,\n",
" -0.0015058581484481692, -0.05551750585436821, -0.05287174880504608,\n",
" 0.016373327001929283, 0.005573437083512545, -0.01235047634691,\n",
" -0.009005329571664333, -0.034047845751047134, -0.018368486315011978,\n",
" -0.00271217105910182, 0.007731246296316385, -0.020981714129447937,\n",
" -0.040900785475969315, -0.03727913647890091, -0.059030722826719284,\n",
" -0.04536821320652962, 0.02257567271590233, 0.08579189330339432,\n",
" 0.014605875127017498, -0.030686434358358383, -0.0019084142986685038,\n",
" 0.02216362953186035,\n",
" ... 1436 more items\n",
" ]\n",
" }\n",
" ],\n",
" model: \"text-embedding-3-small\",\n",
" usage: { prompt_tokens: 13, total_tokens: 13 }\n",
"}\n",
"✅ Insertados textos con embeddings\n"
] ]
} }
], ],
@@ -120,7 +75,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 13, "execution_count": 12,
"id": "2d8fa9a9", "id": "2d8fa9a9",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -129,20 +84,26 @@
"output_type": "stream", "output_type": "stream",
"text": [ "text": [
"🔎 Consulta: ¿Cómo son buscados vectores?\n", "🔎 Consulta: ¿Cómo son buscados vectores?\n",
"\n",
"- Los vectores se usan con el cliente de OPENAI embeddings\n",
" Similaridad: 0.5639\n",
"\n",
"- Supabase permite almacenar vectores usando pgvector.\n",
" Similaridad: 0.4758\n",
"\n",
"- Los embeddings convierten texto en representaciones numéricas.\n",
" Similaridad: 0.3645\n",
"\n" "\n"
] ]
},
{
"data": {
"text/plain": [
"\u001b[32m\"code: PGRST202\\n\"\u001b[39m +\n",
" \u001b[32m'details: \"Searched for the function public.buscar_documentos with parameters match_count, query_embedding or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache.\"\\n'\u001b[39m +\n",
" \u001b[32m\"hint: Perhaps you meant to call the function public.unaccent\\n\"\u001b[39m +\n",
" \u001b[32m'message: \"Could not find the function public.buscar_documentos(match_count, query_embedding) in the schema cache\"'\u001b[39m"
]
},
"execution_count": 12,
"metadata": {},
"output_type": "execute_result"
} }
], ],
"source": [ "source": [
"import { encode } from \"@toon-format/toon\";\n",
"\n",
"const consulta = \"¿Cómo son buscados vectores?\";\n", "const consulta = \"¿Cómo son buscados vectores?\";\n",
"\n", "\n",
"const emb = await openai.embeddings.create({\n", "const emb = await openai.embeddings.create({\n",
@@ -157,22 +118,42 @@
" match_count: 3,\n", " match_count: 3,\n",
"});\n", "});\n",
"\n", "\n",
"if (error) throw error;\n", "/* if (error) throw error; */\n",
"\n", "\n",
"console.log(`🔎 Consulta: ${consulta}\\n`);\n", "console.log(`🔎 Consulta: ${consulta}\\n`);\n",
"for (const r of data ?? []) {\n", "encode(error, {\n",
" console.log(`- ${r.contenido}`);\n", " indent: 2,\n",
" console.log(` Similaridad: ${Number(r.similarity).toFixed(4)}\\n`);\n", " delimiter: \",\",\n",
"}\n" " keyFolding: \"off\",\n",
" flattenDepth: Infinity,\n",
"});\n"
] ]
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 10,
"id": "791a94d8", "id": "791a94d8",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [
"source": [] {
"data": {
"text/plain": [
"{\n",
" code: \u001b[32m\"PGRST202\"\u001b[39m,\n",
" details: \u001b[32m\"Searched for the function public.buscar_documentos with parameters match_count, query_embedding or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache.\"\u001b[39m,\n",
" hint: \u001b[32m\"Perhaps you meant to call the function public.unaccent\"\u001b[39m,\n",
" message: \u001b[32m\"Could not find the function public.buscar_documentos(match_count, query_embedding) in the schema cache\"\u001b[39m\n",
"}"
]
},
"execution_count": 10,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"error"
]
} }
], ],
"metadata": { "metadata": {
+1
View File
@@ -1,6 +1,7 @@
{ {
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.90.1", "@supabase/supabase-js": "^2.90.1",
"@toon-format/toon": "^2.1.0",
"deno": "^2.6.4", "deno": "^2.6.4",
"openai": "^6.16.0", "openai": "^6.16.0",
"supabase": "^2.72.6" "supabase": "^2.72.6"
@@ -123,81 +123,7 @@ app.post(`${prefix}/conversations`, async (c) => {
} }
}); });
/**
* GET /conversations/:conversation_plan_id/messages
* Lista mensajes (assistant/user) desde OpenAI
*/
app.get(`${prefix}/conversations/:id/messages`, async (c) => {
try {
/* const auth = c.req.header("authorization");
await requireUser(auth); */
const conversation_plan_id = c.req.param("id");
assertUuid(conversation_plan_id, "conversation_plan_id");
const supabase = getSupabaseServiceClient();
const openai = getOpenAI();
const { data: convRow, error } = await supabase
.from("conversaciones_plan")
.select("openai_conversation_id, estado")
.eq("id", conversation_plan_id)
.single();
if (error || !convRow) {
throw new HttpError(
404,
"conversation_not_found",
"Conversación no encontrada",
error,
);
}
if (convRow.estado === "ARCHIVADA") {
// si ya está archivada, devolvemos lo guardado
const { data: archived } = await supabase
.from("conversaciones_plan")
.select("conversacion_json")
.eq("id", conversation_plan_id)
.single();
return withCors(
jsonResponse({
source: "supabase",
items: archived?.conversacion_json ?? null,
}),
);
}
const items = await openai.conversations.items.list(
convRow.openai_conversation_id,
);
const conversacion = items.data
.filter((it: any) =>
it.type === "message" &&
(it.role === "assistant" || it.role === "user")
)
.map((it: any) => {
const rawText = it.content.map((c: any) => c.text).join("");
let parsedContent;
try {
parsedContent = JSON.parse(rawText); // 👈 aquí lo convertimos
} catch {
parsedContent = rawText; // si no es JSON, lo dejamos normal
}
return {
role: it.role,
content: parsedContent,
};
});
return withCors(jsonResponse({ source: "openai", items: conversacion }));
} catch (err) {
return withCors(handleErr(err));
}
});
/** /**
* POST /conversations/:conversation_plan_id/messages * POST /conversations/:conversation_plan_id/messages
@@ -285,6 +211,11 @@ app.post(`${prefix}/conversations/:id/messages`, async (c) => {
"Excelente, actualmente tu plan de estudio tiene una redacción clara, pero podrías mejorar el perfil de ingreso para hacerlo más atractivo.", "Excelente, actualmente tu plan de estudio tiene una redacción clara, pero podrías mejorar el perfil de ingreso para hacerlo más atractivo.",
], ],
}, },
"is_refusal": {
type: "boolean",
description:
"Indica si la respuesta es un refusal (es decir, la pregunta no tiene que ver con el plan de estudio)",
},
}, },
}, },
}, },
@@ -295,12 +226,62 @@ app.post(`${prefix}/conversations/:id/messages`, async (c) => {
} }
// Pedimos respuesta estructurada con responses.create // Pedimos respuesta estructurada con responses.create
const schema = pickSchemaFields(definicion, body.campos); const schema = pickSchemaFields(definicion, body.campos ?? []);
const planForPrompt = safePlanForPrompt(plan); const planForPrompt = safePlanForPrompt(plan);
const model = CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO; const model = CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO;
const prompt = body.user_prompt ?? body.content; const prompt = body.user_prompt ?? body.content;
// append message of the user to conversacion_json (which guarantees a JSONB default to '[]')
/**
* appended includes timestamp, user, prompt and fields (if any)
*/
type AppendedMessage = {
timestamp: string;
user: string;
prompt: string;
fields?: string[];
};
type AppendedResponse = {
timestamp: string;
user: "assistant";
refusal: boolean;
message: string;
recommendations?: {
texto_mejora: string;
campo_afectado: string;
aplicada: false;
};
};
type AppendedItem = AppendedMessage | AppendedResponse;
let appended: AppendedItem = {
timestamp: new Date().toISOString(),
user: /* user.email ?? user.id ??*/ "unknown",
prompt,
fields: body.campos,
};
const { error: appendErr } = await supabase.rpc(
"append_conversacion_plan",
{
p_id: conversation_plan_id,
p_append: appended,
},
);
if (appendErr) {
throw new HttpError(
500,
"append_conversation_failed",
"No se pudo agregar el mensaje a la conversación",
appendErr,
);
}
const resp = await openai.responses.create({ const resp = await openai.responses.create({
conversation: row.openai_conversation_id, conversation: row.openai_conversation_id,
model, model,
@@ -322,6 +303,47 @@ app.post(`${prefix}/conversations/:id/messages`, async (c) => {
], ],
}); });
const respuestaJSON = JSON.parse(resp.output_text ?? "{}");
const refusal = respuestaJSON["is-refusal"] === true;
//remove the is-refusal field from respuestaJSON to avoid confusion
delete respuestaJSON["is-refusal"];
// Now an item with the assistant response and the structured data (if any) should be
appended = {
timestamp: new Date().toISOString(),
user: "assistant",
refusal,
// the ai-message field is the response
message: respuestaJSON?.["ai-message"] ?? "",
recommendations: resp.output_text
? Object.entries(respuestaJSON).filter(([k]) => k !== "ai-message")
.map(
([campo_afectado, texto_mejora]) => ({
campo_afectado,
texto_mejora,
aplicada: false,
}),
)
: undefined,
} as AppendedResponse;
const { error: appendRespErr } = await supabase.rpc(
"append_conversacion_plan",
{
p_id: conversation_plan_id,
p_append: appended,
},
);
if (appendRespErr) {
throw new HttpError(
500,
"append_response_failed",
"No se pudo agregar la respuesta a la conversación",
appendRespErr,
);
}
return withCors(jsonResponse({ return withCors(jsonResponse({
ok: true, ok: true,
openai_response_id: resp.id, openai_response_id: resp.id,
@@ -332,91 +354,7 @@ app.post(`${prefix}/conversations/:id/messages`, async (c) => {
} }
}); });
/**
* DELETE /conversations/:conversation_plan_id/archive
* Guarda items en Supabase y elimina la conversación de OpenAI
*/
app.delete(`${prefix}/conversations/:id/archive`, async (c) => {
try {
/* const auth = c.req.header("authorization");
await requireUser(auth); */
const conversation_plan_id = c.req.param("id");
assertUuid(conversation_plan_id, "conversation_plan_id");
const supabase = getSupabaseServiceClient();
const openai = getOpenAI();
const { data: row, error } = await supabase
.from("conversaciones_plan")
.select("id, openai_conversation_id, estado")
.eq("id", conversation_plan_id)
.single();
if (error || !row) {
throw new HttpError(
404,
"conversation_not_found",
"Conversación no encontrada",
error,
);
}
if (row.estado === "ARCHIVADA") {
return withCors(jsonResponse({ ok: true, already: true }));
}
// Marcar estado
await supabase.from("conversaciones_plan")
.update({ estado: "ARCHIVANDO" })
.eq("id", conversation_plan_id);
// Descargar items de OpenAI
const items = await openai.conversations.items.list(
row.openai_conversation_id,
);
const conversacion = items.data.filter((it: any) =>
it.type === "message" && (it.role === "assistant" || it.role === "user")
).map((it: any) => ({
role: it.role,
content: it.content.map((c: any) => c.text).join(""),
}));
// Guardar y marcar como ARCHIVADA
const { error: upErr } = await supabase.from("conversaciones_plan")
.update({
estado: "ARCHIVADA",
conversacion_json: conversacion,
})
.eq("id", conversation_plan_id);
if (upErr) {
throw new HttpError(
500,
"archive_save_failed",
"No se pudo guardar el archivo en Supabase",
upErr,
);
}
// Borrar conversación en OpenAI (best effort)
try {
await openai.conversations.delete(row.openai_conversation_id);
} catch (delErr) {
// Queda archivada en Supabase, pero reportamos warning
return withCors(jsonResponse({
ok: true,
warning: "Archivada en Supabase, pero no se pudo borrar en OpenAI",
details: String(delErr),
}, 200));
}
return withCors(jsonResponse({ ok: true }));
} catch (err) {
return withCors(handleErr(err));
}
});
/** /**
* Unknown routes * Unknown routes
@@ -2,7 +2,7 @@ import { HttpError } from "./errors.ts";
export function pickSchemaFields( export function pickSchemaFields(
definicion: any, definicion: any,
campos?: string[], campos: string[],
) { ) {
if (!definicion || definicion.type !== "object" || !definicion.properties) { if (!definicion || definicion.type !== "object" || !definicion.properties) {
return definicion; return definicion;
@@ -18,10 +18,15 @@ export function pickSchemaFields(
"Listo: mejoré la redacción del perfil de ingreso y propuse un tema de investigación alineado al plan.", "Listo: mejoré la redacción del perfil de ingreso y propuse un tema de investigación alineado al plan.",
], ],
}, },
"is-refusal": {
type: "boolean",
description:
"Indica si el plan fue rechazado por el modelo. En caso de ser true, se espera un mensaje de rechazo en `ai-message`.",
},
}, },
}; };
let out = structuredClone(definicion); const out = structuredClone(definicion);
// Si piden campos, filtramos propiedades/required a esos campos // Si piden campos, filtramos propiedades/required a esos campos
const entries = Object.entries(out.properties).filter(([k]) => const entries = Object.entries(out.properties).filter(([k]) =>
@@ -1,5 +1,3 @@
alter table "public"."conversaciones_plan" add column "nombre" text default ('Chat '::text || CURRENT_DATE); alter table "public"."conversaciones_plan" add column "nombre" text default ('Chat '::text || CURRENT_DATE);
alter table "public"."conversaciones_plan" alter column "conversacion_json" set default '[]'::jsonb; alter table "public"."conversaciones_plan" alter column "conversacion_json" set default '[]'::jsonb;
@@ -0,0 +1,21 @@
CREATE OR REPLACE FUNCTION public.append_conversacion_asignatura(p_id uuid, p_append jsonb)
RETURNS void
LANGUAGE sql
AS $function$
update conversaciones_asignatura
set conversacion_json = coalesce(conversacion_json, '[]'::jsonb) || p_append
where id = p_id;
$function$
;
CREATE OR REPLACE FUNCTION public.append_conversacion_plan(p_id uuid, p_append jsonb)
RETURNS void
LANGUAGE sql
AS $function$
update conversaciones_plan
set conversacion_json = coalesce(conversacion_json, '[]'::jsonb) || p_append
where id = p_id;
$function$
;