From a70f0c52a9ae2e810cc96c801e3caf958fb13a65 Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Fri, 13 Feb 2026 09:31:58 -0600 Subject: [PATCH] feat: implement create-chat-conversation function with OpenAI integration and Supabase support - 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. --- notebooks/conversations-openai.ipynb | 196 +++++++- .../create-chat-conversation/index.ts | 433 +++++++++++++++++- .../create-chat-conversation/lib/cors.ts | 12 + .../create-chat-conversation/lib/env.ts | 9 + .../create-chat-conversation/lib/errors.ts | 31 ++ .../create-chat-conversation/lib/openai.ts | 9 + .../create-chat-conversation/lib/plan.ts | 57 +++ .../create-chat-conversation/lib/supabase.ts | 30 ++ 8 files changed, 742 insertions(+), 35 deletions(-) create mode 100644 supabase/functions/create-chat-conversation/lib/cors.ts create mode 100644 supabase/functions/create-chat-conversation/lib/env.ts create mode 100644 supabase/functions/create-chat-conversation/lib/errors.ts create mode 100644 supabase/functions/create-chat-conversation/lib/openai.ts create mode 100644 supabase/functions/create-chat-conversation/lib/plan.ts create mode 100644 supabase/functions/create-chat-conversation/lib/supabase.ts diff --git a/notebooks/conversations-openai.ipynb b/notebooks/conversations-openai.ipynb index a02b848..e2628b6 100644 --- a/notebooks/conversations-openai.ipynb +++ b/notebooks/conversations-openai.ipynb @@ -357,18 +357,18 @@ "metadata": {}, "outputs": [ { - "data": { - "text/plain": [ - "{\n", - " id: \u001b[32m\"conv_698f33afabe48195a8a5788835263c32097da13f4f3911d1\"\u001b[39m,\n", - " object: \u001b[32m\"conversation.deleted\"\u001b[39m,\n", - " deleted: \u001b[33mtrue\u001b[39m\n", - "}" - ] - }, - "execution_count": 47, - "metadata": {}, - "output_type": "execute_result" + "ename": "Error", + "evalue": "404 Conversation with id 'conv_698f3438114c8194a400f01568cf1a56058487a13fa1da16' not found.", + "output_type": "error", + "traceback": [ + "Stack trace:", + "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)", + " at eventLoopTick (ext:core/01_core.js:187:7)", + " at async :8:1" + ] } ], "source": [ @@ -377,6 +377,7 @@ "await supabase.from(\"conversaciones_plan\").update({\n", " estado: \"ARCHIVANDO\",\n", " conversacion_json: items,\n", + " openai_conversation_id: null,\n", "}).eq(\"id\", conversationPlane.id);\n", "\n", "await openai.conversations.delete(conversation.openai_conversation_id);" @@ -537,6 +538,177 @@ "execution_count": null, "id": "3e705a02", "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": [], "source": [] } diff --git a/supabase/functions/create-chat-conversation/index.ts b/supabase/functions/create-chat-conversation/index.ts index 3da0253..50612fa 100644 --- a/supabase/functions/create-chat-conversation/index.ts +++ b/supabase/functions/create-chat-conversation/index.ts @@ -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; + 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); diff --git a/supabase/functions/create-chat-conversation/lib/cors.ts b/supabase/functions/create-chat-conversation/lib/cors.ts new file mode 100644 index 0000000..1366d69 --- /dev/null +++ b/supabase/functions/create-chat-conversation/lib/cors.ts @@ -0,0 +1,12 @@ +export const corsHeaders: Record = { + "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 }); +} diff --git a/supabase/functions/create-chat-conversation/lib/env.ts b/supabase/functions/create-chat-conversation/lib/env.ts new file mode 100644 index 0000000..22a07c7 --- /dev/null +++ b/supabase/functions/create-chat-conversation/lib/env.ts @@ -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; +} diff --git a/supabase/functions/create-chat-conversation/lib/errors.ts b/supabase/functions/create-chat-conversation/lib/errors.ts new file mode 100644 index 0000000..f655324 --- /dev/null +++ b/supabase/functions/create-chat-conversation/lib/errors.ts @@ -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, + }, + }); +} diff --git a/supabase/functions/create-chat-conversation/lib/openai.ts b/supabase/functions/create-chat-conversation/lib/openai.ts new file mode 100644 index 0000000..aa7f9a9 --- /dev/null +++ b/supabase/functions/create-chat-conversation/lib/openai.ts @@ -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(); +} diff --git a/supabase/functions/create-chat-conversation/lib/plan.ts b/supabase/functions/create-chat-conversation/lib/plan.ts new file mode 100644 index 0000000..7990dbb --- /dev/null +++ b/supabase/functions/create-chat-conversation/lib/plan.ts @@ -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}`); + } +} diff --git a/supabase/functions/create-chat-conversation/lib/supabase.ts b/supabase/functions/create-chat-conversation/lib/supabase.ts new file mode 100644 index 0000000..88758f7 --- /dev/null +++ b/supabase/functions/create-chat-conversation/lib/supabase.ts @@ -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; +}