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",
"npm:@supabase/supabase-js@2": "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:deno@^2.6.4": "2.6.4",
"npm:openai@6.16.0": "6.16.0_zod@3.25.76",
@@ -186,6 +187,9 @@
"@supabase/storage-js"
]
},
"@toon-format/toon@2.1.0": {
"integrity": "sha512-JwWptdF5eOA0HaQxbKAzkpQtR4wSWTEfDlEy/y3/4okmOAX1qwnpLZMmtEWr+ncAhTTY1raCKH0kteHhSXnQqg=="
},
"@types/bun@1.3.5": {
"integrity": "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w==",
"dependencies": [
@@ -519,8 +523,7 @@
"minipass",
"minizlib",
"yallist"
],
"deprecated": true
]
},
"tr46@0.0.3": {
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
@@ -591,6 +594,7 @@
"packageJson": {
"dependencies": [
"npm:@supabase/supabase-js@^2.90.1",
"npm:@toon-format/toon@^2.1.0",
"npm:@types/bun@^1.3.5",
"npm:deno@^2.6.4",
"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",
"execution_count": 26,
"execution_count": null,
"id": "1403c789",
"metadata": {},
"outputs": [],
"source": [
"import { title } from \"node:process\";\n",
"\n",
"const combinedSchema = {\n",
" type: \"object\",\n",
" $defs: {\n",
@@ -126,10 +128,12 @@
" fabricante: { type: \"string\", maxLength: 100 },\n",
"\n",
" // Agregar campo del preset\n",
" perishables: { $ref: \"#/$defs/perishables\" },\n",
" origen: { $ref: \"#/$defs/origen\" },\n",
" perishables: {\n",
" $ref: \"#/$defs/perishables\",\n",
" \"x-column\": \"perishables\",\n",
" },\n",
"\n",
" perecedero: { type: \"null\", \"x-column\": \"perishable\", const: null },\n",
" origen: { $ref: \"#/$defs/origen\" },\n",
" },\n",
" required: [\"nombre\", \"fabricante\", \"perishables\", \"origen\", \"perecedero\"],\n",
" additionalProperties: false,\n",
+54 -73
View File
@@ -2,7 +2,7 @@
"cells": [
{
"cell_type": "code",
"execution_count": 3,
"execution_count": 2,
"id": "902bff53",
"metadata": {},
"outputs": [],
@@ -19,7 +19,7 @@
},
{
"cell_type": "code",
"execution_count": 4,
"execution_count": 3,
"id": "3b0acbc3",
"metadata": {},
"outputs": [],
@@ -32,63 +32,18 @@
},
{
"cell_type": "code",
"execution_count": 8,
"execution_count": 1,
"id": "dae842ad",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{\n",
" object: \"list\",\n",
" data: [\n",
" {\n",
" 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"
"ename": "ReferenceError",
"evalue": "openai is not defined",
"output_type": "error",
"traceback": [
"Stack trace:",
"ReferenceError: openai is not defined",
" at <anonymous>:5:15"
]
}
],
@@ -120,7 +75,7 @@
},
{
"cell_type": "code",
"execution_count": 13,
"execution_count": 12,
"id": "2d8fa9a9",
"metadata": {},
"outputs": [
@@ -129,20 +84,26 @@
"output_type": "stream",
"text": [
"🔎 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"
]
},
{
"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": [
"import { encode } from \"@toon-format/toon\";\n",
"\n",
"const consulta = \"¿Cómo son buscados vectores?\";\n",
"\n",
"const emb = await openai.embeddings.create({\n",
@@ -157,22 +118,42 @@
" match_count: 3,\n",
"});\n",
"\n",
"if (error) throw error;\n",
"/* if (error) throw error; */\n",
"\n",
"console.log(`🔎 Consulta: ${consulta}\\n`);\n",
"for (const r of data ?? []) {\n",
" console.log(`- ${r.contenido}`);\n",
" console.log(` Similaridad: ${Number(r.similarity).toFixed(4)}\\n`);\n",
"}\n"
"encode(error, {\n",
" indent: 2,\n",
" delimiter: \",\",\n",
" keyFolding: \"off\",\n",
" flattenDepth: Infinity,\n",
"});\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 10,
"id": "791a94d8",
"metadata": {},
"outputs": [],
"source": []
"outputs": [
{
"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": {
+1
View File
@@ -1,6 +1,7 @@
{
"dependencies": {
"@supabase/supabase-js": "^2.90.1",
"@toon-format/toon": "^2.1.0",
"deno": "^2.6.4",
"openai": "^6.16.0",
"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
@@ -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.",
],
},
"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
const schema = pickSchemaFields(definicion, body.campos);
const schema = pickSchemaFields(definicion, body.campos ?? []);
const planForPrompt = safePlanForPrompt(plan);
const model = CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO;
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({
conversation: row.openai_conversation_id,
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({
ok: true,
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
@@ -2,7 +2,7 @@ import { HttpError } from "./errors.ts";
export function pickSchemaFields(
definicion: any,
campos?: string[],
campos: string[],
) {
if (!definicion || definicion.type !== "object" || !definicion.properties) {
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.",
],
},
"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
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" 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$
;