Compare commits

...

299 Commits

Author SHA1 Message Date
Guillermo.Arrieta 1a9465edf2 columna de color añadida a estados_plan
Deploy Function / deploy (push) Successful in 40s
Deploy Migrations to Production / deploy (push) Successful in 14s
Update database types / deploy (push) Failing after 8s
2026-04-14 10:37:12 -06:00
info.ingenieria f6a9837b47 chore: update database seed & typescript types 2026-03-27 16:58:10 +00:00
Guillermo.Arrieta aa4f989615 columna añadida a lineas_plan para persistir el color
Deploy Function / deploy (push) Successful in 40s
Deploy Migrations to Production / deploy (push) Successful in 22s
Update database types / deploy (push) Successful in 29s
2026-03-26 22:29:55 -06:00
info.ingenieria 3d1aafff22 chore: update database seed & typescript types 2026-03-26 22:12:51 +00:00
roberto.silva 397f495601 Merge pull request 'Que se valide que al cambiar de ciclo un prerrequisito no quede inválida #79' (#85) from issue/79-que-se-valide-que-al-cambiar-de-ciclo-un-prerrequi into main
Deploy Function / deploy (push) Successful in 32s
Deploy Migrations to Production / deploy (push) Successful in 12s
Update database types / deploy (push) Successful in 26s
Reviewed-on: AlexRG/genesis-2#85
2026-03-26 22:11:37 +00:00
roberto.silva 6227c05e9d Que se valide que al cambiar de ciclo un prerrequisito no quede inválida fix #79
Deploy Function / deploy (pull_request) Successful in 42s
Deploy Migrations to Production / deploy (pull_request) Successful in 12s
2026-03-26 16:10:07 -06:00
info.ingenieria 08a73213bb chore: update database seed & typescript types 2026-03-25 22:53:50 +00:00
Guillermo.Arrieta dbfe082f0b Merge pull request 'migraciones para permitir full text search sobre asignaturas' (#84) from issue/83-nueva-asignatura-clonada-desde-el-sistema into main
Deploy Function / deploy (push) Successful in 32s
Deploy Migrations to Production / deploy (push) Successful in 12s
Update database types / deploy (push) Successful in 29s
Reviewed-on: AlexRG/genesis-2#84
2026-03-25 22:47:04 +00:00
Guillermo.Arrieta 542f2bc61a migraciones para soportar el full text search
Deploy Function / deploy (pull_request) Successful in 31s
Deploy Migrations to Production / deploy (pull_request) Successful in 12s
2026-03-25 16:44:11 -06:00
Guillermo.Arrieta 220b830aa8 Full text search sobre asignaturas para clonación de asignaturas 2026-03-24 20:46:35 -06:00
info.ingenieria 77a5b3ad06 chore: update database seed & typescript types 2026-03-23 22:48:30 +00:00
roberto.silva 9ce5f0a471 Merge pull request 'Que el contenido temático se muestre en el historial acadia #197' (#82) from issue/81-que-el-contenido-temtico-se-muestre-en-el-historia into main
Deploy Function / deploy (push) Successful in 34s
Deploy Migrations to Production / deploy (push) Successful in 12s
Update database types / deploy (push) Successful in 2m32s
Reviewed-on: AlexRG/genesis-2#82
2026-03-23 22:45:00 +00:00
roberto.silva 81b8e444d0 Que el contenido temático se muestre en el historial
Deploy Function / deploy (pull_request) Successful in 28s
Deploy Migrations to Production / deploy (pull_request) Successful in 12s
2026-03-23 16:39:40 -06:00
info.ingenieria e9e7ebfb57 chore: update database seed & typescript types 2026-03-23 15:37:46 +00:00
Guillermo.Arrieta cca45c38bc Merge pull request 'Creada edge function wrapper para carbone.io del lci con un método (descargar reporte)' (#80) from issue/76-crear-edge-function-para-plantillas-de-carbone into main
Deploy Function / deploy (push) Successful in 27s
Deploy Migrations to Production / deploy (push) Successful in 10s
Reviewed-on: AlexRG/genesis-2#80
2026-03-20 23:47:29 +00:00
Guillermo.Arrieta 7a1ec8799b fix #76: se corrige el content-type de la response para que el cliente pueda recibir los documentos adecuadamente
Deploy Function / deploy (pull_request) Successful in 30s
Deploy Migrations to Production / deploy (pull_request) Successful in 10s
si el content-type es de tipo application/pdf, el cliente de supabase lo recibe adecuadamente como pdf, pero si es de otro tipo se debe mandar como application/octet-stream para que el cliente de supabase lo reciba como blob
2026-03-20 17:42:42 -06:00
Guillermo.Arrieta d378c43821 Se corrigió hardcode de id de template y se permite recibir body para el downloadReport 2026-03-20 13:08:20 -06:00
Guillermo.Arrieta 93c4e46e62 Se creó la edge function que sirve de wrapper a carbone y ya permite descargar el pdf del plan de estudios
Por hacer:
- permitir descargar los otros dos anexos tanto en pdf como en word/excel segun el caso
- quitar el hardcode de convertir a pdf de downloadReport
- Extraer el template id no de una variable de entorno, sino de la columna correspondiente tanto en la tabla de la estructura del plan como en la tabla de la estructura de la asignatura. En alguna parte de la BDD se tendrá que poner el template id del anexo del mapa curricular
- Permitir otros métodos
2026-03-20 12:29:24 -06:00
info.ingenieria 28c4732f44 chore: update database seed & typescript types 2026-03-18 22:15:17 +00:00
roberto.silva 1dea287287 Merge pull request 'Agregar trigger para cambios en asignatura fix #74' (#75) from issue/74-agregar-trigger-para-cambios-en-asignatura into main
Deploy Function / deploy (push) Successful in 22s
Deploy Migrations to Production / deploy (push) Successful in 12s
Update database types / deploy (push) Successful in 26s
Reviewed-on: AlexRG/genesis-2#75
2026-03-18 22:14:14 +00:00
alexrg 6532ae5c31 Merge branch 'main' into issue/74-agregar-trigger-para-cambios-en-asignatura
Deploy Function / deploy (pull_request) Successful in 24s
Deploy Migrations to Production / deploy (pull_request) Successful in 10s
2026-03-18 22:12:28 +00:00
alexrg ad6fbcc220 Actualizar .gitea/workflows/update.yaml
fix #72
2026-03-18 22:11:51 +00:00
alexrg 877761c318 Eliminar .gitea/workflows/backup.yaml 2026-03-18 22:07:34 +00:00
alexrg 1b8bfcc139 Merge branch 'main' into issue/74-agregar-trigger-para-cambios-en-asignatura
Supa-backup / run_db_backup (pull_request) Successful in 43s
Deploy Function / deploy (pull_request) Successful in 25s
Deploy Migrations to Production / deploy (pull_request) Successful in 10s
2026-03-18 22:06:57 +00:00
supabot 873e1c2612 chore: update database seed & types 2026-03-18 22:06:14 +00:00
alexrg af7a913145 Actualizar .gitea/workflows/update.yaml 2026-03-18 22:05:42 +00:00
roberto.silva 1048926f9d Agregar trigger para cambios en asignatura
Supa-backup / run_db_backup (pull_request) Successful in 44s
CI / test (pull_request) Failing after 38s
Deploy Function / deploy (pull_request) Successful in 29s
Deploy Migrations to Production / deploy (pull_request) Successful in 10s
fix #74
2026-03-18 16:03:03 -06:00
alexrg c4a13ebd1d Actualizar .gitea/workflows/update.yaml 2026-03-18 22:02:00 +00:00
alexrg 32ba862912 Eliminar .gitea/workflows/update-types.yml 2026-03-18 22:01:48 +00:00
alexrg 94ccf73176 Eliminar .gitea/workflows/ci.yaml 2026-03-18 22:01:41 +00:00
alexrg 8095565224 Actualizar .gitea/workflows/update-seed.yaml 2026-03-18 22:01:29 +00:00
alexrg 9245bbf801 Eliminar supabase/roles.sql
Deploy Migrations to Production / deploy (push) Has been cancelled
Deploy Function / deploy (push) Has been cancelled
2026-03-18 21:58:31 +00:00
alexrg 7d1d64ded0 Eliminar supabase/schema.sql
Deploy Function / deploy (push) Has been cancelled
Deploy Migrations to Production / deploy (push) Has been cancelled
2026-03-18 21:58:24 +00:00
alexrg eb656e8e75 Eliminar supabase/data.sql
Deploy Migrations to Production / deploy (push) Has been cancelled
Deploy Function / deploy (push) Has been cancelled
2026-03-18 21:58:18 +00:00
supabot 75d9841db2 chore: update database seed 2026-03-18 21:53:58 +00:00
Guillermo.Arrieta 20b0a030e7 Merge pull request 'Prueba de actualización de seed' (#73) from issue/72-actualizar-seedsql-cada-vez-que-se-actualicen-las- into main
Deploy Function / deploy (push) Successful in 32s
Update database types / deploy (push) Successful in 24s
Update database types / update (push) Failing after 13s
Deploy Migrations to Production / deploy (push) Successful in 12s
Reviewed-on: AlexRG/genesis-2#73
2026-03-18 21:51:23 +00:00
Guillermo.Arrieta 51dbae221a Prueba de action para actualización de seed
Supa-backup / run_db_backup (pull_request) Successful in 41s
CI / test (pull_request) Failing after 40s
Deploy Function / deploy (pull_request) Failing after 22s
Deploy Migrations to Production / deploy (pull_request) Successful in 11s
2026-03-18 15:50:23 -06:00
supabot dbd3364bcf chore: update database seed 2026-03-18 21:46:26 +00:00
alexrg 4dd545d2da Actualizar .gitea/workflows/update-seed.yaml 2026-03-18 21:45:47 +00:00
alexrg fe2ab72da5 Actualizar .gitea/workflows/update-seed.yaml 2026-03-18 21:45:26 +00:00
alexrg bd7016e598 Actualizar .gitea/workflows/update-seed.yaml 2026-03-18 21:42:34 +00:00
alexrg af4dee9564 Actualizar .gitea/workflows/update-seed.yaml 2026-03-18 21:41:29 +00:00
alexrg 5ca47c19be Añadir .gitea/workflows/update-seed.yaml 2026-03-18 21:40:13 +00:00
alexrg b8dd450e9b Actualizar .gitea/workflows/backup.yaml 2026-03-18 17:48:41 +00:00
alexrg 6d9bc660e0 Actualizar .gitea/workflows/backup.yaml 2026-03-18 14:31:35 +00:00
alexrg 8105c64add Actualizar .gitea/workflows/backup.yaml 2026-03-18 14:30:03 +00:00
alexrg 9eaabcd1f4 Actualizar .gitea/workflows/backup.yaml 2026-03-18 14:27:28 +00:00
alexrg 55335e1d77 Actualizar .gitea/workflows/backup.yaml 2026-03-18 14:26:17 +00:00
alexrg 0df513d4dc Actualizar .gitea/workflows/backup.yaml 2026-03-18 14:19:20 +00:00
alexrg e6e19fc190 Actualizar .gitea/workflows/backup.yaml 2026-03-18 14:18:48 +00:00
alexrg 735202096c Actualizar .gitea/workflows/backup.yaml 2026-03-18 14:13:13 +00:00
gitea-actions 808c0770d8 Supabase backup 2026-03-18 00:01:15 +00:00
supabot b53db8eaff chore: update database types 2026-03-17 22:36:22 +00:00
roberto.silva b8b8b8119b Merge pull request 'Se cambia orden en funciones' (#70) from issue/63-aplicar-migraciones-de-asignatura into main
Deploy Function / deploy (push) Failing after 21s
Update database types / update (push) Successful in 13s
Deploy Migrations to Production / deploy (push) Successful in 12s
Reviewed-on: AlexRG/genesis-2#70
2026-03-17 22:33:53 +00:00
roberto.silva 6800594d35 Merge branch 'issue/63-aplicar-migraciones-de-asignatura' of https://github.lci.ulsa.mx/AlexRG/genesis-2 into issue/63-aplicar-migraciones-de-asignatura
Supa-backup / run_db_backup (pull_request) Successful in 37s
CI / test (pull_request) Failing after 38s
Deploy Function / deploy (pull_request) Successful in 24s
Deploy Migrations to Production / deploy (pull_request) Successful in 11s
2026-03-17 16:33:35 -06:00
roberto.silva e0ea71ebc9 Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/genesis-2 into issue/63-aplicar-migraciones-de-asignatura 2026-03-17 16:33:09 -06:00
Guillermo.Arrieta 6e4265bdf8 Actualizar .gitea/workflows/backup.yaml 2026-03-17 22:30:50 +00:00
Guillermo.Arrieta 375db1e7ce Supabase backup 2026-03-17 22:11:03 +00:00
Guillermo.Arrieta 147df4ed66 fix #64: Se quita la estructura duplicada y se importa desde estructuras.ts en _shared
Supa-backup / run_db_backup (pull_request) Successful in 38s
CI / test (pull_request) Failing after 34s
Deploy Function / deploy (pull_request) Successful in 25s
Deploy Migrations to Production / deploy (pull_request) Successful in 11s
Supa-backup / run_db_backup (push) Successful in 33s
Deploy Function / deploy (push) Successful in 24s
Deploy Migrations to Production / deploy (push) Failing after 12s
2026-03-17 16:07:00 -06:00
roberto.silva b29d9b1262 Supabase backup 2026-03-17 21:15:09 +00:00
roberto.silva b43b047485 Merge branch 'issue/63-aplicar-migraciones-de-asignatura' of https://github.lci.ulsa.mx/AlexRG/genesis-2 into issue/63-aplicar-migraciones-de-asignatura
Supa-backup / run_db_backup (pull_request) Successful in 33s
CI / test (pull_request) Failing after 36s
Deploy Function / deploy (pull_request) Successful in 23s
Deploy Migrations to Production / deploy (pull_request) Successful in 10s
2026-03-17 15:14:29 -06:00
roberto.silva a9e010f64d Merge branch 'main' into issue/63-aplicar-migraciones-de-asignatura 2026-03-17 15:13:51 -06:00
roberto.silva 24f090be37 Supabase backup 2026-03-17 21:11:55 +00:00
roberto.silva fb9602b849 Se cambia orden en funciones
Supa-backup / run_db_backup (pull_request) Successful in 34s
CI / test (pull_request) Failing after 35s
Deploy Function / deploy (pull_request) Successful in 23s
Deploy Migrations to Production / deploy (pull_request) Successful in 11s
2026-03-17 15:10:54 -06:00
roberto.silva 2373eccf51 Merge branch 'issue/63-aplicar-migraciones-de-asignatura' of https://github.lci.ulsa.mx/AlexRG/genesis-2 into issue/63-aplicar-migraciones-de-asignatura
Supa-backup / run_db_backup (pull_request) Successful in 34s
CI / test (pull_request) Failing after 36s
Deploy Function / deploy (pull_request) Successful in 26s
Deploy Migrations to Production / deploy (pull_request) Successful in 9s
2026-03-17 15:09:10 -06:00
roberto.silva 1e0ed7347a Supabase backup 2026-03-17 20:52:51 +00:00
roberto.silva 80ae3cd85a Se divide en dos migraciones para manejar el orden 2026-03-17 14:51:12 -06:00
roberto.silva 2b36228789 Supabase backup
Supa-backup / run_db_backup (pull_request) Successful in 38s
CI / test (pull_request) Failing after 33s
Deploy Function / deploy (pull_request) Successful in 25s
Deploy Migrations to Production / deploy (pull_request) Failing after 10s
2026-03-17 20:45:34 +00:00
AlexRG d349feafd8 Supabase backup 2026-03-17 20:44:33 +00:00
roberto.silva 0746c9e799 Se corrige migracion en la declaracion de las funciones 2026-03-17 14:44:02 -06:00
alexrg 0e4ecd26b9 Actualizar .gitea/workflows/update-types.yml
Supa-backup / run_db_backup (push) Successful in 34s
2026-03-17 20:43:57 +00:00
AlexRG 01e98b445f Supabase backup
Supa-backup / run_db_backup (pull_request) Successful in 31s
CI / test (pull_request) Failing after 33s
Deploy Function / deploy (pull_request) Successful in 23s
Deploy Migrations to Production / deploy (pull_request) Failing after 10s
2026-03-17 20:40:46 +00:00
roberto.silva 2a7a808eab Supabase backup 2026-03-17 20:30:58 +00:00
roberto.silva 0151277a3e Supabase backup
Supa-backup / run_db_backup (pull_request) Successful in 32s
CI / test (pull_request) Failing after 32s
Deploy Function / deploy (pull_request) Successful in 24s
Deploy Migrations to Production / deploy (pull_request) Failing after 10s
2026-03-17 20:29:14 +00:00
roberto.silva 86563827d8 Supabase backup 2026-03-17 20:27:29 +00:00
roberto.silva 4b34ce6a0f Merge pull request 'Se agregan reglas para criterios de evaluacion que no pueda exeder el 100%' (#66) from issue/63-aplicar-migraciones-de-asignatura into main
Supa-backup / run_db_backup (push) Successful in 32s
Deploy Function / deploy (push) Successful in 25s
Deploy Migrations to Production / deploy (push) Failing after 11s
Update database types / update (push) Failing after 13s
Reviewed-on: AlexRG/genesis-2#66
2026-03-17 20:27:19 +00:00
roberto.silva 2812f73043 Merge branch 'main' into issue/63-aplicar-migraciones-de-asignatura
Supa-backup / run_db_backup (pull_request) Successful in 32s
CI / test (pull_request) Failing after 33s
Deploy Function / deploy (pull_request) Successful in 24s
Deploy Migrations to Production / deploy (pull_request) Failing after 10s
2026-03-17 20:27:13 +00:00
roberto.silva efdc9956f7 Se agregan reglas para criterios de evaluacion que no pueda exeder el 100%
Supa-backup / run_db_backup (pull_request) Successful in 33s
CI / test (pull_request) Failing after 34s
Deploy Function / deploy (pull_request) Successful in 23s
Deploy Migrations to Production / deploy (pull_request) Failing after 10s
2026-03-17 14:26:31 -06:00
roberto.silva fef340e8e8 Supabase backup 2026-03-17 20:03:29 +00:00
roberto.silva 4c3724f06c Merge pull request 'Aplicar migraciones de asignatura fix #63' (#65) from issue/63-aplicar-migraciones-de-asignatura into main
Supa-backup / run_db_backup (push) Successful in 35s
Deploy Function / deploy (push) Successful in 24s
Deploy Migrations to Production / deploy (push) Successful in 11s
Update database types / update (push) Failing after 13s
Reviewed-on: AlexRG/genesis-2#65
2026-03-17 20:02:53 +00:00
roberto.silva 31a4278f12 Supabase backup 2026-03-17 18:56:33 +00:00
roberto.silva 260cc74111 Aplicar migraciones de asignatura
Supa-backup / run_db_backup (pull_request) Successful in 37s
CI / test (pull_request) Failing after 2m4s
Deploy Function / deploy (pull_request) Successful in 25s
Deploy Migrations to Production / deploy (pull_request) Successful in 10s
fix #63
2026-03-17 12:55:11 -06:00
AlexRG 5ffa735a7c Supabase backup 2026-03-17 17:08:30 +00:00
Your Name 6a56356c39 Add Excel processing functionality with merge pattern application
Supa-backup / run_db_backup (push) Successful in 1m34s
- Introduced `index.ts` to read an Excel file and apply merge patterns to specified ranges.
- Created `lib.ts` with utility functions for column number conversion and merge pattern application.
- Updated execution counts in `carbone.ipynb` to reflect the new code structure.
2026-03-17 11:06:52 -06:00
gitea-actions b766cbf125 Supabase backup 2026-03-17 00:01:17 +00:00
gitea-actions 74b89e4eb5 Supabase backup 2026-03-16 00:01:19 +00:00
gitea-actions c620959b3b Supabase backup 2026-03-15 00:01:17 +00:00
gitea-actions 93be27a373 Supabase backup 2026-03-14 00:01:17 +00:00
AlexRG c2eeda50a4 Supabase backup 2026-03-13 22:07:12 +00:00
alexrg 54e848269d Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/genesis-2
Supa-backup / run_db_backup (push) Successful in 37s
2026-03-13 16:06:07 -06:00
alexrg 28b3df1fbd Implement feature X to enhance user experience and optimize performance 2026-03-13 16:06:03 -06:00
AlexRG 97bbef7a0c Supabase backup 2026-03-13 17:11:00 +00:00
alexrg 357c244f2a feat: add carbone-sdk dependency and create initial CarboneClient implementation
Supa-backup / run_db_backup (push) Successful in 38s
- Added carbone-sdk@^1.6.0 to package.json and deno.lock
- Created a new notebook (carbone.ipynb) with CarboneClient class for interacting with the Carbone API
- Implemented methods for listing templates, categories, tags, uploading templates, and rendering documents
2026-03-13 11:09:22 -06:00
gitea-actions 55fd3f3886 Supabase backup 2026-03-13 00:01:18 +00:00
roberto.silva a00ccf838d Supabase backup 2026-03-12 22:05:42 +00:00
roberto.silva b0a6014a03 Merge pull request 'Agregar contexto de la asignatura al chat y se agrega estructura de contenido tematico y de criterios de evaluación para la respuesta estructurada fix #55' (#62) from issue/55-agregar-contexto-de-la-asignatura-al-chat into main
Supa-backup / run_db_backup (push) Successful in 36s
Deploy Function / deploy (push) Successful in 26s
Deploy Migrations to Production / deploy (push) Successful in 14s
Reviewed-on: AlexRG/genesis-2#62
2026-03-12 22:03:28 +00:00
roberto.silva 631e85b3d6 Agregar contexto de la asignatura al chat y se agrega estructura de contenido tematico y de criterios de evaluación para la respuesta estructurada
Supa-backup / run_db_backup (pull_request) Successful in 38s
CI / test (pull_request) Successful in 44s
Deploy Function / deploy (pull_request) Successful in 25s
Deploy Migrations to Production / deploy (pull_request) Successful in 9s
fix #55
2026-03-12 16:02:38 -06:00
AlexRG 0a1f61f587 Supabase backup 2026-03-12 14:20:53 +00:00
alexrg 76dcf24991 Actualizar .gitea/workflows/backup.yaml
Supa-backup / run_db_backup (push) Successful in 36s
2026-03-12 14:20:17 +00:00
alexrg 0439a30a3f Eliminar data.sql
Supa-backup / run_db_backup (push) Has been cancelled
2026-03-12 14:19:19 +00:00
alexrg dfc9fb162a Eliminar schema.sql
Supa-backup / run_db_backup (push) Has been cancelled
2026-03-12 14:19:05 +00:00
alexrg c40a4f3489 Eliminar roles.sql
Supa-backup / run_db_backup (push) Has been cancelled
2026-03-12 14:18:49 +00:00
AlexRG 3c81256c87 Supabase backup 2026-03-12 14:18:29 +00:00
alexrg 82014cdbe2 Añadir .gitea/workflows/backup.yaml
Supa-backup / run_db_backup (push) Successful in 2m45s
2026-03-12 14:15:43 +00:00
alexrg b4177e823f Merge pull request 'Actualizar supabase/functions/tests/ai-structured-test.ts' (#61) from dummy into main
Deploy Function / deploy (push) Successful in 26s
Deploy Migrations to Production / deploy (push) Successful in 10s
Reviewed-on: AlexRG/genesis-2#61
2026-03-12 14:13:01 +00:00
alexrg 2888a41312 Add new fields to Database type: referencia_biblioteca, referencia_en_linea, and nombre
CI / test (pull_request) Successful in 41s
Deploy Function / deploy (pull_request) Successful in 25s
Deploy Migrations to Production / deploy (pull_request) Successful in 11s
2026-03-12 08:08:36 -06:00
alexrg c55d2da3cb Merge branch 'main' into dummy
Deploy Function / deploy (pull_request) Successful in 24s
Deploy Migrations to Production / deploy (pull_request) Successful in 9s
CI / test (pull_request) Failing after 38s
2026-03-12 13:57:19 +00:00
alexrg 03b94b3460 Eliminar database-types.ts 2026-03-12 13:56:39 +00:00
alexrg 58e4d7f0db Actualizar supabase/functions/tests/ai-structured-test.ts
Deploy Function / deploy (pull_request) Successful in 24s
Deploy Migrations to Production / deploy (pull_request) Successful in 11s
CI / test (pull_request) Failing after 39s
2026-03-12 13:51:11 +00:00
alexrg c1abc0a250 Actualizar .gitea/workflows/producción/push-database.yaml 2026-03-12 13:49:02 +00:00
alexrg e8665f7738 Actualizar .gitea/workflows/producción/deploy-functions.yaml 2026-03-12 13:26:32 +00:00
alexrg 2b22d80706 Actualizar .gitea/workflows/desarrollo/push-database.yaml 2026-03-12 13:26:09 +00:00
alexrg 026a9b7255 Actualizar .gitea/workflows/desarrollo/deploy-functions.yaml 2026-03-12 13:25:14 +00:00
alexrg a92cd1cf45 Actualizar supabase/migrations/20260218223516_1.0.0.sql
Deploy Function / deploy (push) Successful in 23s
Deploy Migrations to Production / deploy (push) Successful in 10s
Update database types / update (push) Successful in 13s
2026-03-12 13:24:06 +00:00
supabot d72ea28860 chore: update database types 2026-03-11 22:47:45 +00:00
Guillermo.Arrieta fb7a011148 Se cambiaron las columnas de bibliografia_asignatura
Deploy Function / deploy (push) Successful in 25s
Update database types / update (push) Successful in 12s
Deploy Migrations to Production / deploy (push) Successful in 10s
Se quitó la columna tipo_fuente y se renombró 'biblioteca_item_id' porque ahora se utilizarán las columnas referencia_bibliografia y referencia_en_linea para determinar el tipo de fuente
2026-03-11 16:46:02 -06:00
alexrg cc6dca4453 Actualizar .gitea/workflows/production.yaml 2026-03-11 22:02:53 +00:00
alexrg e636ae76b5 Añadir .gitea/workflows/production-desarrollo.yaml 2026-03-11 22:02:37 +00:00
alexrg fa1f7e6c7c Actualizar .gitea/workflows/deploy-functions-desarrollo.yaml 2026-03-11 22:01:35 +00:00
alexrg 90b492c950 Añadir .gitea/workflows/deploy-functions-desarrollo 2026-03-11 22:01:23 +00:00
alexrg 12dfcc86e5 Actualizar .gitea/workflows/deploy-functions.yaml 2026-03-11 21:58:52 +00:00
alexrg 37a016481e Actualizar .gitea/workflows/deploy-functions.yaml 2026-03-11 21:55:20 +00:00
roberto.silva 18acc2b010 Se agrega columna nombre a tabla de asignatura_mensajes_ia para pintar nombres en los chats
Deploy Function / deploy (push) Successful in 26s
Deploy Migrations to Production / deploy (push) Successful in 11s
Update database types / update (push) Successful in 13s
2026-03-11 15:49:22 -06:00
roberto.silva d110c4644d Merge pull request 'Agregar contexto de la asignatura al chat #55' (#58) from issue/51-chats-de-ia-en-segundo-plano-para-asignaturas into main
Deploy Function / deploy (push) Successful in 23s
Deploy Migrations to Production / deploy (push) Successful in 15s
Reviewed-on: AlexRG/genesis-2#58
2026-03-10 22:15:46 +00:00
roberto.silva 667dbc7d2d Merge branch 'main' into issue/51-chats-de-ia-en-segundo-plano-para-asignaturas
CI / test (pull_request) Successful in 40s
2026-03-10 22:13:46 +00:00
roberto.silva 2dbe00a670 se agrega wh en asignaturas y se guardan campos correctamente y ademas se corrige incidencia Agregar contexto de la asignatura al chat #55 2026-03-10 16:12:43 -06:00
alexrg b628adc99a Actualizar .gitea/workflows/deploy-functions.yaml 2026-03-10 21:52:06 +00:00
alexrg 6a7550a0d5 Actualizar .gitea/workflows/production.yaml 2026-03-10 21:51:55 +00:00
supabot abd7e62998 chore: update database types 2026-03-10 21:49:10 +00:00
alexrg 115c213135 Actualizar .gitea/workflows/production.yaml 2026-03-10 21:48:32 +00:00
alexrg 3ee2e08974 Tipos
Deploy Function / deploy (push) Successful in 23s
Deploy Migrations to Production / deploy (push) Successful in 10s
2026-03-10 15:47:06 -06:00
alexrg a1d03356c2 Actualizar .gitea/workflows/update-types.yml 2026-03-10 21:46:28 +00:00
alexrg 9be3e1ef13 Actualizar supabase/functions/_shared/openai-service.ts
Deploy Function / deploy (push) Successful in 24s
Deploy Migrations to Production / deploy (push) Successful in 10s
2026-03-10 21:45:05 +00:00
alexrg d92308f946 Actualizar supabase/functions/_shared/openai-service.ts
Deploy Migrations to Production / deploy (push) Has been cancelled
Deploy Function / deploy (push) Has been cancelled
2026-03-10 21:44:53 +00:00
alexrg ff2cb58271 Actualizar .gitea/workflows/production.yaml 2026-03-10 21:44:08 +00:00
alexrg 92dd0e5e9e Actualizar .gitea/workflows/deploy-functions.yaml
Deploy Migrations to Production / deploy (push) Successful in 13s
2026-03-10 21:43:03 +00:00
alexrg 13197cc4fd Actualizar .gitea/workflows/update-types.yml
Deploy Function / deploy (push) Successful in 23s
Deploy Migrations to Production / deploy (push) Successful in 13s
2026-03-10 21:40:22 +00:00
alexrg 241c46a1b8 Actualizar .gitea/workflows/update-types.yml
Deploy Function / deploy (push) Successful in 23s
Deploy Migrations to Production / deploy (push) Successful in 10s
2026-03-10 21:39:08 +00:00
alexrg ff655e5598 Actualizar supabase/migrations/20260310154853_realtime.sql
Deploy Function / deploy (push) Successful in 23s
Deploy Migrations to Production / deploy (push) Successful in 12s
Update database types / update (push) Failing after 4s
2026-03-10 21:31:11 +00:00
alexrg 7d31c6893f Actualizar .gitea/workflows/update-types.yml
Deploy Function / deploy (push) Successful in 23s
Deploy Migrations to Production / deploy (push) Has been cancelled
2026-03-10 21:30:45 +00:00
alexrg 3a990147e1 Actualizar supabase/data.sql
Deploy Function / deploy (push) Successful in 25s
Deploy Migrations to Production / deploy (push) Successful in 14s
2026-03-10 21:25:14 +00:00
alexrg 8d4c661c22 Actualizar supabase/migrations/20260310154853_realtime.sql
Deploy Function / deploy (push) Successful in 23s
Deploy Migrations to Production / deploy (push) Has been cancelled
2026-03-10 21:24:43 +00:00
alexrg 4d67eb6b51 Actualizar .gitea/workflows/update-types.yml
Deploy Function / deploy (push) Has been cancelled
Deploy Migrations to Production / deploy (push) Has been cancelled
2026-03-10 21:24:25 +00:00
alexrg 47e171a5b8 fix: add missing newline and comment for trigger testing in migration script
Deploy Function / deploy (push) Successful in 23s
Deploy Migrations to Production / deploy (push) Successful in 10s
2026-03-10 15:18:57 -06:00
alexrg 6301d52990 Actualizar .gitea/workflows/update-types.yml
Deploy Function / deploy (push) Successful in 24s
Deploy Migrations to Production / deploy (push) Has been cancelled
2026-03-10 21:18:45 +00:00
alexrg 96ea054a8b Merge pull request 'fix: add git diff output to CI error message for better debugging' (#57) from test/ci into main
Deploy Function / deploy (push) Successful in 29s
Deploy Migrations to Production / deploy (push) Successful in 13s
Reviewed-on: AlexRG/genesis-2#57
2026-03-10 21:03:49 +00:00
alexrg f11256633a Merge branch 'main' into test/ci
CI / test (pull_request) Successful in 41s
2026-03-10 21:02:30 +00:00
alexrg 9e5afed71e Actualizar .gitea/workflows/update-types.yml
Deploy Function / deploy (push) Successful in 27s
Deploy Migrations to Production / deploy (push) Successful in 14s
2026-03-10 21:02:16 +00:00
alexrg a256a688f8 Merge branch 'main' into test/ci
CI / test (pull_request) Successful in 39s
2026-03-10 21:01:24 +00:00
alexrg 0d7c737baf Implement feature X to enhance user experience and fix bug Y in module Z
CI / test (pull_request) Successful in 39s
2026-03-10 14:59:30 -06:00
alexrg 481a126093 fix: add git diff output to CI error message for better debugging
CI / test (pull_request) Failing after 40s
2026-03-10 14:53:01 -06:00
alexrg 32c8a8814f Merge pull request 'Actualizar database-types.ts' (#27) from test/ci into main
Deploy Function / deploy (push) Successful in 25s
Deploy Migrations to Production / deploy (push) Successful in 11s
Reviewed-on: AlexRG/genesis-2#27
2026-03-10 20:46:04 +00:00
alexrg 8409001587 Implement feature X to enhance user experience and fix bug Y in module Z
CI / test (pull_request) Failing after 39s
2026-03-10 14:45:09 -06:00
alexrg 0343fb9d01 Merge branch 'main' into test/ci
CI / test (pull_request) Failing after 40s
2026-03-10 20:36:16 +00:00
alexrg 8dc1b8dee1 fix: remove invisible character from Json type definition
CI / test (pull_request) Failing after 40s
2026-03-10 14:35:44 -06:00
alexrg f038ad4914 Añadir .gitea/workflows/update-types.yml
Deploy Function / deploy (push) Successful in 25s
Deploy Migrations to Production / deploy (push) Successful in 14s
2026-03-10 20:32:23 +00:00
Guillermo.Arrieta caa340ef22 hotfix: se actualizó la semilla de la BDD y se corrigieron las migraciones
Deploy Function / deploy (push) Successful in 26s
Deploy Migrations to Production / deploy (push) Successful in 13s
La migración de realtime no era correcta. Hacía drop de la columna criterios_de_evaluacion de la tabla asignaturas y le hacían falta las publicaciones a realtime de planes_estudio y asignaturas
2026-03-10 13:31:28 -06:00
alexrg a430811a1e Merge branch 'main' into test/ci
CI / test (pull_request) Failing after 44s
2026-03-10 16:04:57 +00:00
roberto.silva 3e6b889f6c Merge pull request 'Agregar realtime' (#54) from issue/51-chats-de-ia-en-segundo-plano-para-asignaturas into main
Deploy Function / deploy (push) Successful in 26s
Deploy Migrations to Production / deploy (push) Successful in 11s
Reviewed-on: AlexRG/genesis-2#54
2026-03-10 16:01:04 +00:00
roberto.silva f2337b29b0 Merge branch 'main' into issue/51-chats-de-ia-en-segundo-plano-para-asignaturas
CI / test (pull_request) Failing after 2m0s
2026-03-10 15:58:35 +00:00
roberto.silva b63857d38f Se agrega realtime 2026-03-10 09:57:27 -06:00
alexrg 347f932a21 Disable JWT verification for openai-webhook-responses function
Deploy Function / deploy (push) Successful in 1m26s
Deploy Migrations to Production / deploy (push) Successful in 10s
2026-03-10 09:27:51 -06:00
alexrg 496171cdea Merge branch 'main' into test/ci
CI / test (pull_request) Successful in 1m14s
2026-03-10 14:37:17 +00:00
alexrg 2ff1a5e8a9 Refactor CI workflow to reset database for clean state and remove directory listing step
Deploy Function / deploy (push) Successful in 23s
Deploy Migrations to Production / deploy (push) Successful in 14s
2026-03-10 08:36:02 -06:00
alexrg 0f5803d648 Merge branch 'main' into test/ci
CI / test (pull_request) Failing after 11s
2026-03-10 14:27:48 +00:00
alexrg 3959b97aa1 Improve CI workflow by adding directory listing for debugging and removing generated types preview
Deploy Function / deploy (push) Successful in 24s
Deploy Migrations to Production / deploy (push) Successful in 14s
2026-03-10 08:25:59 -06:00
alexrg b04961a944 Enhance CI workflow to preview generated types and improve error messaging
Deploy Function / deploy (push) Successful in 23s
Deploy Migrations to Production / deploy (push) Successful in 16s
2026-03-10 08:16:42 -06:00
alexrg f81200e1a6 Merge branch 'main' into test/ci
CI / test (pull_request) Failing after 12s
2026-03-10 14:11:42 +00:00
alexrg c2a7eea20a Add GraphQL public type definitions and update constants
Deploy Function / deploy (push) Successful in 25s
Deploy Migrations to Production / deploy (push) Successful in 15s
2026-03-10 08:10:50 -06:00
alexrg ffa9a41846 Merge branch 'main' into test/ci
CI / test (pull_request) Failing after 12s
2026-03-10 14:01:22 +00:00
alexrg 7152f91dfc update generated supabase types
Deploy Function / deploy (push) Successful in 25s
Deploy Migrations to Production / deploy (push) Successful in 11s
2026-03-10 08:00:34 -06:00
alexrg ec927111b3 Merge branch 'main' into test/ci
CI / test (pull_request) Failing after 11s
2026-03-10 13:53:13 +00:00
alexrg 0c2310d0d7 Fix path for generated types in CI workflow
Deploy Function / deploy (push) Successful in 26s
Deploy Migrations to Production / deploy (push) Successful in 13s
2026-03-10 07:51:41 -06:00
alexrg 13645dc00a Refactor Database type definitions by removing unused fields and adding new entities for person, professor, and student 2026-03-10 07:51:37 -06:00
alexrg 1b715baff6 Añadir .gitea/workflows/ci.yaml
Deploy Function / deploy (push) Successful in 28s
Deploy Migrations to Production / deploy (push) Successful in 14s
2026-03-10 01:36:11 +00:00
Guillermo.Arrieta 5958ec15d2 hotfix: identificación de la aplicación para Open Library
Deploy Function / deploy (push) Successful in 26s
Deploy Migrations to Production / deploy (push) Successful in 11s
2026-03-09 17:16:12 -06:00
Guillermo.Arrieta 2823c81052 Merge pull request 'Se añadió la peticion a Open Library de bibliografía' (#53) from issue/48-propuesta-de-bibliografas into main
Deploy Function / deploy (push) Successful in 36s
Deploy Migrations to Production / deploy (push) Successful in 14s
Reviewed-on: AlexRG/genesis-2#53
2026-03-09 23:06:37 +00:00
Guillermo.Arrieta e72951d32d Enhance buscar-bibliografia function with Open Library integration and parameter handling 2026-03-09 17:00:29 -06:00
roberto.silva f53273f3e5 Merge pull request 'Chats de ia en segundo plano para asignaturas #51' (#52) from issue/51-chats-de-ia-en-segundo-plano-para-asignaturas into main
Deploy Function / deploy (push) Successful in 25s
Deploy Migrations to Production / deploy (push) Successful in 10s
Reviewed-on: AlexRG/genesis-2#52
2026-03-09 22:31:23 +00:00
roberto.silva 16092e94a3 Chats de ia en segundo plano para asignaturas
fix #51
2026-03-09 16:27:27 -06:00
roberto.silva 244bf9bd38 Merge pull request 'Chats de la IA en segundo plano #42' (#50) from issue/42-chats-de-la-ia-en-segundo-plano into main
Deploy Function / deploy (push) Successful in 1m38s
Deploy Migrations to Production / deploy (push) Successful in 21s
Reviewed-on: AlexRG/genesis-2#50
2026-03-09 20:19:37 +00:00
roberto.silva 275e6f8917 Merge branch 'main' into issue/42-chats-de-la-ia-en-segundo-plano 2026-03-09 20:19:26 +00:00
roberto.silva ed4891594b Se agrega webhook de conversacion plan 2026-03-09 12:45:54 -06:00
Guillermo.Arrieta 5fac762678 close #48: add buscar-bibliografia function with Google Books API integration
Deploy Function / deploy (push) Successful in 25s
Deploy Migrations to Production / deploy (push) Successful in 11s
- Implemented buscar-bibliografia function in index.ts
- Added Deno import map for dependencies in deno.json
- Included request validation and error handling
- Integrated Google Books API to fetch bibliographic data based on search terms
2026-03-06 19:51:46 -06:00
Guillermo.Arrieta c8c1b72964 Pruebas de generación de bibliografía 2026-03-06 11:58:45 -06:00
roberto.silva 749ec7a962 se agrega wh 2026-03-06 11:06:56 -06:00
roberto.silva d844698ffc Chats de la IA en segundo plano se agregan funciones para asignatura
fix #42
2026-03-06 08:44:41 -06:00
Guillermo.Arrieta d7e232eec7 fix #46: Al generar asignaturas la IA también genera su sistema de evaluación
Deploy Function / deploy (push) Successful in 25s
Deploy Migrations to Production / deploy (push) Successful in 10s
2026-03-04 17:48:23 -06:00
Guillermo.Arrieta 994fb3c7bc hotfix: actualizada semilla de BDD y referencia a BDD
Deploy Function / deploy (push) Successful in 23s
Deploy Migrations to Production / deploy (push) Successful in 10s
2026-03-04 12:54:17 -06:00
roberto.silva 189225f369 Merge pull request 'Corregir Edge Function fix #36 #44' (#45) from issue/38-ajustar-funcin-para-asignatura into main
Deploy Function / deploy (push) Successful in 22s
Deploy Migrations to Production / deploy (push) Successful in 11s
Reviewed-on: AlexRG/genesis-2#45
2026-03-04 15:33:35 +00:00
roberto.silva 7543afb56f Se resuelve conflicto 2026-03-04 09:31:30 -06:00
roberto.silva 5eb34205e0 Merge pull request 'Corregir Edge Function #36' (#44) from issue/38-ajustar-funcin-para-asignatura into main
Deploy Function / deploy (push) Failing after 15s
Deploy Migrations to Production / deploy (push) Failing after 14s
Reviewed-on: AlexRG/genesis-2#44
2026-03-04 15:29:56 +00:00
roberto.silva 3759f7673a Corregir Edge Function
fix #36
2026-03-04 09:13:16 -06:00
alexrg d4ceac77a9 feat: add logging for JWT refresh and session management in Supabase security notebook
Deploy Function / deploy (push) Failing after 14s
Deploy Migrations to Production / deploy (push) Failing after 8s
2026-03-02 14:13:28 -06:00
alexrg 99a6e28f42 Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/genesis-2
Deploy Function / deploy (push) Failing after 15s
Deploy Migrations to Production / deploy (push) Failing after 9s
2026-03-02 14:11:19 -06:00
alexrg 606db3362c feat: add jwt-decode dependency and implement JWT handling in Supabase security notebook 2026-03-02 14:11:05 -06:00
Guillermo.Arrieta d7312d2ab3 Merge pull request 'Creación de planes y asignaturas en segundo plano y con webhook de openai' (#40) from issue/35-crear-webhook-para-responses into main
Deploy Function / deploy (push) Successful in 26s
Deploy Migrations to Production / deploy (push) Successful in 12s
Reviewed-on: AlexRG/genesis-2#40
2026-02-27 18:32:55 +00:00
Guillermo.Arrieta c88bf044d0 estado de asignatura 'fallida' en los tipos 2026-02-27 12:30:05 -06:00
Guillermo.Arrieta c12e2895a8 Merge branch 'main' into issue/35-crear-webhook-para-responses 2026-02-27 12:14:04 -06:00
Guillermo.Arrieta 4944f1d31a Se añade el estado 'fallida' a la asignatura 2026-02-27 12:12:20 -06:00
Guillermo.Arrieta b2307759bf Merge branch 'issue/35-crear-webhook-para-responses' of https://github.lci.ulsa.mx/AlexRG/genesis-2 into issue/35-crear-webhook-para-responses 2026-02-27 12:08:09 -06:00
Guillermo.Arrieta 288278ea6c feat: update AI subject generation and handling responses
- Refactor AIGenerateSubjectInput type to include optional fields for updates.
- Add handling for "asignaturas" responses in OpenAI webhook.
- Implement crear.ts for creating/updating subjects based on AI responses.
- Update tests to validate OpenAI file uploads instead of storage uploads.
2026-02-27 12:07:06 -06:00
Guillermo.Arrieta 39f5b5d338 feat: update AI subject generation and handling responses
- Refactor AIGenerateSubjectInput type to include optional fields for updates.
- Add handling for "asignaturas" responses in OpenAI webhook.
- Implement crear.ts for creating/updating subjects based on AI responses.
- Update tests to validate OpenAI file uploads instead of storage uploads.
2026-02-27 11:34:08 -06:00
Guillermo.Arrieta 527f3aa56f Se inserta el plan de estudios y se mandan crear los datos generales en segundo plano, y la edge funcion de openai-webhook-responses recibe la respuesta para actualizar el plan de estudio con los datos generales 2026-02-25 17:39:09 -06:00
Guillermo.Arrieta 9263587f0b openai-webhook-responces recibe peticiones y hace log de las responses 2026-02-25 15:24:57 -06:00
roberto.silva 2552a796a6 Merge pull request 'Corregir Edge Function #36' (#37) from issue/36-corregir-edge-function into main
Deploy Function / deploy (push) Successful in 24s
Deploy Migrations to Production / deploy (push) Successful in 14s
Reviewed-on: AlexRG/genesis-2#37
2026-02-25 20:43:19 +00:00
roberto.silva 44919f074d Corregir Edge Function
fix#36
2026-02-25 14:42:42 -06:00
Guillermo.Arrieta 8aeb1d0a64 version con express js que no funciona 2026-02-25 12:17:59 -06:00
alexrg 824db85305 fix: simplify refusal handling in message response by removing is-refusal field
Deploy Function / deploy (push) Successful in 22s
Deploy Migrations to Production / deploy (push) Successful in 13s
2026-02-25 10:04:08 -06:00
alexrg 5fae2165b0 fix: change variable declaration from let to const for output in pickSchemaFields function
Deploy Function / deploy (push) Successful in 22s
Deploy Migrations to Production / deploy (push) Successful in 12s
2026-02-25 08:23:10 -06:00
alexrg 738d3970e3 fix: ensure campos is always an array in pickSchemaFields function
Deploy Function / deploy (push) Successful in 22s
Deploy Migrations to Production / deploy (push) Successful in 11s
2026-02-25 08:03:00 -06:00
alexrg 009e65d327 fix: remove required fields and additional properties from message schema
Deploy Function / deploy (push) Successful in 24s
Deploy Migrations to Production / deploy (push) Successful in 10s
2026-02-24 16:12:22 -06:00
alexrg e43927f1b1 fix: add required fields and restrict additional properties in message schema
Deploy Function / deploy (push) Successful in 22s
Deploy Migrations to Production / deploy (push) Successful in 11s
2026-02-24 15:28:56 -06:00
alexrg e906fe258c fix: remove unnecessary JSON.stringify for appended parameter in message handling
Deploy Function / deploy (push) Successful in 20s
Deploy Migrations to Production / deploy (push) Successful in 9s
2026-02-24 15:26:15 -06:00
alexrg 44c86cfa13 fix: rename parameter from p_appended to p_append in message handling
Deploy Function / deploy (push) Successful in 20s
Deploy Migrations to Production / deploy (push) Successful in 10s
2026-02-24 15:24:50 -06:00
alexrg e81a8bd6a5 Merge pull request 'Agregar conversaciones a la tabla como respaldo #33' (#34) from issue/33-agregar-conversaciones-a-la-tabla-como-respaldo into main
Deploy Function / deploy (push) Successful in 25s
Deploy Migrations to Production / deploy (push) Successful in 10s
Reviewed-on: AlexRG/genesis-2#34
2026-02-24 21:21:18 +00:00
alexrg c7bd107297 refactor: remove unused functions for appending conversations in the database 2026-02-24 15:20:24 -06:00
alexrg 7ee9944ab5 feat: add @toon-format/toon dependency and integrate into OpenAI notebooks
- Updated deno.lock and package.json to include @toon-format/toon@^2.1.0.
- Modified background-openai.ipynb to handle error responses and adjust execution counts.
- Enhanced definitions-openai.ipynb to include additional metadata for perishables and origen schemas.
- Refactored embeddings-openai.ipynb to improve error handling and output formatting.
- Updated create-chat-conversation function to append messages and responses to conversations.
- Added SQL functions for appending conversation data to the database.
2026-02-24 15:20:19 -06:00
Guillermo.Arrieta dcaeeb72dd Merge pull request 'Se genera el contenido temático con IA' (#32) from issue/31-generacin-de-columnas-de-tabla-asignaturas-con-ia into main
Deploy Function / deploy (push) Successful in 21s
Deploy Migrations to Production / deploy (push) Successful in 11s
Reviewed-on: AlexRG/genesis-2#32
2026-02-23 23:08:41 +00:00
Guillermo.Arrieta 26c6ad7c20 close #31: Se genera el contenido temático con la IA 2026-02-23 17:06:34 -06:00
roberto.silva 49b1a9805b Se agrega columna nombre de chat
Deploy Function / deploy (push) Successful in 1m33s
Deploy Migrations to Production / deploy (push) Successful in 18s
2026-02-23 15:23:57 -06:00
alexrg ef706dcf17 Agregar manejo de errores en la respuesta de la IA en el notebook de OpenAI
Deploy Function / deploy (push) Successful in 20s
Deploy Migrations to Production / deploy (push) Successful in 11s
2026-02-20 11:54:03 -06:00
alexrg d029fa23e7 Agregar notebook para integración con OpenAI y Supabase
Deploy Function / deploy (push) Has been cancelled
Deploy Migrations to Production / deploy (push) Has been cancelled
2026-02-20 11:53:50 -06:00
alexrg f816e31b63 Actualizar conteo de ejecución y corregir IDs en el notebook de conversaciones de OpenAI 2026-02-20 11:00:41 -06:00
Guillermo.Arrieta 38e1488c8c Se añade un event listener que loggea cuando se va a apagar la función
Deploy Function / deploy (push) Successful in 22s
Deploy Migrations to Production / deploy (push) Successful in 11s
2026-02-19 13:06:34 -06:00
Guillermo.Arrieta a028fd459f hotfix 2: archivo de migración sobrante
Deploy Function / deploy (push) Successful in 20s
Deploy Migrations to Production / deploy (push) Successful in 14s
2026-02-18 17:50:26 -06:00
Guillermo.Arrieta db3355f8ca hotfix: nombre de migración sincronizado con BDD remota
Deploy Function / deploy (push) Successful in 21s
Deploy Migrations to Production / deploy (push) Failing after 12s
2026-02-18 17:45:34 -06:00
Guillermo.Arrieta 5c27e61ce4 Fue arreglado trigger de trg_planes_estudio_log_cambios y se le hizo squash a las migraciones de la BDD
Deploy Function / deploy (push) Successful in 21s
Deploy Migrations to Production / deploy (push) Successful in 9s
2026-02-18 16:29:27 -06:00
alexrg 4b0b8e6a95 Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/genesis-2
Deploy Function / deploy (push) Successful in 21s
Deploy Migrations to Production / deploy (push) Successful in 10s
2026-02-18 09:20:56 -06:00
alexrg ef42d74fea fix: remove conversation_id update logic from cambios_plan trigger 2026-02-18 09:20:54 -06:00
alexrg feaad85fda Actualizar supabase/functions/create-chat-conversation/lib/cors.ts
Deploy Function / deploy (push) Successful in 30s
Deploy Migrations to Production / deploy (push) Successful in 10s
2026-02-17 21:59:33 +00:00
alexrg 21469b3b4c Actualizar supabase/functions/create-chat-conversation/lib/cors.ts
Deploy Function / deploy (push) Successful in 32s
Deploy Migrations to Production / deploy (push) Successful in 9s
2026-02-17 21:55:45 +00:00
alexrg 329faac742 fix: remove unnecessary constraint from planes_estudio table
Deploy Function / deploy (push) Successful in 22s
Deploy Migrations to Production / deploy (push) Successful in 9s
2026-02-17 11:31:12 -06:00
alexrg fed348e214 fix: update query text and similarity values in embeddings example
Deploy Function / deploy (push) Successful in 20s
Deploy Migrations to Production / deploy (push) Successful in 9s
2026-02-17 11:28:29 -06:00
alexrg 63e0ea7fbf feat: add OpenAI embeddings functionality and update Supabase migration
Deploy Function / deploy (push) Has been cancelled
Deploy Migrations to Production / deploy (push) Has been cancelled
2026-02-17 11:28:00 -06:00
alexrg 6dc3167bc4 Merge pull request 'Actualizar supabase/functions/create-chat-conversation/lib/plan.ts' (#30) from hot-fix/ningun-campo-si-vacío into main
Deploy Function / deploy (push) Successful in 21s
Deploy Migrations to Production / deploy (push) Successful in 11s
Reviewed-on: AlexRG/genesis-2#30
2026-02-16 23:36:08 +00:00
alexrg c28274334a Actualizar supabase/functions/create-chat-conversation/lib/plan.ts
fix #29
2026-02-16 23:35:10 +00:00
Guillermo.Arrieta a92fc6ac26 hotfix: prompt de formato arreglado para evitar que se muestren cosas como "\n" en el texto final
Deploy Function / deploy (push) Successful in 1m21s
Deploy Migrations to Production / deploy (push) Successful in 14s
2026-02-16 17:25:38 -06:00
Guillermo.Arrieta 5a23be9961 Se añadieron indicaciones de formato en los prompts para generación de plan o de asignatura
Deploy Function / deploy (push) Successful in 39s
Deploy Migrations to Production / deploy (push) Successful in 11s
close #26
2026-02-16 19:52:21 +00:00
Guillermo.Arrieta e64c0de529 Se usan modelos de openai a partir de variables de entorno 2026-02-16 19:52:21 +00:00
alexrg 34162c6038 Eliminar .gitea/workflows/ci.yaml
Deploy Function / deploy (push) Successful in 44s
Deploy Migrations to Production / deploy (push) Successful in 13s
2026-02-16 19:09:26 +00:00
alexrg e7d7f553a3 Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/genesis-2
Deploy Function / deploy (push) Successful in 32s
Deploy Migrations to Production / deploy (push) Successful in 11s
2026-02-16 11:28:16 -06:00
alexrg 0a4fc51bf7 feat: add definitions for perishables and origin schemas in new notebook 2026-02-16 11:28:13 -06:00
alexrg af945cfa69 Actualizar .gitea/workflows/ci.yaml
Deploy Function / deploy (push) Successful in 32s
Deploy Migrations to Production / deploy (push) Successful in 11s
2026-02-16 16:38:20 +00:00
alexrg afd10eaaf7 Actualizar .gitea/workflows/ci.yaml
Deploy Function / deploy (push) Successful in 53s
Deploy Migrations to Production / deploy (push) Successful in 12s
2026-02-16 16:36:58 +00:00
alexrg 13cf57505f Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/genesis-2
Deploy Function / deploy (push) Successful in 26s
Deploy Migrations to Production / deploy (push) Successful in 11s
2026-02-13 16:12:26 -06:00
alexrg 9d25203ef6 fix: update execution counts and improve message parsing in chat conversation functions 2026-02-13 16:12:23 -06:00
Guillermo.Arrieta 7a81e448f4 hotfix: se cambió el estado default de las asignaturas y se arregló un error donde se requería código de asignatura no nulo
Deploy Function / deploy (push) Successful in 44s
Deploy Migrations to Production / deploy (push) Successful in 14s
2026-02-13 12:39:14 -06:00
alexrg d724bda6f5 Actualizar database-types.ts
CI / test (pull_request) Failing after 3m34s
2026-02-13 16:28:14 +00:00
alexrg 3d407c5b91 Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/genesis-2
Deploy Function / deploy (push) Successful in 24s
Deploy Migrations to Production / deploy (push) Successful in 11s
2026-02-13 10:26:45 -06:00
alexrg 2d13995d5d fix: update type generation script and modify database types structure 2026-02-13 10:26:43 -06:00
alexrg b583f4c0fd Merge pull request 'Corregir para que los mensajes se archiven solo con asistente y usuario' (#24) from issue/7-hacer-la-edge-function-que-te-d-sugerencias into main
Deploy Function / deploy (push) Successful in 25s
Deploy Migrations to Production / deploy (push) Successful in 15s
Reviewed-on: AlexRG/genesis-2#24
2026-02-13 16:18:23 +00:00
alexrg b7720496cf feat: update conversation archiving to store structured message data
CI / test (pull_request) Failing after 7s
2026-02-13 10:17:06 -06:00
alexrg febcce85bb Merge pull request 'Que te dé sugerencias' (#23) from issue/7-hacer-la-edge-function-que-te-d-sugerencias into main
Deploy Function / deploy (push) Successful in 27s
Deploy Migrations to Production / deploy (push) Successful in 16s
Reviewed-on: AlexRG/genesis-2#23
2026-02-13 15:32:53 +00:00
alexrg a70f0c52a9 feat: implement create-chat-conversation function with OpenAI integration and Supabase support
CI / test (pull_request) Failing after 8s
- Added CORS handling for the create-chat-conversation function.
- Implemented health check endpoint for the function.
- Created endpoints for managing conversations and messages with OpenAI.
- Added error handling and response formatting for better API usability.
- Introduced utility functions for environment variable management and Supabase client creation.
- Enhanced schema handling for structured responses from OpenAI.
- Implemented conversation archiving and retrieval logic.
2026-02-13 09:31:58 -06:00
alexrg f441976a7a Merge branch 'fix/borrar-triggers'
Deploy Function / deploy (push) Successful in 33s
Deploy Migrations to Production / deploy (push) Successful in 15s
2026-02-13 08:39:18 -06:00
alexrg cfbd0cebbb Refactor conversation handling in the OpenAI integration notebook
- Updated execution counts for code cells to reflect the correct order of execution.
- Modified the structure of conversation-related database interactions to align with new naming conventions.
- Enhanced the conversation creation process by including additional metadata and improving the response handling.
- Implemented better error handling and logging for conversation archiving and message retrieval.
- Adjusted the schema definitions for input and output to ensure compatibility with the latest API changes.
2026-02-13 08:38:43 -06:00
Guillermo.Arrieta f29465958a errores de imports
Deploy Function / deploy (push) Successful in 37m53s
Deploy Migrations to Production / deploy (push) Successful in 15s
2026-02-12 17:40:22 -06:00
Guillermo.Arrieta 334800fab7 Merge pull request 'Generación de asignaturas con sugerencias' (#22) from acad-ia-2/issue/89-sugerencias-asignaturas into main
Deploy Function / deploy (push) Failing after 0s
Deploy Migrations to Production / deploy (push) Failing after 0s
Reviewed-on: AlexRG/genesis-2#22
2026-02-12 23:30:00 +00:00
Guillermo.Arrieta 9981b30260 feat: update subject suggestion guidelines and remove obsolete migration
CI / test (pull_request) Failing after 8s
- Enhanced the subject suggestion guidelines to specify that descriptions must not exceed 200 characters.
- Deleted the migration file for adding conversation_id to asignaturas and the associated view, as it is no longer needed.
2026-02-12 16:45:38 -06:00
Guillermo.Arrieta 2ff587d639 feat: opcion de update a asignaturas ya creadas para ponerles la información generada con IA
- Created a new enum type "estado_asignatura" with values 'borrador', 'revisada', 'aprobada', and 'generando'.
- Added a new column "estado" to the "asignaturas" table, defaulting to 'generando'.
- Implemented a trigger function to log changes in the "planes_estudio" table, capturing inserts, updates, and deletes.
- Updated the logging mechanism to track changes in various fields, including JSONB data.
- Removed the "asignaturas" table from the supabase_realtime publication.
- Added a script for generating TypeScript types from the local Supabase database.
2026-02-12 16:00:39 -06:00
Guillermo.Arrieta 48ab986cb8 Actualizar esquema de validación: limitar la descripción a 200 caracteres y reducir la cantidad máxima de sugerencias a 15 2026-02-12 16:00:00 -06:00
Guillermo.Arrieta 24b6338074 Implementar validación y generación de sugerencias de asignaturas con integración a Supabase 2026-02-12 16:00:00 -06:00
Guillermo.Arrieta b1d431a683 Generación con IA funcional 2026-02-12 16:00:00 -06:00
Guillermo.Arrieta ce5360a5c2 wip 2026-02-12 16:00:00 -06:00
Guillermo.Arrieta 93c7f11621 nueva edge function para la generación de sugerencais de asignaturas con IA 2026-02-12 16:00:00 -06:00
alexrg a9b77cd792 Actualizar .gitea/workflows/production.yaml
Deploy Function / deploy (push) Successful in 20s
Deploy Migrations to Production / deploy (push) Failing after 0s
2026-02-12 21:27:23 +00:00
alexrg 4365303c49 Actualizar .gitea/workflows/production.yaml
Deploy Function / deploy (push) Successful in 19s
Deploy Migrations to Production / deploy (push) Failing after 11s
2026-02-12 21:21:35 +00:00
alexrg e68c876944 Merge pull request 'Add trigger removal and logging function for cambios_planes_estudio' (#21) from fix/borrar-triggers into main
Deploy Function / deploy (push) Successful in 20s
Deploy Migrations to Production / deploy (push) Has been cancelled
Reviewed-on: AlexRG/genesis-2#21
2026-02-12 21:16:59 +00:00
alexrg 53d9ab2876 Add trigger removal and logging function for cambios_planes_estudio
CI / test (pull_request) Failing after 2m19s
2026-02-12 15:13:19 -06:00
alexrg ada4f3254c Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/genesis-2
Deploy Function / deploy (push) Successful in 18s
Deploy Migrations to Production / deploy (push) Successful in 13s
2026-02-12 15:03:28 -06:00
alexrg e78863a636 Agregar supabase/snippets al archivo .gitignore 2026-02-12 15:03:25 -06:00
alexrg e8a0f1d082 Actualizar .gitea/workflows/production.yaml
Deploy Function / deploy (push) Successful in 20s
Deploy Migrations to Production / deploy (push) Successful in 14s
2026-02-12 20:22:36 +00:00
alexrg 90cfad6f76 Añadir .gitea/workflows/production.yaml
Deploy Function / deploy (push) Successful in 18s
Deploy Migrations to Production / deploy (push) Failing after 15s
2026-02-12 20:19:17 +00:00
alexrg 64362f2e31 Añadir .gitea/workflows/ci.yaml
Deploy Function / deploy (push) Successful in 17s
2026-02-12 20:18:56 +00:00
alexrg 7e064cb615 Merge pull request 'fix/estandarizar-migraciones' (#19) from fix/estandarizar-migraciones into main
Deploy Function / deploy (push) Successful in 21s
Reviewed-on: AlexRG/genesis-2#19
2026-02-12 18:05:23 +00:00
alexrg bdd4921795 Add initial implementation for create-chat-conversation function and related configuration files 2026-02-12 11:59:22 -06:00
alexrg 4615da4223 Implement new feature for user authentication and improve error handling 2026-02-12 10:37:41 -06:00
alexrg 484db49654 Refactor code structure for improved readability and maintainability 2026-02-12 10:35:52 -06:00
alexrg 0b72a227f1 Implement code changes to enhance functionality and improve performance 2026-02-12 10:04:42 -06:00
alexrg 801e23a89c Agregar semilla 2026-02-12 10:01:58 -06:00
alexrg 834e884c90 Remove deprecated AI function and related configuration files for cleaner project structure 2026-02-12 09:47:58 -06:00
alexrg e1410c65ec Remove obsolete SQL migration file for cleaner project structure 2026-02-12 09:42:32 -06:00
alexrg ce9c0cf3e6 Remove unused SQL migration files for cleaner project structure 2026-02-12 09:38:48 -06:00
alexrg a3a7fd0de8 Refactor code structure for improved readability and maintainability 2026-02-12 09:38:06 -06:00
alexrg e7acf50b82 Remove SQL insert statements for faculties and careers from the database schema. 2026-02-12 09:37:40 -06:00
alexrg c33089726c Establecer permisos de funciones y eliminar funciones no utilizadas en las migraciones 2026-02-12 09:37:03 -06:00
alexrg 7755246e8b Agregar supabase/snippets al archivo .gitignore 2026-02-12 09:36:59 -06:00
alexrg 12736b7f1d Actualizar README.md
Deploy Function / deploy (push) Successful in 24s
2026-02-06 15:58:51 +00:00
Guillermo.Arrieta 5c9fde1a9e Generación de datos básicos de asignaturas funcional
Deploy Function / deploy (push) Successful in 24s
close #6:
- Ya se pueden generar asignaturas con IA. Específicamente los datos generales.
- Se actualizaron los tipos de supabase
- Se arreglaron los imports
- TODO: generar contenido temático de la asignatura
2026-02-05 19:42:12 -06:00
Guillermo.Arrieta 7f9226ce38 hotfix de importacion
Deploy Function / deploy (push) Successful in 21s
2026-02-05 16:13:55 -06:00
Guillermo.Arrieta f1563b4cbe Merge pull request 'issue/6-generacin-de-materias' (#16) from issue/6-generacin-de-materias into main
Deploy Function / deploy (push) Failing after 46s
Reviewed-on: AlexRG/genesis-2#16
2026-02-05 22:07:23 +00:00
Guillermo.Arrieta 00f0bddf1c Arreglo de reverts 2026-02-05 16:00:07 -06:00
Guillermo.Arrieta 1ca861c51d Primera versión de la función de ai-generate-subject 2026-02-05 15:54:04 -06:00
Guillermo.Arrieta 42a437afa3 fix(functions): clasificar y estandarizar errores en ai-generate-plan
- Corregido el problema donde la función devolvía siempre 400; ahora se lanzan HttpError con status adecuados (405, 401, 415, 422, 404, 409, 502, 500) según el caso.
- Refactor: usar sendError/sendSuccess y HttpError desde _shared/utils.ts para respuestas consistentes y logging seguro.
- Validación: agrupar errores de parse/validación en un único mensaje numerado (cada error en nueva línea).
- OpenAI: manejo explícito de fallos (MissingEnv, JSON inválido, salida estructurada ausente) y mapeo a status apropiados.
- Supabase: distinguir errores 404/409/500 en consultas e inserts; incluir detalles en internalDetails para diagnóstico server-side.
- Eliminada dependencia de invocación HTTP a ai-structured; usar servicio compartido directo.
- Ajustes menores en mensajes y configuración (deno imports/deno.json) y tests marcados como ignorados donde corresponde.
2026-02-05 15:54:04 -06:00
Guillermo.Arrieta 7660d2c3a0 inicio de funcion de generación de asignatura 2026-02-05 15:53:23 -06:00
Guillermo.Arrieta e5657d6b0f chore(db): actualizar esquema/backups (auditoría de planes, hashes y conversation_id)
- Agrega fn_log_cambios_planes_estudio y trigger para registrar cambios en cambios_plan, usando response_id (reemplaza interaccion_ia_id).
- Extiende planes_estudio con conversation_id (unique) y plan_hash; agrega asignatura_hash en asignaturas; corrige unaccent_immutable y añade extensión http.
- Actualiza seeds/backups (data_backup.sql / scheme_backup.sql) y ajusta script de diff (sql_para_local_como_remoto.sql), elimina el diff viejo y actualiza archivos auxiliares (deno.lock, CLI temp).
2026-02-05 15:52:56 -06:00
alexrg b1a90858dc revert 1af0358e65
Deploy Function / deploy (push) Failing after 43s
revert Merge pull request 'REFACTOR OpenAI service to manage env and convo creation' (#12) from issue/9-hacer-una-funcin-que-inserte-el-id-de-conversacin into main

Reviewed-on: AlexRG/genesis-2#12
2026-02-05 20:41:28 +00:00
roberto.silva 132a51133b revert e1d865e6c3
Deploy Function / deploy (push) Failing after 45s
revert posible solución a issue #60
2026-02-05 16:11:09 +00:00
111 changed files with 23832 additions and 12453 deletions
@@ -0,0 +1,25 @@
name: Deploy Function
on:
pull_request:
paths:
- 'supabase/**'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_DESARROLLO_ACCESS_TOKEN }}
SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_DESARROLLO_PROJECT_ID }}
steps:
- uses: actions/checkout@v3
- uses: supabase/setup-cli@v1
with:
version: latest
- name: Deploy edge function
run: npx supabase@beta functions deploy --project-ref $SUPABASE_PROJECT_ID --use-api
@@ -0,0 +1,25 @@
name: Deploy Migrations to Production
on:
pull_request:
paths:
- 'supabase/**'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_DESARROLLO_ACCESS_TOKEN }}
SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_DESARROLLO_PROJECT_ID }}
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- run: supabase link --project-ref $SUPABASE_PROJECT_ID
- run: supabase db push --debug --yes
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
paths:
- 'supabase/**'
workflow_dispatch:
jobs:
@@ -0,0 +1,27 @@
name: Deploy Migrations to Production
on:
push:
branches:
- main
paths:
- 'supabase/**'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }}
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- run: supabase link --project-ref $SUPABASE_PROJECT_ID
- run: supabase db push --debug --yes
+54
View File
@@ -0,0 +1,54 @@
name: Update database types
on:
push:
branches:
- main
paths:
- supabase/migrations/*.sql
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_DESARROLLO_ACCESS_TOKEN }}
SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_DESARROLLO_PROJECT_ID }}
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- name: Link supabase project
run: supabase link --project-ref $SUPABASE_PROJECT_ID
- name: Generar seed de supabase
run: supabase db dump -f supabase/seed.sql --data-only
- name: Generar tipos de supabase
run: supabase gen types typescript --schema public > supabase/functions/_shared/database.types.ts
- name: Check for changes
id: git_status
run: |
if [[ -n "$(git status --porcelain)" ]]; then
echo "changed=true" >> $GITEA_OUTPUT
else
echo "changed=false" >> $GITEA_OUTPUT
fi
- name: Commit changes
if: steps.git_status.outputs.changed == 'true'
run: |
git config user.name "supabot"
git config user.email "info.ingenieria@lasalle.mx"
git add supabase/seed.sql
git add supabase/functions/_shared/database.types.ts
git commit -m "chore: update database seed & typescript types"
- name: Push changes
if: steps.git_status.outputs.changed == 'true'
run: |
git pull
git push
+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
View File
@@ -32,3 +32,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
supabase/snippets
+2
View File
@@ -13,3 +13,5 @@ bun run index.ts
```
This project was created using `bun init` in bun v1.3.0. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
<!--Comentario: 32014588721e71ba6ae5dc401668634c1bad3b0c0bb117fdfe97520d445962c8 -->
+7 -2
View File
@@ -2,11 +2,16 @@
"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",
"@zod/zod": "jsr:@zod/zod@^4.3.5",
"@david/dax": "jsr:@david/dax@^0.44.2"
"@david/dax": "jsr:@david/dax@^0.44.2",
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts",
"openai": "npm:openai@6.16.0",
"openai/helpers/zod": "npm:openai@6.16.0/helpers/zod",
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
"@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts"
}
}
Generated
+318 -16
View File
@@ -5,8 +5,11 @@
"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",
"jsr:@std/dotenv@*": "0.225.6",
"jsr:@std/fmt@1": "1.0.9",
"jsr:@std/fs@1": "1.0.22",
"jsr:@std/fs@^1.0.20": "1.0.22",
@@ -14,12 +17,20 @@
"jsr:@std/io@0.225": "0.225.3",
"jsr:@std/path@1": "1.1.4",
"jsr:@std/path@^1.1.4": "1.1.4",
"jsr:@supabase/functions-js@2": "2.95.3",
"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:carbone-sdk@^1.6.0": "1.6.0",
"npm:citeproc@^2.4.63": "2.4.63",
"npm:deno@^2.6.4": "2.6.4",
"npm:openai@^6.16.0": "6.16.0",
"npm:supabase@^2.72.6": "2.72.6",
"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.75.0": "2.75.0",
"npm:zod@3": "3.25.76"
},
"jsr": {
@@ -48,6 +59,9 @@
"@david/which@0.4.1": {
"integrity": "896a682b111f92ab866cc70c5b4afab2f5899d2f9bde31ed00203b9c250f225e"
},
"@helpr/express@0.1.1": {
"integrity": "15391b350b92ab5102919c555179c954ff3d5974d7f170b99880836e249756d2"
},
"@openai/openai@6.16.0": {
"integrity": "ccee548f61c382d715091fff0c2c3390a4487823644430b8235df543d6d6a78b",
"dependencies": [
@@ -57,6 +71,9 @@
"@std/bytes@1.0.6": {
"integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
},
"@std/dotenv@0.225.6": {
"integrity": "1d6f9db72f565bd26790fa034c26e45ecb260b5245417be76c2279e5734c421b"
},
"@std/fmt@1.0.9": {
"integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0"
},
@@ -82,6 +99,12 @@
"jsr:@std/internal"
]
},
"@supabase/functions-js@2.95.3": {
"integrity": "cedb60df283f2d9dabf39655a8829f2c99d173e5e11a867c46e6987c59a1d582",
"dependencies": [
"npm:openai@^4.52.5"
]
},
"@zod/zod@4.3.5": {
"integrity": "3d14553f025d6e3d1a836b9c56e366bbce27d942301e8c7c1ac4c365df5f8085"
}
@@ -167,14 +190,24 @@
"@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": [
"bun-types"
]
},
"@types/node@25.0.8": {
"integrity": "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==",
"@types/node-fetch@2.6.13": {
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
"dependencies": [
"@types/node",
"form-data@4.0.5"
]
},
"@types/node@18.19.130": {
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"dependencies": [
"undici-types"
]
@@ -188,9 +221,24 @@
"@types/node"
]
},
"abort-controller@3.0.0": {
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"dependencies": [
"event-target-shim"
]
},
"agent-base@7.1.4": {
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="
},
"agentkeepalive@4.6.0": {
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
"dependencies": [
"humanize-ms"
]
},
"asynckit@0.4.0": {
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"bin-links@6.0.0": {
"integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==",
"dependencies": [
@@ -207,12 +255,36 @@
"@types/node"
]
},
"call-bind-apply-helpers@1.0.2": {
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": [
"es-errors",
"function-bind"
]
},
"carbone-sdk@1.6.0": {
"integrity": "sha512-fo/dPMwWnaCDK8sqwpkQbm7IiRpVKwRJu5gkVZj2gOQe6XyY6U6/N43c5bnA13GD9YxeI3WrWmj+rTbYlw8lEg==",
"dependencies": [
"form-data@4.0.0",
"kitten-cache",
"simple-get"
]
},
"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=="
},
"combined-stream@1.0.8": {
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": [
"delayed-stream"
]
},
"data-uri-to-buffer@4.0.1": {
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="
},
@@ -222,6 +294,15 @@
"ms"
]
},
"decompress-response@6.0.0": {
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dependencies": [
"mimic-response"
]
},
"delayed-stream@1.0.0": {
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
},
"deno@2.6.4": {
"integrity": "sha512-/nHUtrwoqUsjVTBWGqz3SjAbtBSfqCLb38YdkwstrO5qpQd3RC20dxsGYPPANy9RqMFJMfDyARL7RtOcUfatpw==",
"optionalDependencies": [
@@ -235,11 +316,71 @@
"scripts": true,
"bin": true
},
"dunder-proto@1.0.1": {
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": [
"call-bind-apply-helpers",
"es-errors",
"gopd"
]
},
"es-define-property@1.0.1": {
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
},
"es-errors@1.3.0": {
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
},
"es-object-atoms@1.1.1": {
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": [
"es-errors"
]
},
"es-set-tostringtag@2.1.0": {
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dependencies": [
"es-errors",
"get-intrinsic",
"has-tostringtag",
"hasown"
]
},
"event-target-shim@5.0.1": {
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
},
"fetch-blob@3.2.0": {
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"dependencies": [
"node-domexception",
"web-streams-polyfill"
"web-streams-polyfill@3.3.3"
]
},
"form-data-encoder@1.7.2": {
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="
},
"form-data@4.0.0": {
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": [
"asynckit",
"combined-stream",
"mime-types"
]
},
"form-data@4.0.5": {
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dependencies": [
"asynckit",
"combined-stream",
"es-set-tostringtag",
"hasown",
"mime-types"
]
},
"formdata-node@4.4.1": {
"integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
"dependencies": [
"node-domexception",
"web-streams-polyfill@4.0.0-beta.3"
]
},
"formdata-polyfill@4.0.10": {
@@ -248,6 +389,49 @@
"fetch-blob"
]
},
"function-bind@1.1.2": {
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
},
"get-intrinsic@1.3.0": {
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": [
"call-bind-apply-helpers",
"es-define-property",
"es-errors",
"es-object-atoms",
"function-bind",
"get-proto",
"gopd",
"has-symbols",
"hasown",
"math-intrinsics"
]
},
"get-proto@1.0.1": {
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": [
"dunder-proto",
"es-object-atoms"
]
},
"gopd@1.2.0": {
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
},
"has-symbols@1.1.0": {
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
},
"has-tostringtag@1.0.2": {
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dependencies": [
"has-symbols"
]
},
"hasown@2.0.2": {
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dependencies": [
"function-bind"
]
},
"https-proxy-agent@7.0.6": {
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dependencies": [
@@ -255,12 +439,39 @@
"debug"
]
},
"humanize-ms@1.2.1": {
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
"dependencies": [
"ms"
]
},
"iceberg-js@0.8.1": {
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="
},
"imurmurhash@0.1.4": {
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="
},
"jwt-decode@4.0.0": {
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="
},
"kitten-cache@0.3.0": {
"integrity": "sha512-PFu7PEmbDSFehdsGXz7JtE1L5fFktV+apTcmyamWPo5IuDnFaTc5dT24JlrgB2RzYclLunimWUX5i3vBkPfv9w=="
},
"math-intrinsics@1.1.0": {
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
},
"mime-db@1.52.0": {
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
},
"mime-types@2.1.35": {
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": [
"mime-db"
]
},
"mimic-response@3.1.0": {
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="
},
"minipass@7.1.2": {
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="
},
@@ -277,6 +488,12 @@
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": true
},
"node-fetch@2.7.0": {
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": [
"whatwg-url"
]
},
"node-fetch@3.3.2": {
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"dependencies": [
@@ -288,8 +505,37 @@
"npm-normalize-package-bin@5.0.0": {
"integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag=="
},
"openai@6.16.0": {
"once@1.4.0": {
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": [
"wrappy"
]
},
"openai@4.104.0_zod@3.25.76": {
"integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==",
"dependencies": [
"@types/node",
"@types/node-fetch",
"abort-controller",
"agentkeepalive",
"form-data-encoder",
"formdata-node",
"node-fetch@2.7.0",
"zod"
],
"optionalPeers": [
"zod"
],
"bin": true
},
"openai@6.16.0_zod@3.25.76": {
"integrity": "sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==",
"dependencies": [
"zod"
],
"optionalPeers": [
"zod"
],
"bin": true
},
"proc-log@6.1.0": {
@@ -301,36 +547,67 @@
"signal-exit@4.1.0": {
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="
},
"supabase@2.72.6": {
"integrity": "sha512-axO1MkZgwMIxj78vKiGosnuXwzOaQq0Kz0I8jheNMwJH9dKGf3q/arivYT44z8u3t5FjGUxQWOuQoJetzcmm/A==",
"simple-concat@1.0.1": {
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="
},
"simple-get@4.0.1": {
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"dependencies": [
"decompress-response",
"once",
"simple-concat"
]
},
"supabase@2.75.0": {
"integrity": "sha512-Xaai8Wp7F03OMkOWSyS1OMMdk5DxkauJxcMMesj4mO6jDGEZpWBwgo/gIUb4hsckexmr/RXYdpDpTbUIMXahbw==",
"dependencies": [
"bin-links",
"https-proxy-agent",
"node-fetch",
"node-fetch@3.3.2",
"tar"
],
"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",
"minipass",
"minizlib",
"yallist"
]
],
"deprecated": true
},
"tr46@0.0.3": {
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"tslib@2.8.1": {
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"undici-types@7.16.0": {
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="
"undici-types@5.26.5": {
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
},
"web-streams-polyfill@3.3.3": {
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="
},
"web-streams-polyfill@4.0.0-beta.3": {
"integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="
},
"webidl-conversions@3.0.1": {
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"whatwg-url@5.0.0": {
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": [
"tr46",
"webidl-conversions"
]
},
"wrappy@1.0.2": {
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"write-file-atomic@7.0.0": {
"integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==",
"dependencies": [
@@ -348,19 +625,44 @@
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="
}
},
"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",
"https://deno.land/x/zod@v3.22.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c",
"https://deno.land/x/zod@v3.22.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7",
"https://deno.land/x/zod@v3.22.4/helpers/parseUtil.ts": "f791e6e65a0340d85ad37d26cd7a3ba67126cd9957eac2b7163162155283abb1",
"https://deno.land/x/zod@v3.22.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7",
"https://deno.land/x/zod@v3.22.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e",
"https://deno.land/x/zod@v3.22.4/helpers/util.ts": "8baf19b19b2fca8424380367b90364b32503b6b71780269a6e3e67700bb02774",
"https://deno.land/x/zod@v3.22.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268",
"https://deno.land/x/zod@v3.22.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c",
"https://deno.land/x/zod@v3.22.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4",
"https://deno.land/x/zod@v3.22.4/types.ts": "724185522fafe43ee56a52333958764c8c8cd6ad4effa27b42651df873fc151e"
},
"workspace": {
"dependencies": [
"jsr:@david/dax@~0.44.2",
"jsr:@openai/openai@^6.16.0",
"jsr:@zod/zod@^4.3.5"
"jsr:@supabase/functions-js@2",
"jsr:@zod/zod@^4.3.5",
"npm:@supabase/supabase-js@2",
"npm:openai@6.16.0"
],
"packageJson": {
"dependencies": [
"npm:@supabase/supabase-js@^2.90.1",
"npm:@toon-format/toon@^2.1.0",
"npm:@types/bun@^1.3.5",
"npm:carbone-sdk@^1.6.0",
"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"
]
}
}
BIN
View File
Binary file not shown.
Binary file not shown.
+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
}
+772
View File
@@ -0,0 +1,772 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"id": "76919cba",
"metadata": {},
"outputs": [],
"source": [
"import { load } from \"jsr:@std/dotenv\";\n",
"\n",
"const env = await load({\n",
" export: true,\n",
"});\n"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "20231839",
"metadata": {},
"outputs": [],
"source": [
"type ListTemplatesParams = {\n",
" id?: string;\n",
" versionId?: string;\n",
" category?: string;\n",
" origin?: 0 | 1;\n",
" includeVersions?: boolean;\n",
" search?: string;\n",
" limit?: number;\n",
" cursor?: string;\n",
"};\n",
"\n",
"class CarboneClient {\n",
" listTemplates(params?: ListTemplatesParams) {}\n",
" listCategories() {}\n",
" listTags() {}\n",
" uploadTemplate(input: {\n",
" file?: Buffer;\n",
" filename?: string;\n",
" template?: string; // base64\n",
" versioning?: boolean;\n",
" id?: string;\n",
" name?: string;\n",
" comment?: string;\n",
" tags?: string[];\n",
" category?: string;\n",
" sample?: any[];\n",
" deployedAt?: number;\n",
" expireAt?: number;\n",
" }) {}\n",
" patchTemplate(id: string, body: {\n",
" name?: string;\n",
" comment?: string;\n",
" tags?: string[];\n",
" category?: string;\n",
" deployedAt?: number;\n",
" expireAt?: number;\n",
" }) {}\n",
" deleteTemplate(id: string) {}\n",
" downloadTemplate(id: string) {}\n",
" render(templateId: string, body: any, opts?: { download?: boolean }) {}\n",
" renderFromTemplate(body: any, opts?: { download?: boolean }) {}\n",
" downloadRender(renderId: string) {}\n",
"}\n"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "9950c1f7",
"metadata": {},
"outputs": [],
"source": [
"const CARBONE_VERSION = \"5\";\n",
"\n",
"type CarboneSuccess<T> = {\n",
" success: true;\n",
" data: T;\n",
" hasMore?: boolean;\n",
" nextCursor?: string;\n",
"};\n",
"\n",
"type CarboneError = {\n",
" success: false;\n",
" error: string;\n",
"};\n",
"\n",
"type CarboneResponse<T> = CarboneSuccess<T> | CarboneError;\n",
"\n",
"export class CarboneClient {\n",
" constructor(\n",
" private baseUrl: string,\n",
" private apiToken: string,\n",
" ) {}\n",
"\n",
" private buildHeaders(extra?: Record<string, string>) {\n",
" return {\n",
" Authorization: `Bearer ${this.apiToken}`,\n",
" \"carbone-version\": CARBONE_VERSION,\n",
" ...extra,\n",
" };\n",
" }\n",
"\n",
" private async parseResponse<T>(res: Response): Promise<T> {\n",
" const contentType = res.headers.get(\"content-type\") || \"\";\n",
"\n",
" if (!res.ok) {\n",
" let message = `Carbone request failed: ${res.status}`;\n",
" try {\n",
" const err = await res.json();\n",
" message = err?.error || err?.message || message;\n",
" } catch {}\n",
" throw new Error(message);\n",
" }\n",
"\n",
" if (contentType.includes(\"application/json\")) {\n",
" return res.json() as Promise<T>;\n",
" }\n",
"\n",
" return res as unknown as T;\n",
" }\n",
"\n",
" private toQuery(params?: Record<string, unknown>) {\n",
" const qs = new URLSearchParams();\n",
" if (!params) return \"\";\n",
" for (const [key, value] of Object.entries(params)) {\n",
" if (value === undefined || value === null) continue;\n",
" qs.set(key, String(value));\n",
" }\n",
" const s = qs.toString();\n",
" return s ? `?${s}` : \"\";\n",
" }\n",
"\n",
" async listTemplates(params?: {\n",
" id?: string;\n",
" versionId?: string;\n",
" category?: string;\n",
" origin?: 0 | 1;\n",
" includeVersions?: boolean;\n",
" search?: string;\n",
" limit?: number;\n",
" cursor?: string;\n",
" }) {\n",
" const res = await fetch(\n",
" `${this.baseUrl}/templates${this.toQuery(params)}`,\n",
" {\n",
" method: \"GET\",\n",
" headers: this.buildHeaders(),\n",
" },\n",
" );\n",
" return this.parseResponse<\n",
" CarboneResponse<\n",
" Array<{\n",
" id: string;\n",
" versionId: string;\n",
" deployedAt: number;\n",
" createdAt: number;\n",
" expireAt?: number;\n",
" size: number;\n",
" type: string;\n",
" name?: string;\n",
" category?: string;\n",
" comment?: string;\n",
" tags?: string[];\n",
" origin?: number;\n",
" }>\n",
" >\n",
" >(res);\n",
" }\n",
"\n",
" async listCategories() {\n",
" const res = await fetch(`${this.baseUrl}/templates/categories`, {\n",
" method: \"GET\",\n",
" headers: this.buildHeaders(),\n",
" });\n",
" return this.parseResponse<CarboneResponse<Array<{ name: string }>>>(res);\n",
" }\n",
"\n",
" async listTags() {\n",
" const res = await fetch(`${this.baseUrl}/templates/tags`, {\n",
" method: \"GET\",\n",
" headers: this.buildHeaders(),\n",
" });\n",
" return this.parseResponse<CarboneResponse<Array<{ name: string }>>>(res);\n",
" }\n",
"\n",
" async uploadTemplateJson(body: {\n",
" template: string;\n",
" versioning?: boolean;\n",
" id?: string;\n",
" name?: string;\n",
" comment?: string;\n",
" tags?: string[];\n",
" category?: string;\n",
" sample?: any[];\n",
" deployedAt?: number;\n",
" expireAt?: number;\n",
" }) {\n",
" const res = await fetch(`${this.baseUrl}/template`, {\n",
" method: \"POST\",\n",
" headers: this.buildHeaders({\n",
" \"Content-Type\": \"application/json\",\n",
" }),\n",
" body: JSON.stringify(body),\n",
" });\n",
" return this.parseResponse<\n",
" CarboneResponse<{\n",
" id?: string;\n",
" versionId?: string;\n",
" templateId?: string;\n",
" type?: string;\n",
" size?: number;\n",
" createdAt?: number;\n",
" }>\n",
" >(res);\n",
" }\n",
"\n",
" async uploadTemplateFile(input: {\n",
" file: Blob;\n",
" filename: string;\n",
" versioning?: boolean;\n",
" id?: string;\n",
" name?: string;\n",
" comment?: string;\n",
" tags?: string[];\n",
" category?: string;\n",
" sample?: any[];\n",
" deployedAt?: number;\n",
" expireAt?: number;\n",
" }) {\n",
" const form = new FormData();\n",
"\n",
" if (input.versioning !== undefined) {\n",
" form.append(\"versioning\", String(input.versioning));\n",
" }\n",
" if (input.id) form.append(\"id\", input.id);\n",
" if (input.name) form.append(\"name\", input.name);\n",
" if (input.comment) form.append(\"comment\", input.comment);\n",
" if (input.category) form.append(\"category\", input.category);\n",
" if (input.tags) {\n",
" for (const tag of input.tags) form.append(\"tags[]\", tag);\n",
" }\n",
" if (input.sample) form.append(\"sample\", JSON.stringify(input.sample));\n",
" if (input.deployedAt !== undefined) {\n",
" form.append(\"deployedAt\", String(input.deployedAt));\n",
" }\n",
" if (input.expireAt !== undefined) {\n",
" form.append(\"expireAt\", String(input.expireAt));\n",
" }\n",
"\n",
" // template should be appended last\n",
" form.append(\"template\", input.file, input.filename);\n",
"\n",
" const res = await fetch(`${this.baseUrl}/template`, {\n",
" method: \"POST\",\n",
" headers: this.buildHeaders(),\n",
" body: form,\n",
" });\n",
" return this.parseResponse<\n",
" CarboneResponse<{\n",
" id?: string;\n",
" versionId?: string;\n",
" templateId?: string;\n",
" type?: string;\n",
" size?: number;\n",
" createdAt?: number;\n",
" }>\n",
" >(res);\n",
" }\n",
"\n",
" async patchTemplate(\n",
" id: string,\n",
" body: {\n",
" name?: string;\n",
" comment?: string;\n",
" tags?: string[];\n",
" category?: string;\n",
" deployedAt?: number;\n",
" expireAt?: number;\n",
" },\n",
" ) {\n",
" const res = await fetch(`${this.baseUrl}/template/${id}`, {\n",
" method: \"PATCH\",\n",
" headers: this.buildHeaders({\n",
" \"Content-Type\": \"application/json\",\n",
" }),\n",
" body: JSON.stringify(body),\n",
" });\n",
" return this.parseResponse<CarboneResponse<true>>(res);\n",
" }\n",
"\n",
" async deleteTemplate(id: string) {\n",
" const res = await fetch(`${this.baseUrl}/template/${id}`, {\n",
" method: \"DELETE\",\n",
" headers: this.buildHeaders(),\n",
" });\n",
" return this.parseResponse<CarboneResponse<true>>(res);\n",
" }\n",
"\n",
" async downloadTemplate(id: string) {\n",
" const res = await fetch(`${this.baseUrl}/template/${id}`, {\n",
" method: \"GET\",\n",
" headers: this.buildHeaders(),\n",
" });\n",
"\n",
" if (!res.ok) {\n",
" const err = await res.json().catch(() => null);\n",
" throw new Error(\n",
" err?.error || `Failed to download template: ${res.status}`,\n",
" );\n",
" }\n",
"\n",
" return {\n",
" buffer: Buffer.from(await res.arrayBuffer()),\n",
" contentType: res.headers.get(\"content-type\"),\n",
" contentDisposition: res.headers.get(\"content-disposition\"),\n",
" };\n",
" }\n",
"\n",
" async render(\n",
" templateIdOrVersionId: string,\n",
" body: Record<string, unknown>,\n",
" opts?: { download?: boolean },\n",
" ) {\n",
" const query = opts?.download ? \"?download=true\" : \"\";\n",
" const res = await fetch(\n",
" `${this.baseUrl}/render/${templateIdOrVersionId}${query}`,\n",
" {\n",
" method: \"POST\",\n",
" headers: this.buildHeaders({\n",
" \"Content-Type\": \"application/json\",\n",
" }),\n",
" body: JSON.stringify(body),\n",
" },\n",
" );\n",
"\n",
" if (opts?.download) {\n",
" if (!res.ok) {\n",
" const err = await res.json().catch(() => null);\n",
" throw new Error(err?.error || `Render failed: ${res.status}`);\n",
" }\n",
" return {\n",
" buffer: Buffer.from(await res.arrayBuffer()),\n",
" contentType: res.headers.get(\"content-type\"),\n",
" contentDisposition: res.headers.get(\"content-disposition\"),\n",
" };\n",
" }\n",
"\n",
" return this.parseResponse<CarboneResponse<{ renderId: string }>>(res);\n",
" }\n",
"\n",
" async renderFromTemplate(\n",
" body: Record<string, unknown>,\n",
" opts?: { download?: boolean },\n",
" ) {\n",
" const query = opts?.download ? \"?download=true\" : \"\";\n",
" const res = await fetch(`${this.baseUrl}/render/template${query}`, {\n",
" method: \"POST\",\n",
" headers: this.buildHeaders({\n",
" \"Content-Type\": \"application/json\",\n",
" }),\n",
" body: JSON.stringify(body),\n",
" });\n",
"\n",
" if (opts?.download) {\n",
" if (!res.ok) {\n",
" const err = await res.json().catch(() => null);\n",
" throw new Error(err?.error || `Render failed: ${res.status}`);\n",
" }\n",
" return {\n",
" buffer: Buffer.from(await res.arrayBuffer()),\n",
" contentType: res.headers.get(\"content-type\"),\n",
" contentDisposition: res.headers.get(\"content-disposition\"),\n",
" };\n",
" }\n",
"\n",
" return this.parseResponse<CarboneResponse<{ renderId: string }>>(res);\n",
" }\n",
"\n",
" async downloadRender(renderId: string) {\n",
" const res = await fetch(`${this.baseUrl}/render/${renderId}`, {\n",
" method: \"GET\",\n",
" });\n",
"\n",
" if (!res.ok) {\n",
" const err = await res.json().catch(() => null);\n",
" throw new Error(err?.error || `Failed to download render: ${res.status}`);\n",
" }\n",
"\n",
" return {\n",
" buffer: Buffer.from(await res.arrayBuffer()),\n",
" contentType: res.headers.get(\"content-type\"),\n",
" contentDisposition: res.headers.get(\"content-disposition\"),\n",
" };\n",
" }\n",
"}\n"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "95b24436",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{\n",
" success: true,\n",
" data: [\n",
" {\n",
" versionId: \"1990f3351b4313779ae3ef3caeaea14dea51c3a98a691fb90a347dec53f875e6\",\n",
" id: \"1373945625988937236\",\n",
" deployedAt: 1773246290,\n",
" createdAt: 1773246316,\n",
" expireAt: 0,\n",
" size: 29008,\n",
" type: \"docx\",\n",
" name: \"Anexo 1\",\n",
" category: \"Planes de estudio\",\n",
" comment: \"Plantilla de anexo 1 SEP RVOE Acuerdo 17-11-17\",\n",
" tags: [],\n",
" origin: 1\n",
" },\n",
" {\n",
" versionId: \"caee5a7804647382f6df62ef92cc8f04c822b6a297479c5068e22d08def685d7\",\n",
" id: \"1373945108764607184\",\n",
" deployedAt: 1773246225,\n",
" createdAt: 1773246251,\n",
" expireAt: 0,\n",
" size: 29642,\n",
" type: \"xlsx\",\n",
" name: \"Anexo 2\",\n",
" category: \"\",\n",
" comment: \"\",\n",
" tags: [],\n",
" origin: 1\n",
" },\n",
" {\n",
" versionId: \"b0ab3c6fe90c73151fcae64d8509e406803381f3966aa88d30d8d30e2b1ffe8f\",\n",
" id: \"1373944894291796699\",\n",
" deployedAt: 1773246204,\n",
" createdAt: 1773246230,\n",
" expireAt: 0,\n",
" size: 23806,\n",
" type: \"docx\",\n",
" name: \"Anexo 3\",\n",
" category: \"\",\n",
" comment: \"\",\n",
" tags: [],\n",
" origin: 1\n",
" }\n",
" ],\n",
" hasMore: false\n",
"}\n",
"{ success: true, data: [ { name: \"Planes de estudio\" } ] }\n",
"{ success: true, data: [] }\n"
]
}
],
"source": [
"const carbone = new CarboneClient(\n",
" \"https://carbone.lci.ulsa.mx\",\n",
" Deno.env.get(\"CARBONE_API_TOKEN\")!,\n",
");\n",
"\n",
"const templates = await carbone.listTemplates({ limit: 20 });\n",
"console.log(templates);\n",
"\n",
"const categories = await carbone.listCategories();\n",
"console.log(categories);\n",
"\n",
"const tags = await carbone.listTags();\n",
"console.log(tags);\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "8196fac4",
"metadata": {},
"outputs": [],
"source": [
"const templateID = \"1373945625988937236\"; // Anexo 1 SEP RVOE Acuerdo 17-11-17\n"
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "caf8bc20",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{\n",
" success: \u001b[33mtrue\u001b[39m,\n",
" data: {\n",
" category: \u001b[32m\"Planes de estudio\"\u001b[39m,\n",
" comment: \u001b[32m\"Plantillaaa de anexo 1 SEP RVOE Acuerdo 17-11-17\"\u001b[39m,\n",
" versionId: \u001b[32m\"1990f3351b4313779ae3ef3caeaea14dea51c3a98a691fb90a347dec53f875e6\"\u001b[39m\n",
" }\n",
"}"
]
},
"execution_count": 6,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"await carbone.patchTemplate(templateID, {\n",
" category: \"Planes de estudio\",\n",
" comment: \"Plantillaaa de anexo 1 SEP RVOE Acuerdo 17-11-17\",\n",
"});\n"
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "929883b9",
"metadata": {},
"outputs": [],
"source": [
"const result = await carbone.render(\n",
" templateID,\n",
" {\n",
" data: {\n",
" \"vigencia\": \"2026\",\n",
" \"area_de_estudio\":\n",
" \"Educación. Campo amplio: Formación docente y educación especial (CMPE 2016, INEGI)\",\n",
" \"perfil_de_egreso\":\n",
" \"Conocimientos:\\n- Conocer los fundamentos teóricos y normativos de la educación especial y la inclusión educativa, incluidas las leyes y políticas nacionales aplicables.\\n- Conocer modelos de atención educativa (modelo ecológico, enfoque por competencias, Diseño Universal para el Aprendizaje, respuesta a la intervención) y sus implicaciones para la práctica de sombra.\\n- Conocer características y necesidades educativas de estudiantes con discapacidad, trastornos del desarrollo y necesidades educativas especiales específicas.\\n\\nHabilidades:\\n- Diseñar y aplicar estrategias individualizadas de acompañamiento (sombra) para facilitar la participación y el aprendizaje en contextos educativos regulares.\\n- Realizar observaciones sistemáticas, registros conductuales y adaptaciones curriculares básicas en colaboración con el personal docente y familias.\\n- Colaborar en equipos interdisciplinarios y comunicarse efectivamente con docentes, familias y especialistas para articular apoyos.\\n- Aplicar procedimientos de evaluación formativa y ajustar estrategias de intervención basadas en evidencia.\\n\\nActitudes y valores:\\n- Actuar con ética profesional, confidencialidad y respeto hacia la dignidad, autonomía y derechos de las personas con discapacidad.\\n- Mantener una actitud reflexiva, crítica y de mejora continua sustentada en la evidencia y en el aprendizaje colaborativo.\\n- Mostrar compromiso por la inclusión, la equidad y la promoción de entornos accesibles y participativos.\",\n",
" \"diseno_curricular\": \"Rígido\",\n",
" \"perfil_de_ingreso\":\n",
" \"Conocimientos previos:\\n- Certificado de Bachillerato o equivalente con dominio básico de lectura, redacción y razonamiento lógico-matemático.\\n- Conocimientos introductorios en psicología, pedagogía o atención a la diversidad (deseable, no obligatorio).\\n\\nHabilidades y aptitudes:\\n- Habilidad comunicativa efectiva y disposición para el trabajo en equipo.\\n- Capacidad de observación, registro y análisis de comportamientos en contextos educativos.\\n- Actitud empática, paciente y respetuosa frente a la diversidad funcional y cultural.\\n\\nCondiciones personales:\\n- Disponibilidad para prácticas en centros educativos y visitas domiciliarias.\\n- Compromiso ético y disposición para formación continua y trabajo con poblaciones vulnerables.\",\n",
" \"curso_propedeutico\":\n",
" \"Se ofrecerá un curso propedéutico opcional dirigido a aspirantes que no cuenten con formación previa en pedagogía o atención a la diversidad. Características:\\n- Población: Aspirantes sin experiencia formal en educación o con antecedentes en áreas no afines.\\n- Duración: 8 semanas (40 horas totales), modalidad mixta (sincrónico y asincrónico).\\n- Contenidos: Fundamentos de psicología del desarrollo, nociones básicas de pedagogía inclusiva, comunicación efectiva y manejo básico de registros.\\n- Relación con el plan de estudios: Prepara al aspirante para cursar asignaturas iniciales de didáctica y atención a la diversidad; no substituye requisitos de admisión, pero fortalece rendimiento académico inicial.\\n- Criterios de aplicación: Se recomienda para el 30% de la cohorte cuya evaluación de diagnóstico inicial identifique brechas relevantes en competencias lectoras, comunicativas o conocimientos básicos en educación.\",\n",
" \"modalidad_educativa\": \"Mixta\",\n",
" \"antecedente_academico\":\n",
" \"Certificado de Bachillerato o equivalente. Preferentemente procedencia de áreas afines como Ciencias de la Educación, Psicología, Trabajo Social, Pedagogía o áreas de ciencias sociales relacionadas con atención a la infancia y discapacidad.\",\n",
" \"carga_horaria_a_la_semana\": 40,\n",
" \"programa_de_investigacion\": null,\n",
" \"clave_del_plan_de_estudios\": \"LLS-LESE-2026\",\n",
" \"duracion_del_ciclo_escolar\": \"16 semanas\",\n",
" \"fines_de_aprendizaje_o_formacion\":\n",
" \"El programa tiene por finalidad formar profesionales de la educación especializados en el acompañamiento tipo 'sombra', capaces de intervenir en contextos educativos regulares y de atención especializada mediante prácticas fundamentadas en la evidencia, la colaboración interprofesional y el respeto a los derechos de las personas con discapacidad. Los fines de aprendizaje se articulan en competencias que guían la formación:\\n- Analizar los marcos normativos, éticos y teóricos que sustentan la educación especial y la inclusión, con el fin de garantizar intervenciones respetuosas de los derechos y la dignidad de las personas con discapacidad.\\n- Identificar las características desarrollo-cognitivas, socioemocionales y comunicativas de estudiantes con diferentes tipos de discapacidad o necesidades educativas especiales, para adecuar estrategias de apoyo.\\n- Diseñar planes de acompañamiento individualizados basados en la evaluación formativa, que integren adaptaciones curriculares, apoyos conductuales y mediaciones pedagógicas pertinentes al contexto escolar.\\n- Implementar técnicas de observación, registro y evaluación del comportamiento y del progreso académico, utilizando instrumentos estandarizados y protocolos de registro confiables.\\n- Colaborar y coordinar con docentes titulares, equipos interdisciplinares y familias para articular apoyos, comunicando hallazgos y proponiendo ajustes en el entorno y la práctica pedagógica.\\n- Implementar estrategias de mediación y apoyo que promuevan la participación activa, la autonomía funcional y la inclusión social del alumno dentro del aula regular.\\n- Evaluar la eficacia de las intervenciones de sombra mediante indicadores cualitativos y cuantitativos, ajustando las acciones en ciclos de mejora continua.\\n- Promover entornos accesibles y diseñar ajustes razonables y recursos didácticos adaptados que favorezcan el aprendizaje y la interacción social.\\n- Fomentar actitudes profesionales basadas en la ética, la confidencialidad, el respeto a la diversidad cultural y la toma de decisiones centrada en la persona.\\n- Difundir prácticas basadas en evidencia y favorecer la investigación aplicada en contextos escolares para la mejora de modelos de acompañamiento y políticas inclusivas.\",\n",
" \"nivel_y_nombre_del_plan_de_estudios\":\n",
" \"Licenciatura en Educación Especial (orientación: Sombra)\",\n",
" \"nombre_autorizado_de_la_institucion\":\n",
" \"Universidad La Salle México\",\n",
" \"total_de_ciclos_del_plan_de_estudios\": \"9\",\n",
" \"sustento_teorico_del_modelo_curricular\":\n",
" \"Lista de pasos conceptuales previos:\\n- Revisar requisitoss normativos del Acuerdo 17/11/17 y la normativa educativa nacional aplicable.\\n- Estructurar campos obligatorios del Anexo 1 y verificar coherencia entre ingreso, perfil y fines de aprendizaje.\\n- Diseñar el marco curricular alineado con teorías de inclusión, UDL y prácticas basadas en evidencia.\\n- Determinar la operatividad administrativa y los mecanismos de evaluación periódica.\\n- Validar la carga horaria, duración de ciclos y practicum con estándares nacionales y de buenas prácticas.\\n\\nMarco teórico y justificación del modelo curricular:\\n El modelo curricular de la Licenciatura en Educación Especial con orientación en acompañamiento tipo sombra parte de la premisa de que la inclusión educativa es un derecho humano y una responsabilidad institucional que requiere profesionales formados de manera específica para acompañar procesos de aprendizaje y participación en contextos escolares regulares. Se sustenta en los siguientes referentes teóricos y conceptuales:\\n - Diseño Universal para el Aprendizaje (UDL): plantea principios para anticipar barreras y diseñar múltiples vías de acceso al currículo, favoreciendo la planificación de adaptaciones y apoyos que el profesional en sombra debe conocer y aplicar.\\n - Enfoque ecológico y bio-psicosocial: reconoce que las necesidades de los estudiantes emergen de la interacción entre individuo, familia, escuela y comunidad; por tanto, la intervención del profesional sombra se concibe como mediación contextual, no como sustitución del docente.\\n - Teorías socioculturales del aprendizaje (Vygotsky): sustentan la importancia del andamiaje y la zona de desarrollo próximo, herramientas clave para la intervención individualizada y para promover la autonomía del estudiante mediante mediación intencional.\\n - Modelos colaborativos y de co-intervención: el plan se apoya en evidencia sobre la efectividad de prácticas colaborativas entre docente titular, especialista y familia para lograr inclusión sostenible; el profesional sombra es formado para integrarse de forma articulada en estos equipos.\\n - Prácticas basadas en evidencia y evaluación formativa: el currículo incorpora métodos de observación sistemática, medición del progreso y diseño de intervenciones ajustadas a resultados, promoviendo la investigación aplicada y la cultura de evaluación.\\n - Perspectiva de derechos y normativa nacional: el modelo articula la formación con el marco legal mexicano en materia educativa y de discapacidad, así como con principios de ética profesional y confidencialidad.\\n\\n La estructura curricular combina contenidos conceptuales, metodológicos y prácticos; integra prácticas supervisadas desde el primer año, seminarios de reflexión profesional, y proyectos integradores que vinculan investigación aplicada con mejora institucional. La formación busca que el egresado maneje herramientas para la evaluación, diseño e implementación de apoyos que fomenten la inclusión, la participación y el desarrollo integral del estudiante.\",\n",
" \"justificacion_de_la_propuesta_curricular\":\n",
" \"Modalidad mixta: explicación de implementación tecnológica, autogestión y flexibilidad espacio-temporal.\\n- Plataforma tecnológica: El programa empleará un LMS institucional (por ejemplo, Moodle institucional o plataforma propietaria) que soporta contenidos asincrónicos (módulos temáticos, lecturas, foros), actividades evaluables y repositorio de recursos adaptativos. Además se dispondrá de herramientas para videoconferencia (Zoom/Teams) para sesiones sincrónicas, tutorías y supervisión de práctica.\\n- Autogestión del aprendizaje: Las actividades asincrónicas incluirán guías de estudio, rúbricas claras y recursos multimedia. Se promoverá la metacognición mediante diarios de aprendizaje y portafolios electrónicos que permitan evidenciar competencias profesionales.\\n- Flexibilidad espacio-temporal: El diseño mixto permite que agentes (estudiantes, tutores, docentes) articulen sesiones presenciales para prácticas y evaluación, con trabajo a distancia para contenidos teóricos y asesorías. Esto facilita el acceso y la posibilidad de estancias en centros educativos diversos.\\n- Recursos didácticos y tecnológicos: Materiales accesibles (texto enriquecido, transcripciones, subtítulos, lecturas en formatos alternativos) y simuladores para entrenamiento de situaciones complejas. Herramientas para registro y análisis de observación (plantillas digitales, apps para registro de conductas) soportarán la formación práctica.\\n- Vinculación con asignaturas: Las plataformas alojarán los recursos de cada asignatura, enlaces a prácticas supervisadas, y espacios de interacción interprofesional. Los procesos de evaluación combinarán evidencias en línea y en campo, asegurando validez y confiabilidad.\\n- Soporte institucional: Tutorías académicas y tecnopedagógicas, acompañamiento para prácticas y mesas de análisis de casos. Se garantizará la interoperabilidad entre las actividades presenciales y virtuales para mantener coherencia pedagógica y cumplimiento de competencias.\",\n",
" \"administracion_y_operatividad_del_plan_de_estudios\":\n",
" \"Estructura administrativa y operativa:\\n- Coordinación académica: Existirá una Coordinación de la Licenciatura responsable del seguimiento académico, la vinculación con centros de práctica y la supervisión de procesos de aseguramiento de la calidad.\\n- Organización de la oferta: Diseño rígido en 9 semestres, con secuencia establecida de asignaturas obligatorias y optativas estructuradas por niveles formativos.\\n- Carga por ciclo: La carga promedio por semestre se organizará en torno a 40 horas semanales efectivas, que incluyen docencia teórica, actividades prácticas y tiempo de estudio guiado.\\n- Prácticas y estancias: Prácticas profesionales progresivas desde el segundo semestre, con incremento de horas prácticas hasta culminar en estancias intensivas en contextos escolares en el último año. Se definirá un manual de prácticas con perfiles de supervisores y criterios de evaluación.\\n- Plantilla docente: Perfil docente con grado mínimo de maestría en áreas afines, experiencia en inclusión y prácticas de sombra, y formación en investigación aplicada. Se promoverá la actualización continua y la supervisión por expertos.\\n- Gestión curricular: Cronograma anual, sesiones de coordinación docente por asignatura, sistema de registro de evidencias curriculares y control de avance estudiantil.\\n- Siembra de asignaturas por semestre (ejemplo operativo): Semestre 1-3: fundamentos y diagnóstico; Semestre 4-6: intervención y metodologías; Semestre 7-9: prácticas intensivas, proyectos integradores y seminario de titulación.\\n- Tutoría académica: Cada estudiante contará con un tutor académico y un tutor de prácticas, responsables de seguimiento del avance y apoyo en la articulación teoría-práctica.\",\n",
" \"propuesta_de_evaluacion_periodica_del_plan_de_estudios\":\n",
" \"Metodología de evaluación del plan de estudios:\\n- Objetivo: Mantener actualización curricular y pertinencia disciplinar mediante evaluación anual y revisiones integrales cada tres años.\\n- Instrumentos: Encuestas de egresados y empleadores, análisis de desempeño en prácticas, revisión de trayectorias académicas, indicadores de inserción laboral y evaluación por pares académicos.\\n- Periodicidad: Informes anuales de seguimiento; revisión curricular bianual focalizada en asignaturas clave; evaluación integral cada 3 años con participación de comité externo.\\n- Procedimiento: Recolección de datos cuantitativos y cualitativos, análisis por la coordinación, diseño de plan de mejora con responsables y plazos, seguimiento de indicadores y reporte público de avances.\\n- Indicadores clave: Tasa de titulación, desempeño en prácticas (evaluación de supervisores), satisfacción de empleadores, cumplimiento de cargas académicas, actualización de contenidos y vinculación con evidencia científica.\\n- Participación: Incluye estudiantes, docentes, empleadores, instituciones receptoras de prácticas y expertos externos para asegurar validez y pertinencia.\",\n",
" \"nombre_y_cargo_de_la_persona_facultada_para_autorizar_el_plan_de_estudios\":\n",
" \"Dr. Francisco Javier Ortega, Rector\",\n",
" },\n",
" convertTo: \"docx\",\n",
" reportName: \"anexo1.pdf\",\n",
" },\n",
" { download: true },\n",
");\n",
"\n",
"await Deno.writeFile(\"../anexo1.docx\", result.buffer);\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "29cdb42f",
"metadata": {},
"outputs": [],
"source": [
"const result = await carbone.render(\n",
" templateID,\n",
" {\n",
" data: {\n",
" \"vigencia\": \"2026\",\n",
" \"area_de_estudio\":\n",
" \"Educación. Campo amplio: Formación docente y educación especial (CMPE 2016, INEGI)\",\n",
" \"perfil_de_egreso\":\n",
" \"Conocimientos:\\n- Conocer los fundamentos teóricos y normativos de la educación especial y la inclusión educativa, incluidas las leyes y políticas nacionales aplicables.\\n- Conocer modelos de atención educativa (modelo ecológico, enfoque por competencias, Diseño Universal para el Aprendizaje, respuesta a la intervención) y sus implicaciones para la práctica de sombra.\\n- Conocer características y necesidades educativas de estudiantes con discapacidad, trastornos del desarrollo y necesidades educativas especiales específicas.\\n\\nHabilidades:\\n- Diseñar y aplicar estrategias individualizadas de acompañamiento (sombra) para facilitar la participación y el aprendizaje en contextos educativos regulares.\\n- Realizar observaciones sistemáticas, registros conductuales y adaptaciones curriculares básicas en colaboración con el personal docente y familias.\\n- Colaborar en equipos interdisciplinarios y comunicarse efectivamente con docentes, familias y especialistas para articular apoyos.\\n- Aplicar procedimientos de evaluación formativa y ajustar estrategias de intervención basadas en evidencia.\\n\\nActitudes y valores:\\n- Actuar con ética profesional, confidencialidad y respeto hacia la dignidad, autonomía y derechos de las personas con discapacidad.\\n- Mantener una actitud reflexiva, crítica y de mejora continua sustentada en la evidencia y en el aprendizaje colaborativo.\\n- Mostrar compromiso por la inclusión, la equidad y la promoción de entornos accesibles y participativos.\",\n",
" \"diseno_curricular\": \"Rígido\",\n",
" \"perfil_de_ingreso\":\n",
" \"Conocimientos previos:\\n- Certificado de Bachillerato o equivalente con dominio básico de lectura, redacción y razonamiento lógico-matemático.\\n- Conocimientos introductorios en psicología, pedagogía o atención a la diversidad (deseable, no obligatorio).\\n\\nHabilidades y aptitudes:\\n- Habilidad comunicativa efectiva y disposición para el trabajo en equipo.\\n- Capacidad de observación, registro y análisis de comportamientos en contextos educativos.\\n- Actitud empática, paciente y respetuosa frente a la diversidad funcional y cultural.\\n\\nCondiciones personales:\\n- Disponibilidad para prácticas en centros educativos y visitas domiciliarias.\\n- Compromiso ético y disposición para formación continua y trabajo con poblaciones vulnerables.\",\n",
" \"curso_propedeutico\":\n",
" \"Se ofrecerá un curso propedéutico opcional dirigido a aspirantes que no cuenten con formación previa en pedagogía o atención a la diversidad. Características:\\n- Población: Aspirantes sin experiencia formal en educación o con antecedentes en áreas no afines.\\n- Duración: 8 semanas (40 horas totales), modalidad mixta (sincrónico y asincrónico).\\n- Contenidos: Fundamentos de psicología del desarrollo, nociones básicas de pedagogía inclusiva, comunicación efectiva y manejo básico de registros.\\n- Relación con el plan de estudios: Prepara al aspirante para cursar asignaturas iniciales de didáctica y atención a la diversidad; no substituye requisitos de admisión, pero fortalece rendimiento académico inicial.\\n- Criterios de aplicación: Se recomienda para el 30% de la cohorte cuya evaluación de diagnóstico inicial identifique brechas relevantes en competencias lectoras, comunicativas o conocimientos básicos en educación.\",\n",
" \"modalidad_educativa\": \"Mixta\",\n",
" \"antecedente_academico\":\n",
" \"Certificado de Bachillerato o equivalente. Preferentemente procedencia de áreas afines como Ciencias de la Educación, Psicología, Trabajo Social, Pedagogía o áreas de ciencias sociales relacionadas con atención a la infancia y discapacidad.\",\n",
" \"carga_horaria_a_la_semana\": 40,\n",
" \"programa_de_investigacion\": null,\n",
" \"clave_del_plan_de_estudios\": \"LLS-LESE-2026\",\n",
" \"duracion_del_ciclo_escolar\": \"16 semanas\",\n",
" \"fines_de_aprendizaje_o_formacion\":\n",
" \"El programa tiene por finalidad formar profesionales de la educación especializados en el acompañamiento tipo 'sombra', capaces de intervenir en contextos educativos regulares y de atención especializada mediante prácticas fundamentadas en la evidencia, la colaboración interprofesional y el respeto a los derechos de las personas con discapacidad. Los fines de aprendizaje se articulan en competencias que guían la formación:\\n- Analizar los marcos normativos, éticos y teóricos que sustentan la educación especial y la inclusión, con el fin de garantizar intervenciones respetuosas de los derechos y la dignidad de las personas con discapacidad.\\n- Identificar las características desarrollo-cognitivas, socioemocionales y comunicativas de estudiantes con diferentes tipos de discapacidad o necesidades educativas especiales, para adecuar estrategias de apoyo.\\n- Diseñar planes de acompañamiento individualizados basados en la evaluación formativa, que integren adaptaciones curriculares, apoyos conductuales y mediaciones pedagógicas pertinentes al contexto escolar.\\n- Implementar técnicas de observación, registro y evaluación del comportamiento y del progreso académico, utilizando instrumentos estandarizados y protocolos de registro confiables.\\n- Colaborar y coordinar con docentes titulares, equipos interdisciplinares y familias para articular apoyos, comunicando hallazgos y proponiendo ajustes en el entorno y la práctica pedagógica.\\n- Implementar estrategias de mediación y apoyo que promuevan la participación activa, la autonomía funcional y la inclusión social del alumno dentro del aula regular.\\n- Evaluar la eficacia de las intervenciones de sombra mediante indicadores cualitativos y cuantitativos, ajustando las acciones en ciclos de mejora continua.\\n- Promover entornos accesibles y diseñar ajustes razonables y recursos didácticos adaptados que favorezcan el aprendizaje y la interacción social.\\n- Fomentar actitudes profesionales basadas en la ética, la confidencialidad, el respeto a la diversidad cultural y la toma de decisiones centrada en la persona.\\n- Difundir prácticas basadas en evidencia y favorecer la investigación aplicada en contextos escolares para la mejora de modelos de acompañamiento y políticas inclusivas.\",\n",
" \"nivel_y_nombre_del_plan_de_estudios\":\n",
" \"Licenciatura en Educación Especial (orientación: Sombra)\",\n",
" \"nombre_autorizado_de_la_institucion\":\n",
" \"Universidad La Salle México\",\n",
" \"total_de_ciclos_del_plan_de_estudios\": \"9\",\n",
" \"sustento_teorico_del_modelo_curricular\":\n",
" \"Lista de pasos conceptuales previos:\\n- Revisar requisitoss normativos del Acuerdo 17/11/17 y la normativa educativa nacional aplicable.\\n- Estructurar campos obligatorios del Anexo 1 y verificar coherencia entre ingreso, perfil y fines de aprendizaje.\\n- Diseñar el marco curricular alineado con teorías de inclusión, UDL y prácticas basadas en evidencia.\\n- Determinar la operatividad administrativa y los mecanismos de evaluación periódica.\\n- Validar la carga horaria, duración de ciclos y practicum con estándares nacionales y de buenas prácticas.\\n\\nMarco teórico y justificación del modelo curricular:\\n El modelo curricular de la Licenciatura en Educación Especial con orientación en acompañamiento tipo sombra parte de la premisa de que la inclusión educativa es un derecho humano y una responsabilidad institucional que requiere profesionales formados de manera específica para acompañar procesos de aprendizaje y participación en contextos escolares regulares. Se sustenta en los siguientes referentes teóricos y conceptuales:\\n - Diseño Universal para el Aprendizaje (UDL): plantea principios para anticipar barreras y diseñar múltiples vías de acceso al currículo, favoreciendo la planificación de adaptaciones y apoyos que el profesional en sombra debe conocer y aplicar.\\n - Enfoque ecológico y bio-psicosocial: reconoce que las necesidades de los estudiantes emergen de la interacción entre individuo, familia, escuela y comunidad; por tanto, la intervención del profesional sombra se concibe como mediación contextual, no como sustitución del docente.\\n - Teorías socioculturales del aprendizaje (Vygotsky): sustentan la importancia del andamiaje y la zona de desarrollo próximo, herramientas clave para la intervención individualizada y para promover la autonomía del estudiante mediante mediación intencional.\\n - Modelos colaborativos y de co-intervención: el plan se apoya en evidencia sobre la efectividad de prácticas colaborativas entre docente titular, especialista y familia para lograr inclusión sostenible; el profesional sombra es formado para integrarse de forma articulada en estos equipos.\\n - Prácticas basadas en evidencia y evaluación formativa: el currículo incorpora métodos de observación sistemática, medición del progreso y diseño de intervenciones ajustadas a resultados, promoviendo la investigación aplicada y la cultura de evaluación.\\n - Perspectiva de derechos y normativa nacional: el modelo articula la formación con el marco legal mexicano en materia educativa y de discapacidad, así como con principios de ética profesional y confidencialidad.\\n\\n La estructura curricular combina contenidos conceptuales, metodológicos y prácticos; integra prácticas supervisadas desde el primer año, seminarios de reflexión profesional, y proyectos integradores que vinculan investigación aplicada con mejora institucional. La formación busca que el egresado maneje herramientas para la evaluación, diseño e implementación de apoyos que fomenten la inclusión, la participación y el desarrollo integral del estudiante.\",\n",
" \"justificacion_de_la_propuesta_curricular\":\n",
" \"Modalidad mixta: explicación de implementación tecnológica, autogestión y flexibilidad espacio-temporal.\\n- Plataforma tecnológica: El programa empleará un LMS institucional (por ejemplo, Moodle institucional o plataforma propietaria) que soporta contenidos asincrónicos (módulos temáticos, lecturas, foros), actividades evaluables y repositorio de recursos adaptativos. Además se dispondrá de herramientas para videoconferencia (Zoom/Teams) para sesiones sincrónicas, tutorías y supervisión de práctica.\\n- Autogestión del aprendizaje: Las actividades asincrónicas incluirán guías de estudio, rúbricas claras y recursos multimedia. Se promoverá la metacognición mediante diarios de aprendizaje y portafolios electrónicos que permitan evidenciar competencias profesionales.\\n- Flexibilidad espacio-temporal: El diseño mixto permite que agentes (estudiantes, tutores, docentes) articulen sesiones presenciales para prácticas y evaluación, con trabajo a distancia para contenidos teóricos y asesorías. Esto facilita el acceso y la posibilidad de estancias en centros educativos diversos.\\n- Recursos didácticos y tecnológicos: Materiales accesibles (texto enriquecido, transcripciones, subtítulos, lecturas en formatos alternativos) y simuladores para entrenamiento de situaciones complejas. Herramientas para registro y análisis de observación (plantillas digitales, apps para registro de conductas) soportarán la formación práctica.\\n- Vinculación con asignaturas: Las plataformas alojarán los recursos de cada asignatura, enlaces a prácticas supervisadas, y espacios de interacción interprofesional. Los procesos de evaluación combinarán evidencias en línea y en campo, asegurando validez y confiabilidad.\\n- Soporte institucional: Tutorías académicas y tecnopedagógicas, acompañamiento para prácticas y mesas de análisis de casos. Se garantizará la interoperabilidad entre las actividades presenciales y virtuales para mantener coherencia pedagógica y cumplimiento de competencias.\",\n",
" \"administracion_y_operatividad_del_plan_de_estudios\":\n",
" \"Estructura administrativa y operativa:\\n- Coordinación académica: Existirá una Coordinación de la Licenciatura responsable del seguimiento académico, la vinculación con centros de práctica y la supervisión de procesos de aseguramiento de la calidad.\\n- Organización de la oferta: Diseño rígido en 9 semestres, con secuencia establecida de asignaturas obligatorias y optativas estructuradas por niveles formativos.\\n- Carga por ciclo: La carga promedio por semestre se organizará en torno a 40 horas semanales efectivas, que incluyen docencia teórica, actividades prácticas y tiempo de estudio guiado.\\n- Prácticas y estancias: Prácticas profesionales progresivas desde el segundo semestre, con incremento de horas prácticas hasta culminar en estancias intensivas en contextos escolares en el último año. Se definirá un manual de prácticas con perfiles de supervisores y criterios de evaluación.\\n- Plantilla docente: Perfil docente con grado mínimo de maestría en áreas afines, experiencia en inclusión y prácticas de sombra, y formación en investigación aplicada. Se promoverá la actualización continua y la supervisión por expertos.\\n- Gestión curricular: Cronograma anual, sesiones de coordinación docente por asignatura, sistema de registro de evidencias curriculares y control de avance estudiantil.\\n- Siembra de asignaturas por semestre (ejemplo operativo): Semestre 1-3: fundamentos y diagnóstico; Semestre 4-6: intervención y metodologías; Semestre 7-9: prácticas intensivas, proyectos integradores y seminario de titulación.\\n- Tutoría académica: Cada estudiante contará con un tutor académico y un tutor de prácticas, responsables de seguimiento del avance y apoyo en la articulación teoría-práctica.\",\n",
" \"propuesta_de_evaluacion_periodica_del_plan_de_estudios\":\n",
" \"Metodología de evaluación del plan de estudios:\\n- Objetivo: Mantener actualización curricular y pertinencia disciplinar mediante evaluación anual y revisiones integrales cada tres años.\\n- Instrumentos: Encuestas de egresados y empleadores, análisis de desempeño en prácticas, revisión de trayectorias académicas, indicadores de inserción laboral y evaluación por pares académicos.\\n- Periodicidad: Informes anuales de seguimiento; revisión curricular bianual focalizada en asignaturas clave; evaluación integral cada 3 años con participación de comité externo.\\n- Procedimiento: Recolección de datos cuantitativos y cualitativos, análisis por la coordinación, diseño de plan de mejora con responsables y plazos, seguimiento de indicadores y reporte público de avances.\\n- Indicadores clave: Tasa de titulación, desempeño en prácticas (evaluación de supervisores), satisfacción de empleadores, cumplimiento de cargas académicas, actualización de contenidos y vinculación con evidencia científica.\\n- Participación: Incluye estudiantes, docentes, empleadores, instituciones receptoras de prácticas y expertos externos para asegurar validez y pertinencia.\",\n",
" \"nombre_y_cargo_de_la_persona_facultada_para_autorizar_el_plan_de_estudios\":\n",
" \"Dr. Francisco Javier Ortega, Rector\",\n",
" },\n",
" convertTo: \"pdf\",\n",
" reportName: \"anexo1.pdf\",\n",
" },\n",
" { download: true },\n",
");\n",
"\n",
"await Deno.writeFile(\"../dist/anexo1.pdf\", result.buffer);\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "18fec251",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{\n",
" success: true,\n",
" data: [\n",
" {\n",
" versionId: \"1990f3351b4313779ae3ef3caeaea14dea51c3a98a691fb90a347dec53f875e6\",\n",
" id: \"1373945625988937236\",\n",
" deployedAt: 1773246290,\n",
" createdAt: 1773246316,\n",
" expireAt: 0,\n",
" size: 29008,\n",
" type: \"docx\",\n",
" name: \"Anexo 1\",\n",
" category: \"Planes de estudio\",\n",
" comment: \"Plantillaaa de anexo 1 SEP RVOE Acuerdo 17-11-17\",\n",
" tags: [],\n",
" origin: 1\n",
" }\n",
" ],\n",
" hasMore: false\n",
"}\n"
]
}
],
"source": [
"const versions = await carbone.listTemplates({\n",
" id: \"1373945625988937236\",\n",
" includeVersions: true,\n",
"});\n",
"\n",
"console.log(versions);\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "fc4b1068",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{\n",
" success: true,\n",
" data: { renderId: \"xaVIgwAb09fKX_0iLOFYawYW5leG8xLnBkZg.pdf\" }\n",
"}\n"
]
}
],
"source": [
"const result = await carbone.render(\n",
" templateID,\n",
" {\n",
" data: {\n",
" \"vigencia\": \"2026\",\n",
" \"area_de_estudio\":\n",
" \"Educación. Campo amplio: Formación docente y educación especial (CMPE 2016, INEGI)\",\n",
" \"perfil_de_egreso\":\n",
" \"Conocimientos:\\n- Conocer los fundamentos teóricos y normativos de la educación especial y la inclusión educativa, incluidas las leyes y políticas nacionales aplicables.\\n- Conocer modelos de atención educativa (modelo ecológico, enfoque por competencias, Diseño Universal para el Aprendizaje, respuesta a la intervención) y sus implicaciones para la práctica de sombra.\\n- Conocer características y necesidades educativas de estudiantes con discapacidad, trastornos del desarrollo y necesidades educativas especiales específicas.\\n\\nHabilidades:\\n- Diseñar y aplicar estrategias individualizadas de acompañamiento (sombra) para facilitar la participación y el aprendizaje en contextos educativos regulares.\\n- Realizar observaciones sistemáticas, registros conductuales y adaptaciones curriculares básicas en colaboración con el personal docente y familias.\\n- Colaborar en equipos interdisciplinarios y comunicarse efectivamente con docentes, familias y especialistas para articular apoyos.\\n- Aplicar procedimientos de evaluación formativa y ajustar estrategias de intervención basadas en evidencia.\\n\\nActitudes y valores:\\n- Actuar con ética profesional, confidencialidad y respeto hacia la dignidad, autonomía y derechos de las personas con discapacidad.\\n- Mantener una actitud reflexiva, crítica y de mejora continua sustentada en la evidencia y en el aprendizaje colaborativo.\\n- Mostrar compromiso por la inclusión, la equidad y la promoción de entornos accesibles y participativos.\",\n",
" \"diseno_curricular\": \"Rígido\",\n",
" \"perfil_de_ingreso\":\n",
" \"Conocimientos previos:\\n- Certificado de Bachillerato o equivalente con dominio básico de lectura, redacción y razonamiento lógico-matemático.\\n- Conocimientos introductorios en psicología, pedagogía o atención a la diversidad (deseable, no obligatorio).\\n\\nHabilidades y aptitudes:\\n- Habilidad comunicativa efectiva y disposición para el trabajo en equipo.\\n- Capacidad de observación, registro y análisis de comportamientos en contextos educativos.\\n- Actitud empática, paciente y respetuosa frente a la diversidad funcional y cultural.\\n\\nCondiciones personales:\\n- Disponibilidad para prácticas en centros educativos y visitas domiciliarias.\\n- Compromiso ético y disposición para formación continua y trabajo con poblaciones vulnerables.\",\n",
" \"curso_propedeutico\":\n",
" \"Se ofrecerá un curso propedéutico opcional dirigido a aspirantes que no cuenten con formación previa en pedagogía o atención a la diversidad. Características:\\n- Población: Aspirantes sin experiencia formal en educación o con antecedentes en áreas no afines.\\n- Duración: 8 semanas (40 horas totales), modalidad mixta (sincrónico y asincrónico).\\n- Contenidos: Fundamentos de psicología del desarrollo, nociones básicas de pedagogía inclusiva, comunicación efectiva y manejo básico de registros.\\n- Relación con el plan de estudios: Prepara al aspirante para cursar asignaturas iniciales de didáctica y atención a la diversidad; no substituye requisitos de admisión, pero fortalece rendimiento académico inicial.\\n- Criterios de aplicación: Se recomienda para el 30% de la cohorte cuya evaluación de diagnóstico inicial identifique brechas relevantes en competencias lectoras, comunicativas o conocimientos básicos en educación.\",\n",
" \"modalidad_educativa\": \"Mixta\",\n",
" \"antecedente_academico\":\n",
" \"Certificado de Bachillerato o equivalente. Preferentemente procedencia de áreas afines como Ciencias de la Educación, Psicología, Trabajo Social, Pedagogía o áreas de ciencias sociales relacionadas con atención a la infancia y discapacidad.\",\n",
" \"carga_horaria_a_la_semana\": 40,\n",
" \"programa_de_investigacion\": null,\n",
" \"clave_del_plan_de_estudios\": \"LLS-LESE-2026\",\n",
" \"duracion_del_ciclo_escolar\": \"16 semanas\",\n",
" \"fines_de_aprendizaje_o_formacion\":\n",
" \"El programa tiene por finalidad formar profesionales de la educación especializados en el acompañamiento tipo 'sombra', capaces de intervenir en contextos educativos regulares y de atención especializada mediante prácticas fundamentadas en la evidencia, la colaboración interprofesional y el respeto a los derechos de las personas con discapacidad. Los fines de aprendizaje se articulan en competencias que guían la formación:\\n- Analizar los marcos normativos, éticos y teóricos que sustentan la educación especial y la inclusión, con el fin de garantizar intervenciones respetuosas de los derechos y la dignidad de las personas con discapacidad.\\n- Identificar las características desarrollo-cognitivas, socioemocionales y comunicativas de estudiantes con diferentes tipos de discapacidad o necesidades educativas especiales, para adecuar estrategias de apoyo.\\n- Diseñar planes de acompañamiento individualizados basados en la evaluación formativa, que integren adaptaciones curriculares, apoyos conductuales y mediaciones pedagógicas pertinentes al contexto escolar.\\n- Implementar técnicas de observación, registro y evaluación del comportamiento y del progreso académico, utilizando instrumentos estandarizados y protocolos de registro confiables.\\n- Colaborar y coordinar con docentes titulares, equipos interdisciplinares y familias para articular apoyos, comunicando hallazgos y proponiendo ajustes en el entorno y la práctica pedagógica.\\n- Implementar estrategias de mediación y apoyo que promuevan la participación activa, la autonomía funcional y la inclusión social del alumno dentro del aula regular.\\n- Evaluar la eficacia de las intervenciones de sombra mediante indicadores cualitativos y cuantitativos, ajustando las acciones en ciclos de mejora continua.\\n- Promover entornos accesibles y diseñar ajustes razonables y recursos didácticos adaptados que favorezcan el aprendizaje y la interacción social.\\n- Fomentar actitudes profesionales basadas en la ética, la confidencialidad, el respeto a la diversidad cultural y la toma de decisiones centrada en la persona.\\n- Difundir prácticas basadas en evidencia y favorecer la investigación aplicada en contextos escolares para la mejora de modelos de acompañamiento y políticas inclusivas.\",\n",
" \"nivel_y_nombre_del_plan_de_estudios\":\n",
" \"Licenciatura en Educación Especial (orientación: Sombra)\",\n",
" \"nombre_autorizado_de_la_institucion\":\n",
" \"Universidad La Salle México\",\n",
" \"total_de_ciclos_del_plan_de_estudios\": \"9\",\n",
" \"sustento_teorico_del_modelo_curricular\":\n",
" \"Lista de pasos conceptuales previos:\\n- Revisar requisitoss normativos del Acuerdo 17/11/17 y la normativa educativa nacional aplicable.\\n- Estructurar campos obligatorios del Anexo 1 y verificar coherencia entre ingreso, perfil y fines de aprendizaje.\\n- Diseñar el marco curricular alineado con teorías de inclusión, UDL y prácticas basadas en evidencia.\\n- Determinar la operatividad administrativa y los mecanismos de evaluación periódica.\\n- Validar la carga horaria, duración de ciclos y practicum con estándares nacionales y de buenas prácticas.\\n\\nMarco teórico y justificación del modelo curricular:\\n El modelo curricular de la Licenciatura en Educación Especial con orientación en acompañamiento tipo sombra parte de la premisa de que la inclusión educativa es un derecho humano y una responsabilidad institucional que requiere profesionales formados de manera específica para acompañar procesos de aprendizaje y participación en contextos escolares regulares. Se sustenta en los siguientes referentes teóricos y conceptuales:\\n - Diseño Universal para el Aprendizaje (UDL): plantea principios para anticipar barreras y diseñar múltiples vías de acceso al currículo, favoreciendo la planificación de adaptaciones y apoyos que el profesional en sombra debe conocer y aplicar.\\n - Enfoque ecológico y bio-psicosocial: reconoce que las necesidades de los estudiantes emergen de la interacción entre individuo, familia, escuela y comunidad; por tanto, la intervención del profesional sombra se concibe como mediación contextual, no como sustitución del docente.\\n - Teorías socioculturales del aprendizaje (Vygotsky): sustentan la importancia del andamiaje y la zona de desarrollo próximo, herramientas clave para la intervención individualizada y para promover la autonomía del estudiante mediante mediación intencional.\\n - Modelos colaborativos y de co-intervención: el plan se apoya en evidencia sobre la efectividad de prácticas colaborativas entre docente titular, especialista y familia para lograr inclusión sostenible; el profesional sombra es formado para integrarse de forma articulada en estos equipos.\\n - Prácticas basadas en evidencia y evaluación formativa: el currículo incorpora métodos de observación sistemática, medición del progreso y diseño de intervenciones ajustadas a resultados, promoviendo la investigación aplicada y la cultura de evaluación.\\n - Perspectiva de derechos y normativa nacional: el modelo articula la formación con el marco legal mexicano en materia educativa y de discapacidad, así como con principios de ética profesional y confidencialidad.\\n\\n La estructura curricular combina contenidos conceptuales, metodológicos y prácticos; integra prácticas supervisadas desde el primer año, seminarios de reflexión profesional, y proyectos integradores que vinculan investigación aplicada con mejora institucional. La formación busca que el egresado maneje herramientas para la evaluación, diseño e implementación de apoyos que fomenten la inclusión, la participación y el desarrollo integral del estudiante.\",\n",
" \"justificacion_de_la_propuesta_curricular\":\n",
" \"Modalidad mixta: explicación de implementación tecnológica, autogestión y flexibilidad espacio-temporal.\\n- Plataforma tecnológica: El programa empleará un LMS institucional (por ejemplo, Moodle institucional o plataforma propietaria) que soporta contenidos asincrónicos (módulos temáticos, lecturas, foros), actividades evaluables y repositorio de recursos adaptativos. Además se dispondrá de herramientas para videoconferencia (Zoom/Teams) para sesiones sincrónicas, tutorías y supervisión de práctica.\\n- Autogestión del aprendizaje: Las actividades asincrónicas incluirán guías de estudio, rúbricas claras y recursos multimedia. Se promoverá la metacognición mediante diarios de aprendizaje y portafolios electrónicos que permitan evidenciar competencias profesionales.\\n- Flexibilidad espacio-temporal: El diseño mixto permite que agentes (estudiantes, tutores, docentes) articulen sesiones presenciales para prácticas y evaluación, con trabajo a distancia para contenidos teóricos y asesorías. Esto facilita el acceso y la posibilidad de estancias en centros educativos diversos.\\n- Recursos didácticos y tecnológicos: Materiales accesibles (texto enriquecido, transcripciones, subtítulos, lecturas en formatos alternativos) y simuladores para entrenamiento de situaciones complejas. Herramientas para registro y análisis de observación (plantillas digitales, apps para registro de conductas) soportarán la formación práctica.\\n- Vinculación con asignaturas: Las plataformas alojarán los recursos de cada asignatura, enlaces a prácticas supervisadas, y espacios de interacción interprofesional. Los procesos de evaluación combinarán evidencias en línea y en campo, asegurando validez y confiabilidad.\\n- Soporte institucional: Tutorías académicas y tecnopedagógicas, acompañamiento para prácticas y mesas de análisis de casos. Se garantizará la interoperabilidad entre las actividades presenciales y virtuales para mantener coherencia pedagógica y cumplimiento de competencias.\",\n",
" \"administracion_y_operatividad_del_plan_de_estudios\":\n",
" \"Estructura administrativa y operativa:\\n- Coordinación académica: Existirá una Coordinación de la Licenciatura responsable del seguimiento académico, la vinculación con centros de práctica y la supervisión de procesos de aseguramiento de la calidad.\\n- Organización de la oferta: Diseño rígido en 9 semestres, con secuencia establecida de asignaturas obligatorias y optativas estructuradas por niveles formativos.\\n- Carga por ciclo: La carga promedio por semestre se organizará en torno a 40 horas semanales efectivas, que incluyen docencia teórica, actividades prácticas y tiempo de estudio guiado.\\n- Prácticas y estancias: Prácticas profesionales progresivas desde el segundo semestre, con incremento de horas prácticas hasta culminar en estancias intensivas en contextos escolares en el último año. Se definirá un manual de prácticas con perfiles de supervisores y criterios de evaluación.\\n- Plantilla docente: Perfil docente con grado mínimo de maestría en áreas afines, experiencia en inclusión y prácticas de sombra, y formación en investigación aplicada. Se promoverá la actualización continua y la supervisión por expertos.\\n- Gestión curricular: Cronograma anual, sesiones de coordinación docente por asignatura, sistema de registro de evidencias curriculares y control de avance estudiantil.\\n- Siembra de asignaturas por semestre (ejemplo operativo): Semestre 1-3: fundamentos y diagnóstico; Semestre 4-6: intervención y metodologías; Semestre 7-9: prácticas intensivas, proyectos integradores y seminario de titulación.\\n- Tutoría académica: Cada estudiante contará con un tutor académico y un tutor de prácticas, responsables de seguimiento del avance y apoyo en la articulación teoría-práctica.\",\n",
" \"propuesta_de_evaluacion_periodica_del_plan_de_estudios\":\n",
" \"Metodología de evaluación del plan de estudios:\\n- Objetivo: Mantener actualización curricular y pertinencia disciplinar mediante evaluación anual y revisiones integrales cada tres años.\\n- Instrumentos: Encuestas de egresados y empleadores, análisis de desempeño en prácticas, revisión de trayectorias académicas, indicadores de inserción laboral y evaluación por pares académicos.\\n- Periodicidad: Informes anuales de seguimiento; revisión curricular bianual focalizada en asignaturas clave; evaluación integral cada 3 años con participación de comité externo.\\n- Procedimiento: Recolección de datos cuantitativos y cualitativos, análisis por la coordinación, diseño de plan de mejora con responsables y plazos, seguimiento de indicadores y reporte público de avances.\\n- Indicadores clave: Tasa de titulación, desempeño en prácticas (evaluación de supervisores), satisfacción de empleadores, cumplimiento de cargas académicas, actualización de contenidos y vinculación con evidencia científica.\\n- Participación: Incluye estudiantes, docentes, empleadores, instituciones receptoras de prácticas y expertos externos para asegurar validez y pertinencia.\",\n",
" \"nombre_y_cargo_de_la_persona_facultada_para_autorizar_el_plan_de_estudios\":\n",
" \"Dr. Francisco Javier Ortega, Rector\",\n",
" },\n",
" convertTo: \"pdf\",\n",
" reportName: \"anexo1.pdf\",\n",
" },\n",
" { download: false },\n",
");\n",
"\n",
"console.log(result);\n",
"\n",
"// const file = await carbone.downloadRender(render.data.renderId);\n",
"const file = await carbone.downloadRender(\n",
" \"X1Bx6Stre95Sm54jHxIwpQYW5leG8xLnBkZg.pdf\",\n",
");\n",
"await Deno.writeFile(\"dist/anexo1.pdf\", file.buffer);\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "5fe4c4d1",
"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
}
+43
View File
@@ -0,0 +1,43 @@
import ExcelJS from "exceljs";
import { applyMergePattern } from "./lib";
/* const inputFile = Bun.file("mapa.xlsx");
const inputBuffer = await inputFile.arrayBuffer();
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(inputBuffer); */
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile("mapa.xlsx");
const sheetName = 'RÍGIDO-Anexo 2 (A)'
const sheet = workbook.getWorksheet(sheetName)
if (!sheet) {
throw new Error(`Workbook has no sheet named: ${sheetName}`);
}
const CICLOS = 9;
const matrizAsignaturas = {
startColumns: ["C", "I", "O", "U", "AA"],
firstStartRow: 8,
mergeWidthInColumns: 5,
mergeHeightInRows: 3,
mergeBlockCount: CICLOS,
rowStepBetweenBlocks: 14 - 8,
} as const;
const semestres = {
startColumns: ["A"],
firstStartRow: 7,
mergeWidthInColumns: 1,
mergeHeightInRows: 5,
mergeBlockCount: CICLOS,
rowStepBetweenBlocks: 13 - 7,
} as const;
applyMergePattern(sheet, matrizAsignaturas);
applyMergePattern(sheet, semestres);
const outputBuffer = await workbook.xlsx.writeBuffer();
await Bun.write("excel2.xlsx", outputBuffer);
const end = Bun.nanoseconds();
+57
View File
@@ -0,0 +1,57 @@
import { type Worksheet } from "exceljs";
/**
* Converts an Excel column label to a 1-based number.
* Example: A -> 1, Z -> 26, AA -> 27
*/
function columnToNumber(label: string): number {
let n = 0;
for (let i = 0; i < label.length; i++) {
n = n * 26 + label.charCodeAt(i) - 64;
}
return n;
}
/**
* Generates merge ranges like:
* C8:G10, I8:M10, ...
* then C14:G16, I14:M16, ...
*/
type MergeRangePattern = {
startColumns: readonly string[];
firstStartRow: number;
mergeWidthInColumns: number;
mergeHeightInRows: number;
mergeBlockCount: number;
rowStepBetweenBlocks: number;
};
export function applyMergePattern(sheet: Worksheet, config: MergeRangePattern) {
const {
startColumns,
firstStartRow,
mergeWidthInColumns,
mergeHeightInRows,
mergeBlockCount,
rowStepBetweenBlocks
} = config;
for (let block = 0; block < mergeBlockCount; block++) {
const rowStart = firstStartRow + block * rowStepBetweenBlocks;
const rowEnd = rowStart + mergeHeightInRows - 1;
for (const column of startColumns) {
const colStart = columnToNumber(column);
const colEnd = colStart + mergeWidthInColumns - 1;
sheet.mergeCells(rowStart, colStart, rowEnd, colEnd);
}
}
}
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
}
+5 -1
View File
@@ -1,9 +1,13 @@
{
"dependencies": {
"@supabase/supabase-js": "^2.90.1",
"@toon-format/toon": "^2.1.0",
"carbone-sdk": "^1.6.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",
-203
View File
@@ -1,203 +0,0 @@
{
"type": "object",
"required": [
"nombre_autorizado_de_la_institucion",
"nivel_y_nombre_del_plan_de_estudios",
"modalidad_educativa",
"vigencia",
"antecedente_academico",
"area_de_estudio",
"clave_del_plan_de_estudios",
"diseno_curricular",
"total_de_ciclos_del_plan_de_estudios",
"duracion_del_ciclo_escolar",
"carga_horaria_a_la_semana",
"fines_de_aprendizaje_o_formacion",
"perfil_de_egreso",
"programa_de_investigacion",
"curso_propedeutico",
"perfil_de_ingreso",
"administracion_y_operatividad_del_plan_de_estudios",
"sustento_teorico_del_modelo_curricular",
"justificacion_de_la_propuesta_curricular",
"propuesta_de_evaluacion_periodica_del_plan_de_estudios",
"nombre_y_cargo_de_la_persona_facultada_para_autorizar_el_plan_de_estudios"
],
"properties": {
"nombre_autorizado_de_la_institucion": {
"type": "string",
"title": "Nombre autorizado de la institución",
"examples": ["Universidad La Salle A.C."],
"description": "Proporcionar el nombre de la institución autorizado por SEP.",
"referencia_normativa": "Dato administrativo del Anexo 1."
},
"nivel_y_nombre_del_plan_de_estudios": {
"type": "string",
"title": "Nivel y nombre del plan de estudios",
"examples": [
"Licenciatura en Derecho",
"Maestría en Ciencias de la Computación"
],
"description": "Indicar el nivel educativo y denominación del plan de estudios.",
"referencia_normativa": "Corresponde al requisito 'Nivel y denominación del plan de estudios' del Artículo 8, fracción I del Acuerdo 17/11/17."
},
"modalidad_educativa": {
"enum": ["Escolar", "No escolarizada", "Mixta"],
"type": "string",
"title": "Modalidad educativa",
"examples": ["Escolar"],
"description": "Señalar la modalidad educativa en la que se impartirá el plan de estudios: escolar, no escolarizada o mixta.",
"referencia_normativa": "Corresponde al requisito de 'Modalidad educativa' del Artículo 8, fracción II del Acuerdo 17/11/17."
},
"vigencia": {
"type": "string",
"title": "Vigencia",
"examples": ["2024-2029"],
"description": "(Espacio para uso exclusivo de la autoridad educativa)",
"referencia_normativa": "Dato administrativo del Anexo 1."
},
"antecedente_academico": {
"type": "string",
"title": "Antecedente académico",
"examples": [
"Bachillerato concluido o equivalente.",
"Título de Licenciatura en áreas afines a la salud."
],
"description": "Señalar el nivel educativo requerido para ingresar al plan de estudios. Para posgrados, además del nivel educativo; indicar la(s) disciplina(s) o área(s) afín(es) al plan de estudios.",
"referencia_normativa": "Corresponde al requisito 'Perfil de ingreso' (antecedente académico necesario) del Artículo 8, fracción V del Acuerdo 17/11/17."
},
"area_de_estudio": {
"type": "string",
"title": "Área de estudio",
"examples": [
"Ingeniería, industria y construcción",
"Ciencias sociales y derecho"
],
"description": "Señalar el “campo amplio” al que pertenece el plan de estudios. Para ello, considerar la Clasificación Mexicana de Planes de Estudio por Campos de Formación Académica (CMPE 2016), publicado por INEGI.",
"referencia_normativa": "Deberá vincularse con el fin de aprendizaje y perfil de egreso del plan de estudios."
},
"clave_del_plan_de_estudios": {
"type": "string",
"title": "Clave / Año del plan",
"examples": ["2024"],
"description": "Señalar el año en el que solicita el trámite.",
"referencia_normativa": "Dato administrativo del Anexo 1."
},
"diseno_curricular": {
"enum": ["Rígido", "Flexible"],
"type": "string",
"title": "Diseño curricular",
"examples": ["Flexible"],
"description": "Indicar si es “Rígido” o “Flexible”.",
"referencia_normativa": "Define la estructura para el requisito 'Mapa curricular' del Artículo 8, fracción VII del Acuerdo 17/11/17."
},
"total_de_ciclos_del_plan_de_estudios": {
"type": "string",
"title": "Número de ciclos escolares que integrarán el plan de estudio",
"examples": ["9 cuatrimestres", "8 semestres"],
"description": "En diseño flexible especificar mínimo y máximo de ciclos. En diseño rígido especificar el total de ciclos.",
"referencia_normativa": "Corresponde a 'señalando el número de ciclos en que se impartirá' del Artículo 8, fracción III del Acuerdo 17/11/17."
},
"duracion_del_ciclo_escolar": {
"type": "string",
"title": "Duración mínima en semanas de cada ciclo escolar",
"examples": ["16 semanas"],
"description": "Señalar el número mínimo de semanas efectivas de clase que integran el tipo de ciclo en el que se imparte el plan de estudios.",
"referencia_normativa": "Corresponde exactamente al requisito 'Duración mínima en semanas' del Artículo 8, fracción III del Acuerdo 17/11/17."
},
"carga_horaria_a_la_semana": {
"type": "number",
"title": "Carga horaria a la semana",
"examples": [25],
"description": "Indicar la máxima carga horaria impartida a la semana; no mayor a 50 horas bajo la conducción de un académico.",
"referencia_normativa": "Corresponde a la restricción 'sin exceder una carga máxima de 50 horas efectivas por semana' del Artículo 8, fracción III del Acuerdo 17/11/17."
},
"fines_de_aprendizaje_o_formacion": {
"type": "string",
"title": "Fin de aprendizaje o formación",
"examples": [
"El estudiante analizará los principios fundamentales del derecho penal..."
],
"description": "Describir los aprendizajes que lograrán los estudiantes al concluir el plan de estudios. Deberá corresponder con el nivel educativo y la denominación del plan de estudios.",
"referencia_normativa": "Corresponde al requisito 'Descripción de los fines del aprendizaje o formación' del Artículo 8, fracción IV del Acuerdo 17/11/17."
},
"perfil_de_egreso": {
"type": "string",
"title": "Perfil de egreso",
"examples": ["El egresado será capaz de diseñar estrategias legales..."],
"description": "Describir los atributos que habrán desarrollado los estudiantes al concluir el plan de estudios, acordes al nivel educativo y denominación propuesta. De preferencia, dividirlo en conocimientos, habilidades y actitudes.",
"referencia_normativa": "Corresponde al requisito 'Perfil de egreso' del Artículo 8, fracción VI del Acuerdo 17/11/17."
},
"programa_de_investigacion": {
"type": ["string", "null"],
"title": "Programa de investigación",
"examples": [
"Línea 1: Innovación Educativa. Objetivo: Analizar el impacto de..."
],
"description": "(Obligatorio para el nivel de Doctorado y para aquéllas Maestrías orientadas a la investigación). Presentar el programa de investigación que describa los objetivos del programa y líneas de investigación con sus respectivos objetivos. Asimismo, detallar la vinculación con las asignaturas que conforman el plan de estudios, la metodología y presentación de las investigaciones, así como el perfil de los docentes que guiarán su desarrollo.",
"referencia_normativa": "Requisito obligatorio solo para Doctorados o Maestrías con orientación a investigación según el Artículo 10, fracción III incisos b) y c) del Acuerdo 17/11/17."
},
"curso_propedeutico": {
"type": ["string", "null"],
"title": "Curso propedéutico",
"examples": [
"Curso de nivelación de 40 horas obligatorio para aspirantes de áreas ajenas."
],
"description": "Señalar las situaciones en las que aplicará el curso propedéutico, la población a la que estará dirigido, su duración, la relación con el plan de estudios, así como la información que considere relevante.",
"referencia_normativa": "Elemento opcional mencionado en las guías de llenado del Anexo 1."
},
"perfil_de_ingreso": {
"type": "string",
"title": "Perfil de ingreso",
"examples": [
"Conocimientos básicos de matemáticas, capacidad de análisis y redacción."
],
"description": "Indicar las condiciones académicas ideales (conocimientos, habilidades y aptitudes), para incorporarse al plan de estudios. Deberá vincularse con el nivel educativo y campo de estudio.",
"referencia_normativa": "Corresponde al requisito 'Perfil de ingreso' (condiciones mínimas requeridas) del Artículo 8, fracción V del Acuerdo 17/11/17."
},
"administracion_y_operatividad_del_plan_de_estudios": {
"type": ["string", "null"],
"title": "Administración y operatividad del plan de estudios",
"examples": [
"El estudiante podrá inscribir un mínimo de 3 y un máximo de 6 asignaturas por ciclo..."
],
"description": "Describir la administración del plan de estudios, así como su operatividad; considerando el tipo de diseño curricular empleado. Si es flexible; incorporar el número mínimo y máximo de asignaturas que se cursarán por ciclo.",
"referencia_normativa": "Corresponde a la 'descripción detallada de la(s) forma(s) de administración y operatividad' permitida en el Artículo 8 (párrafo tercero) del Acuerdo 17/11/17."
},
"sustento_teorico_del_modelo_curricular": {
"type": ["string", "null"],
"title": "Sustento teórico del modelo curricular",
"examples": [
"Modelo por competencias profesionales fundamentado en el constructivismo..."
],
"description": "Presentar de forma concreta el marco conceptual en el que se justifica y fundamenta el diseño y organización curricular del plan de estudios.",
"referencia_normativa": "Corresponde al 'sustento teórico del modelo curricular' que el particular puede incluir según el Artículo 8 (párrafo tercero) del Acuerdo 17/11/17."
},
"justificacion_de_la_propuesta_curricular": {
"type": ["string", "null"],
"title": "Justificación de la propuesta curricular en la modalidad no escolarizada o mixta",
"examples": [
"Se utilizará una plataforma LMS asincrónica complementada con sesiones síncronas..."
],
"description": "(Exclusivo para modalidades no escolarizada y mixta). Describir cómo se impartirá el plan de estudios en la modalidad educativa elegida, los recursos tecnológicos y/o didácticos que emplearán para su desarrollo y su vinculación con las asignaturas y actividades de aprendizaje.",
"referencia_normativa": "Corresponde a lo solicitado en el Artículo 14 (justificación de modalidad) y Artículo 15 (especificaciones de plataforma/modelo) del Acuerdo 17/11/17."
},
"propuesta_de_evaluacion_periodica_del_plan_de_estudios": {
"type": ["string", "null"],
"title": "Propuesta de evaluación periódica del plan de estudios",
"examples": [
"Revisión quinquenal mediante encuestas a empleadores, egresados y análisis de tendencias del mercado."
],
"description": "Describir de manera detallada la metodología que emplearán para la evaluación del plan de estudios, incluyendo los instrumentos y periodicidad en la que se llevará a cabo; con la finalidad de mantenerlo actualizado y acorde a las necesidades educativas y disciplinares.",
"referencia_normativa": "Corresponde al requisito 'Propuesta de evaluación periódica del Plan de estudio' del Artículo 8, fracción VIII del Acuerdo 17/11/17."
},
"nombre_y_cargo_de_la_persona_facultada_para_autorizar_el_plan_de_estudios": {
"type": "string",
"title": "Nombre y cargo de la persona facultada para autorizar el plan de estudios",
"examples": ["Dr. Juan Pérez Gómez, Rector de la Universidad XYZ"],
"description": "Indicar el nombre y cargo de la persona facultada para autorizar el plan de estudios.",
"referencia_normativa": "Dato administrativo del Anexo 1."
}
},
"additionalProperties": false
}
-126
View File
@@ -1,126 +0,0 @@
create table "public"."tabla_prueba" (
"id" bigint generated by default as identity not null,
"created_at" timestamp with time zone not null default now(),
"columna_prueba" text
);
alter table "public"."tabla_prueba" enable row level security;
CREATE UNIQUE INDEX tabla_prueba_pkey ON public.tabla_prueba USING btree (id);
alter table "public"."tabla_prueba" add constraint "tabla_prueba_pkey" PRIMARY KEY using index "tabla_prueba_pkey";
set check_function_bodies = off;
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$
;
grant delete on table "public"."tabla_prueba" to "anon";
grant insert on table "public"."tabla_prueba" to "anon";
grant references on table "public"."tabla_prueba" to "anon";
grant select on table "public"."tabla_prueba" to "anon";
grant trigger on table "public"."tabla_prueba" to "anon";
grant truncate on table "public"."tabla_prueba" to "anon";
grant update on table "public"."tabla_prueba" to "anon";
grant delete on table "public"."tabla_prueba" to "authenticated";
grant insert on table "public"."tabla_prueba" to "authenticated";
grant references on table "public"."tabla_prueba" to "authenticated";
grant select on table "public"."tabla_prueba" to "authenticated";
grant trigger on table "public"."tabla_prueba" to "authenticated";
grant truncate on table "public"."tabla_prueba" to "authenticated";
grant update on table "public"."tabla_prueba" to "authenticated";
grant delete on table "public"."tabla_prueba" to "postgres";
grant insert on table "public"."tabla_prueba" to "postgres";
grant references on table "public"."tabla_prueba" to "postgres";
grant select on table "public"."tabla_prueba" to "postgres";
grant trigger on table "public"."tabla_prueba" to "postgres";
grant truncate on table "public"."tabla_prueba" to "postgres";
grant update on table "public"."tabla_prueba" to "postgres";
grant delete on table "public"."tabla_prueba" to "service_role";
grant insert on table "public"."tabla_prueba" to "service_role";
grant references on table "public"."tabla_prueba" to "service_role";
grant select on table "public"."tabla_prueba" to "service_role";
grant trigger on table "public"."tabla_prueba" to "service_role";
grant truncate on table "public"."tabla_prueba" to "service_role";
grant update on table "public"."tabla_prueba" to "service_role";
@@ -0,0 +1,5 @@
Ejecutar estos dos comandos para la generación de tipos de supabase:
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
dx supabase gen types typescript --local | Out-File -Encoding utf8 -FilePath "supabase\functions\_shared\database.types.ts"
+1 -1
View File
@@ -4,7 +4,7 @@
*/
import { $ } from "@david/dax";
const OUT_FILE = "supabase/diffs/sql_para_remoto_como_local.sql";
const OUT_FILE = "supabase/diffs/sql_para_local_como_remoto.sql";
console.log("🔍 Comparando base de datos local vs. remota...");
+1 -1
View File
@@ -1 +1 @@
v2.72.7
v2.84.2
+1 -1
View File
@@ -1 +1 @@
v2.185.0
v2.187.0
+1 -1
View File
@@ -1 +1 @@
postgresql://postgres.exdkssurzmjnnhgtiama@aws-0-us-west-1.pooler.supabase.com:5432/postgres
postgresql://postgres.bxkskdxwppdlplrkidcz@aws-1-us-east-2.pooler.supabase.com:5432/postgres
+1 -1
View File
@@ -1 +1 @@
15.8.1.085
17.6.1.063
+1 -1
View File
@@ -1 +1 @@
exdkssurzmjnnhgtiama
bxkskdxwppdlplrkidcz
+1 -1
View File
@@ -1 +1 @@
v12.2.3
v14.1
+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
+61
View File
@@ -1,3 +1,5 @@
[db]
major_version = 15
[functions.ai-generate-plan]
enabled = true
@@ -26,3 +28,62 @@ entrypoint = "./functions/create-chat-conversation/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/create-chat-conversation/*.html" ]
[functions.ai-generate-subject]
enabled = true
verify_jwt = true
import_map = "./functions/ai-generate-subject/deno.json"
# Uncomment to specify a custom file path to the entrypoint.
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
entrypoint = "./functions/ai-generate-subject/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/ai-generate-subject/*.html" ]
[functions.generate-subject-suggestions]
enabled = true
verify_jwt = true
import_map = "./functions/generate-subject-suggestions/deno.json"
# Uncomment to specify a custom file path to the entrypoint.
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
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 = false
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" ]
[functions.carbone-io-wrapper]
enabled = true
verify_jwt = true
import_map = "./functions/carbone-io-wrapper/deno.json"
# Uncomment to specify a custom file path to the entrypoint.
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
entrypoint = "./functions/carbone-io-wrapper/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/carbone-io-wrapper/*.html" ]
File diff suppressed because one or more lines are too long
@@ -1,56 +0,0 @@
set check_function_bodies = off;
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$
;
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
export const definicionesDeEstructurasDeColumnas = {
contenido_tematico: {
"type": "object",
"properties": {
"x-column": {
"type": "string",
"enum": [
"contenido_tematico",
],
},
"x-definicion": {
"type": "array",
"items": {
"type": "object",
"properties": {
"unidad": {
"type": "integer",
},
"titulo": {
"type": "string",
},
"temas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"nombre": {
"type": "string",
},
"horasEstimadas": {
"type": "integer",
"description":
"Horas del tema. La suma de todas las horasEstimadas debe ser igual a las horas académicas del prompt.",
},
},
"required": [
"nombre",
"horasEstimadas",
],
"additionalProperties": false,
},
},
},
"required": [
"unidad",
"titulo",
"temas",
],
"additionalProperties": false,
},
},
},
"required": [
"x-column",
"x-definicion",
],
"additionalProperties": false,
},
criterios_de_evaluacion: {
"type": "object",
"properties": {
"x-column": {
"type": "string",
"enum": [
"criterios_de_evaluacion",
],
},
"x-definicion": {
"type": "array",
"items": {
"type": "object",
"properties": {
"criterio": {
"type": "string",
},
"porcentaje": {
"type": "integer",
},
},
"required": [
"criterio",
"porcentaje",
],
"additionalProperties": false,
},
},
},
"required": [
"x-column",
"x-definicion",
],
"additionalProperties": false,
},
} as const;
+173 -182
View File
@@ -1,202 +1,193 @@
// supabase/functions/_shared/openai-service.ts
/// <reference lib="deno.window" />
import OpenAI from "npm:openai@6.16.0";
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;
OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming;
export type StructuredResponseSuccess<TOutput = unknown> = {
ok: true;
output?: TOutput; // parsed JSON when available
outputText?: string; // raw text when parsing is not possible
model: string;
usage?: OpenAITypes.OpenAI.Responses.Response["usage"] | null;
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
ok: true;
output?: TOutput; // parsed JSON when available
outputText?: string; // raw text when parsing is not possible
model: string;
usage?: OpenAITypes.OpenAI.Responses.Response["usage"] | null;
responseId: string;
conversationId?: string | null;
references: {
openaiFileIds: string[]; // file ids in OpenAI
};
openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers
};
export type StructuredResponseFailure = {
ok: false;
code:
| "MissingEnv"
| "StorageUploadFailed"
| "OpenAIFileUploadFailed"
| "OpenAIRequestFailed";
message: string;
cause?: unknown;
ok: false;
code:
| "MissingEnv"
| "OpenAIFileUploadFailed"
| "OpenAIRequestFailed";
message: string;
cause?: unknown;
};
export type StructuredResponseResult<TOutput = unknown> =
| StructuredResponseSuccess<TOutput>
| StructuredResponseFailure;
| StructuredResponseSuccess<TOutput>
| StructuredResponseFailure;
export interface OpenAIServiceConfig {
supabaseUrl: string;
serviceRoleKey: string;
openAIApiKey: string;
bucket?: string; // default: ai-storage
openAIApiKey: string;
}
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;
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) {
return {
ok: false,
code: "MissingEnv",
message:
"Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY",
};
private readonly openai: OpenAI;
private constructor(openai: OpenAI) {
this.openai = openai;
}
const client = createClient(supabaseUrl, serviceRoleKey);
const openai = new OpenAI({ apiKey: openAIApiKey });
return new OpenAIService(client, openai, bucket);
}
async createConversation(metadata?: Record<string, string>) {
const conversation = await this.openai.conversations.create({
metadata,
});
return conversation;
}
async createStructuredResponse<TOutput = unknown>(
options: StructuredResponseOptions,
files?: File[],
): Promise<StructuredResponseResult<TOutput>> {
try {
const uploadedToStorage = await this.uploadFilesToStorage(files ?? []);
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
// Narrow to non-streaming response
const openaiRaw = (await this.openai.responses.create(
newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming,
)) as OpenAITypes.OpenAI.Responses.Response;
const { model, id: responseId } = openaiRaw;
const usage = openaiRaw?.usage ?? null;
const conversationId =
(
openaiRaw as OpenAITypes.OpenAI.Responses.Response & {
conversation_id?: string | null;
}
).conversation_id ?? null;
// Try to read structured JSON output
let output: TOutput | undefined = undefined;
let outputText: string | undefined = undefined;
// Prefer `output_text` if present (SDK convenience)
const maybeOutputText = openaiRaw.output_text;
if (typeof maybeOutputText === "string" && maybeOutputText.length > 0) {
outputText = maybeOutputText;
static fromEnv(): StructuredResponseFailure | OpenAIService {
const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? "";
if (!openAIApiKey) {
return {
ok: false,
code: "MissingEnv",
message: "Required env vars missing: OPENAI_API_KEY",
};
}
const openai = new OpenAI({ apiKey: openAIApiKey });
return new OpenAIService(openai);
}
async createConversation(metadata?: Record<string, string>) {
const conversation = await this.openai.conversations.create({
metadata,
});
return conversation;
}
async createStructuredResponse<TOutput = unknown>(
options: StructuredResponseOptions,
files?: File[],
): Promise<StructuredResponseResult<TOutput>> {
try {
output = JSON.parse(maybeOutputText) as TOutput;
} catch {
/* non-JSON text, keep as text only */
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
const newOptions = { ...options };
// Attach file references to the request as an extra user message
if (openaiFileIds.length > 0) {
const fileParts:
OpenAITypes.OpenAI.Responses.ResponseInputFile[] =
openaiFileIds.map((id) => ({
type: "input_file",
file_id: id,
}));
const arr = Array.isArray(options.input) ? options.input : [];
arr.push({
role: "user",
content: [
...fileParts,
{
type: "input_text",
text: "Usa estos archivos como referencia",
},
],
});
newOptions.input = arr;
}
// Narrow to non-streaming response
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 = (
openaiRaw as OpenAITypes.OpenAI.Responses.Response & {
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;
// Prefer `output_text` if present (SDK convenience)
const maybeOutputText = openaiRaw.output_text;
if (
typeof maybeOutputText === "string" &&
maybeOutputText.length > 0
) {
outputText = maybeOutputText;
try {
output = JSON.parse(maybeOutputText) as TOutput;
} catch {
/* non-JSON text, keep as text only */
}
} else {
// Fallback: attempt to serialize `openaiRaw.output` into text
const maybeOutput = openaiRaw.output as unknown;
if (typeof maybeOutput === "object" && maybeOutput != null) {
try {
outputText = JSON.stringify(maybeOutput);
output = maybeOutput as TOutput;
} catch {
/* ignore */
}
}
}
return {
ok: true,
output,
outputText,
model: String(model),
usage,
responseId: String(responseId),
conversationId: conversationId ? String(conversationId) : null,
references: { openaiFileIds },
openaiRaw,
};
} catch (err) {
const e = err as Error;
const message = e?.message ?? "Unknown error";
const code = message.includes("OpenAI file upload failed")
? "OpenAIFileUploadFailed"
: "OpenAIRequestFailed";
return { ok: false, code, message, cause: err };
}
} else {
// Fallback: attempt to serialize `openaiRaw.output` into text
const maybeOutput = openaiRaw.output as unknown;
if (typeof maybeOutput === "object" && maybeOutput != null) {
try {
outputText = JSON.stringify(maybeOutput);
output = maybeOutput as TOutput;
} catch {
/* ignore */
}
}
private async uploadFilesToOpenAI(files: File[]): Promise<string[]> {
const ids: string[] = [];
for (const file of files) {
try {
const created = await this.openai.files.create({
file,
purpose: "user_data",
});
ids.push(created.id);
} catch (e) {
throw new Error(
`OpenAI file upload failed: ${
(e as Error)?.message ?? String(e)
}`,
);
}
}
}
return {
ok: true,
output,
outputText,
model: String(model),
usage,
responseId: String(responseId),
conversationId: conversationId ? String(conversationId) : null,
references: { uploadedToStorage, 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")
? "OpenAIFileUploadFailed"
: "OpenAIRequestFailed";
return { ok: false, code, message, cause: err };
return ids;
}
}
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);
private sanitizeFilename(name: string): string {
return name
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9.-]/g, "_");
}
return paths;
}
private async uploadFilesToOpenAI(files: File[]): Promise<string[]> {
const ids: string[] = [];
for (const file of files) {
try {
const created = await this.openai.files.create({
file,
purpose: "user_data",
});
ids.push(created.id);
} catch (e) {
throw new Error(
`OpenAI file upload failed: ${(e as Error)?.message ?? String(e)}`,
);
}
}
return ids;
}
private sanitizeFilename(name: string): string {
return name
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9.-]/g, "_");
}
}
+55
View File
@@ -0,0 +1,55 @@
import { corsHeaders } from "./cors.ts";
// --- CLASE DE ERROR ---
export class HttpError extends Error {
public readonly status: number;
public readonly code: string;
public readonly internalDetails: unknown;
constructor(
status: number,
message: string,
code = "API_ERROR",
internalDetails?: unknown,
) {
super(message);
this.name = "HttpError";
this.status = status;
this.code = code;
this.internalDetails = internalDetails;
}
}
// --- HELPER DE ÉXITO ---
export function sendSuccess<T>(data: T, status = 200): Response {
return new Response(
JSON.stringify(data),
{
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// --- HELPER DE ERROR ---
export function sendError(
status: number,
message: string,
code: string,
): Response {
return new Response(
JSON.stringify({
error: { message, code },
}),
{
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
export interface ResponseMetadata extends Record<string, string | undefined> {
tabla?: string;
accion?: string;
id?: string;
}
@@ -1,5 +1,7 @@
{
"imports": {
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts"
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts",
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
"@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts"
}
}
+203 -116
View File
@@ -3,9 +3,10 @@
// This enables autocomplete, go to definition, etc.
// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import "@supabase/functions-js/edge-runtime.d.ts";
import { corsHeaders } from "../_shared/cors.ts";
import { createClient } from "npm:@supabase/supabase-js@2";
import { HttpError, sendError, sendSuccess } from "../_shared/utils.ts";
import { createClient } from "@supabase/supabase-js";
import type { Database, Json } from "../_shared/database.types.ts";
import type { AIGeneratePlanInput } from "./types.ts";
import { z } from "zod";
@@ -18,7 +19,14 @@ type NivelType =
type TipoCicloType =
Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"];
Deno.serve(async (req) => {
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();
console.log(
@@ -37,12 +45,11 @@ Deno.serve(async (req) => {
new Date().toISOString()
}][${functionName}]: Invalid method: ${method}`,
);
return new Response(
JSON.stringify({ error: "Method not allowed" }),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 405,
},
throw new HttpError(
405,
"Método no permitido.",
"METHOD_NOT_ALLOWED",
{ method },
);
}
@@ -54,12 +61,11 @@ Deno.serve(async (req) => {
new Date().toISOString()
}][${functionName}]: Missing Authorization header`,
);
return new Response(
JSON.stringify({ error: "Authorization header missing" }),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 401,
},
throw new HttpError(
401,
"No autorizado.",
"UNAUTHORIZED",
{ reason: "missing_authorization_header" },
);
}
@@ -70,19 +76,28 @@ Deno.serve(async (req) => {
new Date().toISOString()
}][${functionName}]: Unsupported content type: ${contentType}`,
);
return new Response(
JSON.stringify({ error: "Unsupported content type" }),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 400,
},
throw new HttpError(
415,
"Content-Type no soportado.",
"UNSUPPORTED_MEDIA_TYPE",
{ contentType },
);
}
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
throw new Error("Supabase environment variables are not set");
throw new HttpError(
500,
"Configuración del servidor incompleta.",
"MISSING_ENV",
{
missing: [
!SUPABASE_URL ? "SUPABASE_URL" : null,
!SUPABASE_ANON_KEY ? "SUPABASE_ANON_KEY" : null,
].filter(Boolean),
},
);
}
// If needed for RLS-protected reads, create an anon client with user's JWT
@@ -97,7 +112,12 @@ Deno.serve(async (req) => {
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!SERVICE_ROLE_KEY) {
throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set");
throw new HttpError(
500,
"Configuración del servidor incompleta.",
"MISSING_ENV",
{ missing: ["SUPABASE_SERVICE_ROLE_KEY"] },
);
}
const supabaseService = createClient<Database>(
@@ -105,6 +125,10 @@ Deno.serve(async (req) => {
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) {
@@ -112,12 +136,15 @@ Deno.serve(async (req) => {
`[${new Date().toISOString()}][${functionName}]: Validation errors:`,
validation.errors,
);
return new Response(
JSON.stringify({ errors: validation.errors }),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 400,
},
const message = validation.errors
.map((e, i) => `(${i + 1}) ${e}`)
.join("\n");
throw new HttpError(
422,
message,
"VALIDATION_ERROR",
{ errors: validation.errors },
);
}
@@ -130,8 +157,23 @@ Deno.serve(async (req) => {
.eq("id", payload.datosBasicos.estructuraPlanId)
.single();
if (estructuraPlanError) {
throw new Error(
"Error fetching estructuraPlan: " + estructuraPlanError.message,
const maybeCode = (estructuraPlanError as { code?: string }).code;
if (maybeCode === "PGRST116") {
throw new HttpError(
404,
"No se encontró la estructura del plan.",
"NOT_FOUND",
{
table: "estructuras_plan",
id: payload.datosBasicos.estructuraPlanId,
},
);
}
throw new HttpError(
500,
"No se pudo obtener la estructura del plan.",
"SUPABASE_QUERY_FAILED",
estructuraPlanError,
);
}
@@ -153,85 +195,57 @@ Deno.serve(async (req) => {
? estructuraPlan.definicion as Record<string, unknown>
: {};
const aiStructuredPayload: StructuredResponseOptions = {
model: "gpt-5-nano",
input: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
text: {
format: {
type: "json_schema",
name: "plan_de_estudios_standard",
schema: schemaDef,
strict: true,
},
},
};
// Use shared OpenAI service directly (no HTTP invoke)
const svc = OpenAIService.fromEnv();
if (!(svc instanceof OpenAIService)) {
throw new Error(`OpenAI service misconfiguration: ${svc.message}`);
}
const aiResult = await svc.createStructuredResponse(
aiStructuredPayload,
payload.archivosAdjuntos,
);
if (!aiResult.ok) {
throw new Error(
`OpenAI call failed [${aiResult.code}]: ${aiResult.message}`,
);
}
// Prefer parsed output; fallback to parse outputText
const aiOutput = aiResult.output ??
(aiResult.outputText ? JSON.parse(aiResult.outputText) : null);
const aiOutputJson: Json = aiOutput as unknown as Json;
if (!aiOutput) {
throw new Error("OpenAI response did not contain structured output");
}
// 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)
.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 Error("Error fetching carrera: " + carreraError.message);
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,
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,
// 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",
ai_structured: {
responseId: aiResult.responseId ?? null,
conversationId: aiResult.conversationId ?? null,
model: aiResult.model,
usage: aiResult.usage ?? null,
},
generado_por: "ai-generate-plan",
referencias: {
archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null,
repositoriosIds: payload.iaConfig?.repositoriosIds ?? null,
@@ -254,45 +268,121 @@ Deno.serve(async (req) => {
)
.single();
// TODO: interaccion con IA y cambio de estado
if (planError) {
throw new Error("Error inserting plan: " + planError.message);
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: 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 },
],
text: {
format: {
type: "json_schema",
name: "plan_de_estudios_standard",
schema: schemaDef,
strict: true,
},
},
};
// Use shared OpenAI service directly (no HTTP invoke)
const svc = OpenAIService.fromEnv();
if (!(svc instanceof OpenAIService)) {
throw new HttpError(
500,
"Configuración del servidor incompleta.",
"OPENAI_MISCONFIGURED",
svc,
);
}
// INICIO DE CÓDIGO PARA DEBBUGGING
// console.log(
// `[${
// new Date().toISOString()
// }][${functionName}]: Request processed successfully`,
// );
// const { data: plan_debug } = await supabaseService
// .from("planes_estudio")
// .select("*")
// .eq("id", "7ce657b1-1abf-4972-858d-5fffe1d51499")
// .maybeSingle();
// return sendSuccess(plan_debug);
// FIN DE CÓDIGO PARA DEBBUGGING
const aiResult = await svc.createStructuredResponse(
aiStructuredPayload,
payload.archivosAdjuntos,
);
if (!aiResult.ok) {
const status = aiResult.code === "MissingEnv" ? 500 : 502;
throw new HttpError(
status,
"No se pudo iniciar la generación del plan con IA.",
"OPENAI_REQUEST_FAILED",
aiResult,
);
}
// TODO: update a interaccion_ia y e insert a cambios_plan con id de plan generado
const jsonResponse = {
ok: true,
plan,
};
console.log(
`[${
new Date().toISOString()
}][${functionName}]: Request processed successfully`,
);
return new Response(
JSON.stringify(jsonResponse),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 200,
},
);
return sendSuccess(plan);
} catch (error) {
// Log full error server-side for diagnostics
if (error instanceof Error) {
if (error instanceof HttpError) {
console.error(
`[${
new Date().toISOString()
}][${functionName}]: Request handler error:`,
error.message,
`[${new Date().toISOString()}][${functionName}] ⚠️ Handled Error:`,
{
message: error.message,
code: error.code,
internalDetails: error.internalDetails || "N/A",
},
);
// RESPONSE: Solo enviamos el mensaje limpio y el código
return sendError(error.status, error.message, error.code);
}
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 400,
});
// CASO B: Error Inesperado (Crash, Bug, Syntax Error, etc.)
// El usuario NO debe ver esto.
const unexpectedError = error instanceof Error
? error
: new Error(String(error));
// LOG: Full stack trace y mensaje real
console.error(
`[${
new Date().toISOString()
}][${functionName}] 💥 CRITICAL UNHANDLED ERROR:`,
unexpectedError.stack || unexpectedError.message, // Esto es lo que necesitas para debuguear
);
// RESPONSE: Mensaje genérico y seguro
return sendError(
500,
"Ocurrió un error inesperado en el servidor.",
"INTERNAL_SERVER_ERROR",
);
}
});
@@ -324,10 +414,7 @@ const DatosBasicosSchema: z.ZodType<AIGeneratePlanInput["datosBasicos"]> = z
});
const IAConfigSchema: z.ZodType<AIGeneratePlanInput["iaConfig"]> = z.object({
descripcionEnfoqueAcademico: z.string().min(
10,
"La descripción es muy corta",
),
descripcionEnfoqueAcademico: z.string(),
instruccionesAdicionalesIA: z.string().optional(),
archivosReferencia: z.array(z.string().uuid()).optional(),
repositoriosIds: z.array(z.string().uuid()).optional(),
+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.
`;
@@ -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,8 @@
{
"imports": {
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts",
"openai": "npm:openai@6.16.0",
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
"@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts"
}
}
@@ -0,0 +1,602 @@
import "@supabase/functions-js/edge-runtime.d.ts";
import { corsHeaders } from "../_shared/cors.ts";
import { HttpError, sendError, sendSuccess } from "../_shared/utils.ts";
import { createClient } from "@supabase/supabase-js";
import type { Database, Json } from "../_shared/database.types.ts";
import { definicionesDeEstructurasDeColumnas } from "../_shared/estructuras.ts";
import { z } from "zod";
import {
OpenAIService,
type StructuredResponseOptions,
} from "../_shared/openai-service.ts";
type BeforeUnloadWithDetail = Event & { detail?: { reason?: unknown } };
addEventListener("beforeunload", (ev: BeforeUnloadWithDetail) => {
console.error(
"ALERTA: La función se va a apagar. Razón:",
ev?.detail?.reason,
);
});
const DatosUpdateSchema = z
.object({
id: z.string().uuid("id debe ser un UUID").optional(),
// campos de asignatura (nombres coinciden con columnas DB)
plan_estudio_id: z.string().uuid("plan_estudio_id debe ser un UUID"),
estructura_id: z.string().uuid("estructura_id debe ser un UUID").optional(),
nombre: z.string().min(1).optional(),
codigo: z.union([z.string().min(1), z.null()]).optional(),
tipo: z.union([z.string().min(1), z.null()]).optional(),
creditos: z.number().positive().optional(),
horas_academicas: z.number().int().nonnegative().nullable().optional(),
horas_independientes: z.number().int().nonnegative().nullable().optional(),
numero_ciclo: z.number().int().positive().nullable().optional(),
linea_plan_id: z.string().uuid().nullable().optional(),
orden_celda: z.number().int().nonnegative().nullable().optional(),
})
.strict();
const IAConfigSchema = z
.object({
descripcionEnfoqueAcademico: z.string().optional(),
instruccionesAdicionalesIA: z.string().optional(),
archivosAdjuntos: z.array(z.string().min(1)).optional().default([]),
})
.strict()
.default({});
const UnifiedJsonSchema = z
.object({
datosUpdate: DatosUpdateSchema,
iaConfig: IAConfigSchema,
})
.strict()
.superRefine((val, ctx) => {
// Si no hay id, es un insert => necesitamos campos mínimos
if (!val.datosUpdate.id) {
if (!val.datosUpdate.estructura_id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["datosUpdate", "estructura_id"],
message: "estructura_id es requerido para crear",
});
}
if (!val.datosUpdate.nombre) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["datosUpdate", "nombre"],
message: "nombre es requerido para crear",
});
}
if (val.datosUpdate.creditos === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["datosUpdate", "creditos"],
message: "creditos es requerido para crear",
});
}
}
});
type EdgeAIGenerateSubjectUnifiedInput = z.infer<typeof UnifiedJsonSchema>;
type AsignaturaBaseSeleccionada = {
id: string;
plan_estudio_id: string;
estructura_id: string | null;
nombre: string;
codigo: string | null;
tipo: Database["public"]["Tables"]["asignaturas"]["Row"]["tipo"];
creditos: number;
horas_academicas: number | null;
horas_independientes: number | null;
numero_ciclo: number | null;
linea_plan_id: string | null;
orden_celda: number | null;
estado: Database["public"]["Enums"]["estado_asignatura"];
};
function withColumnDefsAndRefs(
schemaDef: Record<string, unknown>,
): Record<string, unknown> {
const nextSchema: Record<string, unknown> = {
...schemaDef,
$defs: definicionesDeEstructurasDeColumnas,
};
const props = nextSchema["properties"];
if (!props || typeof props !== "object" || Array.isArray(props)) {
return nextSchema;
}
const nextProps: Record<string, unknown> = {
...props as Record<string, unknown>,
};
for (const [key, value] of Object.entries(nextProps)) {
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
const xColumn = (value as Record<string, unknown>)["x-column"];
if (typeof xColumn !== "string" || !xColumn.length) continue;
nextProps[key] = { $ref: `#/$defs/${xColumn}` };
}
nextSchema["properties"] = nextProps;
return nextSchema;
}
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");
}
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 {
const method = req.method;
if (method !== "POST") {
console.error(
`[${
new Date().toISOString()
}][${functionName}]: Invalid method: ${method}`,
);
throw new HttpError(
405,
"Método no permitido.",
"METHOD_NOT_ALLOWED",
{ method },
);
}
const authHeaderRaw = req.headers.get("Authorization") ??
req.headers.get("authorization");
if (!authHeaderRaw) {
console.error(
`[${
new Date().toISOString()
}][${functionName}]: Missing Authorization header`,
);
throw new HttpError(
401,
"No autorizado.",
"UNAUTHORIZED",
{ reason: "missing_authorization_header" },
);
}
const contentType = (req.headers.get("content-type") || "").toLowerCase();
const isJson = contentType.includes("application/json");
if (!isJson) {
console.error(
`[${
new Date().toISOString()
}][${functionName}]: Unsupported content type: ${contentType}`,
);
throw new HttpError(
415,
"Content-Type no soportado.",
"UNSUPPORTED_MEDIA_TYPE",
{ contentType, expected: "application/json" },
);
}
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
throw new HttpError(
500,
"Configuración del servidor incompleta.",
"MISSING_ENV",
{
missing: [
!SUPABASE_URL ? "SUPABASE_URL" : null,
!SUPABASE_ANON_KEY ? "SUPABASE_ANON_KEY" : null,
].filter(Boolean),
},
);
}
// If needed for RLS-protected reads, create an anon client with user's JWT
// Currently not used; kept here for future expansion.
// const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
// global: {
// headers: {
// Authorization: authHeaderRaw,
// },
// },
// });
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!SERVICE_ROLE_KEY) {
throw new HttpError(
500,
"Configuración del servidor incompleta.",
"MISSING_ENV",
{ missing: ["SUPABASE_SERVICE_ROLE_KEY"] },
);
}
const supabaseService = createClient<Database>(
SUPABASE_URL,
SERVICE_ROLE_KEY,
);
// Model names (override via environment variables)
const AI_GENERATE_SUBJECT_UPDATE_MODELO = Deno.env.get(
"AI_GENERATE_SUBJECT_UPDATE_MODELO",
) ?? "gpt-5-nano";
const AI_GENERATE_SUBJECT_INSERT_MODELO = Deno.env.get(
"AI_GENERATE_SUBJECT_INSERT_MODELO",
) ?? "gpt-5-nano";
// -----------------------------
// Unified JSON create/update flow (background)
// -----------------------------
let rawBody: unknown;
try {
rawBody = await req.json();
} catch (e) {
throw new HttpError(
400,
"Body JSON inválido.",
"INVALID_JSON",
{ cause: e },
);
}
const parsedBody = UnifiedJsonSchema.safeParse(rawBody);
if (!parsedBody.success) {
throw new HttpError(
422,
formatZodIssues(parsedBody.error.issues),
"VALIDATION_ERROR",
parsedBody.error,
);
}
const payload: EdgeAIGenerateSubjectUnifiedInput = parsedBody.data;
const { datosUpdate, iaConfig } = payload;
const isUpdate = Boolean(datosUpdate.id);
const svc = OpenAIService.fromEnv();
if (!(svc instanceof OpenAIService)) {
throw new HttpError(
500,
"Configuración del servidor incompleta.",
"OPENAI_MISCONFIGURED",
svc,
);
}
// Resolve fields (merge for update)
const selectCols =
"id,plan_estudio_id,estructura_id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,numero_ciclo,linea_plan_id,orden_celda,estado";
let asignaturaBase: AsignaturaBaseSeleccionada | null = null;
if (isUpdate) {
const { data, error } = await supabaseService
.from("asignaturas")
.select(selectCols)
.eq("id", datosUpdate.id as string)
.single();
if (error) {
const maybeCode = (error as { code?: string }).code;
if (maybeCode === "PGRST116") {
throw new HttpError(
404,
"No se encontró la asignatura.",
"NOT_FOUND",
{ table: "asignaturas", id: datosUpdate.id },
);
}
throw new HttpError(
500,
"No se pudo obtener la asignatura.",
"SUPABASE_QUERY_FAILED",
error,
);
}
asignaturaBase = data as unknown as AsignaturaBaseSeleccionada;
}
const resolved = {
id: (datosUpdate.id ?? asignaturaBase?.id ?? null) as string | null,
plan_estudio_id: datosUpdate.plan_estudio_id ??
asignaturaBase?.plan_estudio_id,
estructura_id: datosUpdate.estructura_id ??
asignaturaBase?.estructura_id ?? null,
nombre: datosUpdate.nombre ?? asignaturaBase?.nombre ?? null,
codigo: datosUpdate.codigo ?? asignaturaBase?.codigo ?? null,
tipo: datosUpdate.tipo ?? asignaturaBase?.tipo ?? null,
creditos: datosUpdate.creditos ?? asignaturaBase?.creditos ?? null,
horas_academicas: datosUpdate.horas_academicas ??
asignaturaBase?.horas_academicas ?? null,
horas_independientes: datosUpdate.horas_independientes ??
asignaturaBase?.horas_independientes ?? null,
numero_ciclo: datosUpdate.numero_ciclo ?? asignaturaBase?.numero_ciclo ??
null,
linea_plan_id: datosUpdate.linea_plan_id ??
asignaturaBase?.linea_plan_id ?? null,
orden_celda: datosUpdate.orden_celda ?? asignaturaBase?.orden_celda ??
null,
};
if (!resolved.estructura_id) {
throw new HttpError(
422,
"estructura_id es requerido.",
"VALIDATION_ERROR",
{ estructura_id: resolved.estructura_id },
);
}
if (!resolved.nombre) {
throw new HttpError(
422,
"nombre es requerido.",
"VALIDATION_ERROR",
{ nombre: resolved.nombre },
);
}
if (resolved.creditos == null) {
throw new HttpError(
422,
"creditos es requerido.",
"VALIDATION_ERROR",
{ creditos: resolved.creditos },
);
}
// Crear/actualizar stub en estado 'generando'
let asignaturaId: string;
if (isUpdate) {
const updatePatch: Database["public"]["Tables"]["asignaturas"]["Update"] =
{
estado: "generando",
tipo_origen: "IA",
plan_estudio_id: resolved.plan_estudio_id,
estructura_id: resolved.estructura_id,
nombre: resolved.nombre,
codigo: resolved.codigo,
creditos: resolved.creditos,
horas_academicas: resolved.horas_academicas,
horas_independientes: resolved.horas_independientes,
numero_ciclo: resolved.numero_ciclo,
linea_plan_id: resolved.linea_plan_id,
orden_celda: resolved.orden_celda,
tipo: resolved
.tipo as Database["public"]["Tables"]["asignaturas"]["Update"][
"tipo"
],
meta_origen: {
generado_por: "ai_generate_subject_unified",
iaConfig: {
descripcionEnfoqueAcademico:
iaConfig.descripcionEnfoqueAcademico ?? null,
instruccionesAdicionalesIA: iaConfig.instruccionesAdicionalesIA ??
null,
archivosAdjuntos: iaConfig.archivosAdjuntos ?? [],
},
} as unknown as Json,
};
const { data, error } = await supabaseService
.from("asignaturas")
.update(updatePatch)
.eq("id", resolved.id as string)
.select("id")
.single();
if (error) {
throw new HttpError(
500,
"No se pudo actualizar la asignatura (stub).",
"SUPABASE_UPDATE_FAILED",
error,
);
}
asignaturaId = data.id;
} else {
const insertPatch: Database["public"]["Tables"]["asignaturas"]["Insert"] =
{
plan_estudio_id: resolved.plan_estudio_id as string,
estructura_id: resolved.estructura_id as string,
nombre: resolved.nombre as string,
codigo: resolved.codigo ?? null,
tipo: (resolved.tipo ?? undefined) as Database["public"]["Tables"][
"asignaturas"
]["Insert"]["tipo"],
creditos: resolved.creditos as number,
horas_academicas: resolved.horas_academicas ?? null,
horas_independientes: resolved.horas_independientes ?? null,
numero_ciclo: resolved.numero_ciclo ?? null,
linea_plan_id: resolved.linea_plan_id ?? null,
orden_celda: resolved.orden_celda ?? null,
estado: "generando",
tipo_origen: "IA",
meta_origen: {
generado_por: "ai_generate_subject_unified",
iaConfig: {
descripcionEnfoqueAcademico:
iaConfig.descripcionEnfoqueAcademico ?? null,
instruccionesAdicionalesIA: iaConfig.instruccionesAdicionalesIA ??
null,
archivosAdjuntos: iaConfig.archivosAdjuntos ?? [],
},
} as unknown as Json,
};
const { data, error } = await supabaseService
.from("asignaturas")
.insert(insertPatch)
.select("id")
.single();
if (error) {
const maybeCode = (error as { code?: string }).code;
const status = maybeCode ? 409 : 500;
throw new HttpError(
status,
"No se pudo crear la asignatura (stub).",
"SUPABASE_INSERT_FAILED",
{ ...error, code: maybeCode },
);
}
asignaturaId = data.id;
}
const { data: estructura, error: estructuraError } = await supabaseService
.from("estructuras_asignatura")
.select("id,nombre,definicion")
.eq("id", resolved.estructura_id)
.single();
if (estructuraError) {
const maybeCode = (estructuraError as { code?: string }).code;
if (maybeCode === "PGRST116") {
throw new HttpError(
404,
"No se encontró la estructura de la asignatura.",
"NOT_FOUND",
{ table: "estructuras_asignatura", id: resolved.estructura_id },
);
}
throw new HttpError(
500,
"No se pudo obtener la estructura de la asignatura.",
"SUPABASE_QUERY_FAILED",
estructuraError,
);
}
const systemPrompt =
`Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el JSON Schema proporcionado.`;
const archivosAdjuntosTexto = (iaConfig.archivosAdjuntos ?? []).length
? `\n- Rutas de archivos adjuntos (referencia): ${
(iaConfig.archivosAdjuntos ?? []).join(", ")
}`
: "";
const userPrompt =
`Genera un borrador completo completo de una ASIGNATURA con base en lo siguiente:\n` +
`- Nombre de la asignatura: ${resolved.nombre}\n` +
`- Código (clave de la asignatura): ${
resolved.codigo ?? "(no especificado)"
}\n` +
`- Tipo: ${resolved.tipo ?? "(no especificado)"}\n` +
`- Número de créditos: ${resolved.creditos}\n` +
`- Horas académicas: ${
resolved.horas_academicas ?? "(no especificado)"
}\n` +
`- Horas independientes: ${
resolved.horas_independientes ?? "(no especificado)"
}\n` +
`- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${
iaConfig.descripcionEnfoqueAcademico ?? "(ninguna)"
}\n` +
`- Instrucciones adicionales (sobre el formato de la respuesta generada): ${
iaConfig.instruccionesAdicionalesIA ?? "(ninguna)"
}` +
archivosAdjuntosTexto +
`\n\nREGLAS ESTRICTAS MATEMÁTICAS:\n` +
`- Si generas el 'contenido_tematico', la suma total de las 'horasEstimadas' de todos los temas en todas las unidades DEBE coincidir exactamente con el total de Horas académicas indicadas arriba (${
resolved.horas_academicas ?? 0
}). No te pases ni te falten horas.\n` +
`- Si generas los 'criterios_de_evaluacion', la suma total de los 'porcentajes' de todos los criterios DEBE ser exactamente 100%. No te pases ni te falten porcentajes.`;
const schemaDef: Record<string, unknown> =
typeof estructura?.definicion === "object" &&
estructura?.definicion !== null
? (estructura.definicion as Record<string, unknown>)
: {};
const schemaForAI = withColumnDefsAndRefs(schemaDef);
const aiStructuredPayload: StructuredResponseOptions = {
model: isUpdate
? AI_GENERATE_SUBJECT_UPDATE_MODELO
: AI_GENERATE_SUBJECT_INSERT_MODELO,
background: true,
metadata: {
tabla: "asignaturas",
accion: isUpdate ? "actualizar" : "crear",
id: asignaturaId,
},
input: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
text: {
format: {
type: "json_schema",
name: "asignatura_contenido_tematico",
schema: schemaForAI,
strict: true,
},
},
};
const aiResult = await svc.createStructuredResponse(aiStructuredPayload);
if (!aiResult.ok) {
const status = aiResult.code === "MissingEnv" ? 500 : 502;
throw new HttpError(
status,
"No se pudo iniciar la generación de la asignatura con IA.",
"OPENAI_REQUEST_FAILED",
aiResult,
);
}
console.log(
`[${
new Date().toISOString()
}][${functionName}]: Subject generation started in background`,
);
return sendSuccess({
id: asignaturaId,
estado: "generando",
openai: { responseId: aiResult.responseId ?? null },
});
} 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",
);
}
});
@@ -0,0 +1,24 @@
import { Tables } from "../_shared/database.types.ts";
export type AIGenerateSubjectInput = {
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;
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;
};
iaConfig?: {
descripcionEnfoqueAcademico?: string;
instruccionesAdicionalesIA?: string;
archivosAdjuntos?: string[]; // rutas (no File)
};
};
@@ -1 +0,0 @@
verify_jwt = false
@@ -1,5 +0,0 @@
{
"imports": {
"openai": "npm:openai@6.16.0"
}
}
-24
View File
@@ -1,24 +0,0 @@
// Function deprecated. This endpoint was replaced by shared module `_shared/openai-service.ts`.
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: {
"content-type": "application/json; charset=utf-8",
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
},
});
Deno.serve((req) => {
if (req.method === "OPTIONS") return json({ ok: true });
return json(
{
ok: false,
error:
"This endpoint is deprecated. Use the shared module _shared/openai-service.ts from your functions.",
},
410,
);
});
@@ -1,779 +0,0 @@
// supabase/functions/ai_generate_plan/index.ts
/// <reference lib="deno.window" />
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "npm:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body, null, 2), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
/** ----------------- LOGGING HELPERS (diagnóstico) ----------------- */
function nowIso() {
return new Date().toISOString();
}
function redactAuthHeader(v: string | null) {
if (!v) return null;
const parts = v.split(" ");
if (parts.length >= 2) return `${parts[0]} [REDACTED]`;
return "[REDACTED]";
}
function safePreview(val: unknown, maxLen = 1400) {
try {
const jsonStr = JSON.stringify(val, (k, v) => {
if (typeof v === "string") {
if (v.length > 800) return v.slice(0, 800) + "…[TRUNCATED]";
if (v.startsWith("data:") && v.includes(";base64,")) return "[DATA_URL_REDACTED]";
}
return v;
});
if (!jsonStr) return String(val);
return jsonStr.length > maxLen ? jsonStr.slice(0, maxLen) + "…[TRUNCATED]" : jsonStr;
} catch {
return String(val);
}
}
function headersSnapshot(req: Request) {
const h: Record<string, string> = {};
for (const [k, v] of req.headers.entries()) {
if (k.toLowerCase() === "authorization") h[k] = redactAuthHeader(v) ?? "";
else h[k] = v;
}
return h;
}
function normalizeNivel(raw: string): string {
const s = (raw ?? "").trim().toLowerCase();
if (!s) return "Otro";
if (s === "licenciatura" || s === "lic") return "Licenciatura";
if (s === "maestria" || s === "maestría" || s === "msc") return "Maestría";
if (s === "doctorado" || s === "phd") return "Doctorado";
if (s === "especialidad") return "Especialidad";
if (s === "diplomado") return "Diplomado";
return "Otro";
}
function normalizeTipoCiclo(raw: string): string {
const s = (raw ?? "").trim().toLowerCase();
if (!s) return "Otro";
if (s === "semestre" || s === "semestres" || s === "sem" || s === "semestre(s)" ||
s === "semestre" || s === "semestre" || s === "semestre") return "Semestre";
if (s === "cuatrimestre" || s === "cuatrimestres" || s === "cuatri") return "Cuatrimestre";
if (s === "trimestre" || s === "trimestres" || s === "trim") return "Trimestre";
// si viene en mayúsculas tipo frontend
if (s === "semestre" || s === "semestre".toLowerCase()) return "Semestre";
if (s === "cuatrimestre" || s === "cuatrimestre".toLowerCase()) return "Cuatrimestre";
if (s === "trimestre" || s === "trimestre".toLowerCase()) return "Trimestre";
if (s === "otro") return "Otro";
// variantes comunes
if (s === "semestre".toLowerCase()) return "Semestre";
if (s === "cuatrimestre".toLowerCase()) return "Cuatrimestre";
if (s === "trimestre".toLowerCase()) return "Trimestre";
// si llega como SEMESTRE / CUATRIMESTRE / TRIMESTRE
if (s === "semestre".toLowerCase()) return "Semestre";
if (s === "cuatrimestre".toLowerCase()) return "Cuatrimestre";
if (s === "trimestre".toLowerCase()) return "Trimestre";
// fallback
return "Otro";
}
function suggestedWeeksFor(tipoCiclo: string) {
switch (tipoCiclo) {
case "Semestre":
return "16";
case "Cuatrimestre":
return "16";
case "Trimestre":
return "12";
default:
return "16";
}
}
function buildPrompt(params: {
datosBasicos: any;
carrera?: any;
estructura?: any;
iaConfig?: any;
}) {
const { datosBasicos, carrera, estructura, iaConfig } = params;
const nombrePlan = String(datosBasicos?.nombrePlan ?? "").trim();
const nivel = normalizeNivel(String(datosBasicos?.nivel ?? ""));
const tipoCiclo = normalizeTipoCiclo(String(datosBasicos?.tipoCiclo ?? ""));
const numCiclos = Number(datosBasicos?.numCiclos ?? 0) || 0;
const semanas = suggestedWeeksFor(tipoCiclo);
const carreraTxt = carrera
? `Carrera: ${carrera?.nombre ?? ""} (id: ${carrera?.id ?? ""})\nFacultad: ${carrera?.facultades?.nombre ?? carrera?.facultad?.nombre ?? ""}`
: "";
const system = [
"Eres un experto en diseño curricular (México) y redacción institucional.",
"Debes generar SOLO JSON válido que cumpla EXACTAMENTE el schema (sin campos extra).",
"Usa español neutro y redacción formal. Si un campo es opcional y no aplica, usa null.",
"Respeta enumeraciones (p.ej. modalidad_educativa, diseno_curricular).",
"Asegura consistencia interna entre nivel, fines, perfiles y área de estudio.",
"La carga_horaria_a_la_semana NO debe exceder 50.",
].join(" ");
const user = [
"Genera un borrador completo del PLAN DE ESTUDIOS con base en lo siguiente:",
"",
"== Datos básicos (NO los cambies) ==",
`- nivel: ${nivel}`,
`- nombre: ${nombrePlan}`,
`- tipo de ciclo: ${tipoCiclo}`,
`- número de ciclos: ${numCiclos}`,
`- duración sugerida del ciclo escolar (semanas efectivas): ${semanas}`,
"",
carreraTxt ? `== Contexto institucional ==\n${carreraTxt}\n` : "",
"== Enfoque / Población / Notas ==",
`- descripcionEnfoque: ${iaConfig?.descripcionEnfoque ?? ""}`,
`- notasAdicionales: ${iaConfig?.notasAdicionales ?? ""}`,
"",
"== Reglas adicionales ==",
"- Alinea: fines_de_aprendizaje_o_formacion ↔ perfil_de_egreso ↔ area_de_estudio.",
`- total_de_ciclos_del_plan_de_estudios debe ser \"${String(numCiclos)}\" (string).`,
`- duracion_del_ciclo_escolar debe ser \"${semanas}\" (string).`,
"- diseno_curricular: elige 'Rígido' o 'Flexible' coherente con la administración.",
"- Si modalidad_educativa es 'No escolarizada' o 'Mixta', llena justificacion_de_la_propuesta_curricular; si es 'Escolar', puede ser null.",
"- clave_del_plan_de_estudios: genera una clave administrativa verosímil (p.ej. 2026-XXX-YYY).",
"",
"Entrega únicamente el JSON final (sin Markdown, sin explicación).",
].join("\n");
return { system, user, computed: { nivel, tipoCiclo, numCiclos, semanas, nombrePlan } };
}
Deno.serve(async (req) => {
const cid = crypto.randomUUID();
const t0 = performance.now();
const log = (msg: string, extra?: unknown) => {
if (extra !== undefined) {
console.log(`[${nowIso()}][ai_generate_plan][${cid}] ${msg}`, safePreview(extra));
} else {
console.log(`[${nowIso()}][ai_generate_plan][${cid}] ${msg}`);
}
};
const warn = (msg: string, extra?: unknown) => {
if (extra !== undefined) {
console.warn(`[${nowIso()}][ai_generate_plan][${cid}] ⚠️ ${msg}`, safePreview(extra));
} else {
console.warn(`[${nowIso()}][ai_generate_plan][${cid}] ⚠️ ${msg}`);
}
};
const errlog = (msg: string, extra?: unknown) => {
if (extra !== undefined) {
console.error(`[${nowIso()}][ai_generate_plan][${cid}] ❌ ${msg}`, safePreview(extra));
} else {
console.error(`[${nowIso()}][ai_generate_plan][${cid}] ❌ ${msg}`);
}
};
if (req.method === "OPTIONS") {
log("OPTIONS preflight ✅", {
method: req.method,
url: req.url,
headers: headersSnapshot(req),
});
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== "POST") {
warn("Method not allowed", { method: req.method });
return json({ ok: false, error: "Method not allowed", cid }, 405);
}
log("START 🚀", { method: req.method, url: req.url });
log("Headers snapshot 🧾", headersSnapshot(req));
try {
// 1) Auth requerido ✅
log("Step 1: Auth check 🔐");
const authHeaderRaw = req.headers.get("Authorization") ??
req.headers.get("authorization");
log("Authorization header present?", {
present: Boolean(authHeaderRaw),
value: redactAuthHeader(authHeaderRaw),
});
if (!authHeaderRaw) {
warn("Missing Authorization header");
return json({ ok: false, error: "Missing Authorization header", cid }, 401);
}
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
log("Env presence (Supabase) 🌍", {
hasSUPABASE_URL: Boolean(SUPABASE_URL),
hasSUPABASE_ANON_KEY: Boolean(SUPABASE_ANON_KEY),
});
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
errlog("Missing SUPABASE_URL / SUPABASE_ANON_KEY");
return json(
{ ok: false, error: "Missing SUPABASE_URL / SUPABASE_ANON_KEY", cid },
500,
);
}
const supabaseUser = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: { headers: { Authorization: authHeaderRaw } },
});
const tAuth = performance.now();
const { data: userData, error: userErr } = await supabaseUser.auth.getUser();
log("Supabase auth.getUser() done ⏱️", { ms: Math.round(performance.now() - tAuth) });
if (userErr || !userData?.user) {
warn("Invalid token", { userErr: userErr?.message ?? userErr ?? null });
return json({ ok: false, error: "Invalid token", cid }, 401);
}
const user = userData.user;
log("Authenticated user ✅", { userId: user.id, email: user.email ?? null });
const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
log("Env presence (service role) 🗝️", { hasSERVICE_ROLE: Boolean(SERVICE_ROLE) });
const supabaseAdmin = SERVICE_ROLE
? createClient(SUPABASE_URL, SERVICE_ROLE)
: supabaseUser;
log("Supabase client selected 🧩", { adminMode: Boolean(SERVICE_ROLE) });
// 2) Parse body 🧾
log("Step 2: Parse body 🧾");
let payload: any = {};
const tParse = performance.now();
try {
payload = await req.json();
} catch (e) {
errlog("Invalid JSON body", { error: (e as Error)?.message ?? String(e) });
return json({ ok: false, error: "Invalid JSON body", cid }, 400);
}
log("Body parsed ⏱️", { ms: Math.round(performance.now() - tParse) });
log("Payload preview", { keys: Object.keys(payload ?? {}), payload: payload ? { ...payload, iaConfig: undefined } : null });
const datosBasicos = payload?.datosBasicos ?? payload?.input?.datosBasicos;
const iaConfig = payload?.iaConfig ?? payload?.input?.iaConfig ?? {};
if (!datosBasicos?.nombrePlan || !datosBasicos?.carreraId || !datosBasicos?.nivel || !datosBasicos?.tipoCiclo || !datosBasicos?.numCiclos) {
warn("Missing required datosBasicos fields", { datosBasicos });
return json({
ok: false,
error: "Missing required fields in datosBasicos (nombrePlan,carreraId,nivel,tipoCiclo,numCiclos).",
cid,
}, 400);
}
// 3) Asegura usuarios_app (por FK en logs) 👤
log("Step 3: Ensure usuarios_app row 👤");
const tUserUpsert = performance.now();
const upsertUser = {
id: user.id,
email: user.email ?? null,
nombre_completo: (user.user_metadata as any)?.full_name ??
(user.user_metadata as any)?.name ?? null,
};
const { error: upsertErr } = await supabaseAdmin
.from("usuarios_app")
.upsert(upsertUser, { onConflict: "id" });
log("usuarios_app upsert done ⏱️", { ms: Math.round(performance.now() - tUserUpsert), ok: !upsertErr });
if (upsertErr) {
errlog("usuarios_app upsert failed", { message: upsertErr.message });
return json({ ok: false, error: "usuarios_app upsert failed", details: upsertErr.message, cid }, 500);
}
// 4) Trae carrera + facultad (contexto prompt) 🎓
log("Step 4: Fetch carrera/facultad 🎓");
const carreraId = datosBasicos.carreraId;
const tCarr = performance.now();
const { data: carrera, error: carreraErr } = await supabaseAdmin
.from("carreras")
.select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)")
.eq("id", carreraId)
.maybeSingle();
log("Carrera fetch done ⏱️", { ms: Math.round(performance.now() - tCarr), found: Boolean(carrera), carreraId });
if (carreraErr) warn("Carrera fetch error (continuing)", { message: carreraErr.message });
// 5) Resolver estructura (schema) desde estructuras_plan.definicion 🧩
log("Step 5: Resolve estructura_plan 🧩");
const estructuraIdFromPayload = datosBasicos?.estructuraId ?? payload?.estructuraId ?? null;
let estructura: any = null;
const tEst = performance.now();
if (estructuraIdFromPayload) {
log("Using estructuraId from payload", { estructuraIdFromPayload });
const { data, error } = await supabaseAdmin
.from("estructuras_plan")
.select("id,nombre,tipo,template,definicion")
.eq("id", estructuraIdFromPayload)
.single();
if (error) {
errlog("estructuraId provided but not found", { message: error.message, estructuraIdFromPayload });
return json({ ok: false, error: "estructuraId not found", details: error.message, cid }, 400);
}
estructura = data;
} else {
// default: la más reciente tipo CURRICULAR, si no existe, la más reciente en general
const { data, error } = await supabaseAdmin
.from("estructuras_plan")
.select("id,nombre,tipo,template,definicion")
.eq("tipo", "CURRICULAR")
.order("actualizado_en", { ascending: false })
.limit(1)
.maybeSingle();
if (error) {
warn("Error fetching CURRICULAR estructura (fallback to any)", { message: error.message });
}
if (data) estructura = data;
if (!estructura) {
const { data: anyE, error: anyErr } = await supabaseAdmin
.from("estructuras_plan")
.select("id,nombre,tipo,template,definicion")
.order("actualizado_en", { ascending: false })
.limit(1)
.maybeSingle();
if (anyErr) {
errlog("No estructuras_plan found", { message: anyErr.message });
return json({ ok: false, error: "No estructuras_plan found", details: anyErr.message, cid }, 500);
}
estructura = anyE;
}
}
log("Estructura resolved ⏱️", {
ms: Math.round(performance.now() - tEst),
estructuraId: estructura?.id ?? null,
nombre: estructura?.nombre ?? null,
tipo: estructura?.tipo ?? null,
template: estructura?.template ?? null,
definicionKeys: estructura?.definicion ? Object.keys(estructura.definicion) : null,
});
if (!estructura?.definicion) {
errlog("estructuras_plan.definicion missing", { estructuraId: estructura?.id ?? null });
return json({ ok: false, error: "estructuras_plan.definicion missing", cid }, 500);
}
// 6) Resolver referencias (archivos / repositorios) 📎
log("Step 6: Resolve references 📎");
const uploadedToStorage: string[] = Array.isArray(iaConfig?.uploadedToStorage)
? iaConfig.uploadedToStorage
: [];
const repositoriosIds: string[] = Array.isArray(iaConfig?.repositoriosIds)
? iaConfig.repositoriosIds
: [];
log("Reference IDs received", {
uploadedToStorageCount: uploadedToStorage.length,
repositoriosIdsCount: repositoriosIds.length,
usarMCP: Boolean(iaConfig?.usarMCP),
});
const archivosInfo: any[] = [];
const vectorInfo: any[] = [];
if (uploadedToStorage.length) {
const { data, error } = await supabaseAdmin
.from("archivos")
.select("id,nombre,mime_type,openai_file_id,ruta_storage")
.in("id", uploadedToStorage);
if (error) warn("archivos lookup failed (continuing)", { message: error.message });
for (const a of data ?? []) archivosInfo.push(a);
}
if (repositoriosIds.length) {
const { data, error } = await supabaseAdmin
.from("vector_stores")
.select("id,nombre,openai_vector_id")
.in("id", repositoriosIds);
if (error) warn("vector_stores lookup failed (continuing)", { message: error.message });
for (const v of data ?? []) vectorInfo.push(v);
}
const openaiFileIds: string[] = archivosInfo
.map((a) => a?.openai_file_id)
.filter((x) => typeof x === "string" && x.length > 0);
const vectorStoreIds: string[] = vectorInfo
.map((v) => v?.openai_vector_id)
.filter((x) => typeof x === "string" && x.length > 0);
const rutasStorage: string[] = archivosInfo
.map((a) => a?.ruta_storage)
.filter((x) => typeof x === "string" && x.length > 0);
log("Resolved reference OpenAI IDs", {
openaiFileIdsCount: openaiFileIds.length,
vectorStoreIdsCount: vectorStoreIds.length,
rutasStorageCount: rutasStorage.length,
});
// 7) Construye prompt + structured wrapper 🧠
log("Step 7: Build prompt + structured schema 🧠");
const { system, user: userPrompt, computed } = buildPrompt({
datosBasicos,
carrera,
estructura,
iaConfig,
});
// definicion en DB puede venir como (schema) o como (format). Normalizamos.
let structured: any = null;
const definicion = estructura.definicion;
if (definicion?.type === "json_schema" && definicion?.schema) {
structured = definicion;
} else if (definicion?.format?.type === "json_schema" && definicion?.format?.schema) {
structured = definicion;
} else {
structured = {
type: "json_schema",
name: "plan_datos",
strict: true,
schema: definicion,
};
}
log("Structured wrapper summary", {
type: structured?.type ?? null,
name: structured?.name ?? null,
strict: structured?.strict ?? null,
hasSchema: Boolean(structured?.schema),
schemaTopKeys: structured?.schema ? Object.keys(structured.schema).slice(0, 20) : null,
});
// 8) Llamar ai-structured (otra Edge Function) 🔁
log("Step 8: Call ai-structured 🔁");
const aiStructuredUrl = `${SUPABASE_URL}/functions/v1/ai-structured`;
const model = payload?.model ?? payload?.openai?.model ?? payload?.response?.model ?? "gpt-5";
const aiStructuredBody = {
response: {
model,
input: [
{ role: "system", content: system },
{ role: "user", content: userPrompt },
],
},
structured,
references: {
openaiFileIds,
vectorStoreIds,
uploadedToStorage,
},
usarMCP: Boolean(iaConfig?.usarMCP),
};
log("ai-structured request snapshot 🧪", {
url: aiStructuredUrl,
model,
inputChars: { system: system.length, user: userPrompt.length },
openaiFileIdsCount: openaiFileIds.length,
vectorStoreIdsCount: vectorStoreIds.length,
usarMCP: Boolean(iaConfig?.usarMCP),
});
const tAi = performance.now();
const aiResp = await fetch(aiStructuredUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: authHeaderRaw,
apikey: SUPABASE_ANON_KEY,
},
body: JSON.stringify(aiStructuredBody),
});
const aiJson = await aiResp.json().catch(() => null);
log("ai-structured response received ⏱️", {
ms: Math.round(performance.now() - tAi),
status: aiResp.status,
okHttp: aiResp.ok,
ok: aiJson?.ok ?? null,
aiCid: aiJson?.cid ?? null,
responseId: aiJson?.responseId ?? null,
conversationId: aiJson?.conversationId ?? null,
outputParseError: aiJson?.outputParseError ?? null,
});
if (!aiResp.ok || !aiJson) {
errlog("ai-structured HTTP failed", { status: aiResp.status, body: aiJson });
return json({
ok: false,
error: "ai-structured HTTP failed",
status: aiResp.status,
details: aiJson,
cid,
}, 500);
}
if (!aiJson.ok || !aiJson.output) {
errlog("ai-structured returned ok=false or missing output", { aiJson });
// Aun así, registra interaccion_ia (sin plan) para auditoría
const { data: inter, error: interErr } = await supabaseAdmin
.from("interacciones_ia")
.insert({
usuario_id: user.id,
plan_estudio_id: null,
tipo: "GENERAR",
modelo: aiJson?.model ?? model,
temperatura: null,
prompt: {
datosBasicos,
iaConfig,
estructura: { id: estructura.id, nombre: estructura.nombre, template: estructura.template, tipo: estructura.tipo },
request: aiStructuredBody,
computed,
},
respuesta: aiJson,
aceptada: false,
conversacion_id: aiJson?.conversationId ?? null,
ids_archivos: uploadedToStorage,
ids_vector_store: repositoriosIds,
rutas_storage: rutasStorage,
})
.select("id")
.maybeSingle();
if (interErr) warn("interacciones_ia insert failed (error path)", { message: interErr.message });
return json({
ok: false,
error: "AI generation failed (structured output invalid).",
cid,
ai: aiJson,
interaccion_ia_id: inter?.id ?? null,
}, 500);
}
// 9) Normaliza/asegura campos clave del output ✅
log("Step 9: Normalize output ✅");
const output = aiJson.output as Record<string, any>;
// fuerza consistencia con datosBasicos
output.nombre = computed.nombrePlan;
output.nivel = computed.nivel;
output.total_de_ciclos_del_plan_de_estudios = String(computed.numCiclos);
output.duracion_del_ciclo_escolar = String(computed.semanas);
// 10) Inserta log de IA (interacciones_ia) 🧾
log("Step 10: Insert interacciones_ia 🧾");
const tInter = performance.now();
const { data: interaccion, error: interErr } = await supabaseAdmin
.from("interacciones_ia")
.insert({
usuario_id: user.id,
plan_estudio_id: null, // se linkea luego (cuando exista el plan)
tipo: "GENERAR",
modelo: aiJson?.model ?? model,
temperatura: null,
prompt: {
datosBasicos,
iaConfig,
estructura: {
id: estructura.id,
nombre: estructura.nombre,
template: estructura.template,
tipo: estructura.tipo,
},
request: aiStructuredBody,
computed,
},
respuesta: aiJson,
aceptada: true, // se está usando para crear el plan
conversacion_id: aiJson?.conversationId ?? null,
ids_archivos: uploadedToStorage,
ids_vector_store: repositoriosIds,
rutas_storage: rutasStorage,
})
.select("id")
.single();
log("interacciones_ia insert done ⏱️", {
ms: Math.round(performance.now() - tInter),
interaccionId: interaccion?.id ?? null,
ok: !interErr,
});
if (interErr) {
errlog("interacciones_ia insert failed", { message: interErr.message });
return json(
{ ok: false, error: "interacciones_ia insert failed", details: interErr.message, cid },
500,
);
}
// 11) Crear plan (planes_estudio) 🏗️
log("Step 11: Insert planes_estudio 🏗️");
const nivelDB = normalizeNivel(String(datosBasicos?.nivel ?? ""));
const tipoCicloDB = normalizeTipoCiclo(String(datosBasicos?.tipoCiclo ?? ""));
const numeroCiclosDB = Number(datosBasicos?.numCiclos ?? 0);
// intenta setear estado BORRADOR si existe
let estadoBorradorId: string | null = null;
try {
const { data: estado } = await supabaseAdmin
.from("estados_plan")
.select("id,clave,orden")
.ilike("clave", "BORRADOR%")
.order("orden", { ascending: true })
.limit(1)
.maybeSingle();
estadoBorradorId = estado?.id ?? null;
} catch {
// ignore
}
const tPlan = performance.now();
const { data: plan, error: planErr } = await supabaseAdmin
.from("planes_estudio")
.insert({
carrera_id: carreraId,
estructura_id: estructura.id,
nombre: computed.nombrePlan,
nivel: nivelDB,
tipo_ciclo: tipoCicloDB,
numero_ciclos: numeroCiclosDB,
datos: output,
estado_actual_id: estadoBorradorId,
activo: true,
tipo_origen: "IA",
meta_origen: {
generado_por: "ai_generate_plan",
cid,
ai_structured: {
cid: aiJson?.cid ?? null,
responseId: aiJson?.responseId ?? null,
conversationId: aiJson?.conversationId ?? null,
model: aiJson?.model ?? model,
usage: aiJson?.usage ?? null,
},
referencias: {
uploadedToStorage,
repositoriosIds,
openaiFileIds,
vectorStoreIds,
},
iaConfig: {
descripcionEnfoque: iaConfig?.descripcionEnfoque ?? null,
poblacionObjetivo: iaConfig?.poblacionObjetivo ?? null,
notasAdicionales: iaConfig?.notasAdicionales ?? null,
usarMCP: Boolean(iaConfig?.usarMCP),
},
},
creado_por: user.id,
actualizado_por: user.id,
})
.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();
log("planes_estudio insert done ⏱️", {
ms: Math.round(performance.now() - tPlan),
planId: plan?.id ?? null,
ok: !planErr,
});
if (planErr) {
errlog("planes_estudio insert failed", { message: planErr.message });
return json(
{ ok: false, error: "planes_estudio insert failed", details: planErr.message, cid },
500,
);
}
// 12) Link interaccion_ia -> plan + historial (cambios_plan) 🧩
log("Step 12: Link interaccion + insert cambios_plan 🧩");
const { error: linkErr } = await supabaseAdmin
.from("interacciones_ia")
.update({ plan_estudio_id: plan.id })
.eq("id", interaccion.id);
if (linkErr) warn("Could not link interaccion_ia to plan (continuing)", { message: linkErr.message });
const { error: cambioErr } = await supabaseAdmin
.from("cambios_plan")
.insert({
plan_estudio_id: plan.id,
cambiado_por: user.id,
tipo: "ACTUALIZACION_CAMPO",
campo: "datos",
valor_anterior: null,
valor_nuevo: output,
interaccion_ia_id: interaccion.id,
});
if (cambioErr) warn("cambios_plan insert failed (continuing)", { message: cambioErr.message });
// 13) (OPCIONAL) Generar materias automáticamente (dejado comentado) 📚
// ---------------------------------------------------------------
// const AUTO_GENERATE_SUBJECTS = false; // 👈 activar cuando decidas
// const AUTO_SUBJECTS_COUNT = 10; // 👈 constante pedida (número de materias)
// if (AUTO_GENERATE_SUBJECTS) {
// for (let i = 1; i <= AUTO_SUBJECTS_COUNT; i++) {
// // TODO: insertar en `asignaturas` con plan_estudio_id = plan.id
// // Recomendación: usar una estructura_asignatura default y llenar datos mínimos.
// }
// }
// ---------------------------------------------------------------
log("RETURN ✅", {
ok: true,
planId: plan.id,
interaccionIaId: interaccion.id,
totalMs: Math.round(performance.now() - t0),
});
return json({
ok: true,
cid,
plan,
plan_datos: output,
interaccion_ia_id: interaccion.id,
ai: {
cid: aiJson?.cid ?? null,
responseId: aiJson?.responseId ?? null,
conversationId: aiJson?.conversationId ?? null,
model: aiJson?.model ?? model,
usage: aiJson?.usage ?? null,
},
references: {
uploadedToStorage,
repositoriosIds,
openaiFileIds,
vectorStoreIds,
rutasStorage,
},
});
} catch (e) {
errlog("Unhandled error 💥", {
message: (e as Error)?.message ?? String(e),
stack: (e as Error)?.stack ?? null,
totalMs: Math.round(performance.now() - t0),
});
return json({ ok: false, error: (e as Error)?.message ?? String(e), cid }, 500);
}
});
@@ -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,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,331 @@
const CARBONE_VERSION = "5";
export type CarboneSuccess<T> = {
success: true;
data: T;
hasMore?: boolean;
nextCursor?: string;
};
export type CarboneError = {
success: false;
error: string;
};
export type CarboneResponse<T> = CarboneSuccess<T> | CarboneError;
export type CarboneDownloadResult = {
buffer: Uint8Array;
contentType: string | null;
contentDisposition: string | null;
};
export class CarboneClient {
constructor(
private baseUrl: string,
private apiToken: string,
) {}
private buildHeaders(extra?: Record<string, string>) {
return {
Authorization: `Bearer ${this.apiToken}`,
"carbone-version": CARBONE_VERSION,
...extra,
};
}
private async parseResponse<T>(res: Response): Promise<T> {
const contentType = res.headers.get("content-type") || "";
if (!res.ok) {
let message = `Carbone request failed: ${res.status}`;
try {
const err = await res.json();
message = err?.error || err?.message || message;
} catch {
// ignore json parse errors
}
throw new Error(message);
}
if (contentType.includes("application/json")) {
return res.json() as Promise<T>;
}
return res as unknown as T;
}
private toQuery(params?: Record<string, unknown>) {
const qs = new URLSearchParams();
if (!params) return "";
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
qs.set(key, String(value));
}
const s = qs.toString();
return s ? `?${s}` : "";
}
async listTemplates(params?: {
id?: string;
versionId?: string;
category?: string;
origin?: 0 | 1;
includeVersions?: boolean;
search?: string;
limit?: number;
cursor?: string;
}) {
const res = await fetch(
`${this.baseUrl}/templates${this.toQuery(params)}`,
{
method: "GET",
headers: this.buildHeaders(),
},
);
return this.parseResponse<
CarboneResponse<
Array<{
id: string;
versionId: string;
deployedAt: number;
createdAt: number;
expireAt?: number;
size: number;
type: string;
name?: string;
category?: string;
comment?: string;
tags?: string[];
origin?: number;
}>
>
>(res);
}
async listCategories() {
const res = await fetch(`${this.baseUrl}/templates/categories`, {
method: "GET",
headers: this.buildHeaders(),
});
return this.parseResponse<CarboneResponse<Array<{ name: string }>>>(res);
}
async listTags() {
const res = await fetch(`${this.baseUrl}/templates/tags`, {
method: "GET",
headers: this.buildHeaders(),
});
return this.parseResponse<CarboneResponse<Array<{ name: string }>>>(res);
}
async uploadTemplateJson(body: {
template: string;
versioning?: boolean;
id?: string;
name?: string;
comment?: string;
tags?: string[];
category?: string;
sample?: unknown[];
deployedAt?: number;
expireAt?: number;
}) {
const res = await fetch(`${this.baseUrl}/template`, {
method: "POST",
headers: this.buildHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(body),
});
return this.parseResponse<
CarboneResponse<{
id?: string;
versionId?: string;
templateId?: string;
type?: string;
size?: number;
createdAt?: number;
}>
>(res);
}
async uploadTemplateFile(input: {
file: Blob;
filename: string;
versioning?: boolean;
id?: string;
name?: string;
comment?: string;
tags?: string[];
category?: string;
sample?: unknown[];
deployedAt?: number;
expireAt?: number;
}) {
const form = new FormData();
if (input.versioning !== undefined) {
form.append("versioning", String(input.versioning));
}
if (input.id) form.append("id", input.id);
if (input.name) form.append("name", input.name);
if (input.comment) form.append("comment", input.comment);
if (input.category) form.append("category", input.category);
if (input.tags) {
for (const tag of input.tags) form.append("tags[]", tag);
}
if (input.sample) form.append("sample", JSON.stringify(input.sample));
if (input.deployedAt !== undefined) {
form.append("deployedAt", String(input.deployedAt));
}
if (input.expireAt !== undefined) {
form.append("expireAt", String(input.expireAt));
}
// template should be appended last
form.append("template", input.file, input.filename);
const res = await fetch(`${this.baseUrl}/template`, {
method: "POST",
headers: this.buildHeaders(),
body: form,
});
return this.parseResponse<
CarboneResponse<{
id?: string;
versionId?: string;
templateId?: string;
type?: string;
size?: number;
createdAt?: number;
}>
>(res);
}
async patchTemplate(
id: string,
body: {
name?: string;
comment?: string;
tags?: string[];
category?: string;
deployedAt?: number;
expireAt?: number;
},
) {
const res = await fetch(`${this.baseUrl}/template/${id}`, {
method: "PATCH",
headers: this.buildHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(body),
});
return this.parseResponse<CarboneResponse<true>>(res);
}
async deleteTemplate(id: string) {
const res = await fetch(`${this.baseUrl}/template/${id}`, {
method: "DELETE",
headers: this.buildHeaders(),
});
return this.parseResponse<CarboneResponse<true>>(res);
}
async downloadTemplate(id: string): Promise<CarboneDownloadResult> {
const res = await fetch(`${this.baseUrl}/template/${id}`, {
method: "GET",
headers: this.buildHeaders(),
});
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(
err?.error || `Failed to download template: ${res.status}`,
);
}
return {
buffer: new Uint8Array(await res.arrayBuffer()),
contentType: res.headers.get("content-type"),
contentDisposition: res.headers.get("content-disposition"),
};
}
async render(
templateIdOrVersionId: string,
body: Record<string, unknown>,
opts?: { download?: boolean },
) {
const query = opts?.download ? "?download=true" : "";
const res = await fetch(
`${this.baseUrl}/render/${templateIdOrVersionId}${query}`,
{
method: "POST",
headers: this.buildHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(body),
},
);
if (opts?.download) {
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error || `Render failed: ${res.status}`);
}
return {
buffer: new Uint8Array(await res.arrayBuffer()),
contentType: res.headers.get("content-type"),
contentDisposition: res.headers.get("content-disposition"),
} satisfies CarboneDownloadResult;
}
return this.parseResponse<CarboneResponse<{ renderId: string }>>(res);
}
async renderFromTemplate(
body: Record<string, unknown>,
opts?: { download?: boolean },
) {
const query = opts?.download ? "?download=true" : "";
const res = await fetch(`${this.baseUrl}/render/template${query}`, {
method: "POST",
headers: this.buildHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(body),
});
if (opts?.download) {
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error || `Render failed: ${res.status}`);
}
return {
buffer: new Uint8Array(await res.arrayBuffer()),
contentType: res.headers.get("content-type"),
contentDisposition: res.headers.get("content-disposition"),
} satisfies CarboneDownloadResult;
}
return this.parseResponse<CarboneResponse<{ renderId: string }>>(res);
}
async downloadRender(renderId: string): Promise<CarboneDownloadResult> {
const res = await fetch(`${this.baseUrl}/render/${renderId}`, {
method: "GET",
});
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error || `Failed to download render: ${res.status}`);
}
return {
buffer: new Uint8Array(await res.arrayBuffer()),
contentType: res.headers.get("content-type"),
contentDisposition: res.headers.get("content-disposition"),
};
}
}
@@ -0,0 +1,8 @@
{
"imports": {
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts",
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
"@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts",
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
}
}
@@ -0,0 +1,252 @@
import { createClient } from "@supabase/supabase-js";
import { z } from "zod";
import { corsHeaders } from "../_shared/cors.ts";
import type { Database, Json } from "../_shared/database.types.ts";
import { HttpError } from "../_shared/utils.ts";
import { CarboneClient } from "./carbone.ts";
const DownloadReportBodySchema = z.record(z.unknown()).optional().default({});
const DownloadReportPlanSchema = z
.object({
action: z.literal("downloadReport"),
plan_estudio_id: z.string().min(1),
body: DownloadReportBodySchema,
})
.strict();
const DownloadReportAsignaturaSchema = z
.object({
action: z.literal("downloadReport"),
asignatura_id: z.string().min(1),
body: DownloadReportBodySchema,
})
.strict();
export const DownloadReportSchema = z.union([
DownloadReportPlanSchema,
DownloadReportAsignaturaSchema,
]);
export type DownloadReportInput = z.infer<typeof DownloadReportSchema>;
type SupabaseClient = ReturnType<typeof createClient<Database>>;
type CarboneDownload = {
buffer: Uint8Array;
contentType: string | null;
contentDisposition: string | null;
};
async function loadPlanDatos(
supabase: SupabaseClient,
planEstudioId: string,
): Promise<{ datos: Json; estructura_id: string }> {
const { data, error } = await supabase
.from("planes_estudio")
.select("datos, estructura_id")
.eq("id", planEstudioId)
.maybeSingle();
if (error) {
throw new HttpError(
500,
"Error consultando el plan de estudios.",
"DB_ERROR",
{
message: error.message,
details: error.details,
hint: error.hint,
},
);
}
if (!data) {
throw new HttpError(404, "Plan de estudios no encontrado.", "NOT_FOUND", {
plan_estudio_id: planEstudioId,
});
}
return { datos: data.datos, estructura_id: data.estructura_id };
}
async function loadTemplateIdForEstructura(
supabase: SupabaseClient,
estructuraId: string,
): Promise<string> {
const { data, error } = await supabase
.from("estructuras_plan")
.select("template_id")
.eq("id", estructuraId)
.maybeSingle();
if (error) {
throw new HttpError(
500,
"Error consultando la estructura del plan.",
"DB_ERROR",
{
message: error.message,
details: error.details,
hint: error.hint,
},
);
}
if (!data) {
throw new HttpError(
404,
"Estructura del plan no encontrada.",
"NOT_FOUND",
{
estructura_id: estructuraId,
},
);
}
if (!data.template_id) {
throw new HttpError(
409,
"La estructura del plan no tiene template_id configurado.",
"MISSING_TEMPLATE_ID",
{ estructura_id: estructuraId },
);
}
return data.template_id;
}
async function loadTemplateIdForAsignatura(
supabase: SupabaseClient,
asignaturaId: string,
): Promise<string> {
const { data, error } = await supabase
.from("plantilla_asignatura")
.select("template_id")
.eq("asignatura_id", asignaturaId)
.maybeSingle();
if (error) {
throw new HttpError(
500,
"Error consultando la plantilla de la asignatura.",
"DB_ERROR",
{
message: error.message,
details: error.details,
hint: error.hint,
},
);
}
if (!data) {
throw new HttpError(404, "Asignatura no encontrada.", "NOT_FOUND", {
asignatura_id: asignaturaId,
});
}
if (!data.template_id) {
throw new HttpError(
409,
"La asignatura no tiene template_id configurado.",
"MISSING_TEMPLATE_ID",
{ asignatura_id: asignaturaId },
);
}
return data.template_id;
}
function ensureCarboneDownload(result: unknown): CarboneDownload {
if (!(result && typeof result === "object" && "buffer" in result)) {
throw new HttpError(
502,
"Respuesta inválida de Carbone.",
"UPSTREAM_INVALID_RESPONSE",
);
}
const download = result as CarboneDownload;
if (!(download.buffer instanceof Uint8Array)) {
throw new HttpError(
502,
"Respuesta inválida de Carbone.",
"UPSTREAM_INVALID_RESPONSE",
);
}
return download;
}
function downloadToResponse(download: CarboneDownload): Response {
const headers = new Headers({
...corsHeaders,
"Content-Type": download.contentType === "application/pdf"
? "application/pdf"
: "application/octet-stream",
Connection: "keep-alive",
});
if (download.contentDisposition) {
headers.set("Content-Disposition", download.contentDisposition);
}
const body = new Uint8Array(
download.buffer.buffer as ArrayBuffer,
download.buffer.byteOffset,
download.buffer.byteLength,
);
return new Response(body, { status: 200, headers });
}
export async function handleDownloadReportAction(args: {
bodyUnknown: unknown;
supabase: SupabaseClient;
carboneBaseUrl: string;
carboneApiToken: string;
}): Promise<Response> {
const input: DownloadReportInput = DownloadReportSchema.parse(
args.bodyUnknown,
);
const carbone = new CarboneClient(args.carboneBaseUrl, args.carboneApiToken);
if ("plan_estudio_id" in input) {
const { datos, estructura_id } = await loadPlanDatos(
args.supabase,
input.plan_estudio_id,
);
const templateId = await loadTemplateIdForEstructura(
args.supabase,
estructura_id,
);
const { data: _ignoredData, ...extraBody } = input.body;
const result = await carbone.render(
templateId,
{ data: datos, ...extraBody },
{ download: true },
);
return downloadToResponse(ensureCarboneDownload(result));
}
const templateId = await loadTemplateIdForAsignatura(
args.supabase,
input.asignatura_id,
);
const hasData = Object.prototype.hasOwnProperty.call(input.body, "data");
if (!hasData) {
throw new HttpError(
400,
"Para asignatura_id se requiere body.data.",
"MISSING_DATA",
{ asignatura_id: input.asignatura_id },
);
}
// console.log("body:", input.body);
const { data, ...extraBody } = input.body as Record<string, unknown>;
const result = await carbone.render(
templateId,
{ data, ...extraBody },
{ download: true },
);
return downloadToResponse(ensureCarboneDownload(result));
}
@@ -0,0 +1,179 @@
// 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 { createClient } from "@supabase/supabase-js";
import { z } from "zod";
import { corsHeaders } from "../_shared/cors.ts";
import type { Database } from "../_shared/database.types.ts";
import { HttpError, sendError } from "../_shared/utils.ts";
import { handleDownloadReportAction } from "./download-report.ts";
const ActionSchema = z.object({
action: z.string().min(1),
});
function getAuthHeader(req: Request): string | null {
return req.headers.get("Authorization") ?? req.headers.get("authorization");
}
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 authHeader = getAuthHeader(req);
if (!authHeader) {
throw new HttpError(401, "No autorizado.", "UNAUTHORIZED", {
reason: "missing_authorization_header",
});
}
const contentType = (req.headers.get("content-type") || "").toLowerCase();
if (!contentType.startsWith("application/json")) {
throw new HttpError(
415,
"Content-Type no soportado.",
"UNSUPPORTED_MEDIA_TYPE",
{ contentType },
);
}
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
throw new HttpError(
500,
"Configuración del servidor incompleta.",
"MISSING_ENV",
{
missing: [
!SUPABASE_URL ? "SUPABASE_URL" : null,
!SERVICE_ROLE_KEY ? "SUPABASE_SERVICE_ROLE_KEY" : null,
].filter(Boolean),
},
);
}
const CARBONE_API_TOKEN = Deno.env.get("CARBONE_API_TOKEN");
const CARBONE_BASE_URL = Deno.env.get("CARBONE_BASE_URL") ||
"https://carbone.lci.ulsa.mx";
if (!CARBONE_API_TOKEN) {
throw new HttpError(
500,
"Configuración del servidor incompleta.",
"MISSING_ENV",
{
missing: [
!CARBONE_API_TOKEN ? "CARBONE_API_TOKEN" : null,
].filter(Boolean),
},
);
}
const bodyUnknown: unknown = await req.json();
const { action } = ActionSchema.parse(bodyUnknown);
const supabase = createClient<Database>(SUPABASE_URL, SERVICE_ROLE_KEY, {
auth: { persistSession: false },
});
switch (action) {
case "downloadReport": {
const response = await handleDownloadReportAction({
bodyUnknown,
supabase,
carboneBaseUrl: CARBONE_BASE_URL,
carboneApiToken: CARBONE_API_TOKEN,
});
console.log(
`[${
new Date().toISOString()
}][${functionName}]: Request processed successfully`,
);
return response;
}
default:
throw new HttpError(
400,
"Acción no soportada.",
"UNSUPPORTED_ACTION",
{ action },
);
}
} 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:
# Requires secrets:
# - CARBONE_API_TOKEN
# Optional:
# - CARBONE_BASE_URL (defaults to https://carbone.lci.ulsa.mx)
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/carbone-io-wrapper' \
--header 'Authorization: Bearer <JWT>' \
--header 'Content-Type: application/json' \
--data '{"action":"downloadReport","plan_estudio_id":"<uuid>","body":{}}'
# Or for asignaturas (must include body.data):
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/carbone-io-wrapper' \
--header 'Authorization: Bearer <JWT>' \
--header 'Content-Type: application/json' \
--data '{"action":"downloadReport","asignatura_id":"<uuid>","body":{"data":{}}}'
*/
@@ -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);
}
}
@@ -1,123 +1,414 @@
// Setup type definitions for built-in Supabase Runtime APIs
import "@supabase/functions-js/edge-runtime.d.ts";
import { corsHeaders } from "../_shared/cors.ts";
import { Hono } from "jsr:@hono/hono";
import { corsHeaders, withCors } from "./lib/cors.ts";
import { HttpError, jsonResponse } from "./lib/errors.ts";
import { getOpenAI } from "./lib/openai.ts";
import { getSupabaseServiceClient, requireUser } from "./lib/supabase.ts";
import { assertUuid, pickSchemaFields, safePlanForPrompt,pickSchemaAsignaturaFields, safeAsignaturaForPrompt, getAsignaturaSystemPrompt } from "./lib/plan.ts";
import { OpenAIService } from "../_shared/openai-service.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
type WebhookPayload = {
type: "INSERT" | "UPDATE" | "DELETE";
table: string;
schema: string;
record: Record<string, unknown> | null;
old_record: Record<string, unknown> | null;
type CreateBody = {
plan_estudio_id: string;
instanciador?: string;
system_prompt?: string;
};
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
const SUPABASE_SERVICE_ROLE_KEY =
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
type AddMessageBody = {
// Guarda mensaje en OpenAI conversation
content: string;
// Si quieres forzar mejoras estructuradas:
campos?: string[];
user_prompt?: string; // si no mandas, usa content
model?: string; // default gpt-5-nano
};
const ALLOWED_SCHEMA = "public";
const ALLOWED_TABLES = new Set(["planes_estudio", "asignaturas"]);
const app = new Hono();
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false },
addEventListener("beforeunload", (ev: any) => {
console.error(
"ALERTA: La función se va a apagar. Razón:",
ev?.detail?.reason,
);
});
function jsonResponse(status: number, body: Record<string, unknown>) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}
// Preflight CORS
app.options(
"*",
(c) => new Response(null, { status: 204, headers: corsHeaders }),
);
async function createConversationId(
openaiService: OpenAIService,
metadata: Record<string, string>,
) {
const conversation = await openaiService.createConversation(metadata);
const conversationId = conversation?.id as string | undefined;
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";
if (!conversationId) {
throw new Error("OpenAI response missing conversation id");
}
app.get(`${prefix}/health`, (c) => withCors(jsonResponse({ ok: true })));
return conversationId;
}
Deno.serve(async (req) => {
if (req.method !== "POST") {
return jsonResponse(405, { error: "Method not allowed" });
}
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
return jsonResponse(500, { error: "Supabase env vars missing" });
}
const openaiService = OpenAIService.fromEnv();
if (!(openaiService instanceof OpenAIService)) {
return jsonResponse(500, { error: openaiService.message });
}
let payload: WebhookPayload;
/**
* POST /conversations
* Crea conversación OpenAI + registro en conversaciones_plan
*/
app.post(`${prefix}/plan/conversations`, async (c) => {
try {
payload = await req.json();
} catch {
return jsonResponse(400, { error: "Invalid JSON body" });
}
/* const auth = c.req.header("authorization");
const user = await requireUser(auth); */
const { type, table, schema, record } = payload;
const body = (await c.req.json().catch(() => ({}))) as Partial<CreateBody>;
const plan_estudio_id = body.plan_estudio_id;
assertUuid(plan_estudio_id ?? "", "plan_estudio_id");
if (schema !== ALLOWED_SCHEMA || !ALLOWED_TABLES.has(table)) {
return jsonResponse(400, { error: "Table not allowed" });
}
const instanciador = /* user.email ?? user.id ?? */ body.instanciador ??
"unknown";
const system_prompt = body.system_prompt ??
"En caso de que te pidan algo que no tiene nada que ver con planes de estudio o asignatura responde con un refusal.";
if (type !== "INSERT") {
return jsonResponse(200, { status: "ignored", reason: "event_type" });
}
const supabase = getSupabaseServiceClient();
const openai = getOpenAI();
if (!record || typeof record !== "object") {
return jsonResponse(400, { error: "Missing record" });
}
// Cargar plan + estructura
const { data: plan, error: planErr } = await supabase
.from("planes_estudio")
.select("*, estructuras_plan (definicion)")
.eq("id", plan_estudio_id)
.single();
const recordId = record["id"] as string | number | undefined;
if (!recordId) {
return jsonResponse(400, { error: "Record id missing" });
}
if (planErr || !plan) {
throw new HttpError(
404,
"plan_not_found",
"Plan de estudio no encontrado",
planErr,
);
}
const existingConversationId = record["conversation_id"] as
| string
| null
| undefined;
if (existingConversationId) {
return jsonResponse(200, {
status: "skipped",
conversation_id: existingConversationId,
// Crear conversación en OpenAI
const conv = await openai.conversations.create({
metadata: {
tabla: "planes_estudio",
id: plan.id,
instanciador,
},
items: [{ type: "message", role: "system", content: system_prompt }],
});
// Crear registro en Supabase
const { data: row, error: insErr } = await supabase
.from("conversaciones_plan")
.insert({
openai_conversation_id: conv.id,
plan_estudio_id: plan.id,
estado: "ACTIVA",
})
.select("id, plan_estudio_id, openai_conversation_id, estado")
.single();
if (insErr || !row) {
// rollback best-effort
try {
await openai.conversations.delete(conv.id);
} catch (_) {}
throw new HttpError(
500,
"db_insert_failed",
"No se pudo registrar la conversación",
insErr,
);
}
return withCors(jsonResponse({ conversation_plan: row }, 201));
} catch (err) {
return withCors(handleErr(err));
}
let conversationId: string;
try {
conversationId = await createConversationId(openaiService, {
table,
record_id: String(recordId),
});
} catch (error) {
return jsonResponse(502, { error: (error as Error).message });
}
const { error } = await supabase
.from(table)
.update({ conversation_id: conversationId })
.eq("id", recordId);
if (error) {
return jsonResponse(500, { error: error.message });
}
return jsonResponse(200, {
status: "updated",
table,
record_id: recordId,
conversation_id: conversationId,
});
});
app.post(`${prefix}/asignatura/conversations`, async (c) => {
try {
const body = (await c.req.json().catch(() => ({}))) as Partial<CreateBody>;
const asignatura_id = body.asignatura_id;
assertUuid(asignatura_id ?? "", "asignatura_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();
// 1. Verificar que la asignatura existe
const { data: asignatura, error: asigErr } = await supabase
.from("asignaturas")
.select("*")
.eq("id", asignatura_id)
.single();
if (asigErr || !asignatura) {
throw new HttpError(404, "asignatura_not_found", "Asignatura no encontrada");
}
// 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 }],
});
// 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();
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/plan/:id/messages`, async (c) => {
try {
const conversation_plan_id = c.req.param("id");
assertUuid(conversation_plan_id, "conversation_plan_id");
const body = (await c.req.json().catch(() => ({}))) as Partial<AddMessageBody>;
if (!body.content || typeof body.content !== "string") {
throw new HttpError(400, "bad_input", "content es requerido");
}
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();
// Usamos el servicio que ya tienes configurado para background
const svc = OpenAIService.fromEnv();
// 1. Traer datos de la asignatura
const { data: row, error } = await supabase
.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, "not_found", "Conversación no encontrada");
const asignatura = (row as any).asignaturas;
const definicion = asignatura?.estructuras_asignatura?.definicion;
const campos = body.campos ?? [];
const isStructured = !!definicion && campos.length > 0;
// 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 (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" }
},
required: ["ai-message", "is_refusal"],
additionalProperties: false
};
// 4. Llamada asincrónica con background: true
const aiResult = await svc.createStructuredResponse({
conversation: row.openai_conversation_id,
model: isStructured ? CREATE_CHAT_CONVERSATION_STRUCTURED_MODELO : CREATE_CHAT_CONVERSATION_NONSTRUCTURED_MODELO,
background: true, // <--- Ahora sí, activamos el modo background
metadata: {
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: "mejoras_asignatura",
schema: schema
}
},
input: [
{ role: "system", content: getAsignaturaSystemPrompt(asignatura, campos) },
{ 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
}));
} catch (err) {
return withCors(handleErr(err));
}
});
/**
* Unknown routes
*/
app.all(
"*",
(c) =>
withCors(
jsonResponse({
error: "not_found",
message: `Route ${c.req.url} not found`,
}, 404),
),
);
function handleErr(err: unknown): Response {
if (err instanceof HttpError) {
return jsonResponse(
{ error: err.code, message: err.message, details: err.details ?? null },
err.status,
);
}
console.error("Unhandled error:", err);
return jsonResponse(
{ error: "internal_error", message: "Unexpected error" },
500,
);
}
Deno.serve(app.fetch);
@@ -0,0 +1,12 @@
export const corsHeaders: Record<string, string> = {
"access-control-allow-origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform",
"access-control-allow-methods": "GET,POST,OPTIONS,DELETE",
};
export function withCors(res: Response) {
const h = new Headers(res.headers);
for (const [k, v] of Object.entries(corsHeaders)) h.set(k, v);
return new Response(res.body, { status: res.status, headers: h });
}
@@ -0,0 +1,9 @@
export function mustGetEnv(name: string): string {
const v = Deno.env.get(name);
if (!v) throw new Error(`Missing env var: ${name}`);
return v;
}
export function getEnv(name: string, fallback?: string): string | undefined {
return Deno.env.get(name) ?? fallback;
}
@@ -0,0 +1,31 @@
export class HttpError extends Error {
status: number;
code: string;
details?: unknown;
constructor(
status: number,
code: string,
message: string,
details?: unknown,
) {
super(message);
this.status = status;
this.code = code;
this.details = details;
}
}
export function jsonResponse(
body: unknown,
status = 200,
headers: HeadersInit = {},
) {
return new Response(JSON.stringify(body), {
status,
headers: {
"content-type": "application/json; charset=utf-8",
...headers,
},
});
}
@@ -0,0 +1,9 @@
import OpenAI from "npm:openai@6.16.0";
import { mustGetEnv } from "./env.ts";
export function getOpenAI() {
// OpenAI lib toma OPENAI_API_KEY de env automáticamente,
// pero lo validamos para fallar rápido:
mustGetEnv("OPENAI_API_KEY");
return new OpenAI();
}
@@ -0,0 +1,149 @@
import { definicionesDeEstructurasDeColumnas } from "../../_shared/estructuras.ts";
import { HttpError } from "./errors.ts";
export function pickSchemaFields(
definicion: any,
campos: string[],
) {
if (!definicion || definicion.type !== "object" || !definicion.properties) {
return definicion;
}
const extra = {
properties: {
"ai-message": {
type: "string",
description:
"Mensaje breve para el usuario final confirmando qué se mejoró y qué se hizo.",
examples: [
"Listo: mejoré la redacción del perfil de ingreso y propuse un tema de investigación alineado al plan.",
],
},
"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`.",
},
},
};
const out = structuredClone(definicion);
// Si piden campos, filtramos propiedades/required a esos campos
const entries = Object.entries(out.properties).filter(([k]) =>
campos.includes(k)
);
out.properties = Object.fromEntries(entries);
if (Array.isArray(out.required)) {
out.required = out.required.filter((k: string) => campos.includes(k));
}
// Siempre agregamos ai-message
out.properties = { ...out.properties, ...extra.properties };
out.required = Array.isArray(out.required)
? [...new Set([...out.required, ...Object.keys(extra.properties)])]
: Object.keys(extra.properties);
return out;
}
export function pickSchemaAsignaturaFields(
definicion: any,
campos: string[],
) {
if (!definicion || definicion.type !== "object" || !definicion.properties) {
return definicion;
}
const extra = {
properties: {
"ai-message": {
type: "string",
description: "Tu respuesta conversacional dirigida al profesor explicando qué mejoraste.",
},
"is_refusal": {
type: "boolean",
description: "Indica si la solicitud es inapropiada.",
},
},
};
const out = structuredClone(definicion);
const finalProperties: Record<string, any> = {};
const finalRequired: string[] = [];
campos.forEach((key) => {
// REVISIÓN DE ESTRUCTURA ESPECIAL
if (key in definicionesDeEstructurasDeColumnas) {
// Extraemos solo el esquema del array que vive en 'x-definicion'
// Esto hace que la IA reciba el esquema del array directamente
const especial = (definicionesDeEstructurasDeColumnas as any)[key];
finalProperties[key] = especial.properties["x-definicion"];
finalRequired.push(key);
}
// CAMPOS NORMALES (los que están en 'datos')
else if (out.properties[key]) {
finalProperties[key] = out.properties[key];
if (out.required?.includes(key)) {
finalRequired.push(key);
}
}
});
out.properties = { ...finalProperties, ...extra.properties };
out.required = [...new Set([...finalRequired, ...Object.keys(extra.properties)])];
out.additionalProperties = false;
return out;
}
export function safePlanForPrompt(plan: any) {
const copy = structuredClone(plan);
if (copy?.estructuras_plan) delete copy.estructuras_plan;
return copy;
}
export function assertUuid(v: string, name: string) {
// validación ligera
if (!v || typeof v !== "string" || v.length < 10) {
throw new HttpError(400, "bad_input", `Invalid ${name}`);
}
}
export function safeAsignaturaForPrompt(asignatura: any) {
const copy = structuredClone(asignatura);
// Eliminamos la definición de la estructura para que no ensucie el prompt
// y solo queden los datos reales de la asignatura
if (copy?.estructuras_asignatura) delete copy.estructuras_asignatura;
return copy;
}
export function getAsignaturaSystemPrompt(asignatura: any, campos: string[]) {
const asignaturaLimpia = safeAsignaturaForPrompt(asignatura);
const nombreAsig = asignaturaLimpia?.nombre || "la asignatura";
if (campos.length === 0) {
return `Eres un asistente experto en diseño curricular.
Tu objetivo es ayudar al profesor a mejorar su asignatura: "${nombreAsig}".
DATOS ACTUALES: ${JSON.stringify(asignaturaLimpia)}.
COMPORTAMIENTO:
- El usuario aún no ha seleccionado campos específicos para mejorar.
- NO propongas cambios técnicos detallados ni rellenes campos del JSON todavía.
- Saluda cordialmente y menciona qué partes de esta asignatura puedes ayudar a mejorar (objetivos, contenidos, criterios, etc.).
- Mantén una conversación fluida y espera a que el usuario elija qué quiere trabajar.`;
}
return `Eres un asistente experto en diseño curricular trabajando sobre: "${nombreAsig}".
DATOS ACTUALES: ${JSON.stringify(asignaturaLimpia)}.
TAREA CRÍTICA:
El usuario ha solicitado mejorar estos ${campos.length} campos: ${campos.join(", ")}.
REGLAS DE ORO:
1. Debes proporcionar una propuesta de mejora para CADA UNO de los campos solicitados.
2. No omitas ninguno. Si un campo no requiere cambios drásticos, optimiza su redacción técnica.
3. En el JSON, cada campo debe contener tu propuesta de texto mejorado.
4. En 'ai-message', resume los cambios hechos en cada uno de los campos solicitados.`;
}
@@ -0,0 +1,30 @@
import { createClient } from "jsr:@supabase/supabase-js";
import { mustGetEnv } from "./env.ts";
import { HttpError } from "./errors.ts";
export function getSupabaseServiceClient() {
const url = mustGetEnv("SUPABASE_URL");
const key = mustGetEnv("SUPABASE_SERVICE_ROLE_KEY");
return createClient(url, key, { auth: { persistSession: false } });
}
export function getSupabaseAnonClient(authHeader?: string) {
const url = mustGetEnv("SUPABASE_URL");
const key = mustGetEnv("SUPABASE_ANON_KEY");
return createClient(url, key, {
auth: { persistSession: false },
global: authHeader ? { headers: { Authorization: authHeader } } : undefined,
});
}
export async function requireUser(authHeader?: string) {
if (!authHeader) {
throw new HttpError(401, "missing_auth", "Missing Authorization header");
}
const anon = getSupabaseAnonClient(authHeader);
const { data, error } = await anon.auth.getUser();
if (error || !data?.user) {
throw new HttpError(401, "invalid_auth", "Invalid or expired token", error);
}
return data.user;
}
@@ -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);
}
}
@@ -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,9 @@
{
"imports": {
"zod": "npm:zod@4.3.5",
"openai": "npm:openai@6.16.0",
"openai/helpers/zod": "npm:openai@6.16.0/helpers/zod",
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
"@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts"
}
}
@@ -0,0 +1,396 @@
import "@supabase/functions-js/edge-runtime.d.ts";
import { corsHeaders } from "../_shared/cors.ts";
import { HttpError, sendError, sendSuccess } from "../_shared/utils.ts";
import { createClient } from "@supabase/supabase-js";
import type { Database, Tables } from "../_shared/database.types.ts";
import { z } from "zod";
import { zodTextFormat } from "openai/helpers/zod";
import {
OpenAIService,
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"];
tipo: Tables<"asignaturas">["tipo"] | null;
creditos: Tables<"asignaturas">["creditos"] | null;
horasAcademicas?: number | null;
horasIndependientes?: number | null;
descripcion: string;
};
const AsignaturaSugeridaItemSchema: z.ZodType<DataAsignaturaSugerida> = z
.object({
nombre: z.string().describe("Nombre de la asignatura a crear"),
codigo: z.string().optional().nullable().describe(
"Código o clave de la asignatura. Un string único que la identifique, como 'MAT101' o 'FIS202'. Opcional, pero recomendado para evitar confusiones.",
),
tipo: z.enum(["TRONCAL", "OBLIGATORIA", "OPTATIVA", "OTRA"]).nullable(),
creditos: z.number().nullable(),
horasAcademicas: z.number().optional().nullable(),
horasIndependientes: z.number().optional().nullable(),
descripcion: z.string().max(200),
});
const RequestSchema = z.object({
plan_estudio_id: z.string().uuid(),
// numero_de_ciclo: z.number().int().positive(), // ya no se usa
enfoque: z.string().trim().min(1).optional(),
cantidad_de_sugerencias: z.number().int().positive().max(15),
sugerencias_conservadas: z
.array(
z.object({
nombre: z.string().trim().min(1),
descripcion: z.string().trim().min(1),
}),
)
.default([]),
});
function normalizeName(value: string): string {
return value
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
}
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");
}
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 {
const method = req.method;
if (method !== "POST") {
throw new HttpError(
405,
"Método no permitido.",
"METHOD_NOT_ALLOWED",
{ method },
);
}
const authHeaderRaw = req.headers.get("Authorization") ??
req.headers.get("authorization");
if (!authHeaderRaw) {
throw new HttpError(
401,
"No autorizado.",
"UNAUTHORIZED",
{ reason: "missing_authorization_header" },
);
}
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 },
);
}
let rawBody: unknown;
try {
rawBody = await req.json();
} catch (e) {
throw new HttpError(
400,
"Body JSON inválido.",
"INVALID_JSON",
{ cause: e },
);
}
const validated = RequestSchema.safeParse(rawBody);
if (!validated.success) {
throw new HttpError(
422,
formatZodIssues(validated.error.issues),
"VALIDATION_ERROR",
validated.error,
);
}
const {
plan_estudio_id,
// numero_de_ciclo,
enfoque,
cantidad_de_sugerencias,
sugerencias_conservadas,
} = validated.data;
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
throw new HttpError(
500,
"Configuración del servidor incompleta.",
"MISSING_ENV",
{
missing: [
!SUPABASE_URL ? "SUPABASE_URL" : null,
!SERVICE_ROLE_KEY ? "SUPABASE_SERVICE_ROLE_KEY" : null,
].filter(Boolean),
},
);
}
const supabaseService = createClient<Database>(
SUPABASE_URL,
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")
.eq("id", plan_estudio_id)
.single();
if (planError) {
const maybeCode = (planError as { code?: string }).code;
if (maybeCode === "PGRST116") {
throw new HttpError(
404,
"No se encontró el plan de estudio.",
"NOT_FOUND",
{ table: "planes_estudio", id: plan_estudio_id },
);
}
throw new HttpError(
500,
"No se pudo obtener el plan de estudio.",
"SUPABASE_QUERY_FAILED",
planError,
);
}
const { data: asignaturas, error: asignaturasError } = await supabaseService
.from("asignaturas")
.select("id,nombre,numero_ciclo,codigo,tipo,creditos")
.eq("plan_estudio_id", plan_estudio_id);
if (asignaturasError) {
throw new HttpError(
500,
"No se pudieron obtener las asignaturas del plan de estudio.",
"SUPABASE_QUERY_FAILED",
asignaturasError,
);
}
const existingNames = new Set(
(asignaturas ?? [])
.map((a) => normalizeName(a.nombre))
.filter(Boolean),
);
const conservedNames = new Set(
(sugerencias_conservadas ?? [])
.map((s) => normalizeName(s.nombre))
.filter(Boolean),
);
const forbiddenNames = Array.from(
new Set([...existingNames, ...conservedNames]),
);
const asignaturasResumen = (asignaturas ?? [])
.map((a) => {
const ciclo = a.numero_ciclo == null ? "(sin ciclo)" : a.numero_ciclo;
const codigo = a.codigo ? ` - ${a.codigo}` : "";
return `- [ciclo ${ciclo}] ${a.nombre}${codigo}`;
})
.join("\n");
const sugerenciasConservadasResumen = (sugerencias_conservadas ?? [])
.map((s) => `- ${s.nombre}: ${s.descripcion}`)
.join("\n");
const systemPrompt =
"Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el esquema proporcionado.";
const userPrompt =
`Necesito sugerencias NUEVAS de asignaturas para un plan de estudios.\n\n` +
`Plan de estudio:\n` +
`- id: ${plan.id}\n` +
`- nombre: ${plan.nombre}\n` +
`- nivel: ${plan.nivel}\n` +
`- tipo_ciclo: ${plan.tipo_ciclo}\n` +
`- numero_ciclos: ${plan.numero_ciclos}\n\n` +
`Datos del plan (JSON):\n${JSON.stringify(plan.datos)}\n\n` +
`Asignaturas existentes en el plan (NO repetir):\n${
asignaturasResumen || "(ninguna)"
}\n\n` +
`Sugerencias conservadas por el usuario (NO repetir):\n${
sugerenciasConservadasResumen || "(ninguna)"
}\n\n` +
`Enfoque (opcional): ${enfoque ?? "(ninguno)"}\n\n` +
`Requisitos estrictos:\n` +
`1) Genera EXACTAMENTE ${cantidad_de_sugerencias} sugerencias.\n` +
`2) No repitas nombres que ya existan en el plan ni los nombres de las sugerencias conservadas.\n` +
`3) Tampoco repitas nombres entre tus nuevas sugerencias.\n` +
`4) Evita nombres demasiado similares (diferencias solo por mayúsculas, tildes, signos o palabras triviales).\n` +
`5) Cada sugerencia debe incluir un nombre y una descripción clara y útil (sin pasarse del límite de 200 caracteres).\n\n` +
`Lista de nombres prohibidos (normalizados):\n` +
forbiddenNames.map((n) => `- ${n}`).join("\n");
const AsignaturaSugeridaSchema = z
.object({
sugerencias: z
.array(AsignaturaSugeridaItemSchema)
.length(cantidad_de_sugerencias)
.describe(
`Arreglo de ${cantidad_de_sugerencias} sugerencias de asignatura`,
),
})
.describe(
`Respuesta estructurada con ${cantidad_de_sugerencias} sugerencias de asignatura`,
);
const svc = OpenAIService.fromEnv();
if (!(svc instanceof OpenAIService)) {
throw new HttpError(
500,
"Configuración del servidor incompleta.",
"OPENAI_MISCONFIGURED",
svc,
);
}
const options: StructuredResponseOptions = {
model: GENERATE_SUBJECT_SUGGESTIONS_MODELO,
input: [
{ role: "system", content: systemPrompt },
{
role: "user",
content: [
{
type: "input_text",
text: userPrompt,
},
],
},
],
text: {
format: zodTextFormat(
AsignaturaSugeridaSchema,
"estructura_asignaturas",
),
},
};
const aiResult = await svc.createStructuredResponse<
typeof AsignaturaSugeridaSchema._output
>(
options,
);
if (!aiResult.ok) {
const status = aiResult.code === "MissingEnv" ? 500 : 502;
throw new HttpError(
status,
"No se pudieron generar sugerencias con IA.",
"OPENAI_REQUEST_FAILED",
aiResult,
);
}
let output = aiResult.output ?? null;
if (output == null && aiResult.outputText) {
try {
output = 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 },
);
}
}
if (output == null) {
throw new HttpError(
502,
"La respuesta de la IA no contiene salida estructurada.",
"OPENAI_MISSING_STRUCTURED_OUTPUT",
{ outputText: aiResult.outputText ?? null },
);
}
const parsed = AsignaturaSugeridaSchema.safeParse(output);
if (!parsed.success) {
throw new HttpError(
502,
"La salida estructurada no coincide con el esquema esperado.",
"OPENAI_SCHEMA_MISMATCH",
parsed.error,
);
}
console.log(
`[${
new Date().toISOString()
}][${functionName}]: Request processed successfully`,
);
return sendSuccess(parsed.data.sugerencias);
} 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",
);
}
});
@@ -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));
});
*/
@@ -1,4 +1,5 @@
// supabase/functions/ai-structured/ai_structured.test.ts
// dummy number
/// <reference lib="deno.ns" />
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
@@ -25,9 +26,6 @@ function mustEnv() {
if (!SUPABASE_ANON_KEY) throw new Error("SUPABASE_ANON_KEY is required");
}
async function getAuthedClient(): Promise<
{ client: SupabaseClient; accessToken: string }
> {
async function getAuthedClient(): Promise<
{ client: SupabaseClient; accessToken: string }
> {
@@ -163,6 +161,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));
});
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -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,5 +1,4 @@
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
@@ -30,6 +29,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";
@@ -48,16 +54,6 @@ CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions";
CREATE EXTENSION IF NOT EXISTS "pgjwt" WITH SCHEMA "extensions";
CREATE EXTENSION IF NOT EXISTS "supabase_vault" WITH SCHEMA "vault";
@@ -79,6 +75,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',
@@ -159,7 +177,8 @@ CREATE TYPE "public"."tipo_cambio" AS ENUM (
'ACTUALIZACION_MAPA',
'TRANSICION_ESTADO',
'OTRO',
'CREACION'
'CREACION',
'ACTUALIZACION'
);
@@ -230,6 +249,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 $$
@@ -246,7 +427,7 @@ ALTER FUNCTION "public"."set_actualizado_en"() OWNER TO "postgres";
CREATE OR REPLACE FUNCTION "public"."unaccent_immutable"("text") RETURNS "text"
LANGUAGE "sql" IMMUTABLE STRICT PARALLEL SAFE
AS $_$
SELECT unaccent('unaccent', $1);
SELECT public.unaccent('public.unaccent', $1);
$_$;
@@ -313,12 +494,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,
@@ -330,9 +509,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)))
);
@@ -382,7 +566,7 @@ CREATE TABLE IF NOT EXISTS "public"."cambios_plan" (
"campo" "text",
"valor_anterior" "jsonb",
"valor_nuevo" "jsonb",
"interaccion_ia_id" "uuid"
"response_id" "text"
);
@@ -404,6 +588,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,
@@ -525,6 +743,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))
);
@@ -664,6 +883,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");
@@ -790,6 +1029,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");
@@ -826,6 +1081,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"();
@@ -854,11 +1113,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;
@@ -894,16 +1148,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;
@@ -1032,10 +1311,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";
@@ -1220,6 +1495,75 @@ 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";
@@ -1326,6 +1670,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";
@@ -1481,4 +1837,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;
@@ -0,0 +1,9 @@
alter publication supabase_realtime add table asignatura_mensajes_ia;
alter publication supabase_realtime add table plan_mensajes_ia;
alter publication supabase_realtime add table asignaturas;
alter publication supabase_realtime add table planes_estudio;
-- Comentario para probar el trigger 3
@@ -0,0 +1 @@
alter table "public"."conversaciones_asignatura" add column "nombre" text default ('Chat '::text || CURRENT_DATE);
@@ -0,0 +1,7 @@
alter table "public"."bibliografia_asignatura" drop column "biblioteca_item_id";
alter table "public"."bibliografia_asignatura" drop column "tipo_fuente";
alter table "public"."bibliografia_asignatura" add column "referencia_biblioteca" text;
alter table "public"."bibliografia_asignatura" add column "referencia_en_linea" text;
@@ -0,0 +1,3 @@
create extension if not exists "pgjwt" with schema "extensions";

Some files were not shown because too many files have changed in this diff Show More