Merge branch 'main' into issue/35-crear-webhook-para-responses
This commit is contained in:
@@ -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]) =>
|
||||
|
||||
Reference in New Issue
Block a user