ai-assistant
by Spinedac3
README.md
# ai-assistant
> **In English:** a generic AI assistant — chat, document search (RAG) and a declarative SQL tool
> builder — served over MCP. Work in progress.
Asistente de IA genérico: chat, búsqueda en documentos (RAG) y un creador de tools sobre SQL
declarativo, expuesto por MCP para que cualquier cliente lo use. Su pareja es
[agent-factory](https://github.com/Spinedac3/agent-factory), que arma agentes programados sobre
estas tools.
## Levantarlo en cinco minutos
Necesitas Node 22, pnpm, Docker y el [CLI de Claude](https://docs.claude.com/en/docs/claude-code)
instalado y con sesión iniciada: el chat corre cada turno como `claude -p`.
```bash
pnpm install
cp .env.example .env
docker compose --profile engines up -d # postgres, solr, embed, minio y las bases demo
pnpm keys:generate # llave RS256 en secrets/, nunca en variables
pnpm kek:generate # llave maestra de las contraseñas de las fuentes
pnpm db:migrate
pnpm admin:create --email tu@empresa.com --name "Tu Nombre" # pide la contraseña
pnpm --filter ai-assistant-web build # el panel
pnpm dev # http://localhost:3000/panel
```
Entra al panel con ese correo. Para tener algo que consultar desde el primer minuto:
```bash
pnpm demo:seed # la base demo de una distribuidora, en los tres motores
pnpm docs:load demo/documents # sus documentos
```
y registra la fuente demo en **Fuentes**: Postgres en `localhost`, puerto `5433`, base `demo`,
usuario `demo_reader` y contraseña `demo-reader`.
La primera vez, `docker compose` construye la imagen de vectores, que trae el modelo bge-m3 y
tarda unos minutos más.
```bash
pnpm check # typecheck, Biome, tests unitarios y gates de arquitectura
pnpm test:integration # también los que usan Postgres, Solr, MinIO y las bases demo
```
## Panel
Una web en `/panel` con el chat interno, los documentos y, según los permisos de cada persona, la
administración. La sirve el mismo servidor desde `PANEL_DIR` (por defecto `web/dist`), así que no
hace falta CORS. En desarrollo, `pnpm --filter ai-assistant-web dev` la levanta con Vite y usa el
servidor local como API. El color de marca se cambia con `VITE_BRAND_COLOR` al construirla.
En el chat se puede adjuntar un PDF de hasta 10 MB: queda en memoria media hora desde su último
uso, solo para quien lo subió, y el modelo lo lee con la tool `read_pdf`, que abre una llamada al
CLI que solo puede leer ese archivo.
## Entrar
| Ruta | Para qué |
|---|---|
| `POST /auth/login` | Correo y contraseña. Bloquea la cuenta 15 minutos tras 5 fallos |
| `POST /auth/system-login` | Token firmado (HS256 o RS256) por un sistema declarado en `EXTERNAL_SYSTEMS_FILE` |
| `GET /.well-known/jwks.json` | Llave pública para que otros servicios verifiquen los tokens |
| `GET /auth/me` | Quién soy y qué permisos tengo |
| `POST /auth/sessions/revoke` | Cierra todas mis sesiones |
Los tokens duran 15 minutos y se renuevan solos mientras hay actividad: la respuesta trae uno
nuevo en el header `x-renewed-token`.
## Chat
| Ruta | Para qué |
|---|---|
| `POST /chat/stream` | Un turno en streaming (SSE): `start`, `delta`, `tool_call_pending`, `tool_result`, `done` o `error` |
| `POST /chat/send` | El mismo turno, respondido de una vez |
| `GET /chat/conversations` | Mis conversaciones (`?q=` busca por título) |
| `GET /chat/conversations/:id` | Una conversación con sus mensajes |
| `PATCH /chat/conversations/:id` | Renombrarla |
| `POST /chat/messages/:id/rate` | Calificar una respuesta; con 1 o 2 estrellas pide el porqué |
Cada respuesta termina con las fuentes que de verdad se consultaron, o con "Respondido sin
consultar fuentes". Si el modelo anuncia una consulta y no la hace, escribe llamadas como texto o
afirma cifras sin fuente, el turno se reintenta en una sesión nueva; si vuelve a pasar, se responde
que no se pudo, nunca un dato inventado.
El modelo se elige en la administración (`PUT /admin/settings/chat.model`, permiso
`settings.manage`) y aplica desde el siguiente mensaje, sin reiniciar. Si nadie lo eligió se usa
`CHAT_MODEL`, que por defecto es `claude-opus-5-5`.
## Herramientas y MCP
El asistente expone sus herramientas por **MCP** en `POST /mcp` (SDK oficial, sin sesiones). Lo
que ve cada quien lo decide una sola función:
| Quién llama | Qué ve |
|---|---|
| El chat del asistente | `find_capability` y `run_capability`: descubre la herramienta con su esquema y la ejecuta. Además `search` y `fetch` para los documentos |
| Un cliente externo (Claude, ChatGPT…) | Lo mismo, más un parámetro `original_question` (salvo en `search` y `fetch`) que se guarda 90 días para mejorar el catálogo |
| La corrida de un agente | Solo sus herramientas, directas, con su esquema de salida |
`find_capability` ordena las capacidades por significado con el mismo servicio de vectores de los
documentos, y por palabras si ese servicio no responde.
Cada llamada pasa por la misma puerta: permisos, validación de entrada y de salida contra el
esquema declarado, limpieza de caracteres invisibles y auditoría en `tool_calls`. La auditoría
guarda quién, qué, cuándo, con qué argumentos y si funcionó, **nunca el resultado**. Los resultados
viajan al proveedor del modelo como parte de la conversación.
El chat obtiene en cada turno un token propio, atado a la persona y a la conversación, que se
revoca al terminar.
### Conectar Claude, ChatGPT u otro cliente MCP
El asistente es su propio servidor OAuth 2.1, así que un cliente externo se conecta solo con la
URL `https://<tu-dominio>/mcp`:
1. El cliente recibe un 401 que le dice dónde están los metadatos
(`/.well-known/oauth-protected-resource`) y se registra solo en `POST /oauth/register`.
2. Abre en el navegador la página de consentimiento: la persona entra con su correo y contraseña
y decide si permite o rechaza.
3. El cliente canjea el código con PKCE (S256) y recibe un token de 1 hora y un refresh de 90
días, que rota en cada uso. Si el refresh anterior vuelve a aparecer pasados unos segundos de
la rotación, la sesión entera se revoca: alguien tiene una copia robada. Dentro de esos
segundos se toma como el mismo cliente pidiendo dos veces y solo se rechaza.
El token nunca da más de lo que la persona ya puede hacer: sus permisos salen de su rol en cada
llamada. Revocar sus sesiones (`POST /auth/sessions/revoke`) también corta estos tokens.
El registro está abierto a cualquier redirect `https` o a esta máquina (`localhost`, `127.0.0.1`,
`[::1]`, donde el puerto puede cambiar entre intentos), porque cada cliente igual necesita que una persona real entre y
acepte; cada registro queda en la auditoría y hay un máximo de 10 por IP cada 10 minutos.
Detrás de un proxy (nginx, un balanceador) hay que poner en `TRUST_PROXY` sus direcciones o CIDR;
sin eso se ignora `X-Forwarded-For`, porque cualquiera puede escribirlo y elegiría la IP que ven los
límites y la auditoría.
Para que los clientes en la nube lleguen, el servidor tiene que estar publicado con HTTPS y `PUBLIC_BASE_URL`
tiene que tener esa dirección.
En Windows, `CLAUDE_BIN` tiene que apuntar al `claude.exe` real: Node no ejecuta el
`claude.cmd` sin una shell.
## Documentos
El asistente busca en los documentos de la organización (procedimientos, políticas, manuales).
Solr guarda el índice, el servicio `embed` (bge-m3) convierte el texto en vectores y MinIO guarda
los originales.
### Cargar documentos
Cada documento entra como `.md` con un encabezado:
```markdown
---
doc_code: BOD-PRO-RECEP-V001
doc_title: Procedimiento de recepción de mercadería
doc_version: V001
area: general
doc_revision: R000 # opcional
doc_type: procedimiento # opcional
effective_date: 2026-01-15 # opcional
tags: [bodega, recepción] # opcional
---
# Título
## Sección
...
```
- **`area`** decide quién lo lee: hace falta el permiso `docs.<area>.read`. De fábrica existe
`docs.general.read`, que tiene el rol `user`; las demás áreas se crean como permisos y se
asignan a roles. La carga por la API rechaza un área que no existe; `pnpm docs:load` no lo
verifica.
- **Versiones**: los códigos que solo difieren en el sufijo `-V001`, `-V002`… son una familia (un
código sin sufijo cuenta como la versión 0 de la suya). Subir una versión mueve las demás de la
familia al core `docs_historical`, con su vector, y solo se busca en la vigente. Una versión
más vieja que la vigente se rechaza: volver atrás se hace subiendo una versión posterior.
- `<!-- page: N -->` en el texto marca dónde empieza cada página; el PDF original puede subirse
junto al `.md` para descargarlo después.
En el panel basta con **subir el PDF**: la IA lo lee por partes de diez páginas en segundo plano,
lo escribe en Markdown con sus marcas de página y propone el encabezado con un área que la persona
puede leer. La persona revisa el encabezado y una vista previa del texto, y lo publica; el PDF
queda como original. Hasta 300 páginas y dos PDF convirtiéndose a la vez por persona; lo que nadie
publica se borra a los 7 días.
Para cargar una carpeta sin pasar por el servidor:
```bash
docker compose up -d solr embed minio # embed tarda un par de minutos en cargar el modelo
pnpm docs:load demo/documents # 13 documentos (14 archivos) de una distribuidora inventada
```
| Ruta | Permiso | Para qué |
|---|---|---|
| `POST /docs` | `docs.manage` | Sube el `.md` (campo `document`) y opcionalmente el PDF (`original`); responde 202 con el trabajo encolado |
| `GET /docs/jobs/:id` | `docs.manage` | Estado del trabajo: `queued`, `running`, `done` (con los pedazos indexados) o `failed` (con el motivo) |
| `POST /docs/conversions` | `docs.manage` | Sube solo el PDF (`original`) para que la IA lo convierta |
| `GET /docs/conversions` · `GET /docs/conversions/:id` | `docs.manage` | Mis conversiones con su avance; la de un id trae además el texto y el encabezado sugerido |
| `POST /docs/conversions/:id/publish` | `docs.manage` | Publica la conversión con el encabezado revisado (`header`) |
| `DELETE /docs/conversions/:id` | `docs.manage` | La descarta con su PDF |
| `GET /docs` | `chat.use` | Documentos vigentes que puedo leer |
| `GET /docs/:code/original?kind=md\|pdf` | `chat.use` | Descarga el original de la versión vigente, si puedo leer su área |
| `POST /docs/:code/reindex` | `docs.manage` | Vuelve a indexar la versión vigente desde el `.md` guardado |
| `DELETE /docs/:code` | `docs.manage` | Retira la versión vigente de la búsqueda y borra sus originales; el histórico queda |
Los trabajos los procesa un worker dentro del servidor (`DOCS_WORKER_ENABLED`), de a uno. Con
varios servidores, el worker se deja encendido en uno solo.
### Cómo busca
Por palabras (con más peso en el código, el título y la sección) y por significado (vectores) en
paralelo, y fusiona ambas listas. Una pregunta que es exactamente un código devuelve ese
documento. Cada resultado se filtra por las áreas que la persona puede leer, también al pedir un
pasaje por su id. Si el servicio de vectores no responde, sigue solo con palabras.
El chat y los clientes externos tienen dos tools para esto, con los nombres que ChatGPT exige
para conectar una fuente de conocimiento:
- `search(query)`: hasta 10 pasajes con id, título y un fragmento.
- `fetch(id)`: el pasaje con la sección completa a la que pertenece (hasta ~12.000 caracteres),
para que un paso nunca se lea sin sus condiciones.
`pnpm test:golden` mide, con el modelo real, si el documento que responde cada una de 30
preguntas sobre los documentos demo sale entre los tres primeros (hoy 30 de 30).
El esquema de Solr vive en `docker/solr/docs/conf/`. Los cores se crean con él la primera vez;
para aplicar un cambio de esquema hay que recrearlos (`docker compose down -v` borra los datos) y
volver a cargar los documentos.
## Fuentes de datos
Las tools leen bases de datos de la organización: SQL Server, MySQL o Postgres. Cada una se
registra una vez en `POST /admin/sources` (permiso `sources.manage`):
```json
{ "code": "erp", "name": "ERP", "engine": "mssql", "host": "erp.interno", "port": 1433,
"database": "ventas", "username": "lector", "password": "…", "timeZone": "America/Guatemala",
"tls": true }
```
- **Solo lectura**: al registrarla se revisa qué puede hacer el usuario, y si puede escribir
(insertar, borrar, crear tablas, ser dueño de la base…) se rechaza con la lista de lo que
puede. Además, en Postgres y MySQL cada consulta corre en una transacción de solo lectura.
- **Contraseña**: se guarda cifrada (AES-256-GCM, una llave por contraseña, cifrada a su vez con
la llave maestra). La llave maestra es un archivo (`pnpm kek:generate` la crea en
`secrets/kek.key`, `SECRETS_KEK_FILE`), nunca la base ni una variable de entorno: respáldala
aparte, sin ella las contraseñas guardadas no se pueden leer. La API nunca devuelve una
contraseña.
- **Zona horaria**: las fechas que la fuente guarda sin zona se leen tal cual, igual en los tres
motores; `timeZone` de la fuente (o `APP_TIMEZONE` si no tiene) queda guardada para que el
creador de tools las interprete.
- **Postgres 14 o anterior** da permiso de crear tablas en `public` a todos; ahí hay que
revocarlo (`revoke create on schema public from public`) o el lector se rechaza.
- Registrar de nuevo un código existente lo reemplaza, verificándolo otra vez.
| Ruta | Para qué |
|---|---|
| `GET /admin/sources` | Las fuentes, sin contraseñas |
| `POST /admin/sources` | Registra o reemplaza una fuente, verificada |
| `POST /admin/sources/:code/test` | Vuelve a verificar conexión y solo lectura |
| `DELETE /admin/sources/:code` | La borra |
### Resultados grandes
Lo que devuelve una tool tiene un tope: 40 KB en el chat y las corridas de agentes, 250 KB para
clientes externos. Si no cabe, los totales quedan intactos, las listas se recortan y el detalle
completo, hasta 100.000 filas por lista, va a un Excel con un link firmado que vence a los 7
días (no pide login; quien reciba el link reenviado puede bajarlo mientras no venza). El Excel lo
escribe el código, nunca el modelo.
**Lo que devuelven las tools viaja al proveedor del modelo** (Anthropic, o el del cliente MCP)
como parte de la conversación. Registra solo fuentes cuyos datos puedan salir de tu red en esas
condiciones.
### Base demo
Una distribuidora inventada (clientes, productos, pedidos, detalle, entregas) igual en los tres
motores, con un usuario `demo_reader` de solo lectura:
```bash
docker compose --profile engines up -d demo-mysql demo-mssql # demo-db (Postgres) ya está arriba
pnpm demo:seed # o solo: pnpm demo:seed postgres
```
## Crear herramientas
Quien tiene `tools.manage` y el permiso de una fuente (`sources.<código>.use`, que nace al
registrarla) arma herramientas sobre ella sin escribir SQL. En el panel, **Herramientas** guía
paso a paso: fuente → tabla o vista → columnas → filtros → totales y orden → nombre → guardar y
publicar. La IA propone lo que puede deducir (qué es cada tabla, cómo usar cada filtro, el nombre,
los resúmenes) y la persona lo revisa; una consulta pegada queda como modo avanzado.
La IA nunca escribe el SQL: el código lo genera desde la definición, con parámetros. Antes de
publicar, cada versión pasa sus chequeos sobre la fuente real: que corra, que su resultado tenga la
forma declarada, que un filtro solo quite filas, que los grupos sumen el total y que las dos mitades
de un rango tengan las filas del rango entero. Se prueba en un chat que la usa por MCP antes de
publicarla. Las rutas están bajo `/admin/tools` (`GET`, `PUT /:name`, `POST /:name/check`,
`/run`, `/publish`, `/guide`, `/chat`).
## Avisos por correo
Con `SMTP_HOST`, `SMTP_FROM` y la contraseña en un archivo (`SMTP_PASSWORD_FILE`), la tool
`send_notice` (permiso `notices.send`) manda correos a cuentas activas: cada aviso lleva una llave
para no repetirse, una persona recibe hasta 25 por hora, y la cola reintenta un aviso que no salió
esperando cada vez el doble. El mismo correo sirve para mandar un enlace de cambio de contraseña
(`POST /admin/users/:id/password-reset`).
## Agentes programados
[agent-factory](https://github.com/Spinedac3/agent-factory) corre agentes a nombre de una persona.
Se declara como cliente de máquina:
```bash
pnpm machine:create agent-factory # deja el secreto en secrets/agent-factory.secret
```
con `MACHINE_CLIENTS_FILE=secrets/machine-clients.json` en el `.env`, y el secreto se copia a la
fábrica. Por cada corrida, la fábrica pide un token en `POST /runs/tokens` (HTTP Basic con su
secreto) con la persona dueña, las herramientas del agente y la duración, hasta 60 minutos. El
token solo alcanza las herramientas que esa persona puede usar en ese momento; las demás vuelven
en `denied`. Cada pedido queda en la auditoría, y la fábrica cierra el token al terminar
(`POST /runs/tokens/revoke`). Un token de corrida sobrevive a un reinicio del asistente.
## Personas, permisos y uso
En **Usuarios** (`users.manage`) se dan de alta personas con un rol y, si hace falta, permisos
extra; los roles y los permisos se crean ahí mismo (`/admin/users`, `/admin/roles`,
`/admin/scopes`). De fábrica hay dos roles: `admin`, con todo, y `user`, que conversa y lee los
documentos generales.
**Uso** (`usage.read`, `GET /admin/usage`) cuenta preguntas, llamadas por MCP, herramientas,
tokens y costo por canal, persona, rol y día, con las cuentas de servicio aparte; nunca lo que
alguien preguntó. **Configuración**
(`settings.manage`) elige el modelo del chat y muestra el diagnóstico de cada servicio.
## Cómo se trabaja en este repo
`CLAUDE.md` describe la forma del código: carpetas, piezas con su ejemplar medido, reglas de
dirección y estilo. Las reglas son tests (`tests/architecture/`) que corren con `pnpm check`. Un
cambio nace como issue con su spec (`docs/plantilla-7ejes.md`) y termina en una PR con su recibo
(`tools/harness/recibo.sh`): los comandos `/grill`, `/implementar` y `/pr` de `.claude/` guían
cada paso.
## Licencia
[AGPL-3.0](LICENSE)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues