feat: implement create-chat-conversation function with OpenAI integration and Supabase support
CI / test (pull_request) Failing after 8s
CI / test (pull_request) Failing after 8s
- Added CORS handling for the create-chat-conversation function. - Implemented health check endpoint for the function. - Created endpoints for managing conversations and messages with OpenAI. - Added error handling and response formatting for better API usability. - Introduced utility functions for environment variable management and Supabase client creation. - Enhanced schema handling for structured responses from OpenAI. - Implemented conversation archiving and retrieval logic.
This commit is contained in:
@@ -357,18 +357,18 @@
|
|||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{
|
{
|
||||||
"data": {
|
"ename": "Error",
|
||||||
"text/plain": [
|
"evalue": "404 Conversation with id 'conv_698f3438114c8194a400f01568cf1a56058487a13fa1da16' not found.",
|
||||||
"{\n",
|
"output_type": "error",
|
||||||
" id: \u001b[32m\"conv_698f33afabe48195a8a5788835263c32097da13f4f3911d1\"\u001b[39m,\n",
|
"traceback": [
|
||||||
" object: \u001b[32m\"conversation.deleted\"\u001b[39m,\n",
|
"Stack trace:",
|
||||||
" deleted: \u001b[33mtrue\u001b[39m\n",
|
"Error: 404 Conversation with id 'conv_698f3438114c8194a400f01568cf1a56058487a13fa1da16' not found.",
|
||||||
"}"
|
" at APIError.generate (https://jsr.io/@openai/openai/6.16.0/core/error.ts:84:14)",
|
||||||
]
|
" at OpenAI.makeStatusError (https://jsr.io/@openai/openai/6.16.0/client.ts:478:28)",
|
||||||
},
|
" at OpenAI.makeRequest (https://jsr.io/@openai/openai/6.16.0/client.ts:728:24)",
|
||||||
"execution_count": 47,
|
" at eventLoopTick (ext:core/01_core.js:187:7)",
|
||||||
"metadata": {},
|
" at async <anonymous>:8:1"
|
||||||
"output_type": "execute_result"
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": [
|
"source": [
|
||||||
@@ -377,6 +377,7 @@
|
|||||||
"await supabase.from(\"conversaciones_plan\").update({\n",
|
"await supabase.from(\"conversaciones_plan\").update({\n",
|
||||||
" estado: \"ARCHIVANDO\",\n",
|
" estado: \"ARCHIVANDO\",\n",
|
||||||
" conversacion_json: items,\n",
|
" conversacion_json: items,\n",
|
||||||
|
" openai_conversation_id: null,\n",
|
||||||
"}).eq(\"id\", conversationPlane.id);\n",
|
"}).eq(\"id\", conversationPlane.id);\n",
|
||||||
"\n",
|
"\n",
|
||||||
"await openai.conversations.delete(conversation.openai_conversation_id);"
|
"await openai.conversations.delete(conversation.openai_conversation_id);"
|
||||||
@@ -537,6 +538,177 @@
|
|||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
"id": "3e705a02",
|
"id": "3e705a02",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"{ data: { ok: true }, error: null }\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"const { data, error } = await supabase.functions.invoke(\n",
|
||||||
|
" \"create-chat-conversation/health\",\n",
|
||||||
|
" {\n",
|
||||||
|
" method: \"GET\",\n",
|
||||||
|
" },\n",
|
||||||
|
");\n",
|
||||||
|
"\n",
|
||||||
|
"console.log({ data, error });\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "57e06acc",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"{\n",
|
||||||
|
" data: {\n",
|
||||||
|
" conversation_plan: {\n",
|
||||||
|
" id: \"afafb048-8108-4b19-b170-8c27f89bfa96\",\n",
|
||||||
|
" plan_estudio_id: \"4660b774-a6e2-44f4-a340-747b41235c7c\",\n",
|
||||||
|
" openai_conversation_id: \"conv_698f41b0582c819694b1565021a2390c09878265d4aa3f0d\",\n",
|
||||||
|
" estado: \"ACTIVA\"\n",
|
||||||
|
" }\n",
|
||||||
|
" },\n",
|
||||||
|
" error: null\n",
|
||||||
|
"}\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"const { data, error } = await supabase.functions.invoke(\n",
|
||||||
|
" \"create-chat-conversation/conversations\",\n",
|
||||||
|
" {\n",
|
||||||
|
" method: \"POST\",\n",
|
||||||
|
" body: {\n",
|
||||||
|
" plan_estudio_id: input.id,\n",
|
||||||
|
" instanciador: \"alex\",\n",
|
||||||
|
" },\n",
|
||||||
|
" },\n",
|
||||||
|
");\n",
|
||||||
|
"\n",
|
||||||
|
"console.log({ data, error });\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 75,
|
||||||
|
"id": "3426d5ff",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"{\n",
|
||||||
|
" respuesta: {\n",
|
||||||
|
" ok: true,\n",
|
||||||
|
" openai_response_id: \"resp_09878265d4aa3f0d00698f41f461b88196858dc9a9ad31fc38\",\n",
|
||||||
|
" data: {\n",
|
||||||
|
" perfil_de_ingreso: \"El aspirante deberá haber concluido el bachillerato con perfil Área 1 (Ciencias Físico-Matemáticas). Debe demostrar sólido dominio de matemáticas, física y química, así como capacidad de razonamiento lógico-abstracto y resolución de problemas complejos. Se espera experiencia en el manejo básico de herramientas computacionales y en la lectura e interpretación de textos científicos, así como habilidad para aprender de forma autónoma y trabajar en equipo. El candidato debe mostrar interés por la física, especialmente en temas teóricos y experimentales, y compromiso con la ética científica, la comunicación técnica y la realización de proyectos con buenas prácticas de investigación. En suma, se valora la curiosidad, la iniciativa, la disciplina y la capacidad para adaptar ideas complejas a contextos interdisciplinarios.\",\n",
|
||||||
|
" programa_de_investigacion: \"No aplica\",\n",
|
||||||
|
" \"ai-message\": \"Se mejoró la redacción del perfil de ingreso para mayor claridad y alineación con las competencias esperadas (matemáticas, física, química, razonamiento, herramientas computacionales, aprendizaje autónomo y trabajo en equipo) y se propusieron temas de investigación adecuados para un proyecto de Bachillerato en gravedad cuántica de bucles. Temas de investigación sugeridos: 1) Modelo computacional de una red de espines 2D para estudiar geometría emergente y observables básicos; 2) Protocolo mínimo de reproducibilidad en simulaciones de redes de espines; 3) Análisis conceptual comparando GR y LQG en cosmología a nivel didáctico. Recomendación: ajustar el alcance y recursos con el tutor para asegurar viabilidad y supervisión adecuada.\"\n",
|
||||||
|
" },\n",
|
||||||
|
" raw: '{\"perfil_de_ingreso\":\"El aspirante deberá haber concluido el bachillerato con perfil Área 1 (Ciencias Físico-Matemáticas). Debe demostrar sólido dominio de matemáticas, física y química, así como capacidad de razonamiento lógico-abstracto y resolución de problemas complejos. Se espera experiencia en el manejo básico de herramientas computacionales y en la lectura e interpretación de textos científicos, así como habilidad para aprender de forma autónoma y trabajar en equipo. El candidato debe mostrar interés por la física, especialmente en temas teóricos y experimentales, y compromiso con la ética científica, la comunicación técnica y la realización de proyectos con buenas prácticas de investigación. En suma, se valora la curiosidad, la iniciativa, la disciplina y la capacidad para adaptar ideas complejas a contextos interdisciplinarios.\",\"programa_de_investigacion\":\"No aplica\",\"ai-message\":\"Se mejoró la redacción del perfil de ingreso para mayor claridad y alineación con las competencias esperadas (matemáticas, física, química, razonamiento, herramientas computacionales, aprendizaje autónomo y trabajo en equipo) y se propusieron temas de investigación adecuados para un proyecto de Bachillerato en gravedad cuántica de bucles. Temas de investigación sugeridos: 1) Modelo computacional de una red de espines 2D para estudiar geometría emergente y observables básicos; 2) Protocolo mínimo de reproducibilidad en simulaciones de redes de espines; 3) Análisis conceptual comparando GR y LQG en cosmología a nivel didáctico. Recomendación: ajustar el alcance y recursos con el tutor para asegurar viabilidad y supervisión adecuada.\"}'\n",
|
||||||
|
" },\n",
|
||||||
|
" error: null\n",
|
||||||
|
"}\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"const { data: respuesta, error } = await supabase.functions.invoke(\n",
|
||||||
|
" `create-chat-conversation/conversations/${data.conversation_plan.id}/messages`,\n",
|
||||||
|
" {\n",
|
||||||
|
" method: \"POST\",\n",
|
||||||
|
" body: {\n",
|
||||||
|
" content:\n",
|
||||||
|
" \"Dale mejor redacción al perfil de ingreso y sugiere tema de investigación.\",\n",
|
||||||
|
" campos: [\"perfil_de_ingreso\", \"programa_de_investigacion\"],\n",
|
||||||
|
" },\n",
|
||||||
|
" },\n",
|
||||||
|
");\n",
|
||||||
|
"\n",
|
||||||
|
"console.log({ respuesta, error });\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 84,
|
||||||
|
"id": "c68073e8",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"{\n",
|
||||||
|
" \"source\": \"openai\",\n",
|
||||||
|
" \"items\": [\n",
|
||||||
|
" {\n",
|
||||||
|
" \"role\": \"assistant\",\n",
|
||||||
|
" \"content\": \"{\\\"perfil_de_ingreso\\\":\\\"El aspirante deberá haber concluido el bachillerato con perfil Área 1 (Ciencias Físico-Matemáticas). Debe demostrar sólido dominio de matemáticas, física y química, así como capacidad de razonamiento lógico-abstracto y resolución de problemas complejos. Se espera experiencia en el manejo básico de herramientas computacionales y en la lectura e interpretación de textos científicos, así como habilidad para aprender de forma autónoma y trabajar en equipo. El candidato debe mostrar interés por la física, especialmente en temas teóricos y experimentales, y compromiso con la ética científica, la comunicación técnica y la realización de proyectos con buenas prácticas de investigación. En suma, se valora la curiosidad, la iniciativa, la disciplina y la capacidad para adaptar ideas complejas a contextos interdisciplinarios.\\\",\\\"programa_de_investigacion\\\":\\\"No aplica\\\",\\\"ai-message\\\":\\\"Se mejoró la redacción del perfil de ingreso para mayor claridad y alineación con las competencias esperadas (matemáticas, física, química, razonamiento, herramientas computacionales, aprendizaje autónomo y trabajo en equipo) y se propusieron temas de investigación adecuados para un proyecto de Bachillerato en gravedad cuántica de bucles. Temas de investigación sugeridos: 1) Modelo computacional de una red de espines 2D para estudiar geometría emergente y observables básicos; 2) Protocolo mínimo de reproducibilidad en simulaciones de redes de espines; 3) Análisis conceptual comparando GR y LQG en cosmología a nivel didáctico. Recomendación: ajustar el alcance y recursos con el tutor para asegurar viabilidad y supervisión adecuada.\\\"}\"\n",
|
||||||
|
" },\n",
|
||||||
|
" {\n",
|
||||||
|
" \"role\": \"user\",\n",
|
||||||
|
" \"content\": \"Dale mejor redacción al perfil de ingreso y sugiere tema de investigación.\"\n",
|
||||||
|
" }\n",
|
||||||
|
" ]\n",
|
||||||
|
"}\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"const { data: respuesta, error } = await supabase.functions.invoke(\n",
|
||||||
|
" `create-chat-conversation/conversations/${data.conversation_plan.id}/messages`,\n",
|
||||||
|
" {\n",
|
||||||
|
" method: \"GET\"\n",
|
||||||
|
" },\n",
|
||||||
|
");\n",
|
||||||
|
"\n",
|
||||||
|
"console.log(JSON.stringify(respuesta, null, 2));\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 87,
|
||||||
|
"id": "7fbae7c3",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/plain": [
|
||||||
|
"{ ok: \u001b[33mtrue\u001b[39m }"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 87,
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "execute_result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"const { data: respuesta, error } = await supabase.functions.invoke(\n",
|
||||||
|
" `create-chat-conversation/conversations/${data.conversation_plan.id}/archive`,\n",
|
||||||
|
" {\n",
|
||||||
|
" method: \"DELETE\"\n",
|
||||||
|
" },\n",
|
||||||
|
");\n",
|
||||||
|
"\n",
|
||||||
|
"respuesta\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "852fa8b0",
|
||||||
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": []
|
"source": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,419 @@
|
|||||||
// Follow this setup guide to integrate the Deno language server with your editor:
|
import { Hono } from "jsr:@hono/hono";
|
||||||
// https://deno.land/manual/getting_started/setup_your_environment
|
import { corsHeaders, withCors } from "./lib/cors.ts";
|
||||||
// This enables autocomplete, go to definition, etc.
|
import { HttpError, jsonResponse } from "./lib/errors.ts";
|
||||||
|
import { getOpenAI } from "./lib/openai.ts";
|
||||||
|
import { getSupabaseServiceClient, requireUser } from "./lib/supabase.ts";
|
||||||
|
import { assertUuid, pickSchemaFields, safePlanForPrompt } from "./lib/plan.ts";
|
||||||
|
|
||||||
// Setup type definitions for built-in Supabase Runtime APIs
|
type CreateBody = {
|
||||||
import "@supabase/functions-js/edge-runtime.d.ts"
|
plan_estudio_id: string;
|
||||||
|
instanciador?: string;
|
||||||
|
system_prompt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
console.log("Hello from Functions!")
|
type AddMessageBody = {
|
||||||
|
// Guarda mensaje en OpenAI conversation
|
||||||
|
content: string;
|
||||||
|
// Si quieres forzar mejoras estructuradas:
|
||||||
|
campos?: string[];
|
||||||
|
user_prompt?: string; // si no mandas, usa content
|
||||||
|
model?: string; // default gpt-5-nano
|
||||||
|
};
|
||||||
|
|
||||||
Deno.serve(async (req) => {
|
const app = new Hono();
|
||||||
const { name } = await req.json()
|
|
||||||
const data = {
|
// Preflight CORS
|
||||||
message: `Hello ${name}!`,
|
app.options(
|
||||||
|
"*",
|
||||||
|
(c) => new Response(null, { status: 204, headers: corsHeaders }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const prefix = "/create-chat-conversation";
|
||||||
|
|
||||||
|
app.get(`${prefix}/health`, (c) => withCors(jsonResponse({ ok: true })));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /conversations
|
||||||
|
* Crea conversación OpenAI + registro en conversaciones_plan
|
||||||
|
*/
|
||||||
|
app.post(`${prefix}/conversations`, async (c) => {
|
||||||
|
try {
|
||||||
|
/* const auth = c.req.header("authorization");
|
||||||
|
const user = await requireUser(auth); */
|
||||||
|
|
||||||
|
const body = (await c.req.json().catch(() => ({}))) as Partial<CreateBody>;
|
||||||
|
const plan_estudio_id = body.plan_estudio_id;
|
||||||
|
assertUuid(plan_estudio_id ?? "", "plan_estudio_id");
|
||||||
|
|
||||||
|
const instanciador = /* user.email ?? user.id ?? */ body.instanciador ??
|
||||||
|
"unknown";
|
||||||
|
const system_prompt = body.system_prompt ??
|
||||||
|
"En caso de que te pidan algo que no tiene nada que ver con planes de estudio o asignatura responde con un refusal.";
|
||||||
|
|
||||||
|
const supabase = getSupabaseServiceClient();
|
||||||
|
const openai = getOpenAI();
|
||||||
|
|
||||||
|
// Cargar plan + estructura
|
||||||
|
const { data: plan, error: planErr } = await supabase
|
||||||
|
.from("planes_estudio")
|
||||||
|
.select("*, estructuras_plan (definicion)")
|
||||||
|
.eq("id", plan_estudio_id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (planErr || !plan) {
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
"plan_not_found",
|
||||||
|
"Plan de estudio no encontrado",
|
||||||
|
planErr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear conversación en OpenAI
|
||||||
|
const conv = await openai.conversations.create({
|
||||||
|
metadata: {
|
||||||
|
tabla: "planes_estudio",
|
||||||
|
id: plan.id,
|
||||||
|
instanciador,
|
||||||
|
},
|
||||||
|
items: [{ type: "message", role: "system", content: system_prompt }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Crear registro en Supabase
|
||||||
|
const { data: row, error: insErr } = await supabase
|
||||||
|
.from("conversaciones_plan")
|
||||||
|
.insert({
|
||||||
|
openai_conversation_id: conv.id,
|
||||||
|
plan_estudio_id: plan.id,
|
||||||
|
estado: "ACTIVA",
|
||||||
|
})
|
||||||
|
.select("id, plan_estudio_id, openai_conversation_id, estado")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (insErr || !row) {
|
||||||
|
// rollback best-effort
|
||||||
|
try {
|
||||||
|
await openai.conversations.delete(conv.id);
|
||||||
|
} catch (_) {}
|
||||||
|
throw new HttpError(
|
||||||
|
500,
|
||||||
|
"db_insert_failed",
|
||||||
|
"No se pudo registrar la conversación",
|
||||||
|
insErr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withCors(jsonResponse({ conversation_plan: row }, 201));
|
||||||
|
} catch (err) {
|
||||||
|
return withCors(handleErr(err));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return new Response(
|
/**
|
||||||
JSON.stringify(data),
|
* GET /conversations/:conversation_plan_id/messages
|
||||||
{ headers: { "Content-Type": "application/json" } },
|
* Lista mensajes (assistant/user) desde OpenAI
|
||||||
)
|
*/
|
||||||
})
|
app.get(`${prefix}/conversations/:id/messages`, async (c) => {
|
||||||
|
try {
|
||||||
|
/* const auth = c.req.header("authorization");
|
||||||
|
await requireUser(auth); */
|
||||||
|
|
||||||
/* To invoke locally:
|
const conversation_plan_id = c.req.param("id");
|
||||||
|
assertUuid(conversation_plan_id, "conversation_plan_id");
|
||||||
|
|
||||||
1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start)
|
const supabase = getSupabaseServiceClient();
|
||||||
2. Make an HTTP request:
|
const openai = getOpenAI();
|
||||||
|
|
||||||
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/create-chat-conversation' \
|
const { data: convRow, error } = await supabase
|
||||||
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' \
|
.from("conversaciones_plan")
|
||||||
--header 'Content-Type: application/json' \
|
.select("openai_conversation_id, estado")
|
||||||
--data '{"name":"Functions"}'
|
.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) => ({
|
||||||
|
role: it.role,
|
||||||
|
content: it.content.map((c: any) => c.text).join(""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return withCors(jsonResponse({ source: "openai", items: conversacion }));
|
||||||
|
} catch (err) {
|
||||||
|
return withCors(handleErr(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /conversations/:conversation_plan_id/messages
|
||||||
|
* Agrega mensaje y opcionalmente solicita respuesta estructurada (json_schema)
|
||||||
|
*/
|
||||||
|
app.post(`${prefix}/conversations/:id/messages`, async (c) => {
|
||||||
|
try {
|
||||||
|
/* const auth = c.req.header("authorization");
|
||||||
|
const user = await requireUser(auth); */
|
||||||
|
|
||||||
|
const conversation_plan_id = c.req.param("id");
|
||||||
|
assertUuid(conversation_plan_id, "conversation_plan_id");
|
||||||
|
|
||||||
|
const body = (await c.req.json().catch(() => ({}))) as Partial<
|
||||||
|
AddMessageBody
|
||||||
|
>;
|
||||||
|
if (!body.content || typeof body.content !== "string") {
|
||||||
|
throw new HttpError(400, "bad_input", "content es requerido");
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = getSupabaseServiceClient();
|
||||||
|
const openai = getOpenAI();
|
||||||
|
|
||||||
|
// Traer conversacion + plan + estructura
|
||||||
|
const { data: row, error } = await supabase
|
||||||
|
.from("conversaciones_plan")
|
||||||
|
.select(
|
||||||
|
"id, openai_conversation_id, plan_estudio_id, estado, planes_estudio(*, estructuras_plan(definicion))",
|
||||||
|
)
|
||||||
|
.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") {
|
||||||
|
throw new HttpError(
|
||||||
|
409,
|
||||||
|
"already_archived",
|
||||||
|
"La conversación ya está archivada",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = (row as any).planes_estudio;
|
||||||
|
const definicion = plan?.estructuras_plan?.definicion;
|
||||||
|
|
||||||
|
// Si NO hay schema o no piden campos: solo agregamos mensaje y regresamos ok
|
||||||
|
const wantsStructured = !!definicion;
|
||||||
|
|
||||||
|
if (!wantsStructured) {
|
||||||
|
await openai.responses.create({
|
||||||
|
conversation: row.openai_conversation_id,
|
||||||
|
model: "gpt-5-nano",
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `Este es el plan de estudios actual ${
|
||||||
|
JSON.stringify(plan)
|
||||||
|
}. Si te hacen una pregunta que no tiene nada que ver con el plan de estudio, responde con un refusal.`,
|
||||||
|
},
|
||||||
|
{ role: "user", content: body.content },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
usuario: /* user.email ?? user.id ??*/ "unknown",
|
||||||
|
plan_estudio_id: row.plan_estudio_id,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
format: {
|
||||||
|
type: "json_schema",
|
||||||
|
name: "definicion",
|
||||||
|
schema: {
|
||||||
|
// Si no hay schema, igual podemos pedir mejoras estructuradas en un campo libre, pero sin validación estricta
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
"ai-message": {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Mensaje de la IA para el usuario final basado en la solicitud",
|
||||||
|
examples: [
|
||||||
|
"Excelente, actualmente tu plan de estudio tiene una redacción clara, pero podrías mejorar el perfil de ingreso para hacerlo más atractivo.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return withCors(jsonResponse({ ok: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pedimos respuesta estructurada con responses.create
|
||||||
|
const schema = pickSchemaFields(definicion, body.campos);
|
||||||
|
const planForPrompt = safePlanForPrompt(plan);
|
||||||
|
|
||||||
|
const model = "gpt-5-nano";
|
||||||
|
const prompt = body.user_prompt ?? body.content;
|
||||||
|
|
||||||
|
const resp = await openai.responses.create({
|
||||||
|
conversation: row.openai_conversation_id,
|
||||||
|
model,
|
||||||
|
text: { format: { type: "json_schema", name: "definicion", schema } },
|
||||||
|
metadata: {
|
||||||
|
usuario: /* user.email ?? user.id ??*/ "unknown",
|
||||||
|
plan_estudio_id: row.plan_estudio_id,
|
||||||
|
},
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
`Eres un asistente que ayuda a mejorar este plan de estudio: ${
|
||||||
|
JSON.stringify(planForPrompt)
|
||||||
|
}. ` +
|
||||||
|
`Si te hacen una pregunta que no tiene nada que ver con el plan de estudio, responde con un refusal.`,
|
||||||
|
},
|
||||||
|
{ role: "user", content: prompt },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let parsed: any = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(resp.output_text ?? "null");
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
return withCors(jsonResponse({
|
||||||
|
ok: true,
|
||||||
|
openai_response_id: resp.id,
|
||||||
|
data: parsed,
|
||||||
|
raw: resp.output_text ?? null,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
return withCors(handleErr(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Guardar y marcar como ARCHIVADA
|
||||||
|
const { error: upErr } = await supabase.from("conversaciones_plan")
|
||||||
|
.update({
|
||||||
|
estado: "ARCHIVADA",
|
||||||
|
conversacion_json: items,
|
||||||
|
})
|
||||||
|
.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
|
||||||
|
*/
|
||||||
|
app.all(
|
||||||
|
"*",
|
||||||
|
(c) =>
|
||||||
|
withCors(
|
||||||
|
jsonResponse({
|
||||||
|
error: "not_found",
|
||||||
|
message: `Route ${c.req.url} not found`,
|
||||||
|
}, 404),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleErr(err: unknown): Response {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: err.code, message: err.message, details: err.details ?? null },
|
||||||
|
err.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error("Unhandled error:", err);
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: "internal_error", message: "Unexpected error" },
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(app.fetch);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export const corsHeaders: Record<string, string> = {
|
||||||
|
"access-control-allow-origin": "*",
|
||||||
|
"access-control-allow-headers":
|
||||||
|
"authorization, x-client-info, apikey, content-type",
|
||||||
|
"access-control-allow-methods": "GET,POST,OPTIONS",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function withCors(res: Response) {
|
||||||
|
const h = new Headers(res.headers);
|
||||||
|
for (const [k, v] of Object.entries(corsHeaders)) h.set(k, v);
|
||||||
|
return new Response(res.body, { status: res.status, headers: h });
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function mustGetEnv(name: string): string {
|
||||||
|
const v = Deno.env.get(name);
|
||||||
|
if (!v) throw new Error(`Missing env var: ${name}`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnv(name: string, fallback?: string): string | undefined {
|
||||||
|
return Deno.env.get(name) ?? fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export class HttpError extends Error {
|
||||||
|
status: number;
|
||||||
|
code: string;
|
||||||
|
details?: unknown;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
status: number,
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
details?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jsonResponse(
|
||||||
|
body: unknown,
|
||||||
|
status = 200,
|
||||||
|
headers: HeadersInit = {},
|
||||||
|
) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import OpenAI from "npm:openai@6.16.0";
|
||||||
|
import { mustGetEnv } from "./env.ts";
|
||||||
|
|
||||||
|
export function getOpenAI() {
|
||||||
|
// OpenAI lib toma OPENAI_API_KEY de env automáticamente,
|
||||||
|
// pero lo validamos para fallar rápido:
|
||||||
|
mustGetEnv("OPENAI_API_KEY");
|
||||||
|
return new OpenAI();
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { HttpError } from "./errors.ts";
|
||||||
|
|
||||||
|
export function pickSchemaFields(
|
||||||
|
definicion: any,
|
||||||
|
campos?: string[],
|
||||||
|
) {
|
||||||
|
if (!definicion || definicion.type !== "object" || !definicion.properties) {
|
||||||
|
return definicion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extra = {
|
||||||
|
properties: {
|
||||||
|
"ai-message": {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Mensaje breve para el usuario final confirmando qué se mejoró y qué se hizo.",
|
||||||
|
examples: [
|
||||||
|
"Listo: mejoré la redacción del perfil de ingreso y propuse un tema de investigación alineado al plan.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let out = structuredClone(definicion);
|
||||||
|
|
||||||
|
// Si piden campos, filtramos propiedades/required a esos campos
|
||||||
|
if (Array.isArray(campos) && campos.length > 0) {
|
||||||
|
const entries = Object.entries(out.properties).filter(([k]) =>
|
||||||
|
campos.includes(k)
|
||||||
|
);
|
||||||
|
out.properties = Object.fromEntries(entries);
|
||||||
|
if (Array.isArray(out.required)) {
|
||||||
|
out.required = out.required.filter((k: string) => campos.includes(k));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Siempre agregamos ai-message
|
||||||
|
out.properties = { ...out.properties, ...extra.properties };
|
||||||
|
out.required = Array.isArray(out.required)
|
||||||
|
? [...new Set([...out.required, ...Object.keys(extra.properties)])]
|
||||||
|
: Object.keys(extra.properties);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safePlanForPrompt(plan: any) {
|
||||||
|
const copy = structuredClone(plan);
|
||||||
|
if (copy?.estructuras_plan) delete copy.estructuras_plan;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertUuid(v: string, name: string) {
|
||||||
|
// validación ligera
|
||||||
|
if (!v || typeof v !== "string" || v.length < 10) {
|
||||||
|
throw new HttpError(400, "bad_input", `Invalid ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { createClient } from "jsr:@supabase/supabase-js";
|
||||||
|
import { mustGetEnv } from "./env.ts";
|
||||||
|
import { HttpError } from "./errors.ts";
|
||||||
|
|
||||||
|
export function getSupabaseServiceClient() {
|
||||||
|
const url = mustGetEnv("SUPABASE_URL");
|
||||||
|
const key = mustGetEnv("SUPABASE_SERVICE_ROLE_KEY");
|
||||||
|
return createClient(url, key, { auth: { persistSession: false } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSupabaseAnonClient(authHeader?: string) {
|
||||||
|
const url = mustGetEnv("SUPABASE_URL");
|
||||||
|
const key = mustGetEnv("SUPABASE_ANON_KEY");
|
||||||
|
return createClient(url, key, {
|
||||||
|
auth: { persistSession: false },
|
||||||
|
global: authHeader ? { headers: { Authorization: authHeader } } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireUser(authHeader?: string) {
|
||||||
|
if (!authHeader) {
|
||||||
|
throw new HttpError(401, "missing_auth", "Missing Authorization header");
|
||||||
|
}
|
||||||
|
const anon = getSupabaseAnonClient(authHeader);
|
||||||
|
const { data, error } = await anon.auth.getUser();
|
||||||
|
if (error || !data?.user) {
|
||||||
|
throw new HttpError(401, "invalid_auth", "Invalid or expired token", error);
|
||||||
|
}
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user