Actualizar database-types.ts #27

Merged
alexrg merged 10 commits from test/ci into main 2026-03-10 20:46:04 +00:00
63 changed files with 19379 additions and 2116 deletions
Showing only changes of commit ec927111b3 - Show all commits
+3 -3
View File
@@ -20,9 +20,9 @@ jobs:
- name: Verify generated types are checked in
run: |
supabase gen types typescript --local > database-types.ts
if ! git diff --ignore-space-at-eol --exit-code --quiet database-types.ts; then
echo "Detected uncommitted changes after build. See status below:"
supabase gen types typescript --local > supabase/functions/_shared/database.types.ts
if ! git diff --ignore-space-at-eol --exit-code --quiet supabase/functions/_shared/database.types.ts; then
echo "Detected uncommitted changes after build. See status below:"
git diff
exit 1
fi
+100
View File
@@ -0,0 +1,100 @@
# Writing Supabase Edge Functions
You're an expert in writing TypeScript and Deno JavaScript runtime. Generate **high-quality Supabase Edge Functions** that adhere to the following best practices:
## Guidelines
1. Try to use Web APIs and Denos core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws)
2. If you are reusing utility methods between Edge Functions, add them to `supabase/functions/_shared` and import using a relative path. Do NOT have cross dependencies between Edge Functions.
3. Do NOT use bare specifiers when importing dependencies. If you need to use an external dependency, make sure it's prefixed with either `npm:` or `jsr:`. For example, `@supabase/supabase-js` should be written as `npm:@supabase/supabase-js`.
4. For external imports, always define a version. For example, `npm:@express` should be written as `npm:express@4.18.2`.
5. For external dependencies, importing via `npm:` and `jsr:` is preferred. Minimize the use of imports from @`deno.land/x` , `esm.sh` and @`unpkg.com` . If you have a package from one of those CDNs, you can replace the CDN hostname with `npm:` specifier.
6. You can also use Node built-in APIs. You will need to import them using `node:` specifier. For example, to import Node process: `import process from "node:process". Use Node APIs when you find gaps in Deno APIs.
7. Do NOT use `import { serve } from "https://deno.land/std@0.168.0/http/server.ts"`. Instead use the built-in `Deno.serve`.
8. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them:
- SUPABASE_URL
- SUPABASE_ANON_KEY
- SUPABASE_SERVICE_ROLE_KEY
- SUPABASE_DB_URL
9. To set other environment variables (ie. secrets) users can put them in a env file and run the `supabase secrets set --env-file path/to/env-file`
10. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with `/function-name` so they are routed correctly.
11. File write operations are ONLY permitted on `/tmp` directory. You can use either Deno or Node File APIs.
12. Use `EdgeRuntime.waitUntil(promise)` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context.
## Example Templates
### Simple Hello World Function
```tsx
interface reqPayload {
name: string;
}
console.info("server started");
Deno.serve(async (req: Request) => {
const { name }: reqPayload = await req.json();
const data = {
message: `Hello ${name} from foo!`,
};
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json", Connection: "keep-alive" },
});
});
```
### Example Function using Node built-in API
```tsx
import { randomBytes } from "node:crypto";
import { createServer } from "node:http";
import process from "node:process";
const generateRandomString = (length) => {
const buffer = randomBytes(length);
return buffer.toString("hex");
};
const randomString = generateRandomString(10);
console.log(randomString);
const server = createServer((req, res) => {
const message = `Hello`;
res.end(message);
});
server.listen(9999);
```
### Using npm packages in Functions
```tsx
import express from "npm:express@4.18.2";
const app = express();
app.get(/(.*)/, (req, res) => {
res.send("Welcome to Supabase");
});
app.listen(8000);
```
### Generate embeddings using built-in @Supabase.ai API
```tsx
const model = new Supabase.ai.Session("gte-small");
Deno.serve(async (req: Request) => {
const params = new URL(req.url).searchParams;
const input = params.get("text");
const output = await model.run(input, { mean_pool: true, normalize: true });
return new Response(JSON.stringify(output), {
headers: {
"Content-Type": "application/json",
Connection: "keep-alive",
},
});
});
```
+1 -1
View File
@@ -2,7 +2,7 @@
"allowScripts": [
"npm:deno@2.6.4",
"npm:supabase-js@1.0.4",
"npm:supabase@2.72.6"
"npm:supabase@2.75.0"
],
"imports": {
"@openai/openai": "jsr:@openai/openai@^6.16.0",
Generated
+28 -6
View File
@@ -5,6 +5,7 @@
"jsr:@david/dax@~0.44.2": "0.44.2",
"jsr:@david/path@0.2": "0.2.0",
"jsr:@david/which@~0.4.1": "0.4.1",
"jsr:@helpr/express@*": "0.1.1",
"jsr:@openai/openai@*": "6.16.0",
"jsr:@openai/openai@^6.16.0": "6.16.0",
"jsr:@std/bytes@^1.0.6": "1.0.6",
@@ -20,12 +21,15 @@
"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:citeproc@^2.4.63": "2.4.63",
"npm:deno@^2.6.4": "2.6.4",
"npm:jwt-decode@4": "4.0.0",
"npm:openai@6.16.0": "6.16.0_zod@3.25.76",
"npm:openai@^4.52.5": "4.104.0_zod@3.25.76",
"npm:openai@^6.16.0": "6.16.0_zod@3.25.76",
"npm:supabase@^2.72.6": "2.72.6",
"npm:supabase@2.75.0": "2.75.0",
"npm:zod@3": "3.25.76"
},
"jsr": {
@@ -54,6 +58,9 @@
"@david/which@0.4.1": {
"integrity": "896a682b111f92ab866cc70c5b4afab2f5899d2f9bde31ed00203b9c250f225e"
},
"@helpr/express@0.1.1": {
"integrity": "15391b350b92ab5102919c555179c954ff3d5974d7f170b99880836e249756d2"
},
"@openai/openai@6.16.0": {
"integrity": "ccee548f61c382d715091fff0c2c3390a4487823644430b8235df543d6d6a78b",
"dependencies": [
@@ -182,6 +189,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": [
@@ -254,6 +264,9 @@
"chownr@3.0.0": {
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="
},
"citeproc@2.4.63": {
"integrity": "sha512-68F95Bp4UbgZU/DBUGQn0qV3HDZLCdI9+Bb2ByrTaNJDL5VEm9LqaiNaxljsvoaExSLEXe1/r6n2Z06SCzW3/Q=="
},
"cmd-shim@8.0.0": {
"integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="
},
@@ -415,6 +428,9 @@
"imurmurhash@0.1.4": {
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="
},
"jwt-decode@4.0.0": {
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="
},
"math-intrinsics@1.1.0": {
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
},
@@ -496,8 +512,8 @@
"signal-exit@4.1.0": {
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="
},
"supabase@2.72.6": {
"integrity": "sha512-axO1MkZgwMIxj78vKiGosnuXwzOaQq0Kz0I8jheNMwJH9dKGf3q/arivYT44z8u3t5FjGUxQWOuQoJetzcmm/A==",
"supabase@2.75.0": {
"integrity": "sha512-Xaai8Wp7F03OMkOWSyS1OMMdk5DxkauJxcMMesj4mO6jDGEZpWBwgo/gIUb4hsckexmr/RXYdpDpTbUIMXahbw==",
"dependencies": [
"bin-links",
"https-proxy-agent",
@@ -507,8 +523,8 @@
"scripts": true,
"bin": true
},
"tar@7.5.2": {
"integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==",
"tar@7.5.7": {
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
"dependencies": [
"@isaacs/fs-minipass",
"chownr",
@@ -561,6 +577,9 @@
}
},
"remote": {
"https://deno.land/std@0.224.0/dotenv/mod.ts": "0180eaeedaaf88647318811cdaa418cc64dc51fb08354f91f5f480d0a1309f7d",
"https://deno.land/std@0.224.0/dotenv/parse.ts": "09977ff88dfd1f24f9973a338f0f91bbdb9307eb5ff6085446e7c423e4c7ba0c",
"https://deno.land/std@0.224.0/dotenv/stringify.ts": "275da322c409170160440836342eaa7cf012a1d11a7e700d8ca4e7f2f8aa4615",
"https://deno.land/x/zod@v3.22.4/ZodError.ts": "4de18ff525e75a0315f2c12066b77b5c2ae18c7c15ef7df7e165d63536fdf2ea",
"https://deno.land/x/zod@v3.22.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef",
"https://deno.land/x/zod@v3.22.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe",
@@ -587,10 +606,13 @@
"packageJson": {
"dependencies": [
"npm:@supabase/supabase-js@^2.90.1",
"npm:@toon-format/toon@^2.1.0",
"npm:@types/bun@^1.3.5",
"npm:citeproc@^2.4.63",
"npm:deno@^2.6.4",
"npm:jwt-decode@4",
"npm:openai@^6.16.0",
"npm:supabase@^2.72.6"
"npm:supabase@2.75.0"
]
}
}
+2273
View File
File diff suppressed because it is too large Load Diff
+577
View File
@@ -0,0 +1,577 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"id": "bb61ec88",
"metadata": {},
"outputs": [],
"source": [
"import { load } from \"jsr:@std/dotenv\";\n",
"import OpenAI from \"jsr:@openai/openai\";\n",
"\n",
"const env = await load({\n",
" export: true,\n",
"});\n",
"\n",
"const openai = new OpenAI();\n"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "8baca5f2",
"metadata": {},
"outputs": [],
"source": [
"import { createClient } from \"@supabase/supabase-js\";\n",
"const supabaseUrl = Deno.env.get(\"SUPABASE_URL\") || env.SUPABASE_URL;\n",
"const supabaseKey = Deno.env.get(\"SUPABASE_ANON_KEY\") || env.SUPABASE_ANON_KEY;\n",
"const supabase = createClient(supabaseUrl, supabaseKey);\n"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "923b8e08",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Esperando a que la IA termine con la asignatura fe5eb09c-501b-4db6-b6d3-f02bdcfdc4b4...\n",
"Conexión WebSocket establecida. La celda está esperando cambios...\n"
]
},
{
"ename": "",
"evalue": "",
"output_type": "error",
"traceback": [
"\u001b[1;31mThe Kernel crashed while executing code in the current cell or a previous cell. \n",
"\u001b[1;31mPlease review the code in the cell(s) to identify a possible cause of the failure. \n",
"\u001b[1;31mClick <a href='https://aka.ms/vscodeJupyterKernelCrash'>here</a> for more info. \n",
"\u001b[1;31mView Jupyter <a href='command:jupyter.viewOutput'>log</a> for further details."
]
}
],
"source": [
"const ID_ASIGNATURA = \"fe5eb09c-501b-4db6-b6d3-f02bdcfdc4b4\";\n",
"console.log(\n",
" `Esperando a que la IA termine con la asignatura ${ID_ASIGNATURA}...`,\n",
");\n",
"\n",
"if (!supabase) {\n",
" console.log(\"No hay supabase\");\n",
"} else {\n",
" // 2. Armas la \"trampa\" (el listener)\n",
" await new Promise((resolve) => {\n",
" const canal = supabase\n",
" .channel(\"test-asignatura\")\n",
" .on(\n",
" \"postgres_changes\",\n",
" {\n",
" event: \"UPDATE\",\n",
" schema: \"public\",\n",
" table: \"asignaturas\",\n",
" filter: `id=eq.${ID_ASIGNATURA}`,\n",
" },\n",
" (payload) => {\n",
" const nuevoEstado = payload.new.estado;\n",
"\n",
" if (nuevoEstado !== \"generando\") {\n",
" console.log(\n",
" `¡Proceso completado! El nuevo estado es: ${nuevoEstado}`,\n",
" );\n",
"\n",
" supabase.removeChannel(canal);\n",
" resolve(true); // ¡Aquí destrabamos la celda de Jupyter!\n",
" } else {\n",
" console.log(\"Hubo un update, pero sigue generando...\");\n",
" }\n",
" },\n",
" )\n",
" .subscribe((status) => {\n",
" if (status === \"SUBSCRIBED\") {\n",
" console.log(\n",
" \"Conexión WebSocket establecida. La celda está esperando cambios...\",\n",
" );\n",
" }\n",
" });\n",
" });\n",
"\n",
" console.log(\"Script finalizado correctamente.\");\n",
"}\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "8a1509d8",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Iniciando conexión abierta y sin filtros...\n",
"Conectado. Esperando cualquier cambio en la tabla asignaturas...\n"
]
},
{
"ename": "",
"evalue": "",
"output_type": "error",
"traceback": [
"\u001b[1;31mThe Kernel crashed while executing code in the current cell or a previous cell. \n",
"\u001b[1;31mPlease review the code in the cell(s) to identify a possible cause of the failure. \n",
"\u001b[1;31mClick <a href='https://aka.ms/vscodeJupyterKernelCrash'>here</a> for more info. \n",
"\u001b[1;31mView Jupyter <a href='command:jupyter.viewOutput'>log</a> for further details."
]
}
],
"source": [
"console.log(`Iniciando conexión abierta y sin filtros...`);\n",
"\n",
"await new Promise((resolve) => {\n",
" const canal = supabase\n",
" .channel(\"test-global-asignaturas\")\n",
" .on(\n",
" \"postgres_changes\",\n",
" {\n",
" event: \"*\", // Escuchamos absolutamente TODO\n",
" schema: \"public\",\n",
" table: \"asignaturas\",\n",
" },\n",
" (payload) => {\n",
" // Imprimimos todo el objeto tal como llega del servidor\n",
" console.log(\"🚨 ¡EVENTO RECIBIDO EN CRUDO!\", payload);\n",
"\n",
" // Cerramos la conexión para liberar la celda\n",
" supabase.removeChannel(canal);\n",
" resolve(true);\n",
" },\n",
" )\n",
" .subscribe((status, err) => {\n",
" if (status === \"SUBSCRIBED\") {\n",
" console.log(\n",
" \"Conectado. Esperando cualquier cambio en la tabla asignaturas...\",\n",
" );\n",
" }\n",
" if (err) {\n",
" console.error(\"Error en el socket:\", err);\n",
" }\n",
" });\n",
"});\n",
"\n",
"console.log(\"Script finalizado.\");\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3602b930",
"metadata": {},
"outputs": [],
"source": [
"import(\"https://esm.sh/@supabase/supabase-js\").then(({ createClient }) => {\n",
" const supabase = createClient(\n",
" \"https://bxkskdxwppdlplrkidcz.supabase.co\",\n",
" \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJ4a3NrZHh3cHBkbHBscmtpZGN6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIwMzU2NDMsImV4cCI6MjA4NzYxMTY0M30.RIyvmB8Ij_qrsiMn0f65awiG5VaCUA5dvb117wuU3jo\",\n",
" );\n",
"\n",
" supabase.channel(\"prueba-consola\")\n",
" .on(\"postgres_changes\", {\n",
" event: \"*\",\n",
" schema: \"public\",\n",
" table: \"asignaturas\",\n",
" }, (payload) => {\n",
" console.log(\"🔥 ¡LLEGÓ EL EVENTO DESDE SUPABASE!\", payload);\n",
" })\n",
" .subscribe((status) => console.log(\"Estado de la conexión:\", status));\n",
"});\n"
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "26b3bb36",
"metadata": {},
"outputs": [],
"source": [
"// plan Crear un plan en la base (sin datos) estado generando\n",
"const { data: plan, error } = await supabase.from(\"planes_estudio\").insert({\n",
" \"carrera_id\": \"8208da08-d549-4359-8865-9d806bc54f19\",\n",
" \"estructura_id\": \"69fb2b77-5a95-47e0-bf1f-389d384200e4\",\n",
" \"estado_actual_id\": \"18f49b67-8077-4371-be6e-2019a3be3562\",\n",
" \"nombre\": \"Ingeniearía en Cifbernética y Ciberseguridad (2025)\",\n",
" \"nivel\": \"Doctorado\",\n",
" \"tipo_ciclo\": \"Semestre\",\n",
" \"numero_ciclos\": 8,\n",
" \"activo\": true,\n",
" \"tipo_origen\": \"IA\",\n",
" \"creado_por\": \"11111111-1111-1111-1111-111111111111\",\n",
" \"actualizado_por\": \"11111111-1111-1111-1111-111111111111\",\n",
"}).select(\"*\").single();\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "8abb080e",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{\n",
" message: \u001b[32m\"TypeError: error sending request for url (http://127.0.0.1:54321/rest/v1/planes_estudio?select=*): client error (Connect): tcp connect error: No se puede establecer una conexión ya que el equipo de destino denegó expresamente dicha conexión. (os error 10061)\"\u001b[39m,\n",
" details: \u001b[32m\"TypeError: error sending request for url (http://127.0.0.1:54321/rest/v1/planes_estudio?select=*): client error (Connect): tcp connect error: No se puede establecer una conexión ya que el equipo de destino denegó expresamente dicha conexión. (os error 10061)\\n\"\u001b[39m +\n",
" \u001b[32m\" at async mainFetch (ext:deno_fetch/26_fetch.js:192:12)\\n\"\u001b[39m +\n",
" \u001b[32m\" at async fetch (ext:deno_fetch/26_fetch.js:475:11)\\n\"\u001b[39m +\n",
" \u001b[32m\" at async <anonymous>:2:31\"\u001b[39m,\n",
" hint: \u001b[32m\"\"\u001b[39m,\n",
" code: \u001b[32m\"\"\u001b[39m\n",
"}"
]
},
"execution_count": 9,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"plan.id;\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a382bdc6",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{\n",
" id: \u001b[32m\"resp_0ae72cbd0894279200699c8e42941c8192ba4a09e280b21a17\"\u001b[39m,\n",
" object: \u001b[32m\"response\"\u001b[39m,\n",
" created_at: \u001b[33m1771867714\u001b[39m,\n",
" status: \u001b[32m\"queued\"\u001b[39m,\n",
" background: \u001b[33mtrue\u001b[39m,\n",
" completed_at: \u001b[1mnull\u001b[22m,\n",
" error: \u001b[1mnull\u001b[22m,\n",
" frequency_penalty: \u001b[33m0\u001b[39m,\n",
" incomplete_details: \u001b[1mnull\u001b[22m,\n",
" instructions: \u001b[1mnull\u001b[22m,\n",
" max_output_tokens: \u001b[1mnull\u001b[22m,\n",
" max_tool_calls: \u001b[1mnull\u001b[22m,\n",
" model: \u001b[32m\"gpt-5-mini-2025-08-07\"\u001b[39m,\n",
" output: [],\n",
" parallel_tool_calls: \u001b[33mtrue\u001b[39m,\n",
" presence_penalty: \u001b[33m0\u001b[39m,\n",
" previous_response_id: \u001b[1mnull\u001b[22m,\n",
" prompt_cache_key: \u001b[1mnull\u001b[22m,\n",
" prompt_cache_retention: \u001b[1mnull\u001b[22m,\n",
" reasoning: { effort: \u001b[32m\"medium\"\u001b[39m, summary: \u001b[1mnull\u001b[22m },\n",
" safety_identifier: \u001b[1mnull\u001b[22m,\n",
" service_tier: \u001b[32m\"auto\"\u001b[39m,\n",
" store: \u001b[33mtrue\u001b[39m,\n",
" temperature: \u001b[33m1\u001b[39m,\n",
" text: {\n",
" format: {\n",
" type: \u001b[32m\"json_schema\"\u001b[39m,\n",
" description: \u001b[1mnull\u001b[22m,\n",
" name: \u001b[32m\"response_schema\"\u001b[39m,\n",
" schema: {\n",
" type: \u001b[32m\"object\"\u001b[39m,\n",
" properties: { \u001b[32m\"ai-message\"\u001b[39m: \u001b[36m[Object]\u001b[39m, \u001b[32m\"suggested-topic\"\u001b[39m: \u001b[36m[Object]\u001b[39m },\n",
" required: [ \u001b[32m\"ai-message\"\u001b[39m, \u001b[32m\"suggested-topic\"\u001b[39m ],\n",
" additionalProperties: \u001b[33mfalse\u001b[39m\n",
" },\n",
" strict: \u001b[33mtrue\u001b[39m\n",
" },\n",
" verbosity: \u001b[32m\"medium\"\u001b[39m\n",
" },\n",
" tool_choice: \u001b[32m\"auto\"\u001b[39m,\n",
" tools: [],\n",
" top_logprobs: \u001b[33m0\u001b[39m,\n",
" top_p: \u001b[33m1\u001b[39m,\n",
" truncation: \u001b[32m\"disabled\"\u001b[39m,\n",
" usage: \u001b[1mnull\u001b[22m,\n",
" user: \u001b[1mnull\u001b[22m,\n",
" metadata: { tabla: \u001b[32m\"planes_estudio\"\u001b[39m, id: \u001b[32m\"2\"\u001b[39m },\n",
" output_text: \u001b[32m\"\"\u001b[39m\n",
"}"
]
},
"execution_count": 12,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"let response = await openai.responses.create({\n",
" model: \"gpt-5-mini\",\n",
" input: \"Hola mundo\",\n",
" metadata: {\n",
" tabla: \"planes_estudio\",\n",
" id: '2',\n",
" },\n",
" text: {\n",
" format: {\n",
" type: \"json_schema\",\n",
" name: \"response_schema\",\n",
" schema: {\n",
" type: \"object\",\n",
" properties: {\n",
" \"ai-message\": {\n",
" type: \"string\",\n",
" description:\n",
" \"Un mensaje de texto que la IA generará para el usuario final. Este mensaje debe ser claro y directo, proporcionando la información solicitada por el usuario o indicando que no se puede procesar la solicitud si no está relacionada con planes de estudio o asignaturas.\",\n",
" },\n",
" \"suggested-topic\": {\n",
" type: \"string\",\n",
" description:\n",
" \"Un tema de investigación sugerido por la IA que sea relevante para el plan de estudio o asignatura mencionada en la solicitud del usuario. Este tema debe ser específico y relacionado con el área de estudio correspondiente.\",\n",
" },\n",
" },\n",
" required: [\"ai-message\", \"suggested-topic\"],\n",
" additionalProperties: false,\n",
" },\n",
" },\n",
" },\n",
" background: true,\n",
"});\n",
"\n",
"response;\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "6b2470ed",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Current status: queued\n",
"Current status: in_progress\n",
"Current status: in_progress\n"
]
},
{
"data": {
"text/plain": [
"{\n",
" id: \u001b[32m\"resp_0ae72cbd0894279200699c8e42941c8192ba4a09e280b21a17\"\u001b[39m,\n",
" object: \u001b[32m\"response\"\u001b[39m,\n",
" created_at: \u001b[33m1771867714\u001b[39m,\n",
" status: \u001b[32m\"completed\"\u001b[39m,\n",
" background: \u001b[33mtrue\u001b[39m,\n",
" billing: { payer: \u001b[32m\"developer\"\u001b[39m },\n",
" completed_at: \u001b[33m1771867721\u001b[39m,\n",
" error: \u001b[1mnull\u001b[22m,\n",
" frequency_penalty: \u001b[33m0\u001b[39m,\n",
" incomplete_details: \u001b[1mnull\u001b[22m,\n",
" instructions: \u001b[1mnull\u001b[22m,\n",
" max_output_tokens: \u001b[1mnull\u001b[22m,\n",
" max_tool_calls: \u001b[1mnull\u001b[22m,\n",
" model: \u001b[32m\"gpt-5-mini-2025-08-07\"\u001b[39m,\n",
" output: [\n",
" {\n",
" id: \u001b[32m\"rs_0ae72cbd0894279200699c8e43cd4c8192bcbc70792b6aa621\"\u001b[39m,\n",
" type: \u001b[32m\"reasoning\"\u001b[39m,\n",
" summary: []\n",
" },\n",
" {\n",
" id: \u001b[32m\"msg_0ae72cbd0894279200699c8e48976481928e1f205210636717\"\u001b[39m,\n",
" type: \u001b[32m\"message\"\u001b[39m,\n",
" status: \u001b[32m\"completed\"\u001b[39m,\n",
" content: [\n",
" {\n",
" type: \u001b[32m\"output_text\"\u001b[39m,\n",
" annotations: [],\n",
" logprobs: [],\n",
" text: \u001b[32m'{\"ai-message\":\"¡Hola! ¿En qué puedo ayudarte hoy? Puedo mostrar ejemplos de \\\\\"Hola mundo\\\\\" en distintos lenguajes, explicar conceptos de programación, ayudar con una tarea o sugerir recursos. Dime qué prefieres.\",\"suggested-topic\":\"El papel del programa \\\\\"Hola Mundo\\\\\" en la enseñanza de la programación: comparación práctica entre Python, Java y C (sintaxis, tiempo de arranque, facilidad de aprendizaje y percepción del estudiante).\"}'\u001b[39m\n",
" }\n",
" ],\n",
" role: \u001b[32m\"assistant\"\u001b[39m\n",
" }\n",
" ],\n",
" parallel_tool_calls: \u001b[33mtrue\u001b[39m,\n",
" presence_penalty: \u001b[33m0\u001b[39m,\n",
" previous_response_id: \u001b[1mnull\u001b[22m,\n",
" prompt_cache_key: \u001b[1mnull\u001b[22m,\n",
" prompt_cache_retention: \u001b[1mnull\u001b[22m,\n",
" reasoning: { effort: \u001b[32m\"medium\"\u001b[39m, summary: \u001b[1mnull\u001b[22m },\n",
" safety_identifier: \u001b[1mnull\u001b[22m,\n",
" service_tier: \u001b[32m\"default\"\u001b[39m,\n",
" store: \u001b[33mtrue\u001b[39m,\n",
" temperature: \u001b[33m1\u001b[39m,\n",
" text: {\n",
" format: {\n",
" type: \u001b[32m\"json_schema\"\u001b[39m,\n",
" description: \u001b[1mnull\u001b[22m,\n",
" name: \u001b[32m\"response_schema\"\u001b[39m,\n",
" schema: {\n",
" type: \u001b[32m\"object\"\u001b[39m,\n",
" properties: { \u001b[32m\"ai-message\"\u001b[39m: \u001b[36m[Object]\u001b[39m, \u001b[32m\"suggested-topic\"\u001b[39m: \u001b[36m[Object]\u001b[39m },\n",
" required: [ \u001b[32m\"ai-message\"\u001b[39m, \u001b[32m\"suggested-topic\"\u001b[39m ],\n",
" additionalProperties: \u001b[33mfalse\u001b[39m\n",
" },\n",
" strict: \u001b[33mtrue\u001b[39m\n",
" },\n",
" verbosity: \u001b[32m\"medium\"\u001b[39m\n",
" },\n",
" tool_choice: \u001b[32m\"auto\"\u001b[39m,\n",
" tools: [],\n",
" top_logprobs: \u001b[33m0\u001b[39m,\n",
" top_p: \u001b[33m1\u001b[39m,\n",
" truncation: \u001b[32m\"disabled\"\u001b[39m,\n",
" usage: {\n",
" input_tokens: \u001b[33m167\u001b[39m,\n",
" input_tokens_details: { cached_tokens: \u001b[33m0\u001b[39m },\n",
" output_tokens: \u001b[33m482\u001b[39m,\n",
" output_tokens_details: { reasoning_tokens: \u001b[33m320\u001b[39m },\n",
" total_tokens: \u001b[33m649\u001b[39m\n",
" },\n",
" user: \u001b[1mnull\u001b[22m,\n",
" metadata: { tabla: \u001b[32m\"planes_estudio\"\u001b[39m, id: \u001b[32m\"2\"\u001b[39m },\n",
" output_text: \u001b[32m'{\"ai-message\":\"¡Hola! ¿En qué puedo ayudarte hoy? Puedo mostrar ejemplos de \\\\\"Hola mundo\\\\\" en distintos lenguajes, explicar conceptos de programación, ayudar con una tarea o sugerir recursos. Dime qué prefieres.\",\"suggested-topic\":\"El papel del programa \\\\\"Hola Mundo\\\\\" en la enseñanza de la programación: comparación práctica entre Python, Java y C (sintaxis, tiempo de arranque, facilidad de aprendizaje y percepción del estudiante).\"}'\u001b[39m\n",
"}"
]
},
"execution_count": 13,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"while (response.status === \"queued\" || response.status === \"in_progress\") {\n",
" console.log(\"Current status: \" + response.status);\n",
" await new Promise((resolve) => setTimeout(resolve, 2000)); // wait 2 seconds\n",
" response = await openai.responses.retrieve(response.id);\n",
"}\n",
"\n",
"response;\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "ebb67bd8",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{ tabla: \u001b[32m\"planes_estudio\"\u001b[39m, id: \u001b[32m\"e39952c1-a53b-43bf-8033-feda493a0651\"\u001b[39m }"
]
},
"execution_count": 24,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"response.metadata;\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cd18fc2d",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{\n",
" error: \u001b[1mnull\u001b[22m,\n",
" data: {\n",
" id: \u001b[32m\"e39952c1-a53b-43bf-8033-feda493a0651\"\u001b[39m,\n",
" carrera_id: \u001b[32m\"8208da08-d549-4359-8865-9d806bc54f19\"\u001b[39m,\n",
" estructura_id: \u001b[32m\"69fb2b77-5a95-47e0-bf1f-389d384200e4\"\u001b[39m,\n",
" nombre: \u001b[32m\"Ingeniearía en Cifbernética y Ciberseguridad (2025)\"\u001b[39m,\n",
" nivel: \u001b[32m\"Doctorado\"\u001b[39m,\n",
" tipo_ciclo: \u001b[32m\"Semestre\"\u001b[39m,\n",
" numero_ciclos: \u001b[33m8\u001b[39m,\n",
" datos: {\n",
" \u001b[32m\"ai-message\"\u001b[39m: \u001b[32m\"¡Hola! ¿Cómo puedo ayudarte hoy? Si te refieres al clásico “Hola mundo” en programación, puedo mostrarte ejemplos en varios lenguajes, explicar su historia o ayudarte a crear tu primer programa. ¿Qué prefieres?\"\u001b[39m,\n",
" \u001b[32m\"suggested-topic\"\u001b[39m: \u001b[32m\"El uso del programa “Hola mundo” como herramienta pedagógica en la enseñanza de la programación: impacto en la comprensión inicial, variaciones por lenguaje y mejores prácticas didácticas.\"\u001b[39m\n",
" },\n",
" estado_actual_id: \u001b[32m\"18f49b67-8077-4371-be6e-2019a3be3562\"\u001b[39m,\n",
" activo: \u001b[33mtrue\u001b[39m,\n",
" tipo_origen: \u001b[32m\"IA\"\u001b[39m,\n",
" meta_origen: {},\n",
" creado_por: \u001b[32m\"11111111-1111-1111-1111-111111111111\"\u001b[39m,\n",
" actualizado_por: \u001b[32m\"11111111-1111-1111-1111-111111111111\"\u001b[39m,\n",
" creado_en: \u001b[32m\"2026-02-20T17:11:28.995222+00:00\"\u001b[39m,\n",
" actualizado_en: \u001b[32m\"2026-02-20T17:12:40.170995+00:00\"\u001b[39m,\n",
" nombre_search: \u001b[32m\"ingeniearia en cifbernetica y ciberseguridad (2025)\"\u001b[39m,\n",
" plan_hash: \u001b[32m\"70d28457bbddfe4f5cde0574\"\u001b[39m\n",
" },\n",
" count: \u001b[1mnull\u001b[22m,\n",
" status: \u001b[33m200\u001b[39m,\n",
" statusText: \u001b[32m\"OK\"\u001b[39m\n",
"}"
]
},
"execution_count": 27,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"await supabase.from(response.metadata.tabla).update({\n",
" datos: JSON.parse(response.output_text),\n",
"}).eq(\"id\", response.metadata.id).select(\"*\").single();\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cbbfd8c0",
"metadata": {},
"outputs": [
{
"ename": "Expected ';', '}' or <eof> at file:///repl.tsx:2:93\n\n ...} ha sido actualizado con los datos generados por la IA.`: response.output_text,\n ~",
"evalue": "Expected ';', '}' or <eof> at file:///repl.tsx:2:93\n\n ...} ha sido actualizado con los datos generados por la IA.`: response.output_text,\n ~",
"output_type": "error",
"traceback": []
}
],
"source": []
},
{
"cell_type": "code",
"execution_count": null,
"id": "a00c79bd",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Deno",
"language": "typescript",
"name": "deno"
},
"language_info": {
"codemirror_mode": "typescript",
"file_extension": ".ts",
"mimetype": "text/x.typescript",
"name": "typescript",
"nbconvert_exporter": "script",
"pygments_lexer": "typescript",
"version": "5.9.2"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+222
View File
@@ -0,0 +1,222 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 14,
"id": "902bff53",
"metadata": {},
"outputs": [],
"source": [
"import { load } from \"jsr:@std/dotenv\";\n",
"import OpenAI from \"jsr:@openai/openai\";\n",
"\n",
"const env = await load({\n",
" export: true,\n",
"});\n",
"\n",
"const openai = new OpenAI();"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "68f89168",
"metadata": {},
"outputs": [],
"source": [
"const perishables = {\n",
" expiryDate: { type: \"string\", format: \"date\" },\n",
" batchNumber: {\n",
" type: \"string\",\n",
" maxLength: 20,\n",
" description: \"Puede ser inventado\",\n",
" },\n",
" storageTempC: {\n",
" type: \"number\",\n",
" minimum: -30,\n",
" maximum: 10,\n",
" description: \"Temperatura de almacenamiento en grados Celsius\",\n",
" },\n",
"};\n",
"\n",
"const origen = {\n",
" country: { type: \"string\", maxLength: 50 },\n",
" city: { type: \"string\", maxLength: 50 },\n",
"};\n",
"\n",
"// Transformar las definiciones: poner en properties, agregar required, y additionalProperties: false\n",
"\n",
"const perishablesWithMetadata = {\n",
" ...perishables,\n",
" type: \"object\",\n",
" required: Object.keys(perishables),\n",
" additionalProperties: false,\n",
"};\n",
"\n",
"const origenWithMetadata = {\n",
" ...origen,\n",
" type: \"object\",\n",
" required: Object.keys(origen),\n",
" additionalProperties: false,\n",
"};\n",
"\n",
"\n",
"\n",
"const combinedSchema = {\n",
" type: \"object\",\n",
" $defs: {\n",
" perishables: perishablesWithMetadata,\n",
" origen: origenWithMetadata,\n",
" },\n",
" properties: {\n",
" nombre: { type: \"string\", maxLength: 100 },\n",
" fabricante: { type: \"string\", maxLength: 100 },\n",
"\n",
" // Agregar campo del preset\n",
" perishables: { $ref: \"#/$defs/perishables\" },\n",
" origen: { $ref: \"#/$defs/origen\" },\n",
" },\n",
" required: [\"nombre\", \"fabricante\", \"perishables\", \"origen\"],\n",
" additionalProperties: false,\n",
"};\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "1403c789",
"metadata": {},
"outputs": [],
"source": [
"import { title } from \"node:process\";\n",
"\n",
"const combinedSchema = {\n",
" type: \"object\",\n",
" $defs: {\n",
" perishables: {\n",
" type: \"object\",\n",
" properties: {\n",
" expiryDate: { type: \"string\", format: \"date\" },\n",
" batchNumber: {\n",
" type: \"string\",\n",
" maxLength: 20,\n",
" description: \"Puede ser inventado\",\n",
" },\n",
" storageTempC: {\n",
" type: \"number\",\n",
" minimum: -30,\n",
" maximum: 10,\n",
" description:\n",
" \"Temperatura de almacenamiento en grados Celsius\",\n",
" },\n",
" },\n",
" required: [\"expiryDate\", \"batchNumber\", \"storageTempC\"],\n",
" additionalProperties: false,\n",
" },\n",
" origen: {\n",
" type: \"object\",\n",
" properties: {\n",
" country: { type: \"string\", maxLength: 50 },\n",
" city: { type: \"string\", maxLength: 50 },\n",
" },\n",
" required: [\"country\", \"city\"],\n",
" additionalProperties: false,\n",
" },\n",
" },\n",
" properties: {\n",
" nombre: { type: \"string\", maxLength: 100 },\n",
" fabricante: { type: \"string\", maxLength: 100 },\n",
"\n",
" // Agregar campo del preset\n",
" perishables: {\n",
" $ref: \"#/$defs/perishables\",\n",
" \"x-column\": \"perishables\",\n",
" },\n",
"\n",
" origen: { $ref: \"#/$defs/origen\" },\n",
" },\n",
" required: [\"nombre\", \"fabricante\", \"perishables\", \"origen\", \"perecedero\"],\n",
" additionalProperties: false,\n",
"};\n"
]
},
{
"cell_type": "code",
"execution_count": 27,
"id": "1fbda124",
"metadata": {},
"outputs": [],
"source": [
"const result = await openai.responses.create({\n",
" model: \"gpt-5-nano\",\n",
" input:\n",
" \"Genera un producto con el siguiente formato JSON, sin texto adicional:\",\n",
" text: {\n",
" format: {\n",
" type: \"json_schema\",\n",
" name: \"producto\",\n",
" schema: combinedSchema,\n",
" },\n",
" },\n",
"});\n"
]
},
{
"cell_type": "code",
"execution_count": 28,
"id": "85e7967a",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{\n",
" nombre: \u001b[32m\"Galletas de avena premium\"\u001b[39m,\n",
" fabricante: \u001b[32m\"Industrias Gourmet S.L.\"\u001b[39m,\n",
" perishables: {\n",
" expiryDate: \u001b[32m\"2026-12-31\"\u001b[39m,\n",
" batchNumber: \u001b[32m\"BATCH-00123\"\u001b[39m,\n",
" storageTempC: \u001b[33m4\u001b[39m\n",
" },\n",
" origen: { country: \u001b[32m\"España\"\u001b[39m, city: \u001b[32m\"Valencia\"\u001b[39m },\n",
" perecedero: \u001b[1mnull\u001b[22m\n",
"}"
]
},
"execution_count": 28,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"JSON.parse(result.output_text)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "dae842ad",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Deno",
"language": "typescript",
"name": "deno"
},
"language_info": {
"codemirror_mode": "typescript",
"file_extension": ".ts",
"mimetype": "text/x.typescript",
"name": "typescript",
"nbconvert_exporter": "script",
"pygments_lexer": "typescript",
"version": "5.9.2"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+177
View File
@@ -0,0 +1,177 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 2,
"id": "902bff53",
"metadata": {},
"outputs": [],
"source": [
"import { load } from \"jsr:@std/dotenv\";\n",
"import OpenAI from \"jsr:@openai/openai\";\n",
"\n",
"const env = await load({\n",
" export: true,\n",
"});\n",
"\n",
"const openai = new OpenAI();"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "3b0acbc3",
"metadata": {},
"outputs": [],
"source": [
"import { createClient } from \"@supabase/supabase-js\";\n",
"const supabaseUrl = Deno.env.get(\"SUPABASE_URL\") || env.SUPABASE_URL;\n",
"const supabaseKey = Deno.env.get(\"SUPABASE_ANON_KEY\") || env.SUPABASE_ANON_KEY;\n",
"const supabase = createClient(supabaseUrl, supabaseKey);"
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "dae842ad",
"metadata": {},
"outputs": [
{
"ename": "ReferenceError",
"evalue": "openai is not defined",
"output_type": "error",
"traceback": [
"Stack trace:",
"ReferenceError: openai is not defined",
" at <anonymous>:5:15"
]
}
],
"source": [
"const textos = [\n",
" \"Los vectores se usan con el cliente de OPENAI embeddings\",\n",
"];\n",
"\n",
"for (const contenido of textos) {\n",
" const emb = await openai.embeddings.create({\n",
" model: \"text-embedding-3-small\",\n",
" input: contenido,\n",
" });\n",
"\n",
" const embedding = emb.data[0].embedding;\n",
" console.log(emb);\n",
" \n",
"\n",
" const { error } = await supabase.from(\"documentos\").insert({\n",
" contenido,\n",
" embedding,\n",
" });\n",
"\n",
" if (error) throw error;\n",
"}\n",
"\n",
"console.log(\"✅ Insertados textos con embeddings\");"
]
},
{
"cell_type": "code",
"execution_count": 12,
"id": "2d8fa9a9",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"🔎 Consulta: ¿Cómo son buscados vectores?\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",
" model: \"text-embedding-3-small\",\n",
" input: consulta,\n",
"});\n",
"\n",
"const query_embedding = emb.data[0].embedding;\n",
"\n",
"const { data, error } = await supabase.rpc(\"buscar_documentos\", {\n",
" query_embedding,\n",
" match_count: 3,\n",
"});\n",
"\n",
"/* if (error) throw error; */\n",
"\n",
"console.log(`🔎 Consulta: ${consulta}\\n`);\n",
"encode(error, {\n",
" indent: 2,\n",
" delimiter: \",\",\n",
" keyFolding: \"off\",\n",
" flattenDepth: Infinity,\n",
"});\n"
]
},
{
"cell_type": "code",
"execution_count": 10,
"id": "791a94d8",
"metadata": {},
"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": {
"kernelspec": {
"display_name": "Deno",
"language": "typescript",
"name": "deno"
},
"language_info": {
"codemirror_mode": "typescript",
"file_extension": ".ts",
"mimetype": "text/x.typescript",
"name": "typescript",
"nbconvert_exporter": "script",
"pygments_lexer": "typescript",
"version": "5.9.2"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
File diff suppressed because it is too large Load Diff
+519
View File
@@ -0,0 +1,519 @@
<?xml version="1.0" encoding="utf-8"?>
<style xmlns="http://purl.org/net/xbiblio/csl" class="in-text" version="1.0" demote-non-dropping-particle="sort-only">
<info>
<title>IEEE Reference Guide version 11.29.2023</title>
<title-short>Institute of Electrical and Electronics Engineers</title-short>
<id>http://www.zotero.org/styles/ieee</id>
<link href="http://www.zotero.org/styles/ieee" rel="self"/>
<link href="https://journals.ieeeauthorcenter.ieee.org/your-role-in-article-production/ieee-editorial-style-manual/" rel="documentation"/>
<author>
<name>Michael Berkowitz</name>
<email>mberkowi@gmu.edu</email>
</author>
<contributor>
<name>Julian Onions</name>
<email>julian.onions@gmail.com</email>
</contributor>
<contributor>
<name>Rintze Zelle</name>
<uri>http://twitter.com/rintzezelle</uri>
</contributor>
<contributor>
<name>Stephen Frank</name>
<uri>http://www.zotero.org/sfrank</uri>
</contributor>
<contributor>
<name>Sebastian Karcher</name>
</contributor>
<contributor>
<name>Giuseppe Silano</name>
<email>g.silano89@gmail.com</email>
<uri>http://giuseppesilano.net</uri>
</contributor>
<contributor>
<name>Patrick O'Brien</name>
</contributor>
<contributor>
<name>Brenton M. Wiernik</name>
</contributor>
<contributor>
<name>Oliver Couch</name>
<email>oliver.couch@gmail.com</email>
</contributor>
<contributor>
<name>Andrew Dunning</name>
<uri>https://orcid.org/0000-0003-0464-5036</uri>
</contributor>
<category citation-format="numeric"/>
<category field="engineering"/>
<category field="generic-base"/>
<summary>IEEE style as per the 2023 guidelines.</summary>
<updated>2024-03-27T11:41:27+00:00</updated>
<rights license="http://creativecommons.org/licenses/by-sa/3.0/">This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 License</rights>
</info>
<locale xml:lang="en">
<date form="text">
<date-part name="month" form="short" suffix=" "/>
<date-part name="day" form="numeric-leading-zeros" suffix=", "/>
<date-part name="year"/>
</date>
<terms>
<term name="chapter" form="short">ch.</term>
<term name="chapter-number" form="short">ch.</term>
<term name="presented at">presented at the</term>
<term name="available at">available</term>
<!-- always use three-letter abbreviations for months -->
<term name="month-06" form="short">Jun.</term>
<term name="month-07" form="short">Jul.</term>
<term name="month-09" form="short">Sep.</term>
</terms>
</locale>
<!-- Macros -->
<macro name="status">
<choose>
<if variable="page issue volume" match="none">
<text variable="status" text-case="capitalize-first" suffix="" font-weight="bold"/>
</if>
</choose>
</macro>
<macro name="edition">
<choose>
<if type="bill book chapter graphic legal_case legislation motion_picture paper-conference report song" match="any">
<choose>
<if is-numeric="edition">
<group delimiter=" ">
<number variable="edition" form="ordinal"/>
<text term="edition" form="short"/>
</group>
</if>
<else>
<text variable="edition" text-case="capitalize-first" suffix="."/>
</else>
</choose>
</if>
</choose>
</macro>
<macro name="issued">
<choose>
<if type="article-journal report" match="any">
<date variable="issued">
<date-part name="month" form="short" suffix=" "/>
<date-part name="year" form="long"/>
</date>
</if>
<else-if type="bill book chapter graphic legal_case legislation song thesis" match="any">
<date variable="issued">
<date-part name="year" form="long"/>
</date>
</else-if>
<else-if type="paper-conference" match="any">
<date variable="issued">
<date-part name="month" form="short"/>
<date-part name="year" prefix=" "/>
</date>
</else-if>
<else-if type="motion_picture" match="any">
<date variable="issued" form="text" prefix="(" suffix=")"/>
</else-if>
<else>
<date variable="issued" form="text"/>
</else>
</choose>
</macro>
<macro name="author">
<names variable="author">
<name and="text" et-al-min="7" et-al-use-first="1" initialize-with=". "/>
<label form="short" prefix=", " text-case="capitalize-first"/>
<et-al font-style="italic"/>
<substitute>
<names variable="editor"/>
<names variable="translator"/>
<text macro="director"/>
</substitute>
</names>
</macro>
<macro name="editor">
<names variable="editor">
<name initialize-with=". " delimiter=", " and="text"/>
<label form="short" prefix=", " text-case="capitalize-first"/>
</names>
</macro>
<macro name="director">
<names variable="director">
<name and="text" et-al-min="7" et-al-use-first="1" initialize-with=". "/>
<et-al font-style="italic"/>
</names>
</macro>
<macro name="locators">
<group delimiter=", ">
<text macro="edition"/>
<group delimiter=" ">
<text term="volume" form="short"/>
<number variable="volume" form="numeric"/>
</group>
<group delimiter=" ">
<number variable="number-of-volumes" form="numeric"/>
<text term="volume" form="short" plural="true"/>
</group>
<group delimiter=" ">
<text term="issue" form="short"/>
<number variable="issue" form="numeric"/>
</group>
</group>
</macro>
<macro name="title">
<choose>
<if type="bill book graphic legal_case legislation motion_picture song standard software" match="any">
<text variable="title" font-style="italic"/>
</if>
<else>
<text variable="title" quotes="true"/>
</else>
</choose>
</macro>
<macro name="publisher">
<choose>
<if type="bill book chapter graphic legal_case legislation motion_picture paper-conference song" match="any">
<group delimiter=": ">
<text variable="publisher-place"/>
<text variable="publisher"/>
</group>
</if>
<else>
<group delimiter=", ">
<text variable="publisher"/>
<text variable="publisher-place"/>
</group>
</else>
</choose>
</macro>
<macro name="event">
<choose>
<!-- Published Conference Paper -->
<if type="paper-conference speech" match="any">
<choose>
<if variable="container-title" match="any">
<group delimiter=" ">
<text term="in"/>
<text variable="container-title" font-style="italic"/>
</group>
</if>
<!-- Unpublished Conference Paper -->
<else>
<group delimiter=" ">
<text term="presented at"/>
<text variable="event"/>
</group>
</else>
</choose>
</if>
</choose>
</macro>
<macro name="access">
<choose>
<if type="webpage post post-weblog" match="any">
<!-- https://url.com/ (accessed Mon. DD, YYYY). -->
<choose>
<if variable="URL">
<group delimiter=". " prefix=" ">
<group delimiter=": ">
<text term="accessed" text-case="capitalize-first"/>
<date variable="accessed" form="text"/>
</group>
<text term="online" prefix="[" suffix="]" text-case="capitalize-first"/>
<group delimiter=": ">
<text term="available at" text-case="capitalize-first"/>
<text variable="URL"/>
</group>
</group>
</if>
</choose>
</if>
<else-if match="any" variable="DOI">
<!-- doi: 10.1000/xyz123. -->
<text variable="DOI" prefix=" doi: " suffix="."/>
</else-if>
<else-if variable="URL">
<!-- Accessed: Mon. DD, YYYY. [Medium]. Available: https://URL.com/ -->
<group delimiter=". " prefix=" " suffix=". ">
<!-- Accessed: Mon. DD, YYYY. -->
<group delimiter=": ">
<text term="accessed" text-case="capitalize-first"/>
<date variable="accessed" form="text"/>
</group>
<!-- [Online Video]. -->
<group prefix="[" suffix="]" delimiter=" ">
<choose>
<if variable="medium" match="any">
<text variable="medium" text-case="capitalize-first"/>
</if>
<else>
<text term="online" text-case="capitalize-first"/>
<choose>
<if type="motion_picture">
<text term="video" text-case="capitalize-first"/>
</if>
</choose>
</else>
</choose>
</group>
</group>
<!-- Available: https://URL.com/ -->
<group delimiter=": " prefix=" ">
<text term="available at" text-case="capitalize-first"/>
<text variable="URL"/>
</group>
</else-if>
</choose>
</macro>
<macro name="page">
<choose>
<if type="article-journal" variable="number" match="all">
<group delimiter=" ">
<text value="Art."/>
<text term="issue" form="short"/>
<text variable="number"/>
</group>
</if>
<else>
<group delimiter=" ">
<label variable="page" form="short"/>
<text variable="page"/>
</group>
</else>
</choose>
</macro>
<macro name="citation-locator">
<group delimiter=" ">
<choose>
<if locator="page">
<label variable="locator" form="short"/>
</if>
<else>
<label variable="locator" form="short" text-case="capitalize-first"/>
</else>
</choose>
<text variable="locator"/>
</group>
</macro>
<macro name="geographic-location">
<group delimiter=", " suffix=".">
<choose>
<if variable="publisher-place">
<text variable="publisher-place" text-case="title"/>
</if>
<else-if variable="event-place">
<text variable="event-place" text-case="title"/>
</else-if>
</choose>
</group>
</macro>
<!-- Series -->
<macro name="collection">
<choose>
<if variable="collection-title" match="any">
<text term="in" suffix=" "/>
<group delimiter=", " suffix=". ">
<text variable="collection-title"/>
<text variable="collection-number" prefix="no. "/>
<text variable="volume" prefix="vol. "/>
</group>
</if>
</choose>
</macro>
<!-- Citation -->
<citation>
<sort>
<key variable="citation-number"/>
</sort>
<layout delimiter=", ">
<group prefix="[" suffix="]" delimiter=", ">
<text variable="citation-number"/>
<text macro="citation-locator"/>
</group>
</layout>
</citation>
<!-- Bibliography -->
<bibliography entry-spacing="0" second-field-align="flush">
<layout>
<!-- Citation Number -->
<text variable="citation-number" prefix="[" suffix="]"/>
<!-- Author(s) -->
<text macro="author" suffix=", "/>
<!-- Rest of Citation -->
<choose>
<!-- Specific Formats -->
<if type="article-journal">
<group delimiter=", ">
<text macro="title"/>
<text variable="container-title" font-style="italic" form="short"/>
<text macro="locators"/>
<text macro="page"/>
<text macro="issued"/>
<text macro="status"/>
</group>
<choose>
<if variable="URL DOI" match="none">
<text value="."/>
</if>
<else>
<text value=","/>
</else>
</choose>
<text macro="access"/>
</if>
<else-if type="paper-conference speech" match="any">
<group delimiter=", " suffix=", ">
<text macro="title"/>
<text macro="event"/>
<text macro="editor"/>
</group>
<text macro="collection"/>
<group delimiter=", " suffix=".">
<text macro="publisher"/>
<text macro="issued"/>
<text macro="page"/>
<text macro="status"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="chapter">
<group delimiter=", " suffix=".">
<text macro="title"/>
<group delimiter=" ">
<text term="in" suffix=" "/>
<text variable="container-title" font-style="italic"/>
</group>
<text macro="locators"/>
<text macro="editor"/>
<text macro="collection"/>
<text macro="publisher"/>
<text macro="issued"/>
<group delimiter=" ">
<label variable="chapter-number" form="short"/>
<text variable="chapter-number"/>
</group>
<text macro="page"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="report">
<group delimiter=", " suffix=".">
<text macro="title"/>
<text macro="publisher"/>
<group delimiter=" ">
<text variable="genre"/>
<text variable="number"/>
</group>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="thesis">
<group delimiter=", " suffix=".">
<text macro="title"/>
<text variable="genre"/>
<text macro="publisher"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="software">
<group delimiter=". " suffix=".">
<text macro="title"/>
<text macro="issued" prefix="(" suffix=")"/>
<text variable="genre"/>
<text macro="publisher"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="article">
<group delimiter=", " suffix=".">
<text macro="title"/>
<text macro="issued"/>
<group delimiter=": ">
<text macro="publisher" font-style="italic"/>
<text variable="number"/>
</group>
</group>
<text macro="access"/>
</else-if>
<else-if type="webpage post-weblog post" match="any">
<group delimiter=", " suffix=".">
<text macro="title"/>
<text variable="container-title"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="patent">
<group delimiter=", ">
<text macro="title"/>
<text variable="number"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<!-- Online Video -->
<else-if type="motion_picture">
<text macro="geographic-location" suffix=". "/>
<group delimiter=", " suffix=".">
<text macro="title"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="standard">
<group delimiter=", " suffix=".">
<text macro="title"/>
<group delimiter=" ">
<text variable="genre"/>
<text variable="number"/>
</group>
<text macro="geographic-location"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<!-- Generic/Fallback Formats -->
<else-if type="bill book graphic legal_case legislation report song" match="any">
<group delimiter=", " suffix=". ">
<text macro="title"/>
<text macro="locators"/>
</group>
<text macro="collection"/>
<group delimiter=", " suffix=".">
<text macro="publisher"/>
<text macro="issued"/>
<text macro="page"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="article-magazine article-newspaper broadcast interview manuscript map patent personal_communication song speech thesis webpage" match="any">
<group delimiter=", " suffix=".">
<text macro="title"/>
<text variable="container-title" font-style="italic"/>
<text macro="locators"/>
<text macro="publisher"/>
<text macro="page"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<else>
<group delimiter=", " suffix=". ">
<text macro="title"/>
<text variable="container-title" font-style="italic"/>
<text macro="locators"/>
</group>
<text macro="collection"/>
<group delimiter=", " suffix=".">
<text macro="publisher"/>
<text macro="page"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else>
</choose>
</layout>
</bibliography>
</style>
+757
View File
@@ -0,0 +1,757 @@
<?xml version="1.0" encoding="utf-8"?>
<locale xmlns="http://purl.org/net/xbiblio/csl" version="1.0" xml:lang="es-MX">
<info>
<translator>
<name>Juan Ignacio Flores Salgado</name>
<uri>https://www.mendeley.com/profiles/juan-ignacio-flores-salgado/</uri>
</translator>
<rights license="http://creativecommons.org/licenses/by-sa/3.0/">This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 License</rights>
<updated>2025-10-16T03:24:00+00:00</updated>
</info>
<style-options punctuation-in-quote="false"/>
<date form="text">
<date-part name="day" prefix="el " suffix=" de "/>
<date-part name="month" suffix=" de "/>
<date-part name="year"/>
</date>
<date form="numeric">
<date-part name="day" form="numeric-leading-zeros" suffix="/"/>
<date-part name="month" form="numeric-leading-zeros" suffix="/"/>
<date-part name="year"/>
</date>
<terms>
<!-- LONG GENERAL TERMS -->
<term name="accessed">consultado</term>
<term name="advance-online-publication">advance online publication</term>
<term name="album">album</term>
<term name="and">y</term>
<term name="and others">et&#160;al.</term>
<term name="anonymous">anónimo</term>
<term name="at">en</term>
<term name="audio-recording">audio recording</term>
<term name="available at">disponible en</term>
<term name="by">de</term>
<term name="circa">circa</term>
<term name="cited">citado</term>
<term name="et-al">et&#160;al.</term>
<term name="film">film</term>
<term name="forthcoming">en preparación</term>
<term name="from">a partir de</term>
<term name="henceforth">henceforth</term>
<term name="ibid">ibid.</term>
<term name="in">en</term>
<term name="in press">en imprenta</term>
<term name="internet">internet</term>
<term name="letter">carta</term>
<term name="loc-cit">loc. cit.</term> <!-- like ibid., the abbreviated form is the regular form -->
<term name="no date">sin fecha</term>
<term name="no-place">no place</term>
<term name="no-publisher">no publisher</term> <!-- sine nomine -->
<term name="on">on</term>
<term name="online">en línea</term>
<term name="op-cit">op. cit.</term> <!-- like ibid., the abbreviated form is the regular form -->
<term name="original-work-published">obra original publicada en</term>
<term name="personal-communication">comunicación personal</term>
<term name="podcast">podcast</term>
<term name="podcast-episode">podcast episode</term>
<term name="preprint">preprint</term>
<term name="presented at">presentado en</term>
<term name="radio-broadcast">radio broadcast</term>
<term name="radio-series">radio series</term>
<term name="radio-series-episode">radio series episode</term>
<term name="reference">
<single>referencia</single>
<multiple>referencias</multiple>
</term>
<term name="retrieved">recuperado</term>
<term name="review-of">review of</term>
<term name="scale">escala</term>
<term name="special-issue">special issue</term>
<term name="special-section">special section</term>
<term name="television-broadcast">television broadcast</term>
<term name="television-series">television series</term>
<term name="television-series-episode">television series episode</term>
<term name="video">video</term>
<term name="working-paper">working paper</term>
<!-- SHORT GENERAL TERMS -->
<term name="anonymous" form="short">anón.</term>
<term name="circa" form="short">c.</term>
<term name="no date" form="short">s/f</term>
<term name="no-place" form="short">n.p.</term>
<term name="no-publisher" form="short">n.p.</term>
<term name="reference" form="short">
<single>ref.</single>
<multiple>refs.</multiple>
</term>
<term name="review-of" form="short">rev. of</term>
<!-- SYMBOLIC GENERAL FORMS -->
<!-- LONG ITEM TYPE FORMS -->
<term name="article">preprint</term>
<term name="article-journal">journal article</term>
<term name="article-magazine">magazine article</term>
<term name="article-newspaper">newspaper article</term>
<term name="bill">bill</term>
<!-- book is in the list of locator terms -->
<term name="broadcast">broadcast</term>
<!-- chapter is in the list of locator terms -->
<term name="classic">classic</term>
<term name="collection">collection</term>
<term name="dataset">dataset</term>
<term name="document">document</term>
<term name="entry">entry</term>
<term name="entry-dictionary">dictionary entry</term>
<term name="entry-encyclopedia">encyclopedia entry</term>
<term name="event">event</term>
<!-- figure is in the list of locator terms -->
<term name="graphic">graphic</term>
<term name="hearing">hearing</term>
<term name="interview">entrevista</term>
<term name="legal_case">legal case</term>
<term name="legislation">legislation</term>
<term name="manuscript">manuscript</term>
<term name="map">map</term>
<term name="motion_picture">video recording</term>
<term name="musical_score">musical score</term>
<term name="pamphlet">pamphlet</term>
<term name="paper-conference">conference paper</term>
<term name="patent">patent</term>
<term name="performance">performance</term>
<term name="periodical">periodical</term>
<term name="personal_communication">comunicación personal</term>
<term name="post">post</term>
<term name="post-weblog">blog post</term>
<term name="regulation">regulation</term>
<term name="report">report</term>
<term name="review">review</term>
<term name="review-book">book review</term>
<term name="software">software</term>
<term name="song">audio recording</term>
<term name="speech">presentation</term>
<term name="standard">standard</term>
<term name="thesis">thesis</term>
<term name="treaty">treaty</term>
<term name="webpage">webpage</term>
<!-- SHORT ITEM TYPE FORMS -->
<term name="article-journal" form="short">journal art.</term>
<term name="article-magazine" form="short">mag. art.</term>
<term name="article-newspaper" form="short">newspaper art.</term>
<!-- book is in the list of locator terms -->
<!-- chapter is in the list of locator terms -->
<term name="document" form="short">doc.</term>
<!-- figure is in the list of locator terms -->
<term name="graphic" form="short">graph.</term>
<term name="interview" form="short">interv.</term>
<term name="manuscript" form="short">MS</term>
<term name="motion_picture" form="short">video rec.</term>
<term name="report" form="short">rep.</term>
<term name="review" form="short">rev.</term>
<term name="review-book" form="short">bk. rev.</term>
<term name="song" form="short">audio rec.</term>
<!-- LONG VERB ITEM TYPE FORMS -->
<!-- Only where applicable -->
<term name="hearing" form="verb">testimony of</term>
<term name="review" form="verb">review of</term>
<term name="review-book" form="verb">review of the book</term>
<!-- SHORT VERB ITEM TYPE FORMS -->
<!-- HISTORICAL ERA TERMS -->
<term name="ad">d.&#160;C.</term>
<term name="bc">a.&#160;C.</term>
<term name="bce">BCE</term>
<term name="ce">CE</term>
<!-- PUNCTUATION -->
<term name="open-quote"></term>
<term name="close-quote"></term>
<term name="open-inner-quote"></term>
<term name="close-inner-quote"></term>
<term name="page-range-delimiter"></term>
<term name="colon">:</term>
<term name="comma">,</term>
<term name="semicolon">;</term>
<!-- ORDINALS -->
<term name="ordinal">a</term>
<term name="ordinal-01" gender-form="feminine" match="whole-number">a</term>
<term name="ordinal-01" gender-form="masculine" match="whole-number">o</term>
<!-- LONG ORDINALS -->
<term name="long-ordinal-01">primera</term>
<term name="long-ordinal-02">segunda</term>
<term name="long-ordinal-03">tercera</term>
<term name="long-ordinal-04">cuarta</term>
<term name="long-ordinal-05">quinta</term>
<term name="long-ordinal-06">sexta</term>
<term name="long-ordinal-07">séptima</term>
<term name="long-ordinal-08">octava</term>
<term name="long-ordinal-09">novena</term>
<term name="long-ordinal-10">décima</term>
<!-- LONG LOCATOR FORMS -->
<term name="act">
<single>act</single>
<multiple>acts</multiple>
</term>
<term name="appendix">
<single>appendix</single>
<multiple>appendices</multiple>
</term>
<term name="article-locator">
<single>article</single>
<multiple>articles</multiple>
</term>
<term name="book">
<single>libro</single>
<multiple>libros</multiple>
</term>
<term name="canon">
<single>canon</single>
<multiple>canons</multiple>
</term>
<term name="chapter">
<single>capítulo</single>
<multiple>capítulos</multiple>
</term>
<term name="column">
<single>columna</single>
<multiple>columnas</multiple>
</term>
<term name="elocation">
<single>location</single>
<multiple>locations</multiple>
</term>
<term name="equation">
<single>equation</single>
<multiple>equations</multiple>
</term>
<term name="figure">
<single>figura</single>
<multiple>figuras</multiple>
</term>
<term name="folio">
<single>folio</single>
<multiple>folios</multiple>
</term>
<term name="issue">
<single>número</single>
<multiple>números</multiple>
</term>
<term name="line">
<single>línea</single>
<multiple>líneas</multiple>
</term>
<term name="note">
<single>nota</single>
<multiple>notas</multiple>
</term>
<term name="opus">
<single>opus</single>
<multiple>opera</multiple>
</term>
<term name="page">
<single>página</single>
<multiple>páginas</multiple>
</term>
<term name="paragraph">
<single>párrafo</single>
<multiple>párrafos</multiple>
</term>
<term name="part">
<single>parte</single>
<multiple>partes</multiple>
</term>
<term name="rule">
<single>rule</single>
<multiple>rules</multiple>
</term>
<term name="scene">
<single>scene</single>
<multiple>scenes</multiple>
</term>
<term name="section">
<single>sección</single>
<multiple>secciones</multiple>
</term>
<term name="sub-verbo">
<single>sub voce</single>
<multiple>sub vocibus</multiple>
</term>
<term name="supplement">
<single>supplement</single>
<multiple>supplements</multiple>
</term>
<term name="table">
<single>table</single>
<multiple>tables</multiple>
</term>
<term name="timestamp"> <!-- generally blank -->
<single/>
<multiple/>
</term>
<term name="title-locator">
<single>title</single>
<multiple>titles</multiple>
</term>
<term name="verse">
<single>verso</single>
<multiple>versos</multiple>
</term>
<term name="volume">
<single>volumen</single>
<multiple>volúmenes</multiple>
</term>
<!-- SHORT LOCATOR FORMS -->
<term name="appendix" form="short">
<single>app.</single>
<multiple>apps.</multiple>
</term>
<term name="article-locator" form="short">
<single>art.</single>
<multiple>arts.</multiple>
</term>
<term name="book" form="short">
<single>lib.</single>
<multiple>libs.</multiple>
</term>
<term name="chapter" form="short">
<single>cap.</single>
<multiple>caps.</multiple>
</term>
<term name="column" form="short">
<single>col.</single>
<multiple>cols.</multiple>
</term>
<term name="elocation" form="short">
<single>loc.</single>
<multiple>locs.</multiple>
</term>
<term name="equation" form="short">
<single>eq.</single>
<multiple>eqs.</multiple>
</term>
<term name="figure" form="short">
<single>fig.</single>
<multiple>figs.</multiple>
</term>
<term name="folio" form="short">
<single>f.</single>
<multiple>ff.</multiple>
</term>
<term name="issue" form="short">
<single>núm.</single>
<multiple>núms.</multiple>
</term>
<term name="line" form="short">
<single>l.</single>
<multiple>ls.</multiple>
</term>
<term name="note" form="short">
<single>n.</single>
<multiple>nn.</multiple>
</term>
<term name="opus" form="short">
<single>op.</single>
<multiple>opp.</multiple>
</term>
<term name="page" form="short">
<single>p.</single>
<multiple>pp.</multiple>
</term>
<term name="paragraph" form="short">
<single>párr.</single>
<multiple>párrs.</multiple>
</term>
<term name="part" form="short">
<single>pt.</single>
<multiple>pts.</multiple>
</term>
<term name="rule" form="short">
<single>r.</single>
<multiple>rr.</multiple>
</term>
<term name="scene" form="short">
<single>sc.</single>
<multiple>scs.</multiple>
</term>
<term name="section" form="short">
<single>sec.</single>
<multiple>secs.</multiple>
</term>
<term name="sub-verbo" form="short">
<single>s.&#160;v.</single>
<multiple>s.&#160;vv.</multiple>
</term>
<term name="supplement" form="short">
<single>supp.</single>
<multiple>supps.</multiple>
</term>
<term name="table" form="short">
<single>tbl.</single>
<multiple>tbls.</multiple>
</term>
<term name="timestamp" form="short"> <!-- generally blank -->
<single/>
<multiple/>
</term>
<term name="title-locator" form="short">
<single>tit.</single>
<multiple>tits.</multiple>
</term>
<term name="verse" form="short">
<single>v.</single>
<multiple>vv.</multiple>
</term>
<term name="volume" form="short">
<single>vol.</single>
<multiple>vols.</multiple>
</term>
<!-- SYMBOLIC LOCATOR FORMS -->
<term name="paragraph" form="symbol">
<single></single>
<multiple></multiple>
</term>
<term name="section" form="symbol">
<single>§</single>
<multiple>§</multiple>
</term>
<!-- LONG NUMBER VARIABLE FORMS -->
<term name="chapter-number">
<single>chapter</single>
<multiple>chapters</multiple>
</term>
<term name="citation-number">
<single>citation</single>
<multiple>citations</multiple>
</term>
<term name="collection-number">
<single>número</single>
<multiple>números</multiple>
</term>
<term name="edition">
<single>edición</single>
<multiple>ediciones</multiple>
</term>
<term name="first-reference-note-number">
<single>reference</single>
<multiple>references</multiple>
</term>
<term name="number">
<single>number</single>
<multiple>numbers</multiple>
</term>
<term name="number-of-pages">
<single>página</single>
<multiple>páginas</multiple>
</term>
<term name="number-of-volumes">
<single>volume</single>
<multiple>volumes</multiple>
</term>
<term name="page-first">
<single>page</single>
<multiple>pages</multiple>
</term>
<term name="printing">
<single>printing</single>
<multiple>printings</multiple>
</term>
<term name="version">versión</term>
<!-- SHORT NUMBER VARIABLE FORMS -->
<term name="chapter-number" form="short">
<single>chap.</single>
<multiple>chaps.</multiple>
</term>
<term name="citation-number" form="short">
<single>cit.</single>
<multiple>cits.</multiple>
</term>
<term name="collection-number" form="short">
<single>núm.</single>
<multiple>núms.</multiple>
</term>
<term name="edition" form="short">
<single>ed.</single>
<multiple>eds.</multiple>
</term>
<term name="first-reference-note-number" form="short">
<single>ref.</single>
<multiple>refs.</multiple>
</term>
<term name="number" form="short">
<single>no.</single>
<multiple>nos.</multiple>
</term>
<term name="number-of-pages" form="short">
<single>p.</single>
<multiple>pp.</multiple>
</term>
<term name="number-of-volumes" form="short">
<single>vol.</single>
<multiple>vols.</multiple>
</term>
<term name="page-first" form="short">
<single>p.</single>
<multiple>pp.</multiple>
</term>
<term name="printing" form="short">
<single>print.</single>
<multiple>prints.</multiple>
</term>
<!-- LONG ROLE FORMS -->
<term name="author"/> <!-- generally blank -->
<term name="chair">
<single>chair</single>
<multiple>chairs</multiple>
</term>
<term name="collection-editor">
<single>ed.</single>
<multiple>eds.</multiple>
</term>
<term name="compiler">
<single>compiler</single>
<multiple>compilers</multiple>
</term>
<term name="composer"/> <!-- generally blank -->
<term name="container-author"/> <!-- generally blank -->
<term name="contributor">
<single>contributor</single>
<multiple>contributors</multiple>
</term>
<term name="curator">
<single>curator</single>
<multiple>curators</multiple>
</term>
<term name="director">
<single>director</single>
<multiple>directores</multiple>
</term>
<term name="editor">
<single>editor</single>
<multiple>editores</multiple>
</term>
<term name="editor-translator">
<single>editor y traductor</single>
<multiple>editores y traductores</multiple>
</term>
<term name="editortranslator">
<single>editor y traductor</single>
<multiple>editores y traductores</multiple>
</term>
<term name="editorial-director">
<single>coordinador</single>
<multiple>coordinadores</multiple>
</term>
<term name="executive-producer">
<single>executive producer</single>
<multiple>executive producers</multiple>
</term>
<term name="guest">
<single>guest</single>
<multiple>guests</multiple>
</term>
<term name="host">
<single>host</single>
<multiple>hosts</multiple>
</term>
<term name="illustrator">
<single>ilustrador</single>
<multiple>ilustradores</multiple>
</term>
<term name="interviewer"/> <!-- generally blank -->
<term name="narrator">
<single>narrator</single>
<multiple>narrators</multiple>
</term>
<term name="organizer">
<single>organizer</single>
<multiple>organizers</multiple>
</term>
<term name="original-author"/> <!-- generally blank -->
<term name="performer">
<single>performer</single>
<multiple>performers</multiple>
</term>
<term name="producer">
<single>producer</single>
<multiple>producers</multiple>
</term>
<term name="recipient"/> <!-- generally blank -->
<term name="reviewed-author"/> <!-- generally blank -->
<term name="script-writer">
<single>writer</single>
<multiple>writers</multiple>
</term>
<term name="series-creator">
<single>series creator</single>
<multiple>series creators</multiple>
</term>
<term name="translator">
<single>traductor</single>
<multiple>traductores</multiple>
</term>
<!-- SHORT ROLE FORMS -->
<term name="compiler" form="short">
<single>comp.</single>
<multiple>comps.</multiple>
</term>
<term name="contributor" form="short">
<single>contrib.</single>
<multiple>contribs.</multiple>
</term>
<term name="curator" form="short">
<single>cur.</single>
<multiple>curs.</multiple>
</term>
<term name="director" form="short">
<single>dir.</single>
<multiple>dirs.</multiple>
</term>
<term name="editor" form="short">
<single>ed.</single>
<multiple>eds.</multiple>
</term>
<term name="editor-translator" form="short">
<single>ed. y trad.</single>
<multiple>eds. y trads.</multiple>
</term>
<term name="editortranslator" form="short">
<single>ed. y trad.</single>
<multiple>eds. y trads.</multiple>
</term>
<term name="editorial-director" form="short">
<single>coord.</single>
<multiple>coords.</multiple>
</term>
<term name="executive-producer" form="short">
<single>exec. prod.</single>
<multiple>exec. prods.</multiple>
</term>
<term name="illustrator" form="short">
<single>ilust.</single>
<multiple>ilusts.</multiple>
</term>
<term name="narrator" form="short">
<single>narr.</single>
<multiple>narrs.</multiple>
</term>
<term name="organizer" form="short">
<single>org.</single>
<multiple>orgs.</multiple>
</term>
<term name="performer" form="short">
<single>perf.</single>
<multiple>perfs.</multiple>
</term>
<term name="producer" form="short">
<single>prod.</single>
<multiple>prods.</multiple>
</term>
<term name="script-writer" form="short">
<single>writ.</single>
<multiple>writs.</multiple>
</term>
<term name="series-creator" form="short">
<single>cre.</single>
<multiple>cres.</multiple>
</term>
<term name="translator" form="short">
<single>trad.</single>
<multiple>trads.</multiple>
</term>
<!-- VERB ROLE FORMS -->
<term name="chair" form="verb">chaired by</term>
<term name="collection-editor" form="verb">edited by</term>
<term name="compiler" form="verb">compiled by</term>
<term name="container-author" form="verb">de</term>
<term name="contributor" form="verb">with</term>
<term name="curator" form="verb">curated by</term>
<term name="director" form="verb">dirigido por</term>
<term name="editor" form="verb">editado por</term>
<term name="editor-translator" form="verb">editado y traducido por</term>
<term name="editortranslator" form="verb">editado y traducido por</term>
<term name="editorial-director" form="verb">coordinado por</term>
<term name="executive-producer" form="verb">executive produced by</term>
<term name="guest" form="verb">with guest</term>
<term name="host" form="verb">hosted by</term>
<term name="illustrator" form="verb">ilustrado por</term>
<term name="interviewer" form="verb">entrevistado por</term>
<term name="narrator" form="verb">narrated by</term>
<term name="organizer" form="verb">organized by</term>
<term name="performer" form="verb">performed by</term>
<term name="producer" form="verb">produced by</term>
<term name="recipient" form="verb">a</term>
<term name="reviewed-author" form="verb">por</term>
<term name="script-writer" form="verb">written by</term>
<term name="series-creator" form="verb">created by</term>
<term name="translator" form="verb">traducido por</term>
<!-- SHORT VERB ROLE FORMS -->
<term name="collection-editor" form="verb-short">ed. by</term>
<term name="compiler" form="verb-short">comp. by</term>
<term name="contributor" form="verb-short">w.</term>
<term name="curator" form="verb-short">cur. by</term>
<term name="director" form="verb-short">dir.</term>
<term name="editor" form="verb-short">ed.</term>
<term name="editor-translator" form="verb-short">ed. y trad.</term>
<term name="editortranslator" form="verb-short">ed. y trad.</term>
<term name="editorial-director" form="verb-short">coord.</term>
<term name="executive-producer" form="verb-short">exec. prod. by</term>
<term name="guest" form="verb-short">w. guest</term>
<term name="host" form="verb-short">hosted by</term>
<term name="illustrator" form="verb-short">ilust.</term>
<term name="narrator" form="verb-short">narr. by</term>
<term name="organizer" form="verb-short">org. by</term>
<term name="performer" form="verb-short">perf. by</term>
<term name="producer" form="verb-short">prod. by</term>
<term name="script-writer" form="verb-short">writ. by</term>
<term name="series-creator" form="verb-short">cre. by</term>
<term name="translator" form="verb-short">trad.</term>
<!-- LONG MONTH FORMS -->
<term name="month-01">enero</term>
<term name="month-02">febrero</term>
<term name="month-03">marzo</term>
<term name="month-04">abril</term>
<term name="month-05">mayo</term>
<term name="month-06">junio</term>
<term name="month-07">julio</term>
<term name="month-08">agosto</term>
<term name="month-09">septiembre</term>
<term name="month-10">octubre</term>
<term name="month-11">noviembre</term>
<term name="month-12">diciembre</term>
<!-- SHORT MONTH FORMS -->
<term name="month-01" form="short">ene.</term>
<term name="month-02" form="short">feb.</term>
<term name="month-03" form="short">mar.</term>
<term name="month-04" form="short">abr.</term>
<term name="month-05" form="short">may</term>
<term name="month-06" form="short">jun.</term>
<term name="month-07" form="short">jul.</term>
<term name="month-08" form="short">ago.</term>
<term name="month-09" form="short">sep.</term>
<term name="month-10" form="short">oct.</term>
<term name="month-11" form="short">nov.</term>
<term name="month-12" form="short">dic.</term>
<!-- SEASONS -->
<term name="season-01">primavera</term>
<term name="season-02">verano</term>
<term name="season-03">otoño</term>
<term name="season-04">invierno</term>
</terms>
</locale>
+520
View File
@@ -0,0 +1,520 @@
<?xml version="1.0" encoding="utf-8"?>
<style xmlns="http://purl.org/net/xbiblio/csl" class="in-text" delimiter-precedes-last="always" demote-non-dropping-particle="sort-only" initialize-with="" initialize-with-hyphen="false" name-as-sort-order="all" name-delimiter=", " names-delimiter=", " page-range-format="minimal" sort-separator=" " version="1.0">
<!-- This file was generated by the Style Variant Builder <https://github.com/citation-style-language/style-variant-builder>. To contribute changes, modify the template and regenerate variants. -->
<info>
<title>NLM/Vancouver: Citing Medicine 2nd edition (citation-sequence)</title>
<title-short>National Library of Medicine, ANSI/NISO Z39.29-2005 (R2010), ICMJE Recommendations/URMs (C-S)</title-short>
<id>http://www.zotero.org/styles/nlm-citation-sequence</id>
<link href="http://www.zotero.org/styles/nlm-citation-sequence" rel="self"/>
<link href="https://www.nlm.nih.gov/citingmedicine" rel="documentation"/>
<link href="https://www.nlm.nih.gov/bsd/uniform_requirements.html" rel="documentation"/>
<link href="https://www.icmje.org/recommendations/" rel="documentation"/>
<author>
<name>Michael Berkowitz</name>
<email>mberkowi@gmu.edu</email>
</author>
<author>
<name>Andrew Dunning</name>
<uri>https://orcid.org/0000-0003-0464-5036</uri>
</author>
<contributor>
<name>Petr Hlustik</name>
<uri>https://orcid.org/0000-0002-1951-0671</uri>
</contributor>
<contributor>
<name>Sebastian Karcher</name>
<uri>https://orcid.org/0000-0001-8249-7388</uri>
</contributor>
<contributor>
<name>Charles Parnot</name>
<uri>https://orcid.org/0000-0002-7346-5883</uri>
</contributor>
<contributor>
<name>Sean Takats</name>
<uri>https://orcid.org/0000-0002-7851-5069</uri>
</contributor>
<category citation-format="numeric"/>
<category field="generic-base"/>
<category field="medicine"/>
<category field="science"/>
<summary>Citing Medicine: The NLM Style Guide for Authors, Editors, and Publishers, 2nd edition (2015), based on ANSI/NISO Z39.29-2005 (R2010); citation-sequence system.</summary>
<updated>2026-02-18T15:24:08+00:00</updated>
<rights license="http://creativecommons.org/licenses/by-sa/3.0/">This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 License</rights>
</info>
<locale xml:lang="en">
<date delimiter=" " form="text">
<date-part name="year"/>
<date-part form="short" name="month" strip-periods="true"/>
<date-part name="day"/>
</date>
<terms>
<term name="available at">available from</term>
<term name="collection-editor">
<single>editor</single>
<multiple>editors</multiple>
</term>
<term form="short" name="month-06">Jun.</term>
<term form="short" name="month-07">Jul.</term>
<term form="short" name="month-09">Sep.</term>
<term name="presented at">presented at</term>
<term form="short" name="section">
<single>sect.</single>
<multiple>sects.</multiple>
</term>
<term form="short" name="supplement">
<single>suppl.</single>
<multiple>suppls.</multiple>
</term>
</terms>
</locale>
<locale xml:lang="fr">
<date delimiter=" " form="text">
<date-part name="day"/>
<date-part form="short" name="month" strip-periods="true"/>
<date-part name="year"/>
</date>
</locale>
<!-- Variable labels -->
<macro name="label-collection-number">
<group delimiter=" ">
<choose>
<if is-numeric="collection-number">
<label form="short" variable="collection-number"/>
</if>
</choose>
<text variable="collection-number"/>
</group>
</macro>
<macro name="label-edition">
<group delimiter=" ">
<choose>
<if is-numeric="edition">
<number form="ordinal" variable="edition"/>
<label form="short" variable="edition"/>
</if>
<else>
<text variable="edition"/>
</else>
</choose>
</group>
</macro>
<macro name="label-number-of-pages">
<group delimiter=" ">
<text variable="number-of-pages"/>
<choose>
<if is-numeric="number-of-pages">
<label form="short" plural="never" variable="number-of-pages"/>
</if>
</choose>
</group>
</macro>
<macro name="label-page">
<group delimiter=" ">
<label form="short" plural="never" variable="page"/>
<text variable="page"/>
</group>
</macro>
<macro name="label-part-number-capitalized">
<group delimiter=" ">
<choose>
<if is-numeric="part-number">
<!-- TODO: Replace with `part-number` label when CSL provides one -->
<text form="short" term="part" text-case="capitalize-first"/>
</if>
</choose>
<text variable="part-number"/>
</group>
</macro>
<macro name="label-supplement-number">
<group delimiter=" ">
<choose>
<if is-numeric="supplement-number">
<!-- TODO: Replace with `supplement-number` label when CSL provides one -->
<text form="short" strip-periods="true" term="supplement" text-case="capitalize-first"/>
</if>
</choose>
<text text-case="capitalize-first" variable="supplement-number"/>
</group>
</macro>
<macro name="label-volume-capitalized">
<group delimiter=" ">
<choose>
<if is-numeric="volume">
<label form="short" text-case="capitalize-first" variable="volume"/>
</if>
</choose>
<text variable="volume"/>
</group>
</macro>
<macro name="author">
<names variable="author">
<label prefix=", "/>
<substitute>
<names variable="editor-translator"/>
<names variable="editor translator"/>
<names variable="editor"/>
<names variable="collection-editor"/>
</substitute>
</names>
</macro>
<macro name="title">
<choose>
<if type="webpage" variable="container-title">
<!-- `webpage` listed under `container-title` (Citing Medicine, ch. 25) -->
<text variable="container-title"/>
</if>
<else>
<text variable="title"/>
</else>
</choose>
</macro>
<macro name="content-type">
<text variable="genre"/>
</macro>
<macro name="type-of-medium">
<choose>
<if variable="medium">
<text text-case="capitalize-first" variable="medium"/>
</if>
<else-if match="any" type="chapter entry-dictionary entry-encyclopedia paper-conference"/>
<else-if variable="URL">
<text term="internet" text-case="capitalize-first"/>
</else-if>
</choose>
</macro>
<macro name="container-preposition">
<choose>
<if match="any" type="chapter paper-conference entry-dictionary entry-encyclopedia">
<text term="in" text-case="capitalize-first"/>
</if>
</choose>
</macro>
<macro name="secondary-authors">
<names variable="editor">
<label prefix=", "/>
</names>
</macro>
<macro name="container-title">
<group delimiter=", ">
<choose>
<if type="webpage"/>
<else-if variable="container-title">
<group delimiter=". ">
<group delimiter=" ">
<choose>
<if match="any" type="article-journal review review-book">
<text form="short" strip-periods="true" variable="container-title"/>
</if>
<else>
<text variable="container-title"/>
</else>
</choose>
<choose>
<if type="article-journal" variable="DOI"/>
<else-if type="article-journal" variable="PMID"/>
<else-if type="article-journal" variable="PMCID"/>
<else-if variable="URL">
<text prefix="[" suffix="]" term="internet" text-case="capitalize-first"/>
</else-if>
</choose>
</group>
<text macro="label-edition"/>
</group>
</else-if>
<!-- TODO: add `event-name` and `event-place` -->
<else-if match="any" type="bill legislation">
<group delimiter=". ">
<text variable="container-title"/>
<group delimiter=" ">
<text form="short" term="section" text-case="capitalize-first"/>
<text variable="section"/>
</group>
</group>
<text variable="number"/>
</else-if>
<else-if type="speech">
<group delimiter=": ">
<group delimiter=" ">
<text text-case="capitalize-first" variable="genre"/>
<text term="presented at"/>
</group>
<text variable="event"/>
</group>
</else-if>
<else>
<group delimiter=", ">
<text macro="label-volume-capitalized"/>
<text variable="volume-title"/>
</group>
<group delimiter=", ">
<text macro="label-part-number-capitalized"/>
<text variable="part-title"/>
</group>
</else>
</choose>
</group>
</macro>
<macro name="place-of-publication">
<choose>
<if type="thesis">
<text prefix="[" suffix="]" variable="publisher-place"/>
</if>
<else-if type="speech"/>
<else>
<text variable="publisher-place"/>
</else>
</choose>
</macro>
<macro name="publisher">
<choose>
<!-- discard publisher for serial publications -->
<if match="none" type="article-journal article-magazine article-newspaper periodical post-weblog review review-book">
<group delimiter=": ">
<text macro="place-of-publication"/>
<text variable="publisher"/>
</group>
</if>
</choose>
</macro>
<macro name="date">
<group delimiter=" ">
<choose>
<if match="any" type="article-journal article-magazine article-newspaper periodical post-weblog review review-book">
<group delimiter=":">
<group delimiter=" ">
<date form="text" variable="issued"/>
<choose>
<if type="article-journal" variable="DOI"/>
<else-if type="article-journal" variable="PMID"/>
<else-if type="article-journal" variable="PMCID"/>
<else>
<text macro="date-of-citation"/>
</else>
</choose>
</group>
<choose>
<if type="article-newspaper">
<text variable="page"/>
</if>
</choose>
</group>
</if>
<else-if match="any" type="bill legislation">
<date form="text" variable="issued"/>
</else-if>
<else-if type="report">
<date date-parts="year-month" form="text" variable="issued"/>
<text macro="date-of-citation"/>
</else-if>
<else-if type="patent">
<group delimiter=", ">
<text variable="number"/>
<date date-parts="year" form="numeric" variable="issued"/>
</group>
<text macro="date-of-citation"/>
</else-if>
<else-if type="speech">
<group delimiter="; ">
<group delimiter=" ">
<date form="text" variable="issued"/>
<text macro="date-of-citation"/>
</group>
<text variable="event-place"/>
</group>
</else-if>
<else>
<date date-parts="year" form="numeric" variable="issued"/>
<text macro="date-of-citation"/>
</else>
</choose>
</group>
</macro>
<macro name="identifier-serial">
<choose>
<if match="any" type="article-journal article-magazine periodical post-weblog review review-book">
<group delimiter=":">
<group>
<text variable="collection-title"/>
<text variable="volume"/>
<group delimiter=" " prefix="(" suffix=")">
<text variable="issue"/>
<text macro="label-supplement-number"/>
</group>
</group>
<text macro="location-pagination-serial"/>
</group>
</if>
</choose>
</macro>
<macro name="date-of-citation">
<choose>
<if variable="URL">
<group delimiter=" " prefix="[" suffix="]">
<text term="cited"/>
<date form="text" variable="accessed"/>
</group>
</if>
</choose>
</macro>
<macro name="location-pagination-monographic">
<group delimiter=" ">
<choose>
<if match="any" type="article-journal article-magazine article-newspaper review review-book"/>
<else-if type="book">
<text macro="label-number-of-pages"/>
</else-if>
<else>
<text macro="label-page"/>
</else>
</choose>
</group>
</macro>
<macro name="location-pagination-serial">
<choose>
<if variable="number">
<text variable="number"/>
</if>
<else>
<text variable="page"/>
</else>
</choose>
</macro>
<macro name="webpage-part">
<choose>
<if type="webpage" variable="container-title">
<text variable="title"/>
</if>
</choose>
</macro>
<macro name="series">
<choose>
<if match="any" type="article-journal article-magazine article-newspaper periodical post-weblog review review-book"/>
<else-if variable="collection-title">
<group delimiter=". " prefix="(" suffix=")">
<names variable="collection-editor">
<label prefix=", "/>
</names>
<group delimiter="; ">
<text variable="collection-title"/>
<text macro="label-collection-number"/>
</group>
</group>
</else-if>
</choose>
</macro>
<macro name="report-number">
<choose>
<if type="report">
<group delimiter=": ">
<group delimiter=" ">
<text term="report" text-case="capitalize-first"/>
<label form="short" text-case="capitalize-first" variable="number"/>
</group>
<text variable="number"/>
</group>
</if>
</choose>
</macro>
<macro name="availability">
<group delimiter=". ">
<group delimiter=": ">
<text text-case="capitalize-first" value="located at"/>
<group delimiter="; ">
<group delimiter=", ">
<text variable="archive_collection"/>
<text variable="archive"/>
<text variable="archive-place"/>
</group>
<text variable="archive_location"/>
</group>
</group>
<group delimiter=" ">
<choose>
<if type="article-journal" variable="DOI"/>
<else-if type="article-journal" variable="PMID"/>
<else-if type="article-journal" variable="PMCID"/>
<else>
<group delimiter=": ">
<text term="available at" text-case="capitalize-first"/>
<text variable="URL"/>
</group>
</else>
</choose>
<text prefix="doi:" variable="DOI"/>
</group>
</group>
</macro>
<macro name="notes">
<group delimiter=". " suffix=".">
<group delimiter="; ">
<group delimiter=": ">
<text value="PubMed PMID"/>
<text variable="PMID"/>
</group>
<group delimiter=": ">
<text value="PubMed Central PMCID"/>
<text variable="PMCID"/>
</group>
</group>
<text variable="references"/>
</group>
</macro>
<citation collapse="citation-number">
<sort>
<key variable="citation-number"/>
</sort>
<layout delimiter="," prefix="(" suffix=")">
<text variable="citation-number"/>
</layout>
</citation>
<macro name="bibliography">
<group delimiter=" ">
<group delimiter=". " suffix=".">
<text macro="author"/>
<group delimiter=" ">
<text macro="title"/>
<text macro="content-type" prefix="[" suffix="]"/>
<choose>
<if type="webpage" variable="container-title">
<text macro="type-of-medium" prefix="[" suffix="]"/>
</if>
<else-if match="none" variable="container-title">
<text macro="type-of-medium" prefix="[" suffix="]"/>
</else-if>
</choose>
</group>
<choose>
<if match="none" variable="container-title">
<text macro="label-edition"/>
</if>
</choose>
<group delimiter=": ">
<text macro="container-preposition"/>
<group delimiter=". ">
<text macro="secondary-authors"/>
<text macro="container-title"/>
</group>
</group>
<group delimiter="; ">
<text macro="publisher"/>
<group delimiter=";">
<text macro="date"/>
<text macro="identifier-serial"/>
</group>
</group>
<text macro="location-pagination-monographic"/>
<text macro="webpage-part"/>
<text macro="series"/>
<text macro="report-number"/>
</group>
<text macro="availability"/>
<text macro="notes"/>
</group>
</macro>
<bibliography et-al-min="7" et-al-use-first="6" second-field-align="flush">
<layout>
<text suffix="." variable="citation-number"/>
<text macro="bibliography"/>
</layout>
</bibliography>
</style>
+354
View File
@@ -0,0 +1,354 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"id": "3b0acbc3",
"metadata": {},
"outputs": [],
"source": [
"import { createClient } from \"@supabase/supabase-js\";\n",
"const supabaseUrl = Deno.env.get(\"SUPABASE_URL\") || env.SUPABASE_URL;\n",
"const supabaseKey = Deno.env.get(\"SUPABASE_ANON_KEY\") || env.SUPABASE_ANON_KEY;\n",
"\n",
"const supabase = createClient(supabaseUrl, supabaseKey);\n",
"const supa = await supabase.auth.signInWithPassword({\n",
" email: 'guillermo.arrieta@lasalle.mx',\n",
" password: 'admin',\n",
"});"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "be47eac0",
"metadata": {},
"outputs": [],
"source": []
},
{
"cell_type": "code",
"execution_count": null,
"id": "6ef7e79f",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{\n",
" access_token: \"eyJhbGciOiJFUzI1NiIsImtpZCI6ImI4MTI2OWYxLTIxZDgtNGYyZS1iNzE5LWMyMjQwYTg0MGQ5MCIsInR5cCI6IkpXVCJ9.eyJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTc3MjQ3MzI3MX1dLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwiYXVkIjoiYXV0aGVudGljYXRlZCIsImVtYWlsIjoiZ3VpbGxlcm1vLmFycmlldGFAbGFzYWxsZS5teCIsImV4cCI6MTc3MjQ3Njg3MSwiZmVhdHVyZV94Ijp0cnVlLCJpYXQiOjE3NzI0NzMyNzEsImlzX2Fub255bW91cyI6ZmFsc2UsImlzcyI6Imh0dHA6Ly8xMjcuMC4wLjE6NTQzMjEvYXV0aC92MSIsInBob25lIjoiIiwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJzZXNzaW9uX2lkIjoiZjc2ZjRkZGYtZTcxOS00NjRiLWIwYWUtODBhNmIxMjBlZmE0Iiwic3ViIjoiOTVkMWYzODEtNzAyMy00ZjMzLWJjNTQtMDhjY2QwOGFiZjQxIiwidGVuYW50X2lkIjoibG9jYWwtZGV2LXRlbmFudCIsInVzZXJfbWV0YWRhdGEiOnsiZW1haWxfdmVyaWZpZWQiOnRydWV9fQ.LEXrbBNetdCb8zfCiLcXhJg3EmX6XuE4cYOaFmYg1PhvvNI5XorbJQHhb4SQVggQDOauIqqkMRsK64GK1nheIw\",\n",
" token_type: \"bearer\",\n",
" expires_in: 3600,\n",
" expires_at: 1772476871,\n",
" refresh_token: \"lwgtrzw2tpzl\",\n",
" user: {\n",
" id: \"95d1f381-7023-4f33-bc54-08ccd08abf41\",\n",
" aud: \"authenticated\",\n",
" role: \"authenticated\",\n",
" email: \"guillermo.arrieta@lasalle.mx\",\n",
" email_confirmed_at: \"2026-01-13T14:54:39.096101Z\",\n",
" phone: \"\",\n",
" confirmed_at: \"2026-01-13T14:54:39.096101Z\",\n",
" last_sign_in_at: \"2026-03-02T17:41:10.98658403Z\",\n",
" app_metadata: { provider: \"email\", providers: [ \"email\" ] },\n",
" user_metadata: { email_verified: true },\n",
" identities: [\n",
" {\n",
" identity_id: \"b2f3094d-a29b-42b5-be69-3397282ffcd7\",\n",
" id: \"95d1f381-7023-4f33-bc54-08ccd08abf41\",\n",
" user_id: \"95d1f381-7023-4f33-bc54-08ccd08abf41\",\n",
" identity_data: {\n",
" email: \"guillermo.arrieta@lasalle.mx\",\n",
" email_verified: false,\n",
" phone_verified: false,\n",
" sub: \"95d1f381-7023-4f33-bc54-08ccd08abf41\"\n",
" },\n",
" provider: \"email\",\n",
" last_sign_in_at: \"2026-01-13T14:54:39.085382Z\",\n",
" created_at: \"2026-01-13T14:54:39.08544Z\",\n",
" updated_at: \"2026-01-13T14:54:39.08544Z\",\n",
" email: \"guillermo.arrieta@lasalle.mx\"\n",
" }\n",
" ],\n",
" created_at: \"2026-01-13T14:54:39.081453Z\",\n",
" updated_at: \"2026-03-02T17:41:11.002577Z\",\n",
" is_anonymous: false\n",
" },\n",
" weak_password: {\n",
" message: \"Password should be at least 6 characters.\",\n",
" reasons: [ \"length\" ]\n",
" }\n",
"}\n"
]
},
{
"data": {
"text/plain": [
"{\n",
" data: {\n",
" subscription: {\n",
" id: \u001b[32mSymbol(\"auth-callback\")\u001b[39m,\n",
" callback: \u001b[36m[AsyncFunction (anonymous)]\u001b[39m,\n",
" unsubscribe: \u001b[36m[Function: unsubscribe]\u001b[39m\n",
" }\n",
" }\n",
"}"
]
},
"execution_count": 3,
"metadata": {},
"output_type": "execute_result"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"TOKEN_REFRESHED {\n",
" aal: \"aal1\",\n",
" amr: [ { method: \"password\", timestamp: 1772473271 } ],\n",
" app_metadata: { provider: \"email\", providers: [ \"email\" ] },\n",
" aud: \"authenticated\",\n",
" email: \"guillermo.arrieta@lasalle.mx\",\n",
" exp: 1772480381,\n",
" feature_x: true,\n",
" iat: 1772476781,\n",
" is_anonymous: false,\n",
" iss: \"http://127.0.0.1:54321/auth/v1\",\n",
" phone: \"\",\n",
" role: \"authenticated\",\n",
" session_id: \"f76f4ddf-e719-464b-b0ae-80a6b120efa4\",\n",
" sub: \"95d1f381-7023-4f33-bc54-08ccd08abf41\",\n",
" tenant_id: \"local-dev-tenant\",\n",
" user_metadata: { email_verified: true }\n",
"}\n",
"{\n",
" access_token: \"eyJhbGciOiJFUzI1NiIsImtpZCI6ImI4MTI2OWYxLTIxZDgtNGYyZS1iNzE5LWMyMjQwYTg0MGQ5MCIsInR5cCI6IkpXVCJ9.eyJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTc3MjQ3MzI3MX1dLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwiYXVkIjoiYXV0aGVudGljYXRlZCIsImVtYWlsIjoiZ3VpbGxlcm1vLmFycmlldGFAbGFzYWxsZS5teCIsImV4cCI6MTc3MjQ4MDM4MSwiZmVhdHVyZV94Ijp0cnVlLCJpYXQiOjE3NzI0NzY3ODEsImlzX2Fub255bW91cyI6ZmFsc2UsImlzcyI6Imh0dHA6Ly8xMjcuMC4wLjE6NTQzMjEvYXV0aC92MSIsInBob25lIjoiIiwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJzZXNzaW9uX2lkIjoiZjc2ZjRkZGYtZTcxOS00NjRiLWIwYWUtODBhNmIxMjBlZmE0Iiwic3ViIjoiOTVkMWYzODEtNzAyMy00ZjMzLWJjNTQtMDhjY2QwOGFiZjQxIiwidGVuYW50X2lkIjoibG9jYWwtZGV2LXRlbmFudCIsInVzZXJfbWV0YWRhdGEiOnsiZW1haWxfdmVyaWZpZWQiOnRydWV9fQ.28CQUM9O7Cn1DlbMsLLaN-19uDmEGC_NvLRL6S3-Z3ym3T5gQ5OXiJ6_NlyblMJfJOQdMo88uGRIAZt9PwkE6A\",\n",
" token_type: \"bearer\",\n",
" expires_in: 3600,\n",
" expires_at: 1772480381,\n",
" refresh_token: \"363vd67k2taw\",\n",
" user: {\n",
" id: \"95d1f381-7023-4f33-bc54-08ccd08abf41\",\n",
" aud: \"authenticated\",\n",
" role: \"authenticated\",\n",
" email: \"guillermo.arrieta@lasalle.mx\",\n",
" email_confirmed_at: \"2026-01-13T14:54:39.096101Z\",\n",
" phone: \"\",\n",
" confirmed_at: \"2026-01-13T14:54:39.096101Z\",\n",
" last_sign_in_at: \"2026-03-02T17:41:10.986584Z\",\n",
" app_metadata: { provider: \"email\", providers: [ \"email\" ] },\n",
" user_metadata: { email_verified: true },\n",
" identities: [\n",
" {\n",
" identity_id: \"b2f3094d-a29b-42b5-be69-3397282ffcd7\",\n",
" id: \"95d1f381-7023-4f33-bc54-08ccd08abf41\",\n",
" user_id: \"95d1f381-7023-4f33-bc54-08ccd08abf41\",\n",
" identity_data: {\n",
" email: \"guillermo.arrieta@lasalle.mx\",\n",
" email_verified: false,\n",
" phone_verified: false,\n",
" sub: \"95d1f381-7023-4f33-bc54-08ccd08abf41\"\n",
" },\n",
" provider: \"email\",\n",
" last_sign_in_at: \"2026-01-13T14:54:39.085382Z\",\n",
" created_at: \"2026-01-13T14:54:39.08544Z\",\n",
" updated_at: \"2026-01-13T14:54:39.08544Z\",\n",
" email: \"guillermo.arrieta@lasalle.mx\"\n",
" }\n",
" ],\n",
" created_at: \"2026-01-13T14:54:39.081453Z\",\n",
" updated_at: \"2026-03-02T18:39:41.80176Z\",\n",
" is_anonymous: false\n",
" }\n",
"}\n",
"TOKEN_REFRESHED {\n",
" aal: \"aal1\",\n",
" amr: [ { method: \"password\", timestamp: 1772473271 } ],\n",
" app_metadata: { provider: \"email\", providers: [ \"email\" ] },\n",
" aud: \"authenticated\",\n",
" email: \"guillermo.arrieta@lasalle.mx\",\n",
" exp: 1772483893,\n",
" feature_x: true,\n",
" iat: 1772480293,\n",
" is_anonymous: false,\n",
" iss: \"http://127.0.0.1:54321/auth/v1\",\n",
" phone: \"\",\n",
" role: \"authenticated\",\n",
" session_id: \"f76f4ddf-e719-464b-b0ae-80a6b120efa4\",\n",
" sub: \"95d1f381-7023-4f33-bc54-08ccd08abf41\",\n",
" tenant_id: \"local-dev-tenant\",\n",
" user_metadata: { email_verified: true }\n",
"}\n",
"{\n",
" access_token: \"eyJhbGciOiJFUzI1NiIsImtpZCI6ImI4MTI2OWYxLTIxZDgtNGYyZS1iNzE5LWMyMjQwYTg0MGQ5MCIsInR5cCI6IkpXVCJ9.eyJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTc3MjQ3MzI3MX1dLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwiYXVkIjoiYXV0aGVudGljYXRlZCIsImVtYWlsIjoiZ3VpbGxlcm1vLmFycmlldGFAbGFzYWxsZS5teCIsImV4cCI6MTc3MjQ4Mzg5MywiZmVhdHVyZV94Ijp0cnVlLCJpYXQiOjE3NzI0ODAyOTMsImlzX2Fub255bW91cyI6ZmFsc2UsImlzcyI6Imh0dHA6Ly8xMjcuMC4wLjE6NTQzMjEvYXV0aC92MSIsInBob25lIjoiIiwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJzZXNzaW9uX2lkIjoiZjc2ZjRkZGYtZTcxOS00NjRiLWIwYWUtODBhNmIxMjBlZmE0Iiwic3ViIjoiOTVkMWYzODEtNzAyMy00ZjMzLWJjNTQtMDhjY2QwOGFiZjQxIiwidGVuYW50X2lkIjoibG9jYWwtZGV2LXRlbmFudCIsInVzZXJfbWV0YWRhdGEiOnsiZW1haWxfdmVyaWZpZWQiOnRydWV9fQ.TUYrfJrHy3fwPwI4VD24oYunaNAehDMnJleKn_JQg5LHwBIqe422qlGkNXvhu7juknS-kcEjJ1MaXBAP4Cu_WQ\",\n",
" token_type: \"bearer\",\n",
" expires_in: 3600,\n",
" expires_at: 1772483893,\n",
" refresh_token: \"s35spp4fy6oe\",\n",
" user: {\n",
" id: \"95d1f381-7023-4f33-bc54-08ccd08abf41\",\n",
" aud: \"authenticated\",\n",
" role: \"authenticated\",\n",
" email: \"guillermo.arrieta@lasalle.mx\",\n",
" email_confirmed_at: \"2026-01-13T14:54:39.096101Z\",\n",
" phone: \"\",\n",
" confirmed_at: \"2026-01-13T14:54:39.096101Z\",\n",
" last_sign_in_at: \"2026-03-02T17:41:10.986584Z\",\n",
" app_metadata: { provider: \"email\", providers: [ \"email\" ] },\n",
" user_metadata: { email_verified: true },\n",
" identities: [\n",
" {\n",
" identity_id: \"b2f3094d-a29b-42b5-be69-3397282ffcd7\",\n",
" id: \"95d1f381-7023-4f33-bc54-08ccd08abf41\",\n",
" user_id: \"95d1f381-7023-4f33-bc54-08ccd08abf41\",\n",
" identity_data: {\n",
" email: \"guillermo.arrieta@lasalle.mx\",\n",
" email_verified: false,\n",
" phone_verified: false,\n",
" sub: \"95d1f381-7023-4f33-bc54-08ccd08abf41\"\n",
" },\n",
" provider: \"email\",\n",
" last_sign_in_at: \"2026-01-13T14:54:39.085382Z\",\n",
" created_at: \"2026-01-13T14:54:39.08544Z\",\n",
" updated_at: \"2026-01-13T14:54:39.08544Z\",\n",
" email: \"guillermo.arrieta@lasalle.mx\"\n",
" }\n",
" ],\n",
" created_at: \"2026-01-13T14:54:39.081453Z\",\n",
" updated_at: \"2026-03-02T19:38:13.130638Z\",\n",
" is_anonymous: false\n",
" }\n",
"}\n"
]
}
],
"source": [
"import { jwtDecode } from \"npm:jwt-decode\";\n",
"\n",
"const listener = supabase.auth.onAuthStateChange(async (event, session) => {\n",
" if (session) {\n",
" const jwt = jwtDecode(session.access_token);\n",
" console.log(event, jwt);\n",
" // store in the session\n",
" localStorage.setItem(\"jwt\", JSON.stringify(session));\n",
"\n",
" }\n",
"});\n",
"\n",
"listener;\n"
]
},
{
"cell_type": "code",
"execution_count": 35,
"id": "0471a2d4",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{\n",
" access_token: \"eyJhbGciOiJFUzI1NiIsImtpZCI6ImI4MTI2OWYxLTIxZDgtNGYyZS1iNzE5LWMyMjQwYTg0MGQ5MCIsInR5cCI6IkpXVCJ9.eyJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTc3MjQ1OTQzMX1dLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwiYXVkIjoiYXV0aGVudGljYXRlZCIsImVtYWlsIjoiZ3VpbGxlcm1vLmFycmlldGFAbGFzYWxsZS5teCIsImV4cCI6MTc3MjQ3MjAwMywiZmVhdHVyZV94Ijp0cnVlLCJpYXQiOjE3NzI0Njg0MDMsImlzX2Fub255bW91cyI6ZmFsc2UsImlzcyI6Imh0dHA6Ly8xMjcuMC4wLjE6NTQzMjEvYXV0aC92MSIsInBob25lIjoiIiwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJzZXNzaW9uX2lkIjoiMTJkNDc0OGUtNDBiMy00MzQ5LWI0MjUtMWFkNzM2YTk2NTM5Iiwic3ViIjoiOTVkMWYzODEtNzAyMy00ZjMzLWJjNTQtMDhjY2QwOGFiZjQxIiwidGVuYW50X2lkIjoibG9jYWwtZGV2LXRlbmFudCIsInVzZXJfbWV0YWRhdGEiOnsiZW1haWxfdmVyaWZpZWQiOnRydWV9fQ.XbxmaKPEo9Vqr1_W1vuXIaYwGiifaBsaqIwFdJvIuMrFPx5VeW9-NeUasWq3ka1DBsEMWaCFeIKykp9QlvDXqQ\",\n",
" token_type: \"bearer\",\n",
" expires_in: 3600,\n",
" expires_at: 1772472003,\n",
" refresh_token: \"gjdfzz4y4y3v\",\n",
" user: {\n",
" id: \"95d1f381-7023-4f33-bc54-08ccd08abf41\",\n",
" aud: \"authenticated\",\n",
" role: \"authenticated\",\n",
" email: \"guillermo.arrieta@lasalle.mx\",\n",
" email_confirmed_at: \"2026-01-13T14:54:39.096101Z\",\n",
" phone: \"\",\n",
" confirmed_at: \"2026-01-13T14:54:39.096101Z\",\n",
" last_sign_in_at: \"2026-03-02T13:50:31.464916Z\",\n",
" app_metadata: { provider: \"email\", providers: [ \"email\" ] },\n",
" user_metadata: { email_verified: true },\n",
" identities: [\n",
" {\n",
" identity_id: \"b2f3094d-a29b-42b5-be69-3397282ffcd7\",\n",
" id: \"95d1f381-7023-4f33-bc54-08ccd08abf41\",\n",
" user_id: \"95d1f381-7023-4f33-bc54-08ccd08abf41\",\n",
" identity_data: {\n",
" email: \"guillermo.arrieta@lasalle.mx\",\n",
" email_verified: false,\n",
" phone_verified: false,\n",
" sub: \"95d1f381-7023-4f33-bc54-08ccd08abf41\"\n",
" },\n",
" provider: \"email\",\n",
" last_sign_in_at: \"2026-01-13T14:54:39.085382Z\",\n",
" created_at: \"2026-01-13T14:54:39.08544Z\",\n",
" updated_at: \"2026-01-13T14:54:39.08544Z\",\n",
" email: \"guillermo.arrieta@lasalle.mx\"\n",
" }\n",
" ],\n",
" created_at: \"2026-01-13T14:54:39.081453Z\",\n",
" updated_at: \"2026-03-02T16:20:03.535209Z\",\n",
" is_anonymous: false\n",
" }\n",
"}\n"
]
}
],
"source": [
"const raw = localStorage.getItem(\"jwt\")\n",
"const jwt = raw ? JSON.parse(raw) : null\n",
"\n",
"console.log(jwt)"
]
},
{
"cell_type": "code",
"execution_count": 36,
"id": "0bfcd412",
"metadata": {},
"outputs": [
{
"ename": "ReferenceError",
"evalue": "useEffect is not defined",
"output_type": "error",
"traceback": [
"Stack trace:",
"ReferenceError: useEffect is not defined",
" at <anonymous>:1:22"
]
}
],
"source": [
"useEffect(() => {\n",
" const onFocus = () => supabase.auth.refreshSession();\n",
" window.addEventListener(\"focus\", onFocus);\n",
" return () => window.removeEventListener(\"focus\", onFocus);\n",
"}, []);"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "88d2d6bc",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Deno",
"language": "typescript",
"name": "deno"
},
"language_info": {
"codemirror_mode": "typescript",
"file_extension": ".ts",
"mimetype": "text/x.typescript",
"name": "typescript",
"nbconvert_exporter": "script",
"pygments_lexer": "typescript",
"version": "5.9.2"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+4 -1
View File
@@ -1,9 +1,12 @@
{
"dependencies": {
"@supabase/supabase-js": "^2.90.1",
"@toon-format/toon": "^2.1.0",
"citeproc": "^2.4.63",
"deno": "^2.6.4",
"jwt-decode": "^4.0.0",
"openai": "^6.16.0",
"supabase": "^2.72.6"
"supabase": "2.75.0"
},
"name": "genesis-2",
"module": "index.ts",
+1 -1
View File
@@ -1 +1 @@
v2.185.0
v2.187.0
+1 -1
View File
@@ -1 +1 @@
buckets-objects-grants-postgres
fix-optimized-search-function
+1 -1
View File
@@ -1 +1 @@
v1.33.0
v1.37.7
+28
View File
@@ -1,3 +1,5 @@
[db]
major_version = 15
[functions.ai-generate-plan]
enabled = true
@@ -48,3 +50,29 @@ entrypoint = "./functions/generate-subject-suggestions/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns.
# For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/generate-subject-suggestions/*.html" ]
[auth.hook.custom_access_token]
enabled = true
uri = "pg-functions://postgres/public/custom_access_token_hook"
[functions.openai-webhook-responses]
enabled = true
verify_jwt = true
import_map = "./functions/openai-webhook-responses/deno.json"
# Uncomment to specify a custom file path to the entrypoint.
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
entrypoint = "./functions/openai-webhook-responses/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns.
# For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/openai-webhook-responses/*.html" ]
[functions.buscar-bibliografia]
enabled = true
verify_jwt = true
import_map = "./functions/buscar-bibliografia/deno.json"
# Uncomment to specify a custom file path to the entrypoint.
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
entrypoint = "./functions/buscar-bibliografia/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns.
# For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/buscar-bibliografia/*.html" ]
+2481
View File
File diff suppressed because one or more lines are too long
+94 -1
View File
@@ -1,4 +1,4 @@
export type Json =
export type Json =
| string
| number
| boolean
@@ -460,6 +460,7 @@ export type Database = {
estado: Database["public"]["Enums"]["estado_conversacion"]
id: string
intento_archivado: number
nombre: string | null
openai_conversation_id: string
plan_estudio_id: string
}
@@ -472,6 +473,7 @@ export type Database = {
estado?: Database["public"]["Enums"]["estado_conversacion"]
id?: string
intento_archivado?: number
nombre?: string | null
openai_conversation_id: string
plan_estudio_id: string
}
@@ -484,6 +486,7 @@ export type Database = {
estado?: Database["public"]["Enums"]["estado_conversacion"]
id?: string
intento_archivado?: number
nombre?: string | null
openai_conversation_id?: string
plan_estudio_id?: string
}
@@ -795,6 +798,27 @@ export type Database = {
},
]
}
person: {
Row: {
email: string | null
first_name: string | null
id: number
last_name: string | null
}
Insert: {
email?: string | null
first_name?: string | null
id?: number
last_name?: string | null
}
Update: {
email?: string | null
first_name?: string | null
id?: number
last_name?: string | null
}
Relationships: []
}
planes_estudio: {
Row: {
activo: boolean
@@ -901,6 +925,36 @@ export type Database = {
},
]
}
professor: {
Row: {
department: string | null
email: string | null
employee_number: string | null
first_name: string | null
id: number
last_name: string | null
salary: number | null
}
Insert: {
department?: string | null
email?: string | null
employee_number?: string | null
first_name?: string | null
id?: number
last_name?: string | null
salary?: number | null
}
Update: {
department?: string | null
email?: string | null
employee_number?: string | null
first_name?: string | null
id?: number
last_name?: string | null
salary?: number | null
}
Relationships: []
}
responsables_asignatura: {
Row: {
asignatura_id: string
@@ -961,6 +1015,36 @@ export type Database = {
}
Relationships: []
}
student: {
Row: {
email: string | null
enrollment_number: string | null
first_name: string | null
id: number
last_name: string | null
major: string | null
semester: number | null
}
Insert: {
email?: string | null
enrollment_number?: string | null
first_name?: string | null
id?: number
last_name?: string | null
major?: string | null
semester?: number | null
}
Update: {
email?: string | null
enrollment_number?: string | null
first_name?: string | null
id?: number
last_name?: string | null
major?: string | null
semester?: number | null
}
Relationships: []
}
tareas_revision: {
Row: {
asignado_a: string
@@ -1206,6 +1290,15 @@ export type Database = {
}
}
Functions: {
append_conversacion_asignatura: {
Args: { p_append: Json; p_id: string }
Returns: undefined
}
append_conversacion_plan: {
Args: { p_append: Json; p_id: string }
Returns: undefined
}
custom_access_token_hook: { Args: { event: Json }; Returns: Json }
unaccent: { Args: { "": string }; Returns: string }
unaccent_immutable: { Args: { "": string }; Returns: string }
}
+27 -48
View File
@@ -2,7 +2,6 @@
/// <reference lib="deno.window" />
import OpenAI from "npm:openai@6.16.0";
import type * as OpenAITypes from "npm:openai@6.16.0";
import { createClient, type SupabaseClient } from "npm:@supabase/supabase-js@2";
// Use non-streaming params to ensure `responses.create` returns a typed Response
export type StructuredResponseOptions =
OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming;
@@ -15,7 +14,6 @@ export type StructuredResponseSuccess<TOutput = unknown> = {
responseId: string;
conversationId?: string | null;
references: {
uploadedToStorage: string[]; // supabase storage paths
openaiFileIds: string[]; // file ids in OpenAI
};
openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers
@@ -24,7 +22,6 @@ export type StructuredResponseFailure = {
ok: false;
code:
| "MissingEnv"
| "StorageUploadFailed"
| "OpenAIFileUploadFailed"
| "OpenAIRequestFailed";
message: string;
@@ -34,40 +31,24 @@ export type StructuredResponseResult<TOutput = unknown> =
| StructuredResponseSuccess<TOutput>
| StructuredResponseFailure;
export interface OpenAIServiceConfig {
supabaseUrl: string;
serviceRoleKey: string;
openAIApiKey: string;
bucket?: string; // default: ai-storage
}
export class OpenAIService {
private readonly supabase: SupabaseClient;
private readonly openai: OpenAI;
private readonly bucket: string;
private constructor(
client: SupabaseClient,
openai: OpenAI,
bucket: string,
) {
this.supabase = client;
private constructor(openai: OpenAI) {
this.openai = openai;
this.bucket = bucket;
}
static fromEnv(): StructuredResponseFailure | OpenAIService {
const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? "";
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? "";
const bucket = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage";
if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) {
if (!openAIApiKey) {
return {
ok: false,
code: "MissingEnv",
message:
"Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY",
message: "Required env vars missing: OPENAI_API_KEY",
};
}
const client = createClient(supabaseUrl, serviceRoleKey);
const openai = new OpenAI({ apiKey: openAIApiKey });
return new OpenAIService(client, openai, bucket);
return new OpenAIService(openai);
}
async createConversation(metadata?: Record<string, string>) {
const conversation = await this.openai.conversations.create({
@@ -80,9 +61,6 @@ export class OpenAIService {
files?: File[],
): Promise<StructuredResponseResult<TOutput>> {
try {
const uploadedToStorage = await this.uploadFilesToStorage(
files ?? [],
);
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
const newOptions = { ...options };
@@ -112,6 +90,11 @@ export class OpenAIService {
const openaiRaw = (await this.openai.responses.create(
newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming,
)) as OpenAITypes.OpenAI.Responses.Response;
const isBackground =
(newOptions as unknown as { background?: boolean })
.background ===
true;
const { model, id: responseId } = openaiRaw;
const usage = openaiRaw?.usage ?? null;
const conversationId = (
@@ -119,6 +102,22 @@ export class OpenAIService {
conversation_id?: string | null;
}
).conversation_id ?? null;
if (isBackground) {
return {
ok: true,
output: undefined,
outputText: undefined,
model: String(model),
usage,
responseId: String(responseId),
conversationId: conversationId
? String(conversationId)
: null,
references: { openaiFileIds },
openaiRaw,
};
}
// Try to read structured JSON output
let output: TOutput | undefined = undefined;
let outputText: string | undefined = undefined;
@@ -154,38 +153,18 @@ export class OpenAIService {
usage,
responseId: String(responseId),
conversationId: conversationId ? String(conversationId) : null,
references: { uploadedToStorage, openaiFileIds },
references: { openaiFileIds },
openaiRaw,
};
} catch (err) {
const e = err as Error;
const message = e?.message ?? "Unknown error";
const code = message.includes("Supabase upload failed")
? "StorageUploadFailed"
: message.includes("OpenAI file upload failed")
const code = message.includes("OpenAI file upload failed")
? "OpenAIFileUploadFailed"
: "OpenAIRequestFailed";
return { ok: false, code, message, cause: err };
}
}
private async uploadFilesToStorage(files: File[]): Promise<string[]> {
const paths: string[] = [];
for (const file of files) {
const safeName = this.sanitizeFilename(file.name);
const path = `${crypto.randomUUID()}-${safeName}`;
const { data, error } = await this.supabase.storage
.from(this.bucket)
.upload(path, file, {
contentType: file.type || "application/octet-stream",
upsert: false,
});
if (error) {
throw new Error(`Supabase upload failed: ${error.message}`);
}
paths.push(data.path);
}
return paths;
}
private async uploadFilesToOpenAI(files: File[]): Promise<string[]> {
const ids: string[] = [];
for (const file of files) {
+6
View File
@@ -47,3 +47,9 @@ export function sendError(
},
);
}
export interface ResponseMetadata extends Record<string, string | undefined> {
tabla?: string;
accion?: string;
id?: string;
}
+103 -116
View File
@@ -19,6 +19,13 @@ type NivelType =
type TipoCicloType =
Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"];
type BeforeUnloadWithDetail = Event & { detail?: { reason?: unknown } };
// Re-registramos con tipo estricto (evita `any` en análisis)
addEventListener("beforeunload", (ev: BeforeUnloadWithDetail) => {
console.error("ALERTA: La función se va a apagar. Razón:", ev.detail?.reason);
});
Deno.serve(async (req: Request): Promise<Response> => {
const url = new URL(req.url);
const functionName = url.pathname.split("/").pop();
@@ -118,6 +125,10 @@ Deno.serve(async (req: Request): Promise<Response> => {
SERVICE_ROLE_KEY,
);
// Model name controlled via env var (single use)
const AI_GENERATE_PLAN_MODELO = Deno.env.get("AI_GENERATE_PLAN_MODELO") ??
"gpt-5-nano";
const formData = await req.formData();
const validation = parseAndValidate(formData);
if (!validation.success) {
@@ -184,8 +195,98 @@ Deno.serve(async (req: Request): Promise<Response> => {
? estructuraPlan.definicion as Record<string, unknown>
: {};
const { data: estado } = await supabaseService
.from("estados_plan")
.select("id,clave,orden")
.eq("clave", "GENERANDO")
.maybeSingle();
if (!estado?.id) {
throw new HttpError(
500,
"No se encontró el estado GENERANDO.",
"MISSING_STATE",
{ clave: "GENERANDO" },
);
}
const { data: carrera, error: carreraError } = await supabaseService
.from("carreras")
.select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)")
.eq("id", payload.datosBasicos.carreraId)
.maybeSingle();
if (carreraError) {
throw new HttpError(
500,
"No se pudo obtener la carrera.",
"SUPABASE_QUERY_FAILED",
carreraError,
);
}
if (!carrera) {
throw new HttpError(
404,
"No se encontró la carrera.",
"NOT_FOUND",
{ table: "carreras", id: payload.datosBasicos.carreraId },
);
}
const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] =
{
carrera_id: carrera.id as string,
estructura_id: estructuraPlan?.id as string,
nombre: payload.datosBasicos.nombrePlan,
nivel: payload.datosBasicos.nivel as NivelType,
tipo_ciclo: payload.datosBasicos.tipoCiclo as TipoCicloType,
numero_ciclos: payload.datosBasicos.numCiclos,
// IMPORTANTE: se inserta SIN `datos` (se actualiza vía webhook)
estado_actual_id: estado.id,
activo: true,
tipo_origen: "IA",
meta_origen: {
generado_por: "ai-generate-plan",
referencias: {
archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null,
repositoriosIds: payload.iaConfig?.repositoriosIds ?? null,
},
iaConfig: {
descripcionEnfoqueAcademico:
payload.iaConfig?.descripcionEnfoqueAcademico ?? null,
instruccionesAdicionalesIA:
payload.iaConfig?.instruccionesAdicionalesIA ?? null,
usarMCP: Boolean(payload.iaConfig?.usarMCP),
},
} as unknown as Json,
};
const { data: plan, error: planError } = await supabaseService
.from("planes_estudio")
.insert(planInsert)
.select(
"id,nombre,nivel,tipo_ciclo,numero_ciclos,carrera_id,estructura_id,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,datos",
)
.single();
if (planError) {
const maybeCode = (planError as { code?: string }).code;
const status = maybeCode ? 409 : 500;
throw new HttpError(
status,
"No se pudo guardar el plan de estudios.",
"SUPABASE_INSERT_FAILED",
{ ...planError, code: maybeCode },
);
}
const aiStructuredPayload: StructuredResponseOptions = {
model: "gpt-5-nano",
model: AI_GENERATE_PLAN_MODELO,
background: true,
metadata: {
tabla: "planes_estudio",
accion: "crear",
id: String(plan.id),
},
input: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
@@ -233,126 +334,12 @@ Deno.serve(async (req: Request): Promise<Response> => {
const status = aiResult.code === "MissingEnv" ? 500 : 502;
throw new HttpError(
status,
"No se pudo generar el plan con IA.",
"No se pudo iniciar la generación del plan con IA.",
"OPENAI_REQUEST_FAILED",
aiResult,
);
}
// Prefer parsed output; fallback to parse outputText
let aiOutput = aiResult.output ?? null;
if (aiOutput == null && aiResult.outputText) {
try {
aiOutput = JSON.parse(aiResult.outputText);
} catch {
throw new HttpError(
502,
"La respuesta de la IA no es JSON válido.",
"OPENAI_INVALID_JSON",
{ outputText: aiResult.outputText },
);
}
}
const aiOutputJson: Json = aiOutput as unknown as Json;
if (!aiOutput) {
throw new HttpError(
502,
"La respuesta de la IA no contiene salida estructurada.",
"OPENAI_MISSING_STRUCTURED_OUTPUT",
{ outputText: aiResult.outputText ?? null },
);
}
// TODO: Insertar interacciones con IA y quizas forzar a que la informacion de datosBasicos sea la misma que la recibida
const { data: estado } = await supabaseService
.from("estados_plan")
.select("id,clave,orden")
.ilike("clave", "BORRADOR%")
.order("orden", { ascending: true })
.limit(1)
.maybeSingle();
const { data: carrera, error: carreraError } = await supabaseService
.from("carreras")
.select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)")
.eq("id", payload.datosBasicos.carreraId)
.maybeSingle();
if (carreraError) {
throw new HttpError(
500,
"No se pudo obtener la carrera.",
"SUPABASE_QUERY_FAILED",
carreraError,
);
}
if (!carrera) {
throw new HttpError(
404,
"No se encontró la carrera.",
"NOT_FOUND",
{ table: "carreras", id: payload.datosBasicos.carreraId },
);
}
const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] =
{
carrera_id: carrera?.id as string,
estructura_id: estructuraPlan?.id as string,
nombre: payload.datosBasicos.nombrePlan,
nivel: payload.datosBasicos.nivel as NivelType,
tipo_ciclo: payload.datosBasicos.tipoCiclo as TipoCicloType,
numero_ciclos: payload.datosBasicos.numCiclos,
datos: aiOutputJson,
estado_actual_id: estado?.id ?? undefined,
activo: true,
tipo_origen: "IA",
meta_origen: {
generado_por: "ai_generate_plan",
ai_structured: {
responseId: aiResult.responseId ?? null,
conversationId: aiResult.conversationId ?? null,
model: aiResult.model,
usage: aiResult.usage ?? null,
},
referencias: {
archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null,
repositoriosIds: payload.iaConfig?.repositoriosIds ?? null,
},
iaConfig: {
descripcionEnfoqueAcademico:
payload.iaConfig?.descripcionEnfoqueAcademico ?? null,
instruccionesAdicionalesIA:
payload.iaConfig?.instruccionesAdicionalesIA ?? null,
usarMCP: Boolean(payload.iaConfig?.usarMCP),
},
} as unknown as Json,
};
const { data: plan, error: planError } = await supabaseService
.from("planes_estudio")
.insert(planInsert)
.select(
"id,nombre,nivel,tipo_ciclo,numero_ciclos,carrera_id,estructura_id,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,datos",
)
.single();
// TODO: interaccion con IA y cambio de estado
if (planError) {
const maybeCode = (planError as { code?: string }).code;
// Common cases:
// - foreign key / constraint violations -> 409
// - others -> 500
const status = maybeCode ? 409 : 500;
throw new HttpError(
status,
"No se pudo guardar el plan de estudios.",
"SUPABASE_INSERT_FAILED",
{ ...planError, code: maybeCode },
);
}
// TODO: update a interaccion_ia y e insert a cambios_plan con id de plan generado
console.log(
+10 -1
View File
@@ -34,4 +34,13 @@ Antes de ejecutar el trabajo, comienza con una lista concisa (3-7 puntos) de los
# Validación de Salida
Después de generar el objeto, realiza una validación breve para asegurar que todos los campos requeridos estén presentes, que los valores cumplan los criterios normativos y que la estructura cumpla con el json_schema indicado.
Corrige cualquier inconsistencia detectada antes de finalizar la entrega.`;
Corrige cualquier inconsistencia detectada antes de finalizar la entrega.
# REGLAS DE FORMATO PARA CAMPOS DE TEXTO (STRING)
1. **Prioridad:** Estas reglas aplican por defecto. Si el prompt del usuario solicita explícitamente un formato distinto, esa instrucción tiene prioridad sobre estas reglas.
2. **Estilo Visual:** Redacta el contenido exclusivamente para visualización en texto plano (estilo 'white-space: pre-wrap').
3. **Estructura Vertical:** Utiliza saltos de línea explícitos para romper líneas y doble salto de línea para separar párrafos.
4. **Indentación Estricta:** Usa exactamente 2 espacios para la indentación jerárquica. No uses tabuladores.
5. **Listas:** Utiliza un guion seguido de un espacio ("- ") para los elementos de lista.
6. **Prohibiciones:** No incluyas etiquetas HTML, sintaxis Markdown ni caracteres de escape literales visibles en el texto final. Asegúrate de que el JSON final contenga saltos de línea válidos ('\n') y no texto escapado.
`;
File diff suppressed because it is too large Load Diff
+16 -29
View File
@@ -1,37 +1,24 @@
import { Tables } from "../_shared/database.types.ts";
export interface UploadedFile {
id: string; // Necesario para React (key)
file: File; // La fuente de verdad (contiene name, size, type)
preview?: string; // Opcional: si fueran imágenes
}
export type AIGenerateSubjectInput = {
plan_estudio_id: Tables<"asignaturas">["plan_estudio_id"];
datosBasicos: {
nombre: Tables<"asignaturas">["nombre"];
datosUpdate: {
id?: Tables<"asignaturas">["id"]; // si viene, es update; si no, es insert
plan_estudio_id: Tables<"asignaturas">["plan_estudio_id"];
estructura_id?: Tables<"asignaturas">["estructura_id"] | null;
nombre?: Tables<"asignaturas">["nombre"];
codigo?: Tables<"asignaturas">["codigo"];
tipo: Tables<"asignaturas">["tipo"] | null;
creditos: Tables<"asignaturas">["creditos"] | null;
horasAcademicas?: Tables<"asignaturas">["horas_academicas"] | null;
horasIndependientes?: Tables<"asignaturas">["horas_independientes"] | null;
estructuraId: Tables<"asignaturas">["estructura_id"] | null;
tipo?: Tables<"asignaturas">["tipo"] | null;
creditos?: Tables<"asignaturas">["creditos"] | null;
horas_academicas?: Tables<"asignaturas">["horas_academicas"] | null;
horas_independientes?: Tables<"asignaturas">["horas_independientes"] | null;
numero_ciclo?: Tables<"asignaturas">["numero_ciclo"] | null;
linea_plan_id?: Tables<"asignaturas">["linea_plan_id"] | null;
orden_celda?: Tables<"asignaturas">["orden_celda"] | null;
};
// clonInterno?: {
// facultadId?: string
// carreraId?: string
// planOrigenId?: string
// asignaturaOrigenId?: string | null
// }
// clonTradicional?: {
// archivoWordAsignaturaId: string | null
// archivosAdicionalesIds: Array<string>
// }
iaConfig?: {
descripcionEnfoqueAcademico: string;
instruccionesAdicionalesIA: string;
archivosReferencia: Array<string>;
repositoriosReferencia?: Array<string>;
descripcionEnfoqueAcademico?: string;
instruccionesAdicionalesIA?: string;
archivosAdjuntos?: string[]; // rutas (no File)
};
archivosAdjuntos?: Array<UploadedFile>;
};
@@ -0,0 +1,3 @@
# Configuration for private npm package dependencies
# For more information on using private registries with Edge Functions, see:
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries
@@ -0,0 +1,6 @@
{
"imports": {
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts",
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
}
}
@@ -0,0 +1,326 @@
import "@supabase/functions-js/edge-runtime.d.ts";
import { z } from "zod";
import { corsHeaders } from "../_shared/cors.ts";
import { HttpError, sendError, sendSuccess } from "../_shared/utils.ts";
console.log("Starting buscar-bibliografia function");
type GoogleBooksVolume = Record<string, unknown>;
type OpenLibraryDoc = Record<string, unknown>;
type EndpointResult =
| { endpoint: "google"; item: GoogleBooksVolume }
| { endpoint: "open_library"; item: OpenLibraryDoc };
interface GoogleBooksVolumesListResponse {
kind?: string;
totalItems?: number;
items?: GoogleBooksVolume[];
}
interface OpenLibrarySearchResponse {
num_found?: number;
start?: number;
docs?: OpenLibraryDoc[];
}
const ISO6391 = z.string().regex(
/^[a-z]{2}$/i,
"Debe ser ISO 639-1 (2 letras)",
);
const ISO6392 = z.string().regex(
/^[a-z]{3}$/i,
"Debe ser ISO 639-2 (3 letras)",
);
const SearchTermsSchema = z
.object({
q: z.string().min(1, "q es requerido"),
})
.strict();
const GoogleParamsSchema = z
.object({
orderBy: z.enum(["newest", "relevance"]).optional(),
langRestrict: ISO6391.optional(),
startIndex: z.number().int().min(0).optional(),
})
.passthrough()
.optional()
.default({});
const OpenLibraryParamsSchema = z
.object({
language: ISO6392.optional(),
page: z.number().int().min(1).optional(),
sort: z.string().optional(),
})
.passthrough()
.optional()
.default({});
const BodySchema = z
.object({
searchTerms: SearchTermsSchema,
// Parámetros por endpoint
google: GoogleParamsSchema,
openLibrary: OpenLibraryParamsSchema,
})
.strict();
type BuscarBibliografiaRequest = z.infer<typeof BodySchema>;
function formatZodIssues(issues: z.ZodIssue[]): string {
return issues
.map((issue, i) => {
const path = issue.path.length ? issue.path.join(".") : "(root)";
return `${i + 1}. ${path}: ${issue.message}`;
})
.join("\n");
}
function buildUrlWithSearchTerms(
baseUrl: string,
searchTerms: Record<string, unknown>,
): string {
const url = new URL(baseUrl);
for (const [key, value] of Object.entries(searchTerms)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
for (const v of value) {
if (v === undefined || v === null) continue;
url.searchParams.append(key, String(v));
}
continue;
}
url.searchParams.set(key, String(value));
}
return url.toString();
}
function pickSerializableParams(
input: Record<string, unknown>,
): Record<
string,
string | number | boolean | Array<string | number | boolean>
> {
const out: Record<
string,
string | number | boolean | Array<string | number | boolean>
> = {};
for (const [key, value] of Object.entries(input)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
const arr = value
.filter((v) => v !== undefined && v !== null)
.filter((v) =>
["string", "number", "boolean"].includes(typeof v)
) as Array<
string | number | boolean
>;
if (arr.length) out[key] = arr;
continue;
}
if (["string", "number", "boolean"].includes(typeof value)) {
out[key] = value as string | number | boolean;
}
}
return out;
}
Deno.serve(async (req: Request): Promise<Response> => {
const url = new URL(req.url);
const functionName = url.pathname.split("/").pop();
console.log(
`[${new Date().toISOString()}][${functionName}]: Request received`,
);
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
try {
if (req.method !== "POST") {
throw new HttpError(405, "Método no permitido.", "METHOD_NOT_ALLOWED", {
method: req.method,
});
}
const contentType = (req.headers.get("content-type") || "").toLowerCase();
if (!contentType.includes("application/json")) {
throw new HttpError(
415,
"Content-Type no soportado.",
"UNSUPPORTED_MEDIA_TYPE",
{ contentType, expected: "application/json" },
);
}
let rawBody: unknown;
try {
rawBody = await req.json();
} catch (e) {
throw new HttpError(400, "Body JSON inválido.", "INVALID_JSON", {
cause: e,
});
}
const parsed = BodySchema.safeParse(rawBody);
if (!parsed.success) {
throw new HttpError(
422,
formatZodIssues(parsed.error.issues),
"VALIDATION_ERROR",
parsed.error,
);
}
const body: BuscarBibliografiaRequest = parsed.data;
const GOOGLE_API_KEY = Deno.env.get("GOOGLE_API_KEY");
if (!GOOGLE_API_KEY) {
throw new HttpError(
500,
"Configuración del servidor incompleta.",
"MISSING_ENV",
{ missing: ["GOOGLE_API_KEY"] },
);
}
const baseUrl = "https://www.googleapis.com/books/v1/volumes";
const googleParams = pickSerializableParams({
...body.google,
...body.searchTerms,
key: GOOGLE_API_KEY,
maxResults: 20,
startIndex: (body.google as Record<string, unknown>)?.startIndex ?? 0,
});
// No mandar parámetros exclusivos de Open Library a Google
delete (googleParams as Record<string, unknown>)["page"];
delete (googleParams as Record<string, unknown>)["language"];
const googleUrl = buildUrlWithSearchTerms(baseUrl, googleParams);
const openLibraryBaseUrl = "https://openlibrary.org/search.json";
const openLibraryParams = pickSerializableParams({
...body.openLibrary,
q: body.searchTerms.q,
limit: 20,
page: (body.openLibrary as Record<string, unknown>)?.page ?? 1,
});
// No mandar parámetros exclusivos de Google a Open Library
delete (openLibraryParams as Record<string, unknown>)["langRestrict"];
delete (openLibraryParams as Record<string, unknown>)["orderBy"];
delete (openLibraryParams as Record<string, unknown>)["startIndex"];
const openLibraryUrl = buildUrlWithSearchTerms(
openLibraryBaseUrl,
openLibraryParams,
);
const [googleResp, openLibraryResp] = await Promise.all([
fetch(googleUrl, { headers: { Accept: "application/json" } }),
fetch(openLibraryUrl, {
headers: {
Accept: "application/json",
"User-Agent": "Acad-IA info.ingenieria.lci@gmail.com",
},
}),
]);
if (!googleResp.ok) {
const text = await googleResp.text().catch(() => "");
throw new HttpError(
502,
"Error al consultar Google Books.",
"GOOGLE_BOOKS_REQUEST_FAILED",
{
status: googleResp.status,
statusText: googleResp.statusText,
body: text || null,
},
);
}
if (!openLibraryResp.ok) {
const text = await openLibraryResp.text().catch(() => "");
throw new HttpError(
502,
"Error al consultar Open Library.",
"OPEN_LIBRARY_REQUEST_FAILED",
{
status: openLibraryResp.status,
statusText: openLibraryResp.statusText,
body: text || null,
},
);
}
const googleData =
(await googleResp.json()) as GoogleBooksVolumesListResponse;
const openLibraryData =
(await openLibraryResp.json()) as OpenLibrarySearchResponse;
const googleItems = Array.isArray(googleData?.items)
? googleData.items.slice(0, 20)
: [];
const openLibraryDocs = Array.isArray(openLibraryData?.docs)
? openLibraryData.docs.slice(0, 20)
: [];
const results: EndpointResult[] = [
...googleItems.map((item) => ({ endpoint: "google", item } as const)),
...openLibraryDocs.map((
item,
) => ({ endpoint: "open_library", item } as const)),
];
return sendSuccess(results);
} catch (error) {
if (error instanceof HttpError) {
console.error(
`[${new Date().toISOString()}][${functionName}] ⚠️ Handled Error:`,
{
message: error.message,
code: error.code,
internalDetails: error.internalDetails || "N/A",
},
);
return sendError(error.status, error.message, error.code);
}
const unexpectedError = error instanceof Error
? error
: new Error(String(error));
console.error(
`[${
new Date().toISOString()
}][${functionName}] 💥 CRITICAL UNHANDLED ERROR:`,
unexpectedError.stack || unexpectedError.message,
);
return sendError(
500,
"Ocurrió un error inesperado en el servidor.",
"INTERNAL_SERVER_ERROR",
);
}
});
/* To invoke locally:
1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start)
2. Make an HTTP request:
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/buscar-bibliografia' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' \
--header 'Content-Type: application/json' \
--data '{"name":"Functions"}'
*/
@@ -0,0 +1,100 @@
// ./plan_mensajes_ia/index.ts
import type { OpenAI } from "openai";
import type { Json } from "../../_shared/database.types.ts";
import { ResponseMetadata } from "../../_shared/utils.ts";
import { supabase } from "../../openai-webhook-responses/supabase.ts";
function extractOutputText(response: OpenAI.Responses.Response): string {
const direct = (response as any).output_text;
if (typeof direct === "string") return direct;
const output = (response as any).output;
if (!Array.isArray(output)) return "";
try {
return output
.filter((item) => item?.type === "message")
.flatMap((item) => item?.content ?? [])
.filter((c) => c?.type === "output_text")
.map((c) => String(c?.text ?? ""))
.join("");
} catch {
return "";
}
}
export async function handleAsignaturaMensajesResponse(
response: OpenAI.Responses.Response,
): Promise<void> {
const metadata = response.metadata as any;
const mensajeId = metadata?.mensaje_id;
console.log("Procesando Webhook para Asignatura. Mensaje ID:", mensajeId);
const isStructured = metadata?.is_structured === "true" || metadata?.is_structured === true;
if (!mensajeId) {
console.warn("No se recibió mensaje_id en la metadata del webhook de asignatura");
return;
}
try {
const outputText = extractOutputText(response);
if (!outputText) {
throw new Error("La respuesta de OpenAI está vacía");
}
let respuestaJSON: any;
try {
respuestaJSON = JSON.parse(outputText);
} catch (e) {
throw new Error(`Error parseando JSON de OpenAI: ${e.message}`);
}
// Normalización de campos de la IA
const aiMessage = respuestaJSON["ai-message"] || respuestaJSON["ai_message"] || "";
const is_refusal = !!respuestaJSON.is_refusal || respuestaJSON["is-refusal"] === true;
let recommendations: any[] = [];
// Si es estructurado y no es un rechazo de la IA, generamos las recomendaciones
if (isStructured && !is_refusal) {
recommendations = Object.entries(respuestaJSON)
.filter(([k]) => !["ai-message", "ai_message", "is-refusal", "is_refusal"].includes(k))
.map(([campo, valor]) => ({
campo_afectado: campo,
texto_mejora: valor,
aplicada: false,
}));
}
// --- CAMBIO CLAVE: TABLA 'asignatura_mensajes_ia' ---
const { error } = await supabase
.from("asignatura_mensajes_ia")
.update({
respuesta: aiMessage,
// Guardamos la propuesta completa para mantener historial
propuesta: {
respuesta: aiMessage,
recommendations
},
is_refusal,
estado: "COMPLETADO",
})
.eq("id", mensajeId);
if (error) throw error;
console.log(`Mensaje de asignatura ${mensajeId} actualizado con éxito.`);
} catch (e) {
console.error("Error en handleAsignaturaMensajesResponse:", { mensajeId, error: e.message });
// Marcamos como error en la tabla correcta para que el front deje de mostrar el spinner
await supabase
.from("asignatura_mensajes_ia")
.update({ estado: "ERROR" })
.eq("id", mensajeId);
}
}
@@ -3,7 +3,8 @@ 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";
import { assertUuid, pickSchemaFields, safePlanForPrompt,pickSchemaAsignaturaFields } from "./lib/plan.ts";
import { OpenAIService } from "../_shared/openai-service.ts";
type CreateBody = {
plan_estudio_id: string;
@@ -22,6 +23,13 @@ type AddMessageBody = {
const app = new Hono();
addEventListener("beforeunload", (ev: any) => {
console.error(
"ALERTA: La función se va a apagar. Razón:",
ev?.detail?.reason,
);
});
// Preflight CORS
app.options(
"*",
@@ -29,6 +37,13 @@ app.options(
);
const prefix = "/create-chat-conversation";
// Model names (module-level) — pueden ser sobrescritos por variables de entorno
const CREATE_CHAT_CONVERSATION_NONSTRUCTURED_MODELO = Deno.env.get(
"CREATE_CHAT_CONVERSATION_NONSTRUCTURED_MODELO",
) ?? "gpt-5-nano";
const CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO = Deno.env.get(
"CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO",
) ?? "gpt-5-nano";
app.get(`${prefix}/health`, (c) => withCors(jsonResponse({ ok: true })));
@@ -36,7 +51,7 @@ 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) => {
app.post(`${prefix}/plan/conversations`, async (c) => {
try {
/* const auth = c.req.header("authorization");
const user = await requireUser(auth); */
@@ -108,292 +123,268 @@ app.post(`${prefix}/conversations`, async (c) => {
return withCors(handleErr(err));
}
});
/**
* GET /conversations/:conversation_plan_id/messages
* Lista mensajes (assistant/user) desde OpenAI
*/
app.get(`${prefix}/conversations/:id/messages`, async (c) => {
app.post(`${prefix}/asignatura/conversations`, async (c) => {
try {
/* const auth = c.req.header("authorization");
await requireUser(auth); */
const body = (await c.req.json().catch(() => ({}))) as Partial<CreateBody>;
const asignatura_id = body.asignatura_id;
assertUuid(asignatura_id ?? "", "asignatura_id");
const conversation_plan_id = c.req.param("id");
assertUuid(conversation_plan_id, "conversation_plan_id");
const instanciador = body.instanciador ?? "unknown";
const system_prompt = body.system_prompt ??
"Eres un asistente experto en currículo académico. Si te piden algo ajeno a la asignatura, responde con un refusal.";
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)
// 1. Verificar que la asignatura existe
const { data: asignatura, error: asigErr } = await supabase
.from("asignaturas")
.select("*")
.eq("id", asignatura_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,
}),
);
if (asigErr || !asignatura) {
throw new HttpError(404, "asignatura_not_found", "Asignatura no encontrada");
}
const items = await openai.conversations.items.list(
convRow.openai_conversation_id,
);
// 2. Crear conversación en OpenAI
const conv = await openai.conversations.create({
metadata: {
tabla: "asignaturas",
id: asignatura.id,
instanciador,
},
items: [{ type: "message", role: "system", content: system_prompt }],
});
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(""),
}));
// 3. Insertar en conversaciones_asignatura (coincidiendo con tu SQL)
const { data: row, error: insErr } = await supabase
.from("conversaciones_asignatura")
.insert({
openai_conversation_id: conv.id,
asignatura_id: asignatura.id,
estado: "ACTIVA",
conversacion_json: [], // Inicializamos como array vacío para los mensajes
// creado_por: user.id // Opcional si tienes el ID del usuario
})
.select("id, asignatura_id, openai_conversation_id, estado")
.single();
return withCors(jsonResponse({ source: "openai", items: conversacion }));
if (insErr || !row) {
try { await openai.conversations.delete(conv.id); } catch (_) {}
throw new HttpError(500, "db_insert_failed", "Error al registrar conversación", insErr);
}
return withCors(jsonResponse({ conversation_asignatura: row }, 201));
} 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) => {
app.post(`${prefix}/conversations/plan/: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
>;
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");
}
console.log("Iniciando generación en background para mensaje_id:");
const supabase = getSupabaseServiceClient();
const svc = OpenAIService.fromEnv();
// 1. Validar existencia y estado de la conversación
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, "not_found", "Conversación no encontrada");
if (row.estado === "ARCHIVADA") throw new HttpError(409, "archived", "Conversación archivada");
const plan = (row as any).planes_estudio;
const definicion = plan?.estructuras_plan?.definicion;
const isStructured = !!definicion;
// 2. Insertar el mensaje en estado PENDIENTE
// Guardamos los metadatos necesarios para procesar la respuesta después
const { data: mensajeInsertado, error: insertErr } = await supabase
.from("plan_mensajes_ia")
.insert({
conversacion_plan_id:conversation_plan_id,
enviado_por: "00000000-0000-0000-0000-000000000000",
mensaje: body.content,
campos: body.campos ?? [],
estado: "PROCESANDO", // Estado inicial
})
.select()
.single();
if (insertErr) throw new HttpError(500, "db_error", "No se pudo crear el registro");
// 3. Preparar Schema y Prompt
const schema = isStructured ? pickSchemaFields(definicion, body.campos ?? []) : {
type: "object",
properties: { "ai-message": { type: "string" }, "is_refusal": { type: "boolean" } }
};
// 4. Llamada asincrónica a OpenAI con Webhook
// Nota: El SDK de OpenAI permite pasar webhooks en ciertos modelos/endpoints
console.log("mandando a openaai ");
const aiResult = await svc.createStructuredResponse({
conversation: row.openai_conversation_id,
model: isStructured ? CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO : CREATE_CHAT_CONVERSATION_NONSTRUCTURED_MODELO,
background: true, // <--- ESTO ES LO QUE TE FALTABA
metadata: {
tabla: "plan_mensajes_ia",
mensaje_id: String(mensajeInsertado.id), // Siempre string
is_structured: String(isStructured)
},
text: {
format: {
type: "json_schema",
name: "definicion",
schema: schema
}
},
input: [
{ role: "system", content: `Asistente de plan: ${JSON.stringify(safePlanForPrompt(plan))}` },
{ role: "user", content: body.content },
],
});
if (!aiResult.ok) {
throw new HttpError(500, "openai_error", "No se pudo encolar la respuesta");
}
// 5. Responder al cliente de inmediato
return withCors(jsonResponse({
ok: true,
mensaje_id: mensajeInsertado.id,
openai_response_id: aiResult.responseId // Para seguimiento
}));
} catch (err) {
return withCors(handleErr(err));
}
});
app.post(`${prefix}/conversations/asignatura/:id/messages`, async (c) => {
try {
const conversation_asig_id = c.req.param("id");
assertUuid(conversation_asig_id, "conversation_asig_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();
// Usamos el servicio que ya tienes configurado para background
const svc = OpenAIService.fromEnv();
// Traer conversacion + plan + estructura
// 1. Traer datos de la asignatura
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)
.from("conversaciones_asignatura")
.select(`id, openai_conversation_id, asignatura_id, asignaturas(*, estructuras_asignatura(definicion))`)
.eq("id", conversation_asig_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",
);
}
if (error || !row) throw new HttpError(404, "not_found", "Conversación no encontrada");
const plan = (row as any).planes_estudio;
const definicion = plan?.estructuras_plan?.definicion;
const asignatura = (row as any).asignaturas;
const definicion = asignatura?.estructuras_asignatura?.definicion;
const isStructured = !!definicion && (body.campos?.length ?? 0) > 0;
// Si NO hay schema o no piden campos: solo agregamos mensaje y regresamos ok
const wantsStructured = !!definicion;
// 2. Insertar el mensaje en estado PROCESANDO (para que el front vea el spinner)
const { data: mensajeInsertado, error: insertErr } = await supabase
.from("asignatura_mensajes_ia")
.insert({
conversacion_asignatura_id: conversation_asig_id,
enviado_por: "00000000-0000-0000-0000-000000000000",
mensaje: body.content,
campos: body.campos ?? [],
estado: "PROCESANDO",
})
.select()
.single();
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.`,
if (insertErr) throw new HttpError(500, "db_error", "No se pudo crear el registro");
// 3. Preparar Schema (Usando tu lógica de asignatura)
const schema = isStructured
? pickSchemaAsignaturaFields(definicion, body.campos ?? [])
: {
type: "object",
properties: {
"ai-message": { type: "string" },
"is_refusal": { type: "boolean" }
},
{ 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.",
],
},
},
},
},
},
});
required: ["ai-message", "is_refusal"],
additionalProperties: false
};
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({
// 4. Llamada asincrónica con background: true
const aiResult = await svc.createStructuredResponse({
conversation: row.openai_conversation_id,
model,
text: { format: { type: "json_schema", name: "definicion", schema } },
model: isStructured ? CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO : CREATE_CHAT_CONVERSATION_NONSTRUCTURED_MODELO,
background: true, // <--- Ahora sí, activamos el modo background
metadata: {
usuario: /* user.email ?? user.id ??*/ "unknown",
plan_estudio_id: row.plan_estudio_id,
tabla: "asignatura_mensajes_ia", // El webhook usará esto para saber dónde hacer el UPDATE
mensaje_id: String(mensajeInsertado.id),
is_structured: String(isStructured),
conversation_id: conversation_asig_id // Extra para el webhook si lo necesita
},
text: {
format: {
type: "json_schema",
name: isStructured ? "mejora_asignatura" : "respuesta_basica",
schema: schema
}
},
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.`,
content: isStructured
? `Asistente de asignatura. Datos: ${JSON.stringify(asignatura)}`
: "Eres un experto en diseño curricular."
},
{ role: "user", content: prompt },
{ role: "user", content: body.content },
],
});
let parsed: any = null;
try {
parsed = JSON.parse(resp.output_text ?? "null");
} catch (_) {}
if (!aiResult.ok) {
throw new HttpError(500, "openai_error", "No se pudo encolar la respuesta");
}
// 5. Responder al cliente de inmediato
return withCors(jsonResponse({
ok: true,
openai_response_id: resp.id,
data: parsed,
raw: resp.output_text ?? null,
mensaje_id: mensajeInsertado.id,
openai_response_id: aiResult.responseId
}));
} 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,
);
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
@@ -1,8 +1,8 @@
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",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform",
"access-control-allow-methods": "GET,POST,OPTIONS,DELETE",
};
export function withCors(res: Response) {
@@ -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,20 +18,23 @@ 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
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));
}
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
@@ -42,6 +45,49 @@ export function pickSchemaFields(
return out;
}
export function pickSchemaAsignaturaFields(
definicion: any,
campos: string[],
) {
// 1. Validación inicial
if (!definicion || definicion.type !== "object" || !definicion.properties) {
return {
type: "object",
properties: {
"ai-message": { type: "string" },
"is_refusal": { type: "boolean" }
},
required: ["ai-message", "is_refusal"],
additionalProperties: false
};
}
// 2. Filtrar solo las propiedades técnicas que el usuario pidió
const filteredProperties = Object.fromEntries(
Object.entries(definicion.properties).filter(([k]) => campos.includes(k))
);
// 3. RECONSTRUIR el esquema incluyendo SIEMPRE los campos de control
const finalSchema = {
type: "object",
properties: {
"ai-message": {
type: "string",
description: "Tu respuesta conversacional dirigida al profesor."
},
"is_refusal": {
type: "boolean",
description: "Indica si la solicitud es inapropiada o no relacionada."
},
...filteredProperties // Aquí entran objetivo, contenido, etc.
},
// Forzamos que ai-message e is_refusal sean obligatorios siempre
required: ["ai-message", "is_refusal", ...Object.keys(filteredProperties)],
additionalProperties: false
};
return finalSchema;
}
export function safePlanForPrompt(plan: any) {
const copy = structuredClone(plan);
@@ -0,0 +1,90 @@
// ./plan_mensajes_ia/index.ts
import type { OpenAI } from "openai";
import type { Json } from "../../_shared/database.types.ts";
import { ResponseMetadata } from "../../_shared/utils.ts";
import { supabase } from "../../openai-webhook-responses/supabase.ts";
function extractOutputText(response: OpenAI.Responses.Response): string {
const direct = (response as unknown as { output_text?: unknown }).output_text;
if (typeof direct === "string") return direct;
const output = (response as unknown as { output?: unknown }).output;
if (!Array.isArray(output)) return "";
// Fallback similar al usado en index.ts
try {
return output
.filter((item) => (item as { type?: unknown })?.type === "message")
.flatMap((item) => (item as { content?: unknown })?.content ?? [])
.filter((c) => (c as { type?: unknown })?.type === "output_text")
.map((c) => String((c as { text?: unknown })?.text ?? ""))
.join("");
} catch {
return "";
}
}
export async function handlePlanMensajesResponse(
response: OpenAI.Responses.Response,
): Promise<void> {
const metadata = response.metadata as any;
const mensajeId = metadata?.mensaje_id;
console.log("ya entre aqui");
const isStructured = metadata?.is_structured === "true" || metadata?.is_structured === true;
if (!mensajeId) {
console.warn("No se recibió mensaje_id en la metadata del webhook");
return;
}
try {
const outputText = extractOutputText(response);
if (!outputText) {
throw new Error("La respuesta de OpenAI está vacía");
}
let respuestaJSON: any;
try {
respuestaJSON = JSON.parse(outputText);
} catch (e) {
throw new Error(`Error parseando JSON de OpenAI: ${e.message}`);
}
const is_refusal = !!respuestaJSON.is_refusal || respuestaJSON["is-refusal"] === true;
let recommendations = [];
if (isStructured && !is_refusal) {
recommendations = Object.entries(respuestaJSON)
.filter(([k]) => k !== "ai-message" && k !== "is-refusal" && k !== "is_refusal")
.map(([campo, valor]) => ({
campo_afectado: campo,
texto_mejora: valor,
aplicada: false,
}));
}
const { error } = await supabase
.from("plan_mensajes_ia")
.update({
respuesta: respuestaJSON["ai-message"] || "",
propuesta: { recommendations },
is_refusal,
estado: "COMPLETADO",
})
.eq("id", mensajeId);
if (error) {
throw error;
}
} catch (e) {
console.error("Error procesando handlePlanMensajesResponse:", { mensajeId, e });
// Opcional: Marcar el mensaje como fallido en la tabla si tienes ese estado
await supabase
.from("plan_mensajes_ia")
.update({ estado: "ERROR" })
.eq("id", mensajeId);
}
}
@@ -10,6 +10,13 @@ import {
type StructuredResponseOptions,
} from "../_shared/openai-service.ts";
addEventListener("beforeunload", (ev: any) => {
console.error(
"ALERTA: La función se va a apagar. Razón:",
ev?.detail?.reason,
);
});
export type DataAsignaturaSugerida = {
nombre: Tables<"asignaturas">["nombre"];
codigo?: Tables<"asignaturas">["codigo"];
@@ -160,6 +167,11 @@ Deno.serve(async (req: Request): Promise<Response> => {
SERVICE_ROLE_KEY,
);
// Model name controlled via env var
const GENERATE_SUBJECT_SUGGESTIONS_MODELO = Deno.env.get(
"GENERATE_SUBJECT_SUGGESTIONS_MODELO",
) ?? "gpt-5-mini";
const { data: plan, error: planError } = await supabaseService
.from("planes_estudio")
.select("id,nombre,nivel,tipo_ciclo,numero_ciclos,datos")
@@ -276,7 +288,7 @@ Deno.serve(async (req: Request): Promise<Response> => {
}
const options: StructuredResponseOptions = {
model: "gpt-5-mini",
model: GENERATE_SUBJECT_SUGGESTIONS_MODELO,
input: [
{ role: "system", content: systemPrompt },
{
@@ -1,118 +0,0 @@
// ...existing code...
import type { DataAsignaturaSugerida } from "./index.ts";
export const sugerencias: DataAsignaturaSugerida[][] = [
[
{
nombre: "Transferencia de Calor",
tipo: "TRONCAL",
creditos: 5,
descripcion:
"Conducción, convección y radiación; diseño de aletas e intercambiadores; análisis transitorio y numérico en equipos térmicos.",
horasAcademicas: 43,
horasIndependientes: 35,
},
{
nombre: "Mecánica de Fluidos II",
tipo: "TRONCAL",
creditos: 4.5,
descripcion:
"Flujo turbulento, capas límite, turbomáquinas y redes de tuberías; medición y control de caudal; CFD básico para aplicaciones mecánicas.",
horasAcademicas: 43,
horasIndependientes: 34,
},
{
nombre: "Diseño de Elementos de Máquinas I",
tipo: "TRONCAL",
creditos: 5,
descripcion:
"Selección y dimensionamiento de ejes, tornillos, resortes, engranajes y uniones; criterios de falla estática y a fatiga; normas y materiales.",
horasAcademicas: 43,
horasIndependientes: 35,
},
{
nombre: "Dinámica de Máquinas",
tipo: "TRONCAL",
creditos: 4.5,
descripcion:
"Modelado y análisis de mecanismos, cinemática y dinámica multicuerpo; fuerzas inerciales, equilibrado, resortes y amortiguadores, trenes de leva.",
horasAcademicas: 43,
horasIndependientes: 34,
},
{
nombre: "Vibraciones Mecánicas",
tipo: "TRONCAL",
creditos: 4,
descripcion:
"Vibraciones libres y forzadas en 1 y múltiples grados; amortiguamiento, resonancia, aislamiento y balanceo; análisis modal y mediciones.",
horasAcademicas: 33,
horasIndependientes: 33,
},
{
nombre: "Control Automático para Sistemas Mecánicos",
tipo: "TRONCAL",
creditos: 4,
descripcion:
"Modelado LTI, funciones de transferencia y espacio de estados; diseño PID y de compensadores; sensores y actuadores en sistemas mecánicos.",
horasAcademicas: 33,
horasIndependientes: 33,
},
],
[
{
nombre: "Estructuras de Datos y Algoritmos",
tipo: "TRONCAL",
creditos: 6,
descripcion:
"Diseño, análisis e implementación de estructuras de datos (listas, pilas, colas, árboles, grafos, tablas hash) y algoritmos fundamentales; complejidad temporal y espacial; aplicación a resolución eficiente de problemas.",
horasAcademicas: 60,
horasIndependientes: 30,
},
{
nombre: "Programación Orientada a Objetos",
tipo: "OBLIGATORIA",
creditos: 5,
descripcion:
"Principios y prácticas de la programación orientada a objetos: clases, objetos, herencia, polimorfismo, diseño basado en patrones, pruebas unitarias y gestión de proyectos simples en lenguajes modernos.",
horasAcademicas: 50,
horasIndependientes: 25,
},
{
nombre: "Matemáticas Discretas",
tipo: "TRONCAL",
creditos: 4,
descripcion:
"Conceptos fundamentales: lógica proposicional y de predicados, teoría de conjuntos, relaciones y funciones, combinatoria, teoría de grafos y demostraciones formales aplicadas a la computación.",
horasAcademicas: 40,
horasIndependientes: 20,
},
{
nombre: "Circuitos Digitales y Lógica",
tipo: "OBLIGATORIA",
creditos: 4,
descripcion:
"Análisis y diseño de circuitos digitales: álgebra booleana, puertas lógicas, minimización, diseño secuencial y combinacional, máquinas de estados y uso de herramientas de simulación.",
horasAcademicas: 40,
horasIndependientes: 20,
},
{
nombre: "Señales y Sistemas Básicos",
tipo: "TRONCAL",
creditos: 4,
descripcion:
"Introducción a señales y sistemas: representación de señales, sistemas lineales invariantes en el tiempo, transformadas (Fourier, Laplace) y aplicaciones básicas en procesamiento y comunicaciones.",
horasAcademicas: 40,
horasIndependientes: 20,
},
{
nombre: "Laboratorio de Programación y Prácticas de Ingeniería",
tipo: "OPTATIVA",
creditos: 3,
descripcion:
"Asignatura práctica orientada a aplicar técnicas de programación, integración de software y herramientas de desarrollo; proyectos cortos que consolidan conceptos de algoritmos y diseño de software.",
horasAcademicas: 30,
horasIndependientes: 15,
},
],
];
// ...existing code...
@@ -0,0 +1,3 @@
# Configuration for private npm package dependencies
# For more information on using private registries with Edge Functions, see:
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries
@@ -0,0 +1,209 @@
import type { OpenAI } from "openai";
import { supabase } from "../supabase.ts";
import type { Json } from "../../_shared/database.types.ts";
import { ResponseMetadata } from "../../_shared/utils.ts";
function extractOutputText(response: OpenAI.Responses.Response): string {
const direct = (response as unknown as { output_text?: unknown }).output_text;
if (typeof direct === "string") return direct;
const output = (response as unknown as { output?: unknown }).output;
if (!Array.isArray(output)) return "";
try {
return output
.filter((item) => (item as { type?: unknown })?.type === "message")
.flatMap((item) => (item as { content?: unknown })?.content ?? [])
.filter((c) => (c as { type?: unknown })?.type === "output_text")
.map((c) => String((c as { text?: unknown })?.text ?? ""))
.join("");
} catch {
return "";
}
}
function splitAiOutputStringsAndColumns(
aiOutput: unknown,
): { aiOutputJson: Json; columnasGeneradas: Record<string, unknown> } {
if (!aiOutput || typeof aiOutput !== "object" || Array.isArray(aiOutput)) {
return { aiOutputJson: aiOutput as unknown as Json, columnasGeneradas: {} };
}
const record = aiOutput as Record<string, unknown>;
const stringsOnly: Record<string, string> = {};
const columnasGeneradas: Record<string, unknown> = {};
for (const [key, value] of Object.entries(record)) {
if (typeof value === "string") {
stringsOnly[key] = value;
} else {
columnasGeneradas[key] = value;
}
}
return { aiOutputJson: stringsOnly as unknown as Json, columnasGeneradas };
}
async function marcarFalloAsignatura(
asignaturaId: string,
reason: string,
extra?: unknown,
): Promise<void> {
try {
const { data: existing, error: existingError } = await supabase
.from("asignaturas")
.select("meta_origen")
.eq("id", asignaturaId)
.maybeSingle();
if (existingError) {
console.warn("No se pudo leer meta_origen para marcar fallo", {
asignaturaId,
existingError,
});
}
const baseMeta = (existing?.meta_origen &&
typeof existing.meta_origen === "object" &&
!Array.isArray(existing.meta_origen))
? (existing.meta_origen as Record<string, unknown>)
: {};
const nextMeta: Record<string, unknown> = {
...baseMeta,
ai_error: {
reason,
extra: extra ?? null,
at: new Date().toISOString(),
},
};
const { error } = await supabase
.from("asignaturas")
.update({ estado: "borrador", meta_origen: nextMeta as unknown as Json })
.eq("id", asignaturaId);
if (error) {
console.warn("No se pudo marcar fallo en asignatura", {
asignaturaId,
error,
});
}
} catch (e) {
console.warn("Fallo inesperado marcando fallo en asignatura", {
asignaturaId,
e,
});
}
}
export async function handleCrearAsignaturaResponse(
response: OpenAI.Responses.Response,
): Promise<void> {
const metadata = response.metadata as ResponseMetadata | null;
const asignaturaId = metadata?.id;
if (!asignaturaId) {
console.warn("No se recibió metadata.id para actualizar la asignatura");
return;
}
try {
const outputText = extractOutputText(response);
if (!outputText) {
console.warn("La respuesta no contiene output_text");
await marcarFalloAsignatura(asignaturaId, "MISSING_OUTPUT_TEXT", {
responseId: response.id,
});
return;
}
let aiOutput: unknown;
try {
aiOutput = JSON.parse(outputText);
} catch (e) {
console.warn("No se pudo parsear JSON de la respuesta", e);
await marcarFalloAsignatura(asignaturaId, "INVALID_JSON", {
responseId: response.id,
outputText,
});
return;
}
const { aiOutputJson, columnasGeneradas } = splitAiOutputStringsAndColumns(
aiOutput,
);
const { data: existing, error: existingError } = await supabase
.from("asignaturas")
.select("meta_origen")
.eq("id", asignaturaId)
.maybeSingle();
if (existingError) {
console.warn("No se pudo leer meta_origen existente", {
asignaturaId,
existingError,
});
}
const baseMeta = (existing?.meta_origen &&
typeof existing.meta_origen === "object" &&
!Array.isArray(existing.meta_origen))
? (existing.meta_origen as Record<string, unknown>)
: {};
const nextMeta: Record<string, unknown> = {
...baseMeta,
ai: {
...(typeof baseMeta.ai === "object" && baseMeta.ai &&
!Array.isArray(baseMeta.ai)
? (baseMeta.ai as Record<string, unknown>)
: {}),
responseId: response.id,
model: response.model,
},
};
const updatePatch: Record<string, unknown> = {
datos: aiOutputJson,
estado: "borrador",
tipo_origen: "IA",
meta_origen: nextMeta as unknown as Json,
};
for (const value of Object.values(columnasGeneradas)) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
continue;
}
const xColumn = (value as Record<string, unknown>)["x-column"];
const xDef = (value as Record<string, unknown>)["x-definicion"];
if (typeof xColumn !== "string" || !xColumn.length) continue;
updatePatch[xColumn] = xDef as unknown as Json;
}
const { error: updateError } = await supabase
.from("asignaturas")
.update(
updatePatch as unknown as {
[k: string]: unknown;
},
)
.eq("id", asignaturaId);
if (updateError) {
console.warn("No se pudo actualizar asignatura con datos", {
asignaturaId,
updateError,
});
await marcarFalloAsignatura(asignaturaId, "SUPABASE_UPDATE_FAILED", {
updateError,
});
return;
}
} catch (e) {
console.warn("Fallo inesperado procesando asignatura", {
asignaturaId,
e,
});
await marcarFalloAsignatura(asignaturaId, "UNEXPECTED", { e });
return;
}
}
@@ -0,0 +1,25 @@
import type { OpenAI } from "openai";
import { ResponseMetadata } from "../../_shared/utils.ts";
import { handleCrearAsignaturaResponse } from "./crear.ts";
export async function handleAsignaturasResponse(
response: OpenAI.Responses.Response,
): Promise<void> {
const metadata = response.metadata as ResponseMetadata | null;
const accion = metadata?.accion;
if (!accion) {
console.warn("No se recibió metadata.accion para asignaturas");
return;
}
switch (accion) {
case "crear":
case "actualizar":
await handleCrearAsignaturaResponse(response);
return;
default:
console.warn("Acción no reconocida para asignaturas:", accion);
return;
}
}
@@ -0,0 +1,8 @@
{
"imports": {
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
"@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts",
"express": "npm:express@5.2.1",
"openai": "npm:openai@6.16.0"
}
}
@@ -0,0 +1,131 @@
// 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.
// Setup type definitions for built-in Supabase Runtime APIs
import "@supabase/functions-js/edge-runtime.d.ts";
import OpenAI from "openai";
import { ResponseMetadata } from "../_shared/utils.ts";
import { handlePlanesEstudioResponse } from "./planes_estudio/index.ts";
import { handleAsignaturasResponse } from "./asignaturas/index.ts";
import { handlePlanMensajesResponse } from "../create-chat-conversation/plan/crear.ts";
import { handleAsignaturaMensajesResponse } from "../create-chat-conversation/asignatura/crear.ts";
console.log("Starting OpenAI webhook responses function");
const client = new OpenAI({
webhookSecret: Deno.env.get("OPENAI_WEBHOOK_SECRET"),
});
async function handleCompletedResponse(
event: OpenAI.Webhooks.ResponseCompletedWebhookEvent,
) {
const response_id = event.data.id;
const response = await client.responses.retrieve(response_id);
const metadata = response.metadata as ResponseMetadata | null;
console.log(("entre"));
if (!metadata || !metadata.tabla) {
console.warn("No se recibió metadata o tabla en la respuesta");
return;
}
switch (metadata.tabla) {
case "planes_estudio":
await handlePlanesEstudioResponse(response);
break;
case "asignaturas":
await handleAsignaturasResponse(response);
break;
case "plan_mensajes_ia":
await handlePlanMensajesResponse(response);
break;
case "asignatura_mensajes_ia":
console.log("modificando asignatura");
await handleAsignaturaMensajesResponse(response);
break;
default:
console.warn("Tabla no reconocida:", metadata.tabla);
}
const direct = (response as unknown as { output_text?: unknown }).output_text;
if (typeof direct === "string" && direct.length) {
console.log("Response output:", direct);
return;
}
const output = (response as unknown as { output?: unknown }).output;
if (!Array.isArray(output)) {
console.log("Response output: (no output)");
return;
}
const outputText = output
.filter((item) => (item as { type?: unknown })?.type === "message")
.flatMap((item) => (item as { content?: unknown })?.content ?? [])
.filter((c) => (c as { type?: unknown })?.type === "output_text")
.map((c) => String((c as { text?: unknown })?.text ?? ""))
.join("");
console.log("Response output:", outputText);
}
Deno.serve(async (req) => {
const url = new URL(req.url);
const functionName = url.pathname.split("/").pop();
console.log(
`[${new Date().toISOString()}][${functionName}]: Request received`,
);
// Opcional: Solo permitir POST
if (req.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
try {
// OpenAI requiere el body como texto crudo (string) para verificar la firma criptográfica
const payload = await req.text();
// Validar firma y parsear el evento. Deno maneja req.headers compatiblemente
const event = await client.webhooks.unwrap(payload, req.headers);
switch (event.type) {
case "response.completed": {
EdgeRuntime.waitUntil(handleCompletedResponse(event));
break;
}
case "response.cancelled": {
console.warn("Response cancelled:", event.data);
break;
}
case "response.failed": {
console.error("Response failed:", event.data);
break;
}
case "response.incomplete": {
console.warn("Response incomplete:", event.data);
break;
}
default: {
throw new Error(`Unhandled event type: ${event.type}`);
}
}
console.log(
`[${
new Date().toISOString()
}][${functionName}]: Request processed successfully`,
);
return new Response("OK", { status: 200 });
} catch (error) {
if (error instanceof OpenAI.InvalidWebhookSignatureError) {
console.error("Invalid signature:", error.message);
return new Response("Invalid signature", { status: 400 });
} else {
console.error("Internal Error:", error);
return new Response("Internal Server Error", { status: 500 });
}
}
});
@@ -0,0 +1,108 @@
import type { OpenAI } from "openai";
import { supabase } from "../supabase.ts";
import type { Json } from "../../_shared/database.types.ts";
import { ResponseMetadata } from "../../_shared/utils.ts";
async function getEstadoId(clave: string): Promise<string | null> {
const { data, error } = await supabase
.from("estados_plan")
.select("id")
.eq("clave", clave)
.maybeSingle();
if (error) {
console.warn("No se pudo obtener estado:", clave, error);
return null;
}
return data?.id ?? null;
}
async function marcarFallido(planId: string): Promise<void> {
const fallId = await getEstadoId("FALLDA");
if (!fallId) {
console.warn("No existe estado FALLDA; no se pudo marcar fallo", {
planId,
});
return;
}
const { error } = await supabase
.from("planes_estudio")
.update({ estado_actual_id: fallId })
.eq("id", planId);
if (error) {
console.warn("No se pudo marcar plan como FALLDA", { planId, error });
}
}
function extractOutputText(response: OpenAI.Responses.Response): string {
const direct = (response as unknown as { output_text?: unknown }).output_text;
if (typeof direct === "string") return direct;
const output = (response as unknown as { output?: unknown }).output;
if (!Array.isArray(output)) return "";
// Fallback similar al usado en index.ts
try {
return output
.filter((item) => (item as { type?: unknown })?.type === "message")
.flatMap((item) => (item as { content?: unknown })?.content ?? [])
.filter((c) => (c as { type?: unknown })?.type === "output_text")
.map((c) => String((c as { text?: unknown })?.text ?? ""))
.join("");
} catch {
return "";
}
}
export async function handleCrearPlanEstudio(
response: OpenAI.Responses.Response,
): Promise<void> {
const metadata = response.metadata as ResponseMetadata | null;
const planId = metadata?.id;
if (!planId) {
console.warn("No se recibió metadata.id para actualizar el plan");
return;
}
try {
const borradorId = await getEstadoId("BORRADOR");
if (!borradorId) {
console.warn("No existe estado BORRADOR");
await marcarFallido(planId);
return;
}
const outputText = extractOutputText(response);
if (!outputText) {
console.warn("La respuesta no contiene output_text");
await marcarFallido(planId);
return;
}
let datos: Json;
try {
datos = JSON.parse(outputText) as Json;
} catch (e) {
console.warn("No se pudo parsear JSON de la respuesta", e);
await marcarFallido(planId);
return;
}
const { error } = await supabase
.from("planes_estudio")
.update({ datos, estado_actual_id: borradorId })
.eq("id", planId);
if (error) {
console.warn("No se pudo actualizar el plan con datos", {
planId,
error,
});
await marcarFallido(planId);
return;
}
} catch (e) {
console.warn("Fallo inesperado procesando plan", { planId, e });
await marcarFallido(planId);
return;
}
}
@@ -0,0 +1,27 @@
import { ResponseMetadata } from "../../_shared/utils.ts";
import type { OpenAI } from "openai";
import { handleCrearPlanEstudio } from "./crear.ts";
export async function handlePlanesEstudioResponse(
response: OpenAI.Responses.Response,
): Promise<void> {
const metadata = response.metadata as ResponseMetadata | null;
if (!metadata || !metadata.accion) {
console.warn("No se recibió acción en la metadata");
return;
}
switch (metadata.accion) {
case "crear":
await handleCrearPlanEstudio(response);
return;
case "actualizar":
// Lógica para actualizar un plan de estudio
return;
case "eliminar":
// Lógica para eliminar un plan de estudio
return;
default:
console.warn("Acción no reconocida:", metadata.accion);
}
}
@@ -0,0 +1,19 @@
import { createClient } from "@supabase/supabase-js";
import type { Database } from "../_shared/database.types.ts";
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!SUPABASE_URL || (!SUPABASE_SERVICE_ROLE_KEY && !SUPABASE_ANON_KEY)) {
console.warn("Missing Supabase environment variables");
}
const supabaseKey = (SUPABASE_SERVICE_ROLE_KEY ?? SUPABASE_ANON_KEY) as string;
const supabase = createClient<Database>(
SUPABASE_URL as string,
supabaseKey,
);
export { supabase };
@@ -141,7 +141,7 @@ Deno.test("ai-structured (multipart + file)", async () => {
assert(typeof data.output?.resumen === "string");
assert(data.output.resumen.length > 0);
// opcional: valida que registró upload(s) a storage
assert(Array.isArray(data.references?.uploadedToStorage));
// opcional: valida que registró upload(s) a OpenAI files
assert(Array.isArray(data.references?.openaiFileIds));
});
*/
@@ -160,6 +160,6 @@ Deno.test({
assert(typeof data.output?.resumen === "string");
assert(data.output.resumen.length > 0);
// opcional: valida que registró upload(s) a storage
assert(Array.isArray(data.references?.uploadedToStorage));
// opcional: valida que registró upload(s) a OpenAI files
assert(Array.isArray(data.references?.openaiFileIds));
});
@@ -1,71 +0,0 @@
alter type "public"."tipo_cambio" rename to "tipo_cambio__old_version_to_be_dropped";
create type "public"."tipo_cambio" as enum ('ACTUALIZACION_CAMPO', 'ACTUALIZACION_MAPA', 'TRANSICION_ESTADO', 'OTRO', 'CREACION');
alter table "public"."cambios_asignatura" alter column tipo type "public"."tipo_cambio" using tipo::text::"public"."tipo_cambio";
alter table "public"."cambios_plan" alter column tipo type "public"."tipo_cambio" using tipo::text::"public"."tipo_cambio";
drop type "public"."tipo_cambio__old_version_to_be_dropped";
set check_function_bodies = off;
create or replace view "public"."plantilla_plan" as SELECT plan.id AS plan_estudio_id,
struct.id AS estructura_id,
struct.template_id
FROM (public.planes_estudio plan
JOIN public.estructuras_plan struct ON ((plan.estructura_id = struct.id)));
CREATE OR REPLACE FUNCTION public.set_actualizado_en()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
begin
new.actualizado_en = now();
return new;
end;
$function$
;
CREATE OR REPLACE FUNCTION public.unaccent_immutable(text)
RETURNS text
LANGUAGE sql
IMMUTABLE PARALLEL SAFE STRICT
AS $function$
SELECT public.unaccent('public.unaccent', $1);
$function$
;
CREATE OR REPLACE FUNCTION public.validar_numero_ciclo_asignatura()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
declare
v_numero_ciclos int;
begin
if new.numero_ciclo is null then
return new;
end if;
select pe.numero_ciclos into v_numero_ciclos
from planes_estudio pe
where pe.id = new.plan_estudio_id;
if v_numero_ciclos is null then
raise exception 'plan_estudio_id inválido %, plan no encontrado', new.plan_estudio_id;
end if;
if new.numero_ciclo < 1 then
raise exception 'numero_ciclo debe ser >= 1 (recibido %)', new.numero_ciclo;
end if;
if new.numero_ciclo > v_numero_ciclos then
raise exception 'numero_ciclo % excede planes_estudio.numero_ciclos % para plan_estudio_id %',
new.numero_ciclo, v_numero_ciclos, new.plan_estudio_id;
end if;
return new;
end;
$function$
;
@@ -1,21 +0,0 @@
-- alter table "public"."asignaturas" drop constraint "asignaturas_facultad_propietaria_id_fkey";
-- alter table "public"."asignaturas" drop constraint "asignaturas_horas_semana_check";
-- alter table "public"."asignaturas" drop column "facultad_propietaria_id";
alter table "public"."asignaturas" drop column "horas_semana";
alter table "public"."asignaturas" add column "horas_academicas" integer;
alter table "public"."asignaturas" add column "horas_independientes" integer;
alter table "public"."asignaturas" add constraint "asignaturas_horas_academicas_check" CHECK (((horas_academicas IS NULL) OR (horas_academicas >= 0))) not valid;
alter table "public"."asignaturas" validate constraint "asignaturas_horas_academicas_check";
alter table "public"."asignaturas" add constraint "asignaturas_horas_independientes_check" CHECK (((horas_independientes IS NULL) OR (horas_independientes >= 0))) not valid;
alter table "public"."asignaturas" validate constraint "asignaturas_horas_independientes_check";
@@ -1,205 +0,0 @@
create extension if not exists "http" with schema "extensions";
alter table "public"."asignaturas" drop constraint if exists "asignaturas_facultad_propietaria_id_fkey";
alter table "public"."cambios_plan" drop constraint "cambios_plan_plan_estudio_id_fkey";
alter type "public"."tipo_cambio" rename to "tipo_cambio__old_version_to_be_dropped";
create type "public"."tipo_cambio" as enum ('ACTUALIZACION_CAMPO', 'ACTUALIZACION_MAPA', 'TRANSICION_ESTADO', 'OTRO', 'CREACION', 'ACTUALIZACION');
alter table "public"."cambios_asignatura" alter column tipo type "public"."tipo_cambio" using tipo::text::"public"."tipo_cambio";
alter table "public"."cambios_plan" alter column tipo type "public"."tipo_cambio" using tipo::text::"public"."tipo_cambio";
drop type "public"."tipo_cambio__old_version_to_be_dropped";
alter table "public"."asignaturas" drop column if exists "facultad_propietaria_id";
alter table "public"."asignaturas" add column "asignatura_hash" text generated always as (encode(SUBSTRING(extensions.digest((id)::text, 'sha512'::text) FROM 1 FOR 12), 'hex'::text)) stored;
alter table "public"."cambios_plan" drop column "interaccion_ia_id";
alter table "public"."cambios_plan" add column "response_id" text;
alter table "public"."planes_estudio" add column "conversation_id" text;
alter table "public"."planes_estudio" add column "plan_hash" text generated always as (encode(SUBSTRING(extensions.digest((id)::text, 'sha512'::text) FROM 1 FOR 12), 'hex'::text)) stored;
CREATE UNIQUE INDEX planes_estudio_conversation_id_key ON public.planes_estudio USING btree (conversation_id);
alter table "public"."planes_estudio" add constraint "planes_estudio_conversation_id_key" UNIQUE using index "planes_estudio_conversation_id_key";
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.fn_log_cambios_planes_estudio()
RETURNS trigger
LANGUAGE plpgsql
AS $function$declare
k text;
old_val jsonb;
new_val jsonb;
v_response_id text;
begin
v_response_id := nullif(new.meta_origen->>'response_id','');
-- INSERT -> CREACION
if tg_op = 'INSERT' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
new.id,
new.creado_por,
'CREACION'::public.tipo_cambio,
null,
null,
to_jsonb(new),
null
);
return new;
end if;
-- DELETE (opcional): si no lo quieres, bórralo
if tg_op = 'DELETE' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
old.id,
old.actualizado_por,
'OTRO'::public.tipo_cambio,
'DELETE',
to_jsonb(old),
null,
null
);
return old;
end if;
-- UPDATE ----------------------------------------------------------
-- 1) Transición de estado
if (new.estado_actual_id is distinct from old.estado_actual_id) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'TRANSICION_ESTADO'::public.tipo_cambio,
'estado_actual_id',
to_jsonb(old.estado_actual_id),
to_jsonb(new.estado_actual_id),
null
);
end if;
-- 2) Cambios en JSONB "datos" (diff top-level por llave)
if (new.datos is distinct from old.datos) then
for k in
select distinct key
from (
select jsonb_object_keys(coalesce(old.datos, '{}'::jsonb)) as key
union all
select jsonb_object_keys(coalesce(new.datos, '{}'::jsonb)) as key
) t
loop
old_val := coalesce(old.datos, '{}'::jsonb) -> k;
new_val := coalesce(new.datos, '{}'::jsonb) -> k;
if (old_val is distinct from new_val) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'ACTUALIZACION_CAMPO'::public.tipo_cambio,
k,
old_val,
new_val,
v_response_id
);
end if;
end loop;
end if;
-- 3) Cambios en columnas "normales" (uno por columna)
if (new.nombre is distinct from old.nombre) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nombre', to_jsonb(old.nombre), to_jsonb(new.nombre), null);
end if;
if (new.nivel is distinct from old.nivel) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nivel', to_jsonb(old.nivel), to_jsonb(new.nivel), null);
end if;
if (new.tipo_ciclo is distinct from old.tipo_ciclo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_ciclo', to_jsonb(old.tipo_ciclo), to_jsonb(new.tipo_ciclo), null);
end if;
if (new.numero_ciclos is distinct from old.numero_ciclos) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'numero_ciclos', to_jsonb(old.numero_ciclos), to_jsonb(new.numero_ciclos), null);
end if;
if (new.activo is distinct from old.activo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'activo', to_jsonb(old.activo), to_jsonb(new.activo), null);
end if;
if (new.carrera_id is distinct from old.carrera_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'carrera_id', to_jsonb(old.carrera_id), to_jsonb(new.carrera_id), null);
end if;
if (new.estructura_id is distinct from old.estructura_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'estructura_id', to_jsonb(old.estructura_id), to_jsonb(new.estructura_id), null);
end if;
if (new.tipo_origen is distinct from old.tipo_origen) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_origen', to_jsonb(old.tipo_origen), to_jsonb(new.tipo_origen), null);
end if;
if (new.conversation_id is distinct from old.conversation_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'conversation_id', to_jsonb(old.conversation_id), to_jsonb(new.conversation_id), null);
end if;
-- 🔥 consumirlo para que NO se guarde en planes_estudio
if v_response_id is not null then
new.meta_origen := new.meta_origen - 'response_id';
end if;
return new;
end;$function$
;
CREATE TRIGGER "agregar-conversation_id-asignaturas" AFTER INSERT ON public.asignaturas FOR EACH ROW EXECUTE FUNCTION supabase_functions.http_request('https://exdkssurzmjnnhgtiama.supabase.co/functions/v1/create-chat-conversation', 'POST', '{"Content-type":"application/json","Authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4ZGtzc3Vyem1qbm5oZ3RpYW1hIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTM3ODYzMiwiZXhwIjoyMDU2OTU0NjMyfQ.s-GHuwnYbIYoMZN9dFbAKgxNyAtQllRCRPLy-GIRaro"}', '{}', '5000');
CREATE TRIGGER "agregar-conversation_id-planes_estudio" AFTER INSERT ON public.planes_estudio FOR EACH ROW EXECUTE FUNCTION supabase_functions.http_request('https://exdkssurzmjnnhgtiama.supabase.co/functions/v1/create-chat-conversation', 'POST', '{"Content-type":"application/json","Authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4ZGtzc3Vyem1qbm5oZ3RpYW1hIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTM3ODYzMiwiZXhwIjoyMDU2OTU0NjMyfQ.s-GHuwnYbIYoMZN9dFbAKgxNyAtQllRCRPLy-GIRaro"}', '{}', '5000');
CREATE TRIGGER trg_planes_estudio_log_cambios AFTER INSERT OR DELETE OR UPDATE ON public.planes_estudio FOR EACH ROW EXECUTE FUNCTION public.fn_log_cambios_planes_estudio();
@@ -1,208 +0,0 @@
create type "public"."estado_conversacion" as enum ('ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR');
alter table "public"."planes_estudio" drop constraint "planes_estudio_conversation_id_key";
drop view if exists "public"."plantilla_plan";
drop index if exists "public"."planes_estudio_conversation_id_key";
create table "public"."conversaciones_asignatura" (
"id" uuid not null default gen_random_uuid(),
"asignatura_id" uuid not null,
"openai_conversation_id" text not null,
"estado" public.estado_conversacion not null default 'ACTIVA'::public.estado_conversacion,
"conversacion_json" jsonb not null default '{}'::jsonb,
"creado_por" uuid,
"creado_en" timestamp with time zone not null default now(),
"archivado_por" uuid,
"archivado_en" timestamp with time zone,
"intento_archivado" integer not null default 0
);
create table "public"."conversaciones_plan" (
"id" uuid not null default gen_random_uuid(),
"plan_estudio_id" uuid not null,
"openai_conversation_id" text not null,
"estado" public.estado_conversacion not null default 'ACTIVA'::public.estado_conversacion,
"conversacion_json" jsonb not null default '{}'::jsonb,
"creado_por" uuid,
"creado_en" timestamp with time zone not null default now(),
"archivado_por" uuid,
"archivado_en" timestamp with time zone,
"intento_archivado" integer not null default 0
);
alter table "public"."planes_estudio" drop column "conversation_id";
CREATE UNIQUE INDEX conversaciones_asignatura_openai_id_unico ON public.conversaciones_asignatura USING btree (openai_conversation_id);
CREATE UNIQUE INDEX conversaciones_asignatura_pkey ON public.conversaciones_asignatura USING btree (id);
CREATE UNIQUE INDEX conversaciones_plan_openai_id_unico ON public.conversaciones_plan USING btree (openai_conversation_id);
CREATE UNIQUE INDEX conversaciones_plan_pkey ON public.conversaciones_plan USING btree (id);
CREATE INDEX idx_conv_asig_asignatura ON public.conversaciones_asignatura USING btree (asignatura_id);
CREATE INDEX idx_conv_asig_estado ON public.conversaciones_asignatura USING btree (estado);
CREATE INDEX idx_conv_plan_estado ON public.conversaciones_plan USING btree (estado);
CREATE INDEX idx_conv_plan_plan_estudio ON public.conversaciones_plan USING btree (plan_estudio_id);
alter table "public"."conversaciones_asignatura" add constraint "conversaciones_asignatura_pkey" PRIMARY KEY using index "conversaciones_asignatura_pkey";
alter table "public"."conversaciones_plan" add constraint "conversaciones_plan_pkey" PRIMARY KEY using index "conversaciones_plan_pkey";
alter table "public"."conversaciones_asignatura" add constraint "conversaciones_asignatura_archivado_por_fkey" FOREIGN KEY (archivado_por) REFERENCES public.usuarios_app(id) ON DELETE SET NULL not valid;
alter table "public"."conversaciones_asignatura" validate constraint "conversaciones_asignatura_archivado_por_fkey";
alter table "public"."conversaciones_asignatura" add constraint "conversaciones_asignatura_asignatura_id_fkey" FOREIGN KEY (asignatura_id) REFERENCES public.asignaturas(id) ON DELETE CASCADE not valid;
alter table "public"."conversaciones_asignatura" validate constraint "conversaciones_asignatura_asignatura_id_fkey";
alter table "public"."conversaciones_asignatura" add constraint "conversaciones_asignatura_creado_por_fkey" FOREIGN KEY (creado_por) REFERENCES public.usuarios_app(id) ON DELETE SET NULL not valid;
alter table "public"."conversaciones_asignatura" validate constraint "conversaciones_asignatura_creado_por_fkey";
alter table "public"."conversaciones_asignatura" add constraint "conversaciones_asignatura_openai_id_unico" UNIQUE using index "conversaciones_asignatura_openai_id_unico";
alter table "public"."conversaciones_plan" add constraint "conversaciones_plan_archivado_por_fkey" FOREIGN KEY (archivado_por) REFERENCES public.usuarios_app(id) ON DELETE SET NULL not valid;
alter table "public"."conversaciones_plan" validate constraint "conversaciones_plan_archivado_por_fkey";
alter table "public"."conversaciones_plan" add constraint "conversaciones_plan_creado_por_fkey" FOREIGN KEY (creado_por) REFERENCES public.usuarios_app(id) ON DELETE SET NULL not valid;
alter table "public"."conversaciones_plan" validate constraint "conversaciones_plan_creado_por_fkey";
alter table "public"."conversaciones_plan" add constraint "conversaciones_plan_openai_id_unico" UNIQUE using index "conversaciones_plan_openai_id_unico";
alter table "public"."conversaciones_plan" add constraint "conversaciones_plan_plan_estudio_id_fkey" FOREIGN KEY (plan_estudio_id) REFERENCES public.planes_estudio(id) ON DELETE CASCADE not valid;
alter table "public"."conversaciones_plan" validate constraint "conversaciones_plan_plan_estudio_id_fkey";
create or replace view "public"."plantilla_plan" as SELECT plan.id AS plan_estudio_id,
struct.id AS estructura_id,
struct.template_id
FROM (public.planes_estudio plan
JOIN public.estructuras_plan struct ON ((plan.estructura_id = struct.id)));
grant delete on table "public"."conversaciones_asignatura" to "anon";
grant insert on table "public"."conversaciones_asignatura" to "anon";
grant references on table "public"."conversaciones_asignatura" to "anon";
grant select on table "public"."conversaciones_asignatura" to "anon";
grant trigger on table "public"."conversaciones_asignatura" to "anon";
grant truncate on table "public"."conversaciones_asignatura" to "anon";
grant update on table "public"."conversaciones_asignatura" to "anon";
grant delete on table "public"."conversaciones_asignatura" to "authenticated";
grant insert on table "public"."conversaciones_asignatura" to "authenticated";
grant references on table "public"."conversaciones_asignatura" to "authenticated";
grant select on table "public"."conversaciones_asignatura" to "authenticated";
grant trigger on table "public"."conversaciones_asignatura" to "authenticated";
grant truncate on table "public"."conversaciones_asignatura" to "authenticated";
grant update on table "public"."conversaciones_asignatura" to "authenticated";
grant delete on table "public"."conversaciones_asignatura" to "postgres";
grant insert on table "public"."conversaciones_asignatura" to "postgres";
grant references on table "public"."conversaciones_asignatura" to "postgres";
grant select on table "public"."conversaciones_asignatura" to "postgres";
grant trigger on table "public"."conversaciones_asignatura" to "postgres";
grant truncate on table "public"."conversaciones_asignatura" to "postgres";
grant update on table "public"."conversaciones_asignatura" to "postgres";
grant delete on table "public"."conversaciones_asignatura" to "service_role";
grant insert on table "public"."conversaciones_asignatura" to "service_role";
grant references on table "public"."conversaciones_asignatura" to "service_role";
grant select on table "public"."conversaciones_asignatura" to "service_role";
grant trigger on table "public"."conversaciones_asignatura" to "service_role";
grant truncate on table "public"."conversaciones_asignatura" to "service_role";
grant update on table "public"."conversaciones_asignatura" to "service_role";
grant delete on table "public"."conversaciones_plan" to "anon";
grant insert on table "public"."conversaciones_plan" to "anon";
grant references on table "public"."conversaciones_plan" to "anon";
grant select on table "public"."conversaciones_plan" to "anon";
grant trigger on table "public"."conversaciones_plan" to "anon";
grant truncate on table "public"."conversaciones_plan" to "anon";
grant update on table "public"."conversaciones_plan" to "anon";
grant delete on table "public"."conversaciones_plan" to "authenticated";
grant insert on table "public"."conversaciones_plan" to "authenticated";
grant references on table "public"."conversaciones_plan" to "authenticated";
grant select on table "public"."conversaciones_plan" to "authenticated";
grant trigger on table "public"."conversaciones_plan" to "authenticated";
grant truncate on table "public"."conversaciones_plan" to "authenticated";
grant update on table "public"."conversaciones_plan" to "authenticated";
grant delete on table "public"."conversaciones_plan" to "postgres";
grant insert on table "public"."conversaciones_plan" to "postgres";
grant references on table "public"."conversaciones_plan" to "postgres";
grant select on table "public"."conversaciones_plan" to "postgres";
grant trigger on table "public"."conversaciones_plan" to "postgres";
grant truncate on table "public"."conversaciones_plan" to "postgres";
grant update on table "public"."conversaciones_plan" to "postgres";
grant delete on table "public"."conversaciones_plan" to "service_role";
grant insert on table "public"."conversaciones_plan" to "service_role";
grant references on table "public"."conversaciones_plan" to "service_role";
grant select on table "public"."conversaciones_plan" to "service_role";
grant trigger on table "public"."conversaciones_plan" to "service_role";
grant truncate on table "public"."conversaciones_plan" to "service_role";
grant update on table "public"."conversaciones_plan" to "service_role";
@@ -1,166 +0,0 @@
drop trigger if exists "agregar-conversation_id-asignaturas" on "public"."asignaturas";
drop trigger if exists "agregar-conversation_id-planes_estudio" on "public"."planes_estudio";
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.fn_log_cambios_planes_estudio()
RETURNS trigger
LANGUAGE plpgsql
AS $function$declare
k text;
old_val jsonb;
new_val jsonb;
v_response_id text;
begin
v_response_id := nullif(new.meta_origen->>'response_id','');
-- INSERT -> CREACION
if tg_op = 'INSERT' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
new.id,
new.creado_por,
'CREACION'::public.tipo_cambio,
null,
null,
to_jsonb(new),
null
);
return new;
end if;
-- DELETE (opcional): si no lo quieres, bórralo
if tg_op = 'DELETE' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
old.id,
old.actualizado_por,
'OTRO'::public.tipo_cambio,
'DELETE',
to_jsonb(old),
null,
null
);
return old;
end if;
-- UPDATE ----------------------------------------------------------
-- 1) Transición de estado
if (new.estado_actual_id is distinct from old.estado_actual_id) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'TRANSICION_ESTADO'::public.tipo_cambio,
'estado_actual_id',
to_jsonb(old.estado_actual_id),
to_jsonb(new.estado_actual_id),
null
);
end if;
-- 2) Cambios en JSONB "datos" (diff top-level por llave)
if (new.datos is distinct from old.datos) then
for k in
select distinct key
from (
select jsonb_object_keys(coalesce(old.datos, '{}'::jsonb)) as key
union all
select jsonb_object_keys(coalesce(new.datos, '{}'::jsonb)) as key
) t
loop
old_val := coalesce(old.datos, '{}'::jsonb) -> k;
new_val := coalesce(new.datos, '{}'::jsonb) -> k;
if (old_val is distinct from new_val) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'ACTUALIZACION_CAMPO'::public.tipo_cambio,
k,
old_val,
new_val,
v_response_id
);
end if;
end loop;
end if;
-- 3) Cambios en columnas "normales" (uno por columna)
if (new.nombre is distinct from old.nombre) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nombre', to_jsonb(old.nombre), to_jsonb(new.nombre), null);
end if;
if (new.nivel is distinct from old.nivel) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nivel', to_jsonb(old.nivel), to_jsonb(new.nivel), null);
end if;
if (new.tipo_ciclo is distinct from old.tipo_ciclo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_ciclo', to_jsonb(old.tipo_ciclo), to_jsonb(new.tipo_ciclo), null);
end if;
if (new.numero_ciclos is distinct from old.numero_ciclos) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'numero_ciclos', to_jsonb(old.numero_ciclos), to_jsonb(new.numero_ciclos), null);
end if;
if (new.activo is distinct from old.activo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'activo', to_jsonb(old.activo), to_jsonb(new.activo), null);
end if;
if (new.carrera_id is distinct from old.carrera_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'carrera_id', to_jsonb(old.carrera_id), to_jsonb(new.carrera_id), null);
end if;
if (new.estructura_id is distinct from old.estructura_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'estructura_id', to_jsonb(old.estructura_id), to_jsonb(new.estructura_id), null);
end if;
if (new.tipo_origen is distinct from old.tipo_origen) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_origen', to_jsonb(old.tipo_origen), to_jsonb(new.tipo_origen), null);
end if;
-- 🔥 consumirlo para que NO se guarde en planes_estudio
if v_response_id is not null then
new.meta_origen := new.meta_origen - 'response_id';
end if;
return new;
end;$function$
;
@@ -1,171 +0,0 @@
create type "public"."estado_asignatura" as enum ('borrador', 'revisada', 'aprobada', 'generando');
alter table "public"."asignaturas" add column "estado" public.estado_asignatura not null default 'generando'::public.estado_asignatura;
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.fn_log_cambios_planes_estudio()
RETURNS trigger
LANGUAGE plpgsql
AS $function$declare
k text;
old_val jsonb;
new_val jsonb;
v_response_id text;
begin
v_response_id := nullif(new.meta_origen->>'response_id','');
-- INSERT -> CREACION
if tg_op = 'INSERT' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
new.id,
new.creado_por,
'CREACION'::public.tipo_cambio,
null,
null,
to_jsonb(new),
null
);
return new;
end if;
-- DELETE (opcional): si no lo quieres, bórralo
if tg_op = 'DELETE' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
old.id,
old.actualizado_por,
'OTRO'::public.tipo_cambio,
'DELETE',
to_jsonb(old),
null,
null
);
return old;
end if;
-- UPDATE ----------------------------------------------------------
-- 1) Transición de estado
if (new.estado_actual_id is distinct from old.estado_actual_id) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'TRANSICION_ESTADO'::public.tipo_cambio,
'estado_actual_id',
to_jsonb(old.estado_actual_id),
to_jsonb(new.estado_actual_id),
null
);
end if;
-- 2) Cambios en JSONB "datos" (diff top-level por llave)
if (new.datos is distinct from old.datos) then
for k in
select distinct key
from (
select jsonb_object_keys(coalesce(old.datos, '{}'::jsonb)) as key
union all
select jsonb_object_keys(coalesce(new.datos, '{}'::jsonb)) as key
) t
loop
old_val := coalesce(old.datos, '{}'::jsonb) -> k;
new_val := coalesce(new.datos, '{}'::jsonb) -> k;
if (old_val is distinct from new_val) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'ACTUALIZACION_CAMPO'::public.tipo_cambio,
k,
old_val,
new_val,
v_response_id
);
end if;
end loop;
end if;
-- 3) Cambios en columnas "normales" (uno por columna)
if (new.nombre is distinct from old.nombre) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nombre', to_jsonb(old.nombre), to_jsonb(new.nombre), null);
end if;
if (new.nivel is distinct from old.nivel) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nivel', to_jsonb(old.nivel), to_jsonb(new.nivel), null);
end if;
if (new.tipo_ciclo is distinct from old.tipo_ciclo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_ciclo', to_jsonb(old.tipo_ciclo), to_jsonb(new.tipo_ciclo), null);
end if;
if (new.numero_ciclos is distinct from old.numero_ciclos) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'numero_ciclos', to_jsonb(old.numero_ciclos), to_jsonb(new.numero_ciclos), null);
end if;
if (new.activo is distinct from old.activo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'activo', to_jsonb(old.activo), to_jsonb(new.activo), null);
end if;
if (new.carrera_id is distinct from old.carrera_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'carrera_id', to_jsonb(old.carrera_id), to_jsonb(new.carrera_id), null);
end if;
if (new.estructura_id is distinct from old.estructura_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'estructura_id', to_jsonb(old.estructura_id), to_jsonb(new.estructura_id), null);
end if;
if (new.tipo_origen is distinct from old.tipo_origen) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_origen', to_jsonb(old.tipo_origen), to_jsonb(new.tipo_origen), null);
end if;
if (new.conversation_id is distinct from old.conversation_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'conversation_id', to_jsonb(old.conversation_id), to_jsonb(new.conversation_id), null);
end if;
-- 🔥 consumirlo para que NO se guarde en planes_estudio
if v_response_id is not null then
new.meta_origen := new.meta_origen - 'response_id';
end if;
return new;
end;$function$
;
@@ -12,6 +12,13 @@ SET client_min_messages = warning;
SET row_security = off;
CREATE EXTENSION IF NOT EXISTS "pg_net" WITH SCHEMA "extensions";
CREATE EXTENSION IF NOT EXISTS "pgsodium";
@@ -23,6 +30,13 @@ COMMENT ON SCHEMA "public" IS 'standard public schema';
CREATE EXTENSION IF NOT EXISTS "http" WITH SCHEMA "extensions";
CREATE EXTENSION IF NOT EXISTS "pg_graphql" WITH SCHEMA "graphql";
@@ -72,6 +86,28 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions";
CREATE TYPE "public"."estado_asignatura" AS ENUM (
'borrador',
'revisada',
'aprobada',
'generando'
);
ALTER TYPE "public"."estado_asignatura" OWNER TO "postgres";
CREATE TYPE "public"."estado_conversacion" AS ENUM (
'ACTIVA',
'ARCHIVANDO',
'ARCHIVADA',
'ERROR'
);
ALTER TYPE "public"."estado_conversacion" OWNER TO "postgres";
CREATE TYPE "public"."estado_tarea_revision" AS ENUM (
'PENDIENTE',
'COMPLETADA',
@@ -151,7 +187,9 @@ CREATE TYPE "public"."tipo_cambio" AS ENUM (
'ACTUALIZACION_CAMPO',
'ACTUALIZACION_MAPA',
'TRANSICION_ESTADO',
'OTRO'
'OTRO',
'CREACION',
'ACTUALIZACION'
);
@@ -222,6 +260,168 @@ CREATE TYPE "public"."tipo_origen" AS ENUM (
ALTER TYPE "public"."tipo_origen" OWNER TO "postgres";
CREATE OR REPLACE FUNCTION "public"."fn_log_cambios_planes_estudio"() RETURNS "trigger"
LANGUAGE "plpgsql"
AS $$declare
k text;
old_val jsonb;
new_val jsonb;
v_response_id text;
begin
v_response_id := nullif(new.meta_origen->>'response_id','');
-- INSERT -> CREACION
if tg_op = 'INSERT' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
new.id,
new.creado_por,
'CREACION'::public.tipo_cambio,
null,
null,
to_jsonb(new),
null
);
return new;
end if;
-- DELETE (opcional): si no lo quieres, bórralo
if tg_op = 'DELETE' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
old.id,
old.actualizado_por,
'OTRO'::public.tipo_cambio,
'DELETE',
to_jsonb(old),
null,
null
);
return old;
end if;
-- UPDATE ----------------------------------------------------------
-- 1) Transición de estado
if (new.estado_actual_id is distinct from old.estado_actual_id) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'TRANSICION_ESTADO'::public.tipo_cambio,
'estado_actual_id',
to_jsonb(old.estado_actual_id),
to_jsonb(new.estado_actual_id),
null
);
end if;
-- 2) Cambios en JSONB "datos" (diff top-level por llave)
if (new.datos is distinct from old.datos) then
for k in
select distinct key
from (
select jsonb_object_keys(coalesce(old.datos, '{}'::jsonb)) as key
union all
select jsonb_object_keys(coalesce(new.datos, '{}'::jsonb)) as key
) t
loop
old_val := coalesce(old.datos, '{}'::jsonb) -> k;
new_val := coalesce(new.datos, '{}'::jsonb) -> k;
if (old_val is distinct from new_val) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'ACTUALIZACION_CAMPO'::public.tipo_cambio,
k,
old_val,
new_val,
v_response_id
);
end if;
end loop;
end if;
-- 3) Cambios en columnas "normales" (uno por columna)
if (new.nombre is distinct from old.nombre) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nombre', to_jsonb(old.nombre), to_jsonb(new.nombre), null);
end if;
if (new.nivel is distinct from old.nivel) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nivel', to_jsonb(old.nivel), to_jsonb(new.nivel), null);
end if;
if (new.tipo_ciclo is distinct from old.tipo_ciclo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_ciclo', to_jsonb(old.tipo_ciclo), to_jsonb(new.tipo_ciclo), null);
end if;
if (new.numero_ciclos is distinct from old.numero_ciclos) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'numero_ciclos', to_jsonb(old.numero_ciclos), to_jsonb(new.numero_ciclos), null);
end if;
if (new.activo is distinct from old.activo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'activo', to_jsonb(old.activo), to_jsonb(new.activo), null);
end if;
if (new.carrera_id is distinct from old.carrera_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'carrera_id', to_jsonb(old.carrera_id), to_jsonb(new.carrera_id), null);
end if;
if (new.estructura_id is distinct from old.estructura_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'estructura_id', to_jsonb(old.estructura_id), to_jsonb(new.estructura_id), null);
end if;
if (new.tipo_origen is distinct from old.tipo_origen) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_origen', to_jsonb(old.tipo_origen), to_jsonb(new.tipo_origen), null);
end if;
-- 🔥 consumirlo para que NO se guarde en planes_estudio
if v_response_id is not null then
new.meta_origen := new.meta_origen - 'response_id';
end if;
return new;
end;$$;
ALTER FUNCTION "public"."fn_log_cambios_planes_estudio"() OWNER TO "postgres";
CREATE OR REPLACE FUNCTION "public"."set_actualizado_en"() RETURNS "trigger"
LANGUAGE "plpgsql"
AS $$
@@ -305,12 +505,10 @@ CREATE TABLE IF NOT EXISTS "public"."asignaturas" (
"id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
"plan_estudio_id" "uuid" NOT NULL,
"estructura_id" "uuid",
"facultad_propietaria_id" "uuid",
"codigo" "text",
"nombre" "text" NOT NULL,
"tipo" "public"."tipo_asignatura" DEFAULT 'OBLIGATORIA'::"public"."tipo_asignatura" NOT NULL,
"creditos" numeric NOT NULL,
"horas_semana" integer,
"numero_ciclo" integer,
"linea_plan_id" "uuid",
"orden_celda" integer,
@@ -322,9 +520,14 @@ CREATE TABLE IF NOT EXISTS "public"."asignaturas" (
"actualizado_por" "uuid",
"creado_en" timestamp with time zone DEFAULT "now"() NOT NULL,
"actualizado_en" timestamp with time zone DEFAULT "now"() NOT NULL,
"horas_academicas" integer,
"horas_independientes" integer,
"asignatura_hash" "text" GENERATED ALWAYS AS ("encode"(SUBSTRING("extensions"."digest"(("id")::"text", 'sha512'::"text") FROM 1 FOR 12), 'hex'::"text")) STORED,
"estado" "public"."estado_asignatura" DEFAULT 'borrador'::"public"."estado_asignatura" NOT NULL,
CONSTRAINT "asignaturas_ciclo_chk" CHECK ((("numero_ciclo" IS NULL) OR ("numero_ciclo" > 0))),
CONSTRAINT "asignaturas_creditos_check" CHECK (("creditos" >= (0)::numeric)),
CONSTRAINT "asignaturas_horas_semana_check" CHECK ((("horas_semana" IS NULL) OR ("horas_semana" >= 0))),
CONSTRAINT "asignaturas_horas_academicas_check" CHECK ((("horas_academicas" IS NULL) OR ("horas_academicas" >= 0))),
CONSTRAINT "asignaturas_horas_independientes_check" CHECK ((("horas_independientes" IS NULL) OR ("horas_independientes" >= 0))),
CONSTRAINT "asignaturas_orden_celda_chk" CHECK ((("orden_celda" IS NULL) OR ("orden_celda" >= 0)))
);
@@ -374,7 +577,7 @@ CREATE TABLE IF NOT EXISTS "public"."cambios_plan" (
"campo" "text",
"valor_anterior" "jsonb",
"valor_nuevo" "jsonb",
"interaccion_ia_id" "uuid"
"response_id" "text"
);
@@ -396,6 +599,40 @@ CREATE TABLE IF NOT EXISTS "public"."carreras" (
ALTER TABLE "public"."carreras" OWNER TO "postgres";
CREATE TABLE IF NOT EXISTS "public"."conversaciones_asignatura" (
"id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
"asignatura_id" "uuid" NOT NULL,
"openai_conversation_id" "text" NOT NULL,
"estado" "public"."estado_conversacion" DEFAULT 'ACTIVA'::"public"."estado_conversacion" NOT NULL,
"conversacion_json" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL,
"creado_por" "uuid",
"creado_en" timestamp with time zone DEFAULT "now"() NOT NULL,
"archivado_por" "uuid",
"archivado_en" timestamp with time zone,
"intento_archivado" integer DEFAULT 0 NOT NULL
);
ALTER TABLE "public"."conversaciones_asignatura" OWNER TO "postgres";
CREATE TABLE IF NOT EXISTS "public"."conversaciones_plan" (
"id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
"plan_estudio_id" "uuid" NOT NULL,
"openai_conversation_id" "text" NOT NULL,
"estado" "public"."estado_conversacion" DEFAULT 'ACTIVA'::"public"."estado_conversacion" NOT NULL,
"conversacion_json" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL,
"creado_por" "uuid",
"creado_en" timestamp with time zone DEFAULT "now"() NOT NULL,
"archivado_por" "uuid",
"archivado_en" timestamp with time zone,
"intento_archivado" integer DEFAULT 0 NOT NULL
);
ALTER TABLE "public"."conversaciones_plan" OWNER TO "postgres";
CREATE TABLE IF NOT EXISTS "public"."estados_plan" (
"id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
"clave" "text" NOT NULL,
@@ -517,6 +754,7 @@ CREATE TABLE IF NOT EXISTS "public"."planes_estudio" (
"creado_en" timestamp with time zone DEFAULT "now"() NOT NULL,
"actualizado_en" timestamp with time zone DEFAULT "now"() NOT NULL,
"nombre_search" "text" GENERATED ALWAYS AS ("lower"("public"."unaccent_immutable"("nombre"))) STORED,
"plan_hash" "text" GENERATED ALWAYS AS ("encode"(SUBSTRING("extensions"."digest"(("id")::"text", 'sha512'::"text") FROM 1 FOR 12), 'hex'::"text")) STORED,
CONSTRAINT "planes_estudio_numero_ciclos_check" CHECK (("numero_ciclos" > 0))
);
@@ -524,6 +762,17 @@ CREATE TABLE IF NOT EXISTS "public"."planes_estudio" (
ALTER TABLE "public"."planes_estudio" OWNER TO "postgres";
CREATE OR REPLACE VIEW "public"."plantilla_plan" AS
SELECT "plan"."id" AS "plan_estudio_id",
"struct"."id" AS "estructura_id",
"struct"."template_id"
FROM ("public"."planes_estudio" "plan"
JOIN "public"."estructuras_plan" "struct" ON (("plan"."estructura_id" = "struct"."id")));
ALTER TABLE "public"."plantilla_plan" OWNER TO "postgres";
CREATE TABLE IF NOT EXISTS "public"."responsables_asignatura" (
"id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
"asignatura_id" "uuid" NOT NULL,
@@ -645,6 +894,26 @@ ALTER TABLE ONLY "public"."carreras"
ALTER TABLE ONLY "public"."conversaciones_asignatura"
ADD CONSTRAINT "conversaciones_asignatura_openai_id_unico" UNIQUE ("openai_conversation_id");
ALTER TABLE ONLY "public"."conversaciones_asignatura"
ADD CONSTRAINT "conversaciones_asignatura_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."conversaciones_plan"
ADD CONSTRAINT "conversaciones_plan_openai_id_unico" UNIQUE ("openai_conversation_id");
ALTER TABLE ONLY "public"."conversaciones_plan"
ADD CONSTRAINT "conversaciones_plan_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."estados_plan"
ADD CONSTRAINT "estados_plan_clave_key" UNIQUE ("clave");
@@ -771,6 +1040,22 @@ CREATE INDEX "bibliografia_asignatura_idx" ON "public"."bibliografia_asignatura"
CREATE INDEX "idx_conv_asig_asignatura" ON "public"."conversaciones_asignatura" USING "btree" ("asignatura_id");
CREATE INDEX "idx_conv_asig_estado" ON "public"."conversaciones_asignatura" USING "btree" ("estado");
CREATE INDEX "idx_conv_plan_estado" ON "public"."conversaciones_plan" USING "btree" ("estado");
CREATE INDEX "idx_conv_plan_plan_estudio" ON "public"."conversaciones_plan" USING "btree" ("plan_estudio_id");
CREATE INDEX "idx_planes_nombre_search" ON "public"."planes_estudio" USING "btree" ("nombre_search");
@@ -807,6 +1092,10 @@ CREATE OR REPLACE TRIGGER "trg_planes_estudio_actualizado_en" BEFORE UPDATE ON "
CREATE OR REPLACE TRIGGER "trg_planes_estudio_log_cambios" AFTER INSERT OR DELETE OR UPDATE ON "public"."planes_estudio" FOR EACH ROW EXECUTE FUNCTION "public"."fn_log_cambios_planes_estudio"();
CREATE OR REPLACE TRIGGER "trg_usuarios_app_actualizado_en" BEFORE UPDATE ON "public"."usuarios_app" FOR EACH ROW EXECUTE FUNCTION "public"."set_actualizado_en"();
@@ -835,11 +1124,6 @@ ALTER TABLE ONLY "public"."asignaturas"
ALTER TABLE ONLY "public"."asignaturas"
ADD CONSTRAINT "asignaturas_facultad_propietaria_id_fkey" FOREIGN KEY ("facultad_propietaria_id") REFERENCES "public"."facultades"("id") ON DELETE SET NULL;
ALTER TABLE ONLY "public"."asignaturas"
ADD CONSTRAINT "asignaturas_linea_plan_fk_compuesta" FOREIGN KEY ("linea_plan_id", "plan_estudio_id") REFERENCES "public"."lineas_plan"("id", "plan_estudio_id") ON DELETE SET NULL;
@@ -875,16 +1159,41 @@ ALTER TABLE ONLY "public"."cambios_plan"
ALTER TABLE ONLY "public"."cambios_plan"
ADD CONSTRAINT "cambios_plan_plan_estudio_id_fkey" FOREIGN KEY ("plan_estudio_id") REFERENCES "public"."planes_estudio"("id") ON DELETE CASCADE;
ALTER TABLE ONLY "public"."carreras"
ADD CONSTRAINT "carreras_facultad_id_fkey" FOREIGN KEY ("facultad_id") REFERENCES "public"."facultades"("id") ON DELETE RESTRICT;
ALTER TABLE ONLY "public"."conversaciones_asignatura"
ADD CONSTRAINT "conversaciones_asignatura_archivado_por_fkey" FOREIGN KEY ("archivado_por") REFERENCES "public"."usuarios_app"("id") ON DELETE SET NULL;
ALTER TABLE ONLY "public"."conversaciones_asignatura"
ADD CONSTRAINT "conversaciones_asignatura_asignatura_id_fkey" FOREIGN KEY ("asignatura_id") REFERENCES "public"."asignaturas"("id") ON DELETE CASCADE;
ALTER TABLE ONLY "public"."conversaciones_asignatura"
ADD CONSTRAINT "conversaciones_asignatura_creado_por_fkey" FOREIGN KEY ("creado_por") REFERENCES "public"."usuarios_app"("id") ON DELETE SET NULL;
ALTER TABLE ONLY "public"."conversaciones_plan"
ADD CONSTRAINT "conversaciones_plan_archivado_por_fkey" FOREIGN KEY ("archivado_por") REFERENCES "public"."usuarios_app"("id") ON DELETE SET NULL;
ALTER TABLE ONLY "public"."conversaciones_plan"
ADD CONSTRAINT "conversaciones_plan_creado_por_fkey" FOREIGN KEY ("creado_por") REFERENCES "public"."usuarios_app"("id") ON DELETE SET NULL;
ALTER TABLE ONLY "public"."conversaciones_plan"
ADD CONSTRAINT "conversaciones_plan_plan_estudio_id_fkey" FOREIGN KEY ("plan_estudio_id") REFERENCES "public"."planes_estudio"("id") ON DELETE CASCADE;
ALTER TABLE ONLY "public"."interacciones_ia"
ADD CONSTRAINT "interacciones_ia_asignatura_id_fkey" FOREIGN KEY ("asignatura_id") REFERENCES "public"."asignaturas"("id") ON DELETE CASCADE;
@@ -1013,7 +1322,6 @@ ALTER PUBLICATION "supabase_realtime" OWNER TO "postgres";
GRANT USAGE ON SCHEMA "public" TO "postgres";
GRANT USAGE ON SCHEMA "public" TO "anon";
GRANT USAGE ON SCHEMA "public" TO "authenticated";
@@ -1198,10 +1506,115 @@ GRANT USAGE ON SCHEMA "public" TO "service_role";
GRANT ALL ON FUNCTION "public"."fn_log_cambios_planes_estudio"() TO "anon";
GRANT ALL ON FUNCTION "public"."fn_log_cambios_planes_estudio"() TO "authenticated";
GRANT ALL ON FUNCTION "public"."fn_log_cambios_planes_estudio"() TO "service_role";
GRANT ALL ON FUNCTION "public"."set_actualizado_en"() TO "anon";
GRANT ALL ON FUNCTION "public"."set_actualizado_en"() TO "authenticated";
GRANT ALL ON FUNCTION "public"."set_actualizado_en"() TO "service_role";
GRANT ALL ON FUNCTION "public"."unaccent"("text") TO "postgres";
GRANT ALL ON FUNCTION "public"."unaccent"("text") TO "anon";
GRANT ALL ON FUNCTION "public"."unaccent"("text") TO "authenticated";
GRANT ALL ON FUNCTION "public"."unaccent"("text") TO "service_role";
GRANT ALL ON FUNCTION "public"."unaccent"("regdictionary", "text") TO "postgres";
GRANT ALL ON FUNCTION "public"."unaccent"("regdictionary", "text") TO "anon";
GRANT ALL ON FUNCTION "public"."unaccent"("regdictionary", "text") TO "authenticated";
GRANT ALL ON FUNCTION "public"."unaccent"("regdictionary", "text") TO "service_role";
GRANT ALL ON FUNCTION "public"."unaccent_immutable"("text") TO "anon";
GRANT ALL ON FUNCTION "public"."unaccent_immutable"("text") TO "authenticated";
GRANT ALL ON FUNCTION "public"."unaccent_immutable"("text") TO "service_role";
GRANT ALL ON FUNCTION "public"."unaccent_init"("internal") TO "postgres";
GRANT ALL ON FUNCTION "public"."unaccent_init"("internal") TO "anon";
GRANT ALL ON FUNCTION "public"."unaccent_init"("internal") TO "authenticated";
GRANT ALL ON FUNCTION "public"."unaccent_init"("internal") TO "service_role";
GRANT ALL ON FUNCTION "public"."unaccent_lexize"("internal", "internal", "internal", "internal") TO "postgres";
GRANT ALL ON FUNCTION "public"."unaccent_lexize"("internal", "internal", "internal", "internal") TO "anon";
GRANT ALL ON FUNCTION "public"."unaccent_lexize"("internal", "internal", "internal", "internal") TO "authenticated";
GRANT ALL ON FUNCTION "public"."unaccent_lexize"("internal", "internal", "internal", "internal") TO "service_role";
GRANT ALL ON FUNCTION "public"."validar_numero_ciclo_asignatura"() TO "anon";
GRANT ALL ON FUNCTION "public"."validar_numero_ciclo_asignatura"() TO "authenticated";
GRANT ALL ON FUNCTION "public"."validar_numero_ciclo_asignatura"() TO "service_role";
@@ -1268,6 +1681,18 @@ GRANT ALL ON TABLE "public"."carreras" TO "service_role";
GRANT ALL ON TABLE "public"."conversaciones_asignatura" TO "anon";
GRANT ALL ON TABLE "public"."conversaciones_asignatura" TO "authenticated";
GRANT ALL ON TABLE "public"."conversaciones_asignatura" TO "service_role";
GRANT ALL ON TABLE "public"."conversaciones_plan" TO "anon";
GRANT ALL ON TABLE "public"."conversaciones_plan" TO "authenticated";
GRANT ALL ON TABLE "public"."conversaciones_plan" TO "service_role";
GRANT ALL ON TABLE "public"."estados_plan" TO "anon";
GRANT ALL ON TABLE "public"."estados_plan" TO "authenticated";
GRANT ALL ON TABLE "public"."estados_plan" TO "service_role";
@@ -1316,6 +1741,12 @@ GRANT ALL ON TABLE "public"."planes_estudio" TO "service_role";
GRANT ALL ON TABLE "public"."plantilla_plan" TO "anon";
GRANT ALL ON TABLE "public"."plantilla_plan" TO "authenticated";
GRANT ALL ON TABLE "public"."plantilla_plan" TO "service_role";
GRANT ALL ON TABLE "public"."responsables_asignatura" TO "anon";
GRANT ALL ON TABLE "public"."responsables_asignatura" TO "authenticated";
GRANT ALL ON TABLE "public"."responsables_asignatura" TO "service_role";
@@ -1417,4 +1848,9 @@ ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TAB
--
-- Dumped schema changes for auth and storage
--
@@ -0,0 +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$
;
@@ -0,0 +1,11 @@
alter table "public"."asignaturas" alter column "estado" drop default;
alter type "public"."estado_asignatura" rename to "estado_asignatura__old_version_to_be_dropped";
create type "public"."estado_asignatura" as enum ('borrador', 'revisada', 'aprobada', 'generando', 'fallida');
alter table "public"."asignaturas" alter column estado type "public"."estado_asignatura" using estado::text::"public"."estado_asignatura";
alter table "public"."asignaturas" alter column "estado" set default 'borrador'::public.estado_asignatura;
drop type "public"."estado_asignatura__old_version_to_be_dropped";
@@ -0,0 +1,192 @@
create type "public"."estado_mensaje_ia" as enum ('PROCESANDO', 'COMPLETADO', 'ERROR');
alter table "public"."asignaturas" alter column "estado" drop default;
alter type "public"."estado_asignatura" rename to "estado_asignatura__old_version_to_be_dropped";
create type "public"."estado_asignatura" as enum ('borrador', 'revisada', 'aprobada', 'generando');
create table "public"."asignatura_mensajes_ia" (
"id" uuid not null default gen_random_uuid(),
"enviado_por" uuid not null default auth.uid(),
"mensaje" text not null,
"campos" text[] not null default '{}'::text[],
"respuesta" text,
"is_refusal" boolean not null default false,
"propuesta" jsonb,
"estado" public.estado_mensaje_ia not null default 'PROCESANDO'::public.estado_mensaje_ia,
"fecha_creacion" timestamp without time zone not null default now(),
"fecha_actualizacion" timestamp without time zone not null default now(),
"conversacion_asignatura_id" uuid not null
);
create table "public"."plan_mensajes_ia" (
"id" uuid not null default gen_random_uuid(),
"enviado_por" uuid not null default auth.uid(),
"mensaje" text not null,
"campos" text[] not null default '{}'::text[],
"respuesta" text,
"is_refusal" boolean not null default false,
"propuesta" jsonb,
"estado" public.estado_mensaje_ia not null default 'PROCESANDO'::public.estado_mensaje_ia,
"fecha_creacion" timestamp without time zone not null default now(),
"fecha_actualizacion" timestamp without time zone not null default now(),
"conversacion_plan_id" uuid not null
);
alter table "public"."asignaturas" alter column estado type "public"."estado_asignatura" using estado::text::"public"."estado_asignatura";
alter table "public"."asignaturas" alter column "estado" set default 'borrador'::public.estado_asignatura;
drop type "public"."estado_asignatura__old_version_to_be_dropped";
alter table "public"."estructuras_asignatura" drop column "version";
alter table "public"."estructuras_asignatura" add column "template_id" text;
alter table "public"."estructuras_asignatura" add column "tipo" public.tipo_estructura_plan;
CREATE UNIQUE INDEX asignatura_mensajes_ia_pkey ON public.asignatura_mensajes_ia USING btree (id);
CREATE UNIQUE INDEX plan_mensajes_ia_pkey ON public.plan_mensajes_ia USING btree (id);
alter table "public"."asignatura_mensajes_ia" add constraint "asignatura_mensajes_ia_pkey" PRIMARY KEY using index "asignatura_mensajes_ia_pkey";
alter table "public"."plan_mensajes_ia" add constraint "plan_mensajes_ia_pkey" PRIMARY KEY using index "plan_mensajes_ia_pkey";
alter table "public"."asignatura_mensajes_ia" add constraint "asignatura_mensajes_ia_conversacion_asignatura_id_fkey" FOREIGN KEY (conversacion_asignatura_id) REFERENCES public.conversaciones_asignatura(id) ON DELETE CASCADE not valid;
alter table "public"."asignatura_mensajes_ia" validate constraint "asignatura_mensajes_ia_conversacion_asignatura_id_fkey";
alter table "public"."plan_mensajes_ia" add constraint "plan_mensajes_ia_conversacion_plan_id_fkey" FOREIGN KEY (conversacion_plan_id) REFERENCES public.conversaciones_plan(id) ON DELETE CASCADE not valid;
alter table "public"."plan_mensajes_ia" validate constraint "plan_mensajes_ia_conversacion_plan_id_fkey";
create or replace view "public"."plantilla_asignatura" as SELECT asignaturas.id AS asignatura_id,
struct.id AS estructura_id,
struct.template_id
FROM (public.asignaturas
JOIN public.estructuras_asignatura struct ON ((asignaturas.estructura_id = struct.id)));
grant delete on table "public"."asignatura_mensajes_ia" to "anon";
grant insert on table "public"."asignatura_mensajes_ia" to "anon";
grant references on table "public"."asignatura_mensajes_ia" to "anon";
grant select on table "public"."asignatura_mensajes_ia" to "anon";
grant trigger on table "public"."asignatura_mensajes_ia" to "anon";
grant truncate on table "public"."asignatura_mensajes_ia" to "anon";
grant update on table "public"."asignatura_mensajes_ia" to "anon";
grant delete on table "public"."asignatura_mensajes_ia" to "authenticated";
grant insert on table "public"."asignatura_mensajes_ia" to "authenticated";
grant references on table "public"."asignatura_mensajes_ia" to "authenticated";
grant select on table "public"."asignatura_mensajes_ia" to "authenticated";
grant trigger on table "public"."asignatura_mensajes_ia" to "authenticated";
grant truncate on table "public"."asignatura_mensajes_ia" to "authenticated";
grant update on table "public"."asignatura_mensajes_ia" to "authenticated";
grant delete on table "public"."asignatura_mensajes_ia" to "postgres";
grant insert on table "public"."asignatura_mensajes_ia" to "postgres";
grant references on table "public"."asignatura_mensajes_ia" to "postgres";
grant select on table "public"."asignatura_mensajes_ia" to "postgres";
grant trigger on table "public"."asignatura_mensajes_ia" to "postgres";
grant truncate on table "public"."asignatura_mensajes_ia" to "postgres";
grant update on table "public"."asignatura_mensajes_ia" to "postgres";
grant delete on table "public"."asignatura_mensajes_ia" to "service_role";
grant insert on table "public"."asignatura_mensajes_ia" to "service_role";
grant references on table "public"."asignatura_mensajes_ia" to "service_role";
grant select on table "public"."asignatura_mensajes_ia" to "service_role";
grant trigger on table "public"."asignatura_mensajes_ia" to "service_role";
grant truncate on table "public"."asignatura_mensajes_ia" to "service_role";
grant update on table "public"."asignatura_mensajes_ia" to "service_role";
grant delete on table "public"."plan_mensajes_ia" to "anon";
grant insert on table "public"."plan_mensajes_ia" to "anon";
grant references on table "public"."plan_mensajes_ia" to "anon";
grant select on table "public"."plan_mensajes_ia" to "anon";
grant trigger on table "public"."plan_mensajes_ia" to "anon";
grant truncate on table "public"."plan_mensajes_ia" to "anon";
grant update on table "public"."plan_mensajes_ia" to "anon";
grant delete on table "public"."plan_mensajes_ia" to "authenticated";
grant insert on table "public"."plan_mensajes_ia" to "authenticated";
grant references on table "public"."plan_mensajes_ia" to "authenticated";
grant select on table "public"."plan_mensajes_ia" to "authenticated";
grant trigger on table "public"."plan_mensajes_ia" to "authenticated";
grant truncate on table "public"."plan_mensajes_ia" to "authenticated";
grant update on table "public"."plan_mensajes_ia" to "authenticated";
grant delete on table "public"."plan_mensajes_ia" to "postgres";
grant insert on table "public"."plan_mensajes_ia" to "postgres";
grant references on table "public"."plan_mensajes_ia" to "postgres";
grant select on table "public"."plan_mensajes_ia" to "postgres";
grant trigger on table "public"."plan_mensajes_ia" to "postgres";
grant truncate on table "public"."plan_mensajes_ia" to "postgres";
grant update on table "public"."plan_mensajes_ia" to "postgres";
grant delete on table "public"."plan_mensajes_ia" to "service_role";
grant insert on table "public"."plan_mensajes_ia" to "service_role";
grant references on table "public"."plan_mensajes_ia" to "service_role";
grant select on table "public"."plan_mensajes_ia" to "service_role";
grant trigger on table "public"."plan_mensajes_ia" to "service_role";
grant truncate on table "public"."plan_mensajes_ia" to "service_role";
grant update on table "public"."plan_mensajes_ia" to "service_role";
drop trigger if exists "protect_buckets_delete" on "storage"."buckets";
drop trigger if exists "protect_objects_delete" on "storage"."objects";
@@ -0,0 +1 @@
alter table "public"."asignaturas" add column "criterios_de_evaluacion" jsonb not null default '[]'::jsonb;
+1993
View File
File diff suppressed because it is too large Load Diff
+217 -26
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
alter publication supabase_realtime drop table asignaturas;