feat: implement create-chat-conversation function with OpenAI integration and Supabase support
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:
2026-02-13 09:31:58 -06:00
parent f441976a7a
commit a70f0c52a9
8 changed files with 742 additions and 35 deletions
@@ -1,32 +1,419 @@
// Follow this setup guide to integrate the Deno language server with your editor:
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.
import { Hono } from "jsr:@hono/hono";
import { corsHeaders, withCors } from "./lib/cors.ts";
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
import "@supabase/functions-js/edge-runtime.d.ts"
type CreateBody = {
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 { name } = await req.json()
const data = {
message: `Hello ${name}!`,
const app = new Hono();
// Preflight CORS
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),
{ headers: { "Content-Type": "application/json" } },
)
})
/**
* 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); */
/* 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)
2. Make an HTTP request:
const supabase = getSupabaseServiceClient();
const openai = getOpenAI();
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/create-chat-conversation' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' \
--header 'Content-Type: application/json' \
--data '{"name":"Functions"}'
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) => ({
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;
}