# Plan — Agente de IA para Soporte (Novasis ERP)

**Estado:** fases 0, 1 y 2 implementadas y probadas en local (§10.2–10.3). Falta pasar todo a test. **Versión 2 — 2026-10-23.**
**Origen:** adaptación del plan "Agente de IA para Soporte" que se diseñó y puso en producción para otro sistema (Futura 1.5). De ese plan se conservan las invariantes de seguridad y las lecciones de producción (su §0). La arquitectura es propia de Novasis.

> Repo: `github.com/kdbf23/novasis-soporte-ia`. Este plan vive acá, no en `backend/docs`: el backend no tiene ningún archivo de este proyecto.

---

## 0. Decisiones

| # | Decisión | Fuente |
|---|---|---|
| D1 | **Ningún humano interviene en la atención.** El agente responde y resuelve solo; lo que no puede resolver lo dice con honestidad y queda registrado. | Usuario, 23/10 |
| D2 | **La sección Tickets es historial y reporte**, no una mesa de ayuda. No hay "hablar con una persona", ni respuestas del staff, ni acciones manuales. | Usuario, 23/10 |
| D3 | **Totalmente aislado del backend**: repo aparte, servicio aparte, **sin cambios en el código del backend**. | Usuario, 23/10 |
| D4 | **Conexión propia a la base del ERP**, con usuario y contraseña propios, **solo lectura**, y **vistas creadas para el agente** en un esquema propio (`soporte_ro`). | Usuario, 23/10 |
| D5 | Los tickets, mensajes, trazas y consumo del agente viven en **una base propia** (`novasis_soporte`), en el mismo servidor Postgres. En la base del ERP el agente no escribe nada. | Este plan, §3 |
| D6 | **El servicio comparte `JWT_SECRET` con el ERP** y verifica el token localmente, para saber usuario y empresa. Igual que el ERP, relee la sesión en la base (estado del usuario y empresa activa) por una vista. | Usuario, 23/10 |
| D7 | **Streaming desde el día 1.** | Lección §0.5 del plan original |
| D8 | NovaIA sigue como está. El agente lee su corpus por una vista, para las preguntas de "cómo se hace". | Este plan, §6 |
| D9 | **Se usa el frontend del ERP**, con una sección propia del agente. Lo que se separa es el servicio, no la pantalla. | Usuario, 23/10 |
| D10 | **Teléfono de contacto** cuando el agente no puede resolver: **+595 971 202885**. | Usuario, 23/10 |
| D11 | **El reporte lo ve solo el superusuario de Novasis**: `is_superadmin` **y** empresa original = `HOLDING_EMPRESA_ID`. Un superadmin de una empresa cliente no lo ve. | Usuario, 23/10 |
| D12 | Subdominio: **`soporte-api.novasispy.com`**. | Usuario, 23/10 |
| D13 | Repo **`kdbf23/novasis-soporte-ia`**, NestJS como el backend. | Usuario, 23/10 |
| D14 | **Alcance global**: el agente responde cualquier pregunta sobre las operaciones de la empresa (ventas, cobranzas, compras, stock, caja, tesorería, contabilidad, RRHH, CRM), ayuda a resolver y guía. Se hace con **consultas sobre vistas**: un paso del agente escribe SQL solo sobre las vistas de negocio que el usuario puede ver; el servicio lo valida y lo ejecuta con el alcance firmado (§5.6, §6.7). | Usuario, 24/10 |
| D15 | **Solo lectura**: el agente nunca ejecuta acciones en el ERP. Guía cómo hacerlas y verifica después. | Usuario, 24/10 |
| D16 | **Soporte IA es un módulo del ERP (`SOPORTE_IA`), activo por defecto para todas las empresas.** Excepción acotada a D3: el backend suma la entrada del seed y una migración de datos (planes, suscripciones, perfiles); nada de código de negocio. Se apaga por empresa excluyendo el submódulo; el servicio exige `SOP_IA_USAR`. Ver `backend/docs/plan-soporte-ia.md` y `docs/despliegue.md`. | Usuario, 24/10 |

Se descartó todo lo de la versión 1 que suponía personas atendiendo: la mesa de soporte, el handoff, las acciones N2, las notificaciones al staff y los privilegios `SOPORTE_MESA_*`.

---

## 1. Objetivo

1. El cliente obtiene respuesta inmediata sobre sus documentos electrónicos, **los datos de las operaciones de su empresa** y el uso del sistema, **sin que nadie de Novasis intervenga** (D14).
2. Cuando la solución está en manos del propio cliente (corregir un dato, reenviar un evento desde una alerta), el agente lo guía hasta la pantalla exacta y después **verifica que haya funcionado**.
3. Novasis tiene un **reporte confiable** de lo que el agente resolvió y de lo que no, por categoría y por empresa. Los "no resueltos" son la lista de trabajo para mejorar el producto y el agente.

---

## 2. Arquitectura

```
Frontend ERP (app.novasispy.com)
  sección "Soporte IA" (carpeta nueva, aditiva)
        │  HTTPS + el mismo Bearer del ERP
        ▼
novasis-soporte-ia (soporte-api.novasispy.com, pm2)
  1. verifica el JWT con JWT_SECRET (el mismo del ERP) → sub
     + vistas de sesión: usuario activo, empresa activa, privilegios
  2. extractor → máquina de estados → redactor (tool runner, SSE)
  3. herramientas q_* = SELECT sobre vistas
        │                                   │
        │ rol soporte_ia (solo lectura)     │ rol soporte_app (lectura/escritura)
        ▼                                   ▼
  smartfactpy · esquema soporte_ro     novasis_soporte
  (vistas filtradas por empresa)       (tickets, mensajes, trazas, consumo, config)
```

**Qué toca de lo existente:**

| Pieza | ¿Se toca? |
|---|---|
| Código del backend | **No.** El servicio solo comparte el valor de `JWT_SECRET` (configuración, no código). |
| Base `smartfactpy` | Se agregan **un esquema (`soporte_ro`) y dos roles**. No cambia ninguna tabla, dato ni política existente. Ver §5.4: hay un acoplamiento con las migraciones que conviene conocer. |
| Frontend | **Aditivo:** una carpeta nueva, una ruta y un ítem de menú. Es lo mínimo para que el cliente llegue a la pantalla (§7). |
| Servidor | Un proceso pm2, un vhost de Apache con certificado, y una base nueva en el mismo Postgres. |

**Secretos del servicio:** la API key del LLM, las dos contraseñas de base y `JWT_SECRET`, compartido con el ERP (D6). No puede escribir en la base del ERP ni leer nada fuera de las vistas.

Compartir `JWT_SECRET` tiene un costo: quien comprometa el servicio puede firmar tokens válidos para el ERP. Se acepta, con dos reglas:
- el secreto vive solo en el `.env` del servicio, nunca en el repo;
- si se rota en el ERP, se rota en los dos a la vez.

---

## 3. Datos propios (`novasis_soporte`)

Base aparte, en el mismo servidor Postgres. Rol `soporte_app`, dueño solo de esa base. Se gestiona con Prisma desde el repo nuevo, con las mismas convenciones del backend: enums de Prisma, migraciones en directorio.

```sql
tickets (                              -- un hilo de conversación = un ticket
  id uuid, numero serial,              -- SOP-<numero>
  empresa_id, empresa_ruc, empresa_razon_social,   -- snapshot para el reporte (no hay joins al ERP)
  usuario_id, usuario_nombre,
  canal,                               -- portal (WhatsApp, más adelante)
  estado,                              -- ABIERTO | CERRADO
  resultado,                           -- RESUELTO | RESUELTO_AUTOSERVICIO | NO_RESUELTO | SIN_CONSULTA | ABANDONADO
  categoria,                           -- documento_sifen | evento_sifen | envio_correo | timbrado_certificado
                                       -- | consulta_operativa | error_sistema | otro
  motivo_no_resuelto?,                 -- vocabulario cerrado (§8.2): para que el reporte agrupe
  conformidad?,                        -- POSITIVA | NEGATIVA (👍/👎 del cliente)
  documento_tipo?, documento_id?, nro_documento?, cdc?,
  estado_bot jsonb,                    -- máquina de estados: intención, campos pendientes, documento elegido
  resumen jsonb,                       -- memoria estructurada, no transcript
  modelo, prompt_version,
  created_at, primera_respuesta_at, cerrado_at, last_activity_at
)

mensajes (
  id, ticket_id, orden, autor,         -- cliente | bot | sistema
  texto, tokens_in, tokens_out, tokens_cache_read, modelo,
  primer_token_ms, latencia_ms,
  correcta?,                           -- etiqueta de calidad de Novasis (golden set, §9.2)
  created_at
)

trazas (                               -- auditoría: qué consultó el agente y qué vio
  id, ticket_id, mensaje_id,
  herramienta, parametros jsonb,       -- lo que pidió el modelo
  empresa_id_efectivo, usuario_id_efectivo,   -- lo que fijó el servidor (no el modelo)
  respuesta jsonb,                     -- resultado completo de las vistas
  proyeccion jsonb,                    -- lo que efectivamente vio el redactor (§6.3)
  duracion_ms, error?, fuera_de_alcance boolean, created_at
)

consumo_diario (empresa_id, fecha, tokens_in, tokens_out, costo_usd)   -- tope diario (§9.3)
config (clave, valor, ambito, updated_at)   -- ambito: global | empresa:<uuid>. Nunca secretos.
```

**Retención:** mensajes y trazas, 12 meses, con purga mensual. Los tickets (sin el texto) y las métricas son permanentes.

---

## 4. Identidad y alcance

### 4.1 Quién pregunta

El frontend manda al servicio el **mismo Bearer** que usa con el ERP. El servicio replica lo que hace `src/auth/jwt.strategy.ts` del ERP:

1. **Verifica la firma y el vencimiento** con `JWT_SECRET` (HS256, igual que el ERP). Del payload `{sub, username, empresa_id}` toma `sub`.
2. **Relee la sesión** en `soporte_ro.v_sesion_usuario`, filtrada por `app.usuario_id = sub`:
   - si `active = false` o `estado` está en `Bloqueado`, `Inactivo` o `Eliminado` → 401, como en el ERP;
   - **empresa efectiva = `empresa_id_actual ?? empresa_id`**, igual que el ERP. No se confía en el `empresa_id` del token, porque un token emitido antes de un `switchEmpresa` sigue vivo hasta que vence.
3. **Privilegios** desde `soporte_ro.v_privilegios_usuario`: los del perfil (capa 2), con el bypass de `is_superadmin`, más el bloqueo por plan (capa 1, submódulos activos de la suscripción).

Todo eso queda en caché 60 s por usuario, igual que haría el ERP en cada request.

**Si el servicio y el ERP difieren, gana el ERP.** En la fase 0 hay un test de contraste: para cada usuario de test, los privilegios que calcula el servicio tienen que coincidir con los que devuelve `GET /auth/me`. Si un día divergen (por ejemplo, porque cambia la lógica de permisos del ERP), el test lo marca.

Solo se aceptan **access tokens**. Los refresh tokens usan otro secreto (`JWT_REFRESH_SECRET`), que el servicio no tiene.

### 4.2 El alcance lo fija el servidor, nunca el modelo

- `usuario_id` sale del token verificado y `empresa_id` de la sesión releída (§4.1). **Ninguna herramienta los recibe como parámetro.**
- Cada consulta a las vistas corre en una transacción que fija el alcance con `set_config(..., true)`, que es local a la transacción (§5.2).
- **El ticket queda atado a la empresa en la que nació.** Si el usuario cambia de empresa en el ERP, escribir en un ticket de otra empresa devuelve 403. Es el bug de la fase 5 del plan original ("la sesión no se reinicia al cambiar de empresa"), cerrado por diseño.

### 4.3 Lo que el usuario puede ver

Un cajero no puede enterarse por el chat de algo que no ve en pantalla. Cada herramienta declara el privilegio que requiere, y se verifica contra los privilegios calculados en §4.1, incluido el bloqueo por plan. Sin el privilegio, la herramienta devuelve `sin_permiso` (no "no existe"), y el agente le sugiere al usuario consultarlo con el administrador de su empresa.

El **alcance por sucursal** se aplica en la vista de documentos con una segunda variable de transacción (`app.usuario_id`) y la tabla de asignación de sucursales del usuario. En la fase 0 hay que verificar el modelo exacto que usa `src/utils/alcance-sucursal.ts` y replicar esa regla en la vista.

---

## 5. Las vistas (`smartfactpy`, esquema `soporte_ro`)

### 5.1 Roles

| Rol | Login | Para qué |
|---|---|---|
| `soporte_ro_owner` | No | Dueño de las vistas con datos de la empresa y de las funciones guarda. Es miembro de `novasis_ai_readonly`, de donde hereda el `SELECT` sobre las tablas base **y** las políticas RLS (ver abajo). Como no puede iniciar sesión, solo se llega a esos permisos a través de sus vistas. |
| `soporte_ro_sesion` | No | Dueño **solo** de las tres vistas de sesión (`v_sesion_usuario`, `v_privilegios_usuario`, `v_alcance_usuario`). Tiene `BYPASSRLS`, `USAGE` sobre `public` y `SELECT` solo sobre las tablas que esas vistas necesitan. Sobre `usuario` el permiso es **por columna**: no puede leer `password` ni `pin_supervisor`. |
| `soporte_ia` | Sí, contraseña propia | El que usa el servicio. `USAGE` sobre `soporte_ro` y `SELECT` sobre sus vistas. **Nada sobre `public`.** `statement_timeout 5s`, `idle_in_transaction_session_timeout 5s`, `CONNECTION LIMIT 10`. |

**Por qué hay un dueño aparte para la sesión.** Para saber la empresa del usuario hay que leer su fila de `usuario` **antes** de conocer la empresa. Además, esa fila guarda la empresa original, mientras que la sesión puede estar en otra después de un `switchEmpresa`. Con la RLS aplicada, la fila quedaría oculta. Por eso esas dos vistas tienen un dueño con `BYPASSRLS`, acotado a esas tablas y filtrado por `app.usuario_id` con su propia función guarda. Las vistas de sesión nunca exponen `password` ni ningún otro secreto.

**Dos cerraduras, no una:**

1. **El filtro dentro de cada vista**, que llama a una función guarda (§5.2). Es la cerradura principal.
2. **La RLS que ya existe** (`ai_tenant_isolation` sobre `app.empresa_id`). Como el dueño de las vistas es miembro de `novasis_ai_readonly`, las políticas se le aplican a él, y las vistas consultan las tablas base con los permisos de su dueño. Si una vista llegara a olvidar el filtro, la RLS igual recortaría las tablas que tienen `empresa_id`.

*No se le da `BYPASSRLS` al dueño: es un privilegio fuerte y apagaría la segunda cerradura.*

### 5.2 Función guarda y patrón de consulta

```sql
CREATE FUNCTION soporte_ro.app_empresa() RETURNS uuid
LANGUAGE plpgsql STABLE AS $$
DECLARE v text := current_setting('app.empresa_id', true);
BEGIN
  IF v IS NULL OR v = '' THEN
    RAISE EXCEPTION 'soporte_ro: app.empresa_id no está fijado';
  END IF;
  RETURN v::uuid;
END $$;
```

El mismo patrón sirve para `soporte_ro.app_usuario()`.

Hace falta la guarda porque, en una conexión reciclada del pool, la variable vuelve a `''` en vez de desaparecer. Sin la guarda, eso devolvería cero filas en silencio. Lo descubrió el plan original contra el pool real (su §6.5.3, punto 5).

**Las funciones de una vista se ejecutan con los permisos de quien consulta**, no con los del dueño de la vista: `soporte_ia` necesita `EXECUTE` sobre las guardas. Es seguro, porque solo leen variables de la propia sesión. (Apareció al verificar en local.)

El servicio consulta **siempre** así:

```sql
BEGIN;
SELECT set_config('app.empresa_id', $1, true), set_config('app.usuario_id', $2, true);
SELECT ... FROM soporte_ro.v_documentos WHERE ...;   -- una sola sentencia, generada por código
COMMIT;
```

`set_config(..., true)` y nunca `SET` a secas: `SET` persiste en la conexión y el siguiente usuario del pool heredaría la empresa anterior.

**Alcance firmado (desde el 24/10, por D14).** Con SQL escrito por el modelo (§6.7), una sentencia podría hacer `set_config('app.empresa_id', '<otra>', true)` y pasarse de empresa, o darse un privilegio. Por eso el servidor fija cinco variables (`app.empresa_id`, `app.usuario_id`, `app.sucursales`, `app.establecimientos`, `app.privilegios`) más `app.firma` = sha256(clave|los cinco valores). La clave vive en `soporte_ro_privado.clave`, que `soporte_ia` no puede leer, y la misma va en `SOPORTE_ALCANCE_SECRETO` del servicio. `soporte_ro.alcance_firmado()` (SECURITY DEFINER, la única que lee la clave) recalcula la firma, y **cada guarda la verifica**: si el SQL cambió una variable, la consulta falla. `verificar.sql` y `pnpm test:db` lo prueban.

Las guardas que usan las vistas de negocio:

| Función | Devuelve |
|---|---|
| `app_empresa()`, `app_usuario()` | el valor fijado, o excepción |
| `puede(privilegio)`, `puede_alguno(VARIADIC privilegios)` | si el usuario tiene el privilegio del ERP (lista firmada) |
| `sucursales_permitidas()`, `establecimientos_permitidos()` | la lista firmada, o NULL = sin restricción |
| `en_lista(valor, lista)` | SQL puro (se expande en línea): lista NULL o valor NULL = visible |

### 5.3 Las vistas

Solo con las columnas necesarias. Sin XML ni JSON de SIFEN, sin credenciales, sin hashes.

| Vista | Base | Filtro | Herramientas |
|---|---|---|---|
| `v_sesion_usuario` | `usuario`: id, username, nombres, `active`, `estado`, `is_superadmin`, `empresa_id`, `empresa_id_actual`. **Sin `password`.** | `id = app_usuario()` | Autenticación (§4.1) |
| `v_privilegios_usuario` | Códigos de privilegio de los perfiles activos del usuario, más los submódulos activos del plan de la empresa efectiva | `usuario = app_usuario()` | Autenticación (§4.1) |
| `v_alcance_usuario` | Sucursales y establecimientos asignados al usuario en su empresa efectiva | `usuario = app_usuario()` | Alcance por sucursal (§4.3) |
| `v_empresa` | `empresas`, `empresas_timbrado` (ambiente, vigencia), `certificados` (vencimiento), `suscripciones` (estado) | `id = app_empresa()` | `q_salud_empresa` |
| `v_documentos` | `UNION ALL` de `factura_cab`, `nota_credito_cab` y `nota_remision_cab`: tipo, id, sucursal, número formateado, fecha, estado, `estado_sifen`, `mensaje_sifen`, `cdc`, `nro_lote`, `fecha_*_sifen`, `estado_envio_email`, `evento_aplicado`, `estado_evento`, `mensaje_evento`, receptor, total y moneda | `empresa_id = app_empresa()` + alcance por sucursal | `q_buscar_documento`, `q_estado_documento`, `q_listar_documentos`, `q_envio_correo` |
| `v_eventos` | `eventos_cab` + `eventos_detalle` (tipo, estado, `cod_respuesta`, mensaje, fecha) | por `eventos_cab.empresa_id` | `q_estado_evento` |
| `v_alertas_abiertas` | `ai_alertas` sin resolver: tipo, criticidad, título, `accion_sugerida`, ruta, `privilegio_requerido`, sucursal, entidad. Sin `datos` (jsonb libre) | `empresa_id = app_empresa()` | `q_alertas_abiertas` |
| `v_guias` | `ayuda_documentos` + `ayuda_chunks`: título, `screen_key`, contenido | **Ninguno**: es documentación del producto, global y sin datos de clientes | `q_buscar_guia` |

`q_buscar_guia` hace búsqueda de texto completo en castellano (`to_tsvector('spanish', …)` + `ts_rank`) sobre `v_guias`. No usa el pgvector de NovaIA, porque eso exigiría la clave de embeddings que NovaIA guarda cifrada. El corpus es chico (un par de miles de fragmentos) y alcanza sin índice; si un día no alcanza, se pasa a vista materializada. Como NovaIA mantiene el corpus actualizado, el agente siempre lee la versión vigente de las guías.

**`q_estado_ticket`** (el seguimiento: "¿qué me respondieron la otra vez?") no usa vistas: lee los tickets de la base propia, filtrados por la empresa efectiva de la sesión (§4.1).

**Columnas que se dejaron afuera a propósito**, porque suman acoplamiento sin aportar: `ai_alertas.usuario_destino_id` (de octubre; las alertas ya se filtran por privilegio y sucursal) y `nota_remision_cab.tipo_aprobacion`. Las notas de débito y las autofacturas **no guardan estado SIFEN** en el ERP, así que no están en `v_documentos`.

### 5.4 El acoplamiento que no se puede evitar

Una vista crea una **dependencia de Postgres sobre las columnas que usa**. Si una migración del backend hace `DROP COLUMN` o `ALTER COLUMN … TYPE` sobre una de esas columnas, **`prisma migrate deploy` falla** con "cannot alter type of a column used by a view". Renombrar columnas o agregar nuevas no molesta.

Es la única forma en que este proyecto puede tocar al ERP. Por eso:

- Las vistas usan **las columnas mínimas**. La lista exacta de columnas acopladas se publica en el repo nuevo (`db/columnas-acopladas.md`, generada desde `pg_depend`).
- **Procedimiento si un deploy del ERP falla por esto** (dos minutos, sin pérdida de datos, porque las vistas no guardan nada):
  1. `DROP SCHEMA soporte_ro CASCADE;`
  2. Correr `prisma migrate deploy` en el backend.
  3. Re-crear el esquema y las vistas: `db/roles.sql` (crea el esquema y sus permisos) y después `db/vistas/aplicar.sql`. Los dos son idempotentes.
- El esquema `soporte_ro` está **fuera del alcance de Prisma** (el datasource del backend solo mira `public`), así que no aparece como drift. La migración RLS existente solo recorre `public`, así que tampoco lo toca.

*Alternativa evaluada: funciones `plpgsql` en lugar de vistas. Postgres no rastrea sus dependencias, así que nunca bloquean un deploy, pero se rompen en silencio en tiempo de ejecución. Se eligieron vistas (pedido del usuario), con el procedimiento de arriba para el caso raro.*

### 5.5 Verificación (después de crear los roles; todo debe dar `false` o cero filas)

```sql
select has_table_privilege('soporte_ia','public.usuario','SELECT'),
       has_table_privilege('soporte_ia','public.factura_cab','SELECT'),
       has_table_privilege('soporte_ia','public.factura_det','SELECT'),
       has_schema_privilege('soporte_ia','public','CREATE');
select rolsuper, rolbypassrls, rolcreaterole from pg_roles where rolname in ('soporte_ia','soporte_ro_owner');
select roleid::regrole from pg_auth_members where member = 'soporte_ia'::regrole;      -- cero filas
-- sin app.empresa_id fijado: la vista debe FALLAR (excepción), no devolver 0 filas
select count(*) from soporte_ro.v_documentos;
```

Más la prueba que importa: con dos empresas de test, una consulta acotada a A no devuelve filas de B, **incluso usando una conexión reciclada del pool**.

La creación de roles, `GRANT` y membresías la corre alguien con superusuario. El script queda en `db/roles.sql` del repo nuevo, idempotente.

---

### 5.6 Las vistas de negocio (`db/vistas/negocio.sql`, desde el 24/10)

41 vistas sobre las que escribe SQL el consultor (§6.7): ventas (`v_ventas`, `v_ventas_lineas`, `v_ventas_pagos`, `v_notas_credito`, `v_presupuestos`, `v_pedidos`), caja (`v_sesiones_caja`, `v_movimientos_caja`), clientes y cobranzas (`v_clientes`, `v_vendedores`, `v_cuotas_por_cobrar`, `v_recibos`, `v_recibos_aplicaciones`, `v_recibos_medios`, `v_rendiciones_cobranza`, `v_comisiones`), compras y gastos (`v_proveedores`, `v_compras`, `v_ordenes_compra`, `v_cuentas_pagar`, `v_pagos_proveedor`, `v_gastos`, `v_gastos_lineas`), inventario (`v_productos`, `v_stock`, `v_movimientos_inventario`, `v_lotes`), tesorería (`v_cuentas_tesoreria`, `v_movimientos_tesoreria`, `v_cheques`, `v_cotizaciones`), contabilidad (`v_asientos`, `v_asientos_lineas`, `v_plan_cuentas`), RRHH (`v_empleados`, `v_liquidaciones_sueldo`, `v_vacaciones`), y `v_sucursales`, `v_usuarios`, `v_oportunidades`, `v_embarques`. El consultor ve además `v_documentos`, `v_empresa` y `v_alertas_abiertas`.

Reglas de todas:

- **Filtro por empresa, por el privilegio del ERP que da esa pantalla y, si el dato es de una sucursal, por el alcance del usuario.** Todo sale del alcance firmado. Las columnas sensibles van con su propio privilegio (`CASE WHEN puede('INV_STK_COSTO_VER') THEN costo END`): costos, saldos de crédito del cliente, costo de importación. Las oportunidades del CRM, sin `CRM_OPO_OPORTUNIDAD_VER_AJENAS`, solo las del propio vendedor.
- **`security_barrier`** en todas (también en las de `aplicar.sql`). Sin eso Postgres puede evaluar un filtro del que consulta antes que el de la vista: un cast que falla mostraría en el error un valor de otra empresa. Las tablas `crm_*` ni siquiera tienen la RLS del ERP.
- **Las guardas van como `(SELECT soporte_ro.f())`**: Postgres las evalúa una vez por consulta (InitPlan), no por fila, y el índice por `empresa_id` se sigue usando. Las 41 vistas juntas se recorren en ~120 ms en la empresa más grande.
- **Nunca**: contraseñas, pin, tokens, claves, xml, json libres, datos de tarjeta, cuentas bancarias completas (se muestran los últimos 4 dígitos) ni **montos por empleado**. De RRHH solo hay datos sin sueldo y los totales de la planilla.
- **Se recrean desde cero** en cada corrida (DROP + CREATE en la misma transacción): `CREATE OR REPLACE` no puede quitar ni renombrar columnas.

`soporte_ia` tiene **`USAGE` sobre `public`, sin ninguna tabla**. Hace falta para planificar: `productos`, `marcas` y `categorias` tienen índices sobre `public.f_unaccent`, y el planificador expande esa función con los permisos de quien consulta. Sin `USAGE`, toda consulta sobre productos fallaba. Con `USAGE` puede llamar funciones del ERP, pero corren con sus permisos (ninguna tabla) y en una transacción READ ONLY. `verificar.sql` comprueba que no haya en `public` funciones SECURITY DEFINER que pueda ejecutar.

`verificar.sql` (§5.5) suma: ninguna tabla de `public` legible, todas las vistas `security_barrier`, todas fallan sin contexto, y **con el alcance firmado de una empresa que no existe ninguna devuelve filas; con una empresa real y sin privilegios, ninguna de negocio devuelve filas**. Eso atrapa una vista nueva que se olvide de un filtro.

El catálogo para el modelo (`src/consultas/catalogo-vistas.ts`) describe cada vista y declara sus privilegios; las columnas se leen de `information_schema` (las que `soporte_ia` puede ver). Un test compara los privilegios del catálogo con los del filtro de cada vista en el `.sql`.

---

## 6. El agente

### 6.1 Catálogo de herramientas

Ninguna recibe empresa, RUC ni usuario. Las de documentos, alertas, empresa y guías son `SELECT` fijos generados por código. **La excepción es `q_consulta_negocio` (D14)**: ahí el modelo sí escribe SQL, pero solo sobre las vistas de §5.6, validado y con el alcance firmado (§6.7). El invariante que importa no cambió: **el alcance lo fija el servidor y lo hace cumplir la base**, no el modelo.

| Herramienta | Qué devuelve | Privilegio |
|---|---|---|
| `q_buscar_documento(texto, tipo?)` | Candidatos con el número normalizado (`001-001-0000123`, `123`, `1-1-123`). Si es ambiguo (una factura y una NC con el mismo número), devuelve la lista para preguntar. | Ver ese tipo de documento |
| `q_estado_documento(documento_ref)` | Estado en el ERP y en SIFEN, CDC, fechas, lote, evento y **diagnóstico clasificado** (§6.2) | Ídem |
| `q_estado_evento(documento_ref)` | ECAN/EINU/EINO: estado, mensaje, si es reenviable o liberable **por el propio cliente**. Viene junto con `q_estado_documento` | Ver el documento (el estado del evento ya se ve en su pantalla) |
| `q_envio_correo(documento_ref)` | `estado_envio_email`, con el destinatario enmascarado | Ver el documento |
| `q_listar_documentos(filtros)` | "¿Qué tengo rechazado esta semana?", con tope de filas | Ídem |
| `q_alertas_abiertas(modulo?)` | Alertas abiertas, con su acción y su ruta | Según `privilegio_requerido` |
| `q_salud_empresa()` | Timbrado y ambiente, vencimiento del certificado, estado de la suscripción | — |
| `q_buscar_guia(pregunta, screen_key?)` | Fragmentos de las guías, con título y pantalla | — |
| `q_estado_ticket(numero?)` | Tickets anteriores de la empresa y su resultado | — |
| `q_consulta_negocio(pregunta)` | Filas de las vistas de negocio que responden la pregunta (§6.7) | El de cada vista |

### 6.2 Diagnóstico determinístico de un documento

Son funciones puras con tests. El modelo explica el veredicto, no lo decide.

| Situación | Cómo se detecta | Qué hace el agente | Resultado |
|---|---|---|---|
| Aprobado | `estado_sifen = Aprobado` | Informa el CDC. Si "no le llegó", mira `estado_envio_email` | RESUELTO |
| Rechazo por dato del cliente | `Rechazado` con mensaje de validación | Explica el mensaje y el dato a corregir, con link a la pantalla | RESUELTO_AUTOSERVICIO |
| Evento rechazado o sin respuesta | `estado_evento` + alerta `sifen_evento` abierta | Lleva al cliente a la alerta, donde ya tiene "Reenviar" o "Liberar" | RESUELTO_AUTOSERVICIO |
| **Falla del lado de SIFEN** | Código `0100`, PKI, timeouts en el mensaje | "No es un dato tuyo: es una falla de SIFEN y el sistema reintenta solo" (lección §0.4) | RESUELTO, o NO_RESUELTO si persiste más de X horas |
| **Pendiente o "Documento firmado"** | `Enviado`/`Documento firmado` | Menos de 2 h: el ERP lo confirma solo cada 5 min (rescate por CDC), **"no lo vuelvas a emitir"**. Más de 2 h: NO_RESUELTO, motivo `trabado_sifen` | RESUELTO / NO_RESUELTO |
| **"Rechazado" con "duplicad"** | Mensaje de CDC duplicado | Puede estar aprobado en SIFEN y el ERP lo rescata solo: "no lo reemitas". Si sigue después de 2 h, NO_RESUELTO | Ídem |
| Empresa sin datos en el hub | "No se encontraron datos de la empresa" | Requiere a Novasis | NO_RESUELTO, motivo `sync_empresa_hub` |
| Timbrado o certificado vencido | `q_salud_empresa` | Explica qué renovar y dónde | RESUELTO_AUTOSERVICIO |
| No encontrado | `q_buscar_documento` vacío | Pide el número **una sola vez** más (anti-repregunta); después cierra | NO_RESUELTO, motivo `no_encontrado` |

El código de respuesta de SIFEN **no se guarda en el documento**: la clasificación se hace sobre `mensaje_sifen`, con tests armados con mensajes reales.

**Sin el middleware SIFEN.** El agente no consulta `consultar-cdc` en vivo: necesitaría las credenciales funcionales de cada empresa y rompería el aislamiento. En su lugar observa lo que el ERP ya hace solo: la sync de cada 5 minutos con rescate por CDC.

**Verificación después del autoservicio.** Cuando el agente lleva al cliente a hacer algo ("reenviá el evento desde la alerta"), el estado del ticket guarda qué debería cambiar. Si el cliente vuelve con "listo", el agente re-consulta y confirma. Si no cambió, lo dice.

### 6.3 Qué ve el cliente y qué no

El redactor recibe una **proyección** calculada en código, no el resultado completo de las vistas (que queda en `trazas.respuesta`).

| Se dice | No se dice nunca |
|---|---|
| Estado de **su** documento, CDC y mensaje de SIFEN | Nombres de tablas, ids internos, código |
| Qué tiene que hacer él y en qué pantalla | Datos de otras empresas |
| "No lo vuelvas a emitir", si corresponde | RUC o nombre del receptor sin enmascarar, salvo que pregunte por eso |
| Que el sistema lo está reintentando solo | Detalles internos de por qué falla la plataforma |

**Cuando no puede resolverlo** (D1): lo dice sin rodeos y da el teléfono de contacto, **+595 971 202885** (D10, configurable en `SOPORTE_TELEFONO_CONTACTO`). El ticket queda NO_RESUELTO con su motivo, y aparece en el reporte. No hay "te va a contactar un asesor", porque nadie va a hacerlo desde este sistema.

### 6.4 Pipeline del turno

```
texto del cliente
  ▼ extractor (LLM, structured outputs)
{ intencion: incidente_documento | consulta_dato | consulta_operativa | seguimiento | saludo,
  tipo_documento?, nro_documento?, categoria_probable? }
  ▼ máquina de estados (TS puro, con tests)
  ├─ faltan campos            → pide solo lo que falta, sin repreguntar lo ya dado
  ├─ el documento no existe aún → no pide número (lección §0.4: emisión bloqueada)
  └─ completo                 → redactor
  ▼ redactor (tool runner con stream) sobre el catálogo
  ▼ cierre del turno declarado por el modelo: resuelto | autoservicio | no_resuelto | incompleto | saludo
     → una función determinística fija el resultado y la categoría del ticket
```

- **Un saludo no es una consulta**: un ticket con solo saludos se cierra como SIN_CONSULTA y no entra en las métricas.
- **Conformidad**: al cerrar un turno resuelto, el cliente ve 👍/👎. 👎 sobre un RESUELTO lo pasa a NO_RESUELTO, con motivo `cliente_disconforme`.
- **Un `refusal` del modelo** se trata como NO_RESUELTO, nunca como pantalla en blanco.
- **Inactividad**: a las 48 h sin mensajes, el ticket se cierra. Si quedó a mitad de camino (faltaban datos), es ABANDONADO y no cuenta.

### 6.5 Modelo y parámetros

Se arranca con el modelo más capaz en todas las etapas y se baja de nivel **solo con datos** y pasando el golden set (§9.2).

```bash
ANTHROPIC_API_KEY=
EXTRACTOR_MODEL=claude-opus-5-5
EXTRACTOR_EFFORT=low                  # structured outputs: output_config.format
EXTRACTOR_MAX_TOKENS=1024
REDACTOR_MODEL=claude-opus-5-5
REDACTOR_EFFORT=medium
REDACTOR_MAX_TOKENS=4096              # techo anti-runaway, no dial de ahorro
```

Notas de la API vigente que afectan el diseño:

- **Opus 5.5 no permite apagar el thinking**: se controla con `effort`, que hay que fijar explícito (el default es `medium`).
- **`tool_choice` forzado da 400**: se usa `auto`, `strict: true` en cada herramienta y la instrucción en el prompt.
- **Streaming con herramientas:** `client.beta.messages.toolRunner({ ...params, stream: true })`. Entre llamadas a herramientas, el servicio emite eventos `progreso` ("Consultando tu factura…").
- **Prompt caching:** el system prompt y las herramientas (estables y ordenadas) van antes del breakpoint; el historial del ticket, después. Se verifica con `usage.cache_read_input_tokens`.
- **Fallback ante rechazo:** `fallbacks: "default"`, con la beta `server-side-fallback-2026-07-01`.
- **Candidatos a bajar de nivel:** el extractor a `claude-haiku-5-5` (mucho más barato y rápido), y el redactor a `claude-sonnet-5-5`.

**Presupuesto de latencia** (la causa probable del bajo uso en el sistema original): primer evento visible en menos de 1 s, primer texto en menos de 4 s p50. Si el extractor en Opus pasa de ~2 s, es el primer candidato a Haiku 5.5.

### 6.6 Seguridad del contenido

- El texto del cliente y los campos libres de la base (razón social, descripciones, mensajes de SIFEN) entran al contexto **delimitados como dato**. Su contenido nunca decide qué herramienta se llama ni con qué alcance.
- **Cable trampa:** si el texto menciona un RUC distinto al de la empresa, o una referencia a un documento que no aparece en las vistas, la traza se marca `fuera_de_alcance` y se cuenta en el reporte. La frontera real es el filtro de las vistas; esto es solo el aviso.
- NovaIA, por diseño, no le manda datos de la empresa al modelo; **este agente sí** le manda el estado de los documentos de la propia empresa, minimizados y con terceros enmascarados. La API de Anthropic no entrena con datos de clientes de API.

### 6.7 El consultor (preguntas de negocio)

`src/consultas/consultor.service.ts`. Corre como consulta previa cuando el extractor marca `consulta_negocio`, y como herramienta del redactor para las preguntas que siguen.

1. **Esquema a medida del usuario.** El modelo ve solo las vistas que el usuario puede usar, con su descripción y sus columnas. Las que existen pero no puede ver aparecen solo por nombre, para que distinga `sin_permiso` de `no_cubierto`.
2. **Salida con esquema fijo**: `estado` (`consultas`, `sin_permiso`, `no_cubierto`, `aclarar`), `explicacion` (qué supuso: "el mes actual") y hasta 3 `consultas` {propósito, sql}.
3. **Validador** (`validar-sql.ts`, node-sql-parser): una sola sentencia SELECT, sin comentarios ni `;`; solo `soporte_ro.<vista permitida>` o CTE propios; funciones de una lista blanca (nada de `set_config`, `current_setting`, `pg_*`, `dblink`, `generate_series`…); se envuelve con `LIMIT`. **No es la frontera**: es la capa de adelante, que rechaza temprano con un error que el modelo puede corregir.
4. **Ejecución** en `runScoped` con el alcance de la sesión: `SET LOCAL TIME ZONE 'America/Asuncion'`, `statement_timeout` de 4 s, un `SAVEPOINT` por consulta.
5. **Un reintento** con el error a la vista (de validación o de la base).
6. Las filas van al redactor como `<datos_del_sistema>`: columnas + matriz, tope de 200 filas y 12.000 caracteres por consulta, sin el SQL.

Los tokens del consultor se suman al costo del turno y al tope diario de la empresa.

**Alcance por sucursal en la respuesta.** Las vistas ya filtran por las sucursales del usuario, pero el modelo no lo sabía y presentaba la cifra como si fuera de toda la empresa (`maura` ve solo el establecimiento 003: 89.961.200 Gs. de 381.297.368 de la empresa). Desde el 24/10 el esquema le avisa al consultor y la respuesta lo aclara ("son solo de tus sucursales").

**Modelo: Haiku 5.5** (`CONSULTOR_MODEL`). En 24 preguntas sobre dos empresas, 12 simples y 12 difíciles (comparaciones, vencidos, margen, días de cobro, cobros mayores al total), Opus 5.5, Sonnet 5.5 y Haiku 5.5 dieron **las mismas cifras**; solo cambiaron interpretaciones ambiguas. Haiku tardó ~2,2 s contra ~4 s de Opus y cuesta ~US$ 0,0004 por pregunta contra ~0,01. Se prueba con `node scripts/probar-consultor.js` (copia local, modelo real).

### 6.9 Manual del sistema (preguntas de uso, 26/10/2026)

Las guías de NovaIA no cubren todo ("¿puedo emitir una factura a crédito con plazo?" terminaba
derivada a un humano). La verdad de cómo se usa el sistema está en el código, así que el agente
responde desde ahí, como un manual de usuario.

- **`pnpm manual:construir`** (local, ~6 s): foto del código del panel y del backend (`git archive`
  de la rama del entorno) en `manual/codigo` y `manual/indice.json` con una ficha por pantalla
  (158): ubicación en el menú (o, si no está en el menú, desde dónde se abre), y los textos reales
  que ve el usuario (títulos, campos, botones, opciones, ayudas, mensajes) siguiendo los imports de
  sus componentes; más las reglas del backend (94 grupos de mensajes de error). No se versiona.
- **Enrutador** (Haiku 5.5, effort medium, índice de pantallas cacheado): elige 1 a 5 pantallas
  (siempre la que ejecuta la acción, y la de configuración si hace falta) y los términos. Si trae
  solo un tipo, se completa con el mejor resultado por palabras del que falta.
- **Pasajes**: de cada pantalla elegida, los textos de las ventanas que mejor coinciden (BM25 sobre
  ventanas de 8 textos de un mismo componente).
- **q_leer_codigo**: el redactor puede leer un archivo de la foto para una condición puntual (nunca
  sale de `manual/codigo`; nunca muestra código).
- **Capturas**: `e2e/manual-capturas.real.spec.js` del panel recorre las pantallas en test con un
  usuario de demostración y guarda `manual/capturas/<id>.png`; el servicio las sirve en
  `/manual/capturas` y el chat las muestra dentro de los pasos (solo `manual:archivo.png`).
- Resultado medido: la pregunta del crédito se responde con pasos y nombres exactos (POS ›
  Condición de pago › Crédito › A plazo, Métodos de Pago para los días) en ~12–15 s.
- **Privacidad de las capturas**: queda nítido solo el texto que está escrito en el código (también
  dentro de condiciones: `{isMobile ? "Nueva" : "Nueva Condición"}`, `x || "2-15 caracteres"`);
  lo demás va como barra gris. Campos vacíos y valores iniciales de un formulario de alta se ven.
- **Guías paso a paso** (26/10/2026): `e2e/manual-guias.real.spec.js` del panel HACE la tarea en
  test (abre el diálogo, escribe un ejemplo, elige la opción) y en cada paso guarda la captura en
  ese estado y los recuadros de lo que se toca, en % de la imagen: `manual/guias/<id>.json` +
  `<id>-NN.png`. **Nunca confirma**: `clic()` se niega con Guardar/Emitir/Cobrar/Anular/abrir o
  cerrar caja…, y la guía termina en Cancelar. El servicio las carga (sin todas sus imágenes, no),
  las suma al índice del enrutador como `guia:<id>` (si no elige ninguna, la que comparta 3+
  palabras), y `q_buscar_guia` las trae en `guias_paso_a_paso`. El redactor pone la marca
  `[[guia:<id>]]` sola en una línea; el chat la muestra como visor (`GuiaInteractiva`): la
  pantalla entera, la cámara se acerca a lo marcado, el resto se oscurece, el recuadro late con el
  número del paso y una flecha rebota hacia él; avanza solo, con Anterior/Siguiente, puntos y
  ampliar. `GET /manual/guias/:id` (público, como las capturas).
  Pendiente: guías del POS (factura a crédito) — necesitan un usuario y una caja de prueba propios
  del robot en test; con un usuario real no se abre ni se usa una caja.

### 6.10 Capturas del cliente (26/10/2026)

Un problema como "No se pudo consultar el evento de cancelación… login al middleware… Usuario no
válido" es difícil de explicar con palabras: el cliente pega la captura y el agente la interpreta.

- **Panel**: adjuntar, pegar (Ctrl+V) o arrastrar hasta 3 imágenes por mensaje. Se achican en el
  navegador (lado mayor 1568 px, lo que el modelo aprovecha) antes de mandarlas en el mismo POST,
  en base64. El texto puede ir vacío si hay captura.
- **Servicio**: valida tipo y firma de bytes (PNG/JPEG/WebP, ≤ 3 MB c/u; un HTML disfrazado no
  pasa), las guarda en `adjuntos` (bytea, base propia) y las sirve solo al dueño del ticket
  (`GET /tickets/:id/adjuntos/:adjunto`, con token) y al reporte de Novasis.
- **Lectura** (Haiku 5.5, ~2,5 s, ~US$0,0005): transcribe pantalla, mensajes textuales, números
  de documento; si hay elementos repetidos, uno completo y lo que cambia. Se guarda en
  `mensajes.lectura_capturas` y la consulta del turno pasa a ser lo escrito + la lectura (extractor,
  máquina de estados, consultas trabajan con texto; los turnos siguientes no reenvían la imagen).
  El redactor ve además la imagen en el turno en que llega. Los textos de la captura son datos.
- **Diagnóstico de alertas**: `diagnosticoDeAlerta` suma causa y quién lo resuelve a las alertas
  con errores conocidos. "login al middleware / Middleware SIFEN login error" se clasifica como
  `empresa_no_en_hub` (lo resuelve Novasis; reintentar no lo arregla).
- **Retención**: las imágenes, 90 días (pantallas enteras con datos de la empresa); la lectura en
  texto sigue la retención de los mensajes (12 meses).

### 6.8 Incidencias en YouTrack (implementado 26/10/2026; falta el token del bot)

El usuario quiere que lo que el agente no puede resolver quede como incidencia en YouTrack,
proyecto **Novasis ERP (`NE`)** de `https://novasis.youtrack.cloud`. Encaja con D1/D2 si se carga
solo lo que requiere trabajo de Novasis (no cada consulta) y sin humanos en el chat.

- **Qué se carga** (decide el código, nunca el modelo: un mensaje podría inducirlo a crear basura):
  NO_RESUELTO con motivo `posible_error_sistema`, `trabado_sifen`, `falla_sifen_persistente`,
  `sync_empresa_hub` o `consulta_sin_respuesta_en_guias` (tarea de documentación), y `cliente_disconforme`
  (respuesta a revisar, prioridad baja). No: `sin_permiso`, `no_encontrado`, `fuera_de_alcance`.
- **Una incidencia por problema, no por ticket.** Firma = motivo + clave normalizada (mensaje de SIFEN
  sin números, herramienta + error, módulo de la pregunta). Una ocurrencia nueva suma un comentario y
  el contador de empresas; una firma cerrada que vuelve a aparecer la reabre.
- **Prioridad**: Critical si afecta a 3+ empresas en 24 h o es `trabado_sifen`; Major para error del
  sistema; Normal para SIFEN; Minor para guías y disconformidad. Etiqueta `soporte-ia` + la del motivo.
- **Fuera del turno**: tabla `incidencias` en la base propia (bandeja de salida) y un cron que crea o
  comenta con reintentos. Si YouTrack no responde, el chat no se entera. Kill switch en `config`
  (`youtrack_activo`).
- **Datos mínimos** (YouTrack es un servicio externo): motivo, firma, RUC y razón social de la empresa,
  número de ticket y enlace al reporte. Nunca el texto del cliente ni datos de sus clientes.
- **Credencial propia**: un usuario bot con permiso solo en `NE` y su token permanente
  (`YOUTRACK_URL`, `YOUTRACK_TOKEN`, `YOUTRACK_PROYECTO=NE`). El MCP del usuario es para revisar y
  priorizar desde Claude, no para el servicio.
- **Después**: el reporte muestra el estado de la incidencia de cada falla.
- **Implementación (26/10)**: `src/incidencias/` — `decision.ts` (motivos cargables, firma = motivo|categoría, prioridad, título, descripción con datos mínimos), `incidencias.service.ts` (evaluar al cerrar un turno NO_RESUELTO y en 👎; `drenar` crea el issue, aplica `Priority` por comando y comenta una línea por ocurrencia nueva), `youtrack.client.ts` (REST con token, resuelve el id del proyecto por shortName), cron cada 5 min con kill switch `youtrack_activo`. Tabla `incidencias` (migración `20261026230000`). `evaluar` nunca lanza y `drenar` reintenta con backoff cuadrático (máx. 12). El detalle del reporte devuelve la incidencia del ticket. Pendiente: token del bot en el server (`/root/.soporte_youtrack`) y la escalada de prioridad por 3+ empresas en 24 h.

---

## 7. Frontend (aditivo)

Todo en una carpeta nueva: `frontend/src/soporte-ia/`. Contra el código existente, solo una ruta en `routes.jsx`, un ítem en `dataEstatica.jsx` y la variable `VITE_SOPORTE_API_URL` (en `.env` de test y en `.env.production`).

### 7.1 Para el cliente: "Soporte IA"

- **Chat** con streaming (`fetch` + `ReadableStream`, porque axios no hace streaming en el navegador), progreso visible y 👍/👎. El token se toma del mismo `localStorage` que usa `api.config.js`. Ante un 401, se reintenta una vez después del refresh normal del ERP.
- **Mis tickets**: el historial de conversaciones de la empresa, con número, fecha, categoría y resultado. Es de solo lectura. Se puede retomar un ticket abierto.
- Sin botón de "hablar con una persona" (D1).
- **Visible para todos los usuarios** de una empresa con el agente encendido (`ia_activa`, §9.3). Lo que cada uno puede consultar lo limitan sus propios privilegios (§4.3).

### 7.2 Para Novasis: "Reporte de soporte IA"

Visible solo para el **superadmin de Novasis** (D11): `is_superadmin` y empresa **original** igual a `HOLDING_EMPRESA_ID` (el mismo valor que en el ERP). Se usa la original para que un cambio de empresa no le quite el acceso, y para que un superadmin de una empresa cliente nunca lo tenga. El servicio lo verifica con `NovasisGuard`; el ítem de menú es `soloHolding`.

- **Autoresolución honesta**: (RESUELTO + RESUELTO_AUTOSERVICIO con conformidad no negativa) / tickets con consulta real. No N1 sobre el total: esa fórmula daba 64% cuando la resolución real era 36% (lección §0.2).
- **NO_RESUELTO por motivo, categoría y empresa**: es la lista de trabajo.
- Detalle de un ticket: el hilo, más las **trazas** (qué consultó el agente y qué vio).
- Etiqueta correcta/incorrecta por respuesta, para el golden set. **Es revisión de calidad, no atención**: el cliente nunca ve nada de esto.
- Latencia (p50/p95 del primer token), costo por empresa y por ticket, tasa de conformidad.

---

## 8. El servicio `novasis-soporte-ia`

### 8.1 Estructura (calcada del backend)

```
novasis-soporte-ia/
├── src/
│   ├── main.ts                       # prefix /api/v1, ValidationPipe, Winston, CORS solo app.novasispy.com
│   ├── config/envs.ts                # Joi, como el backend
│   ├── sesion/                       # ErpJwtGuard: Bearer → JWT_SECRET → vistas de sesión (caché 60 s); NovasisGuard
│   ├── erp-readonly/                 # pg Pool (rol soporte_ia) + runScoped(empresa, usuario, fn)
│   ├── catalogo/                     # las q_*: SQL fijo + normalizador + diagnostico/ (funciones puras)
│   ├── consultas/                    # preguntas de negocio: catálogo de vistas, validador de SQL, consultor (§6.7)
│   ├── pipeline/                     # extractor, maquina-estados, redactor, cierre-turno
│   ├── tickets/                      # controller: POST /tickets, POST /tickets/:id/mensajes (SSE), GET
│   ├── reporte/                      # endpoints del reporte (§7.2)
│   ├── llm/anthropic.service.ts      # un cliente compartido, caching, fallbacks
│   ├── prompts/                      # versionados → prompt_version
│   ├── prisma/                       # cliente de novasis_soporte (base propia)
│   └── health/                       # /health (bases y ERP) y /health/llm
├── prisma/                           # schema y migraciones de novasis_soporte
├── db/
│   ├── roles.sql                     # roles y grants en smartfactpy (lo corre un superusuario)
│   ├── vistas/aplicar.sql            # esquema soporte_ro, funciones guarda y vistas (idempotente)
│   ├── vistas/verificar.sql          # §5.5
│   └── columnas-acopladas.md         # §5.4
└── test/                             # unitarios del diagnóstico y la máquina de estados; golden set
```

Dos clientes de base claramente separados: **Prisma** para la base propia, y **`pg` a secas** para las vistas del ERP (no hay modelo Prisma que generar para ellas). Mismas convenciones de lint y SWC que el backend, y **`tsc --noEmit` antes de desplegar**.

### 8.2 Motivos de NO_RESUELTO (vocabulario cerrado)

`trabado_sifen`, `falla_sifen_persistente`, `sync_empresa_hub`, `no_encontrado`, `sin_permiso`, `consulta_sin_respuesta_en_guias`, `posible_error_sistema`, `cliente_disconforme`, `fuera_de_alcance`.

Cada motivo es una fila del reporte. Si crece la categoría `otro`, falta un motivo.

---

## 9. Operación

### 9.1 Despliegue

- **pm2** `soporte-ia` en el droplet. Tiene que estar ahí porque el 5432 está cerrado desde afuera.
- **Base** `novasis_soporte` en el mismo Postgres.
- **Vhost** `soporte-api.novasispy.com` (test: `soporte-api.factupy.es`) con certbot. El frontend le habla directo.
- **SSE detrás de Apache:** `flushpackets=on` en el `ProxyPass` y sin `mod_deflate` para `text/event-stream`. Sin eso, Apache bufferea y el cliente ve todo junto al final, que es el problema de 10–20 s que se quiere evitar. **Se verifica en test antes de cerrar la fase 1.**
- **RAM:** el droplet de prod tiene 3,9 GB con 13 apps de Node más Postgres. Un Nest liviano suma ~100–150 MB; hay que medirlo en test.
- **Procedimiento:** `git pull` → `pnpm install` → `npx prisma migrate deploy` → `npx prisma generate` → `pnpm build` → `pm2 restart soporte-ia --update-env`, siempre con `nvm use 24` antes. Si cambiaron las vistas, `db/vistas/aplicar.sql` va primero.

### 9.2 Calidad

**Golden set:** sale de las etiquetas correcta/incorrecta del reporte. **Ningún cambio de prompt, modelo o effort se despliega sin correrlo.** El extractor y el diagnóstico se comparan automáticamente; del redactor se muestrean unos 10 casos a mano. Con 50 casos etiquetados, la compuerta funciona.

### 9.3 Configuración

| Nivel | Qué | Dónde |
|---|---|---|
| Secretos | API key, contraseñas de las dos bases, `JWT_SECRET` (el mismo valor que el ERP), `SOPORTE_ALCANCE_SECRETO` (el mismo que `soporte_ro_privado.clave`) | `.env` del servicio. **Nunca en la base ni en pantalla** |
| Modelo | Modelo, effort y max_tokens por etapa (`EXTRACTOR_*`, `CONSULTOR_*`, `REDACTOR_*`) | `.env`, con el golden set como compuerta |
| Operación | `ia_activa` (global o por empresa: es el kill switch y el encendido gradual), tope diario de tokens por empresa, umbrales de anti-repregunta, horas para "trabado", texto de contacto | Tabla `config` |

**El consumo lo paga Novasis.** El servicio registra en su propia base (no en `ai_consumo`, que es del ERP) y aplica el tope diario por empresa. Con el tope superado o el LLM caído, el chat muestra el texto de contacto y no inventa respuestas.

---

## 10. Fases

Cada fase cierra con su suite Playwright contra test (`frontend/e2e/soporte-ia-fN.real.spec.js`). Las fases con LLM gastan tokens reales en cada corrida.

| # | Entregable | Valor si se corta acá |
|---|---|---|
| **0** | Repo `novasis-soporte-ia` con el esqueleto Nest. `db/roles.sql`, `db/vistas/*` y la verificación de §5.5. Módulo `erp-readonly` con `runScoped`. Guard del JWT con las vistas de sesión. Las herramientas como servicios, el normalizador de números y el diagnóstico de §6.2, con tests. Endpoints del catálogo solo para Novasis. Sin LLM. | Diagnóstico de cualquier documento con aislamiento probado |
| **1** | El agente (desarrollado en local; en test solo el despliegue): base propia, pipeline con streaming, cierre de turno, conformidad, config y tope, vhost con SSE, y la pantalla **Soporte IA** en el frontend. Piloto: MOVIMIENTO CAFE en test, después 2–3 empresas en prod. | El cliente resuelve solo lo que es suyo |
| **2** | El **reporte** de Novasis, el golden set con su runner, la purga por retención y la detección de una misma falla en varias empresas. | Se mide y se mejora sin miedo |
| **3** | (Opcional) **Análisis automático de los NO_RESUELTO**: un proceso nocturno que agrupa los motivos y redacta, para el reporte, la causa probable y la acción sugerida. Es interno: no vuelve nunca al cliente. | Los no resueltos llegan a desarrollo ya analizados |
| **4** | (Opcional) WhatsApp. | Segundo canal |

---

### 10.1 Estado de la fase 0 (23/10/2026)

**Hecho y probado contra la base local `novasis`:**
- **Roles y vistas.** `db/roles.sql` y `db/vistas/aplicar.sql` aplicados; `db/vistas/verificar.sql` pasa:
  - las 8 vistas con contexto lanzan excepción sin él;
  - dos empresas reales se ven separadas;
  - la RLS existente se le aplica al dueño de las vistas, que es la segunda cerradura.
- **Servicio.** Guard del JWT (con el `JWT_SECRET` del ERP) más las vistas de sesión, `runScoped` READ ONLY y el catálogo.
- **Tests:**
  - `pnpm test`: 63 unitarios del normalizador, el clasificador de mensajes y el diagnóstico, con mensajes reales de SIFEN.
  - `pnpm test:db`: 10 pruebas contra la base. Cubren conexión reciclada, `soporte_ia` sin acceso a `public`, aislamiento por empresa e id, `sin_permiso`, alcance por sucursal y salud de la empresa.
- **Prueba por HTTP.** Sin token, 401. Con un token firmado con otro secreto, 401. Con un parámetro `empresa_id` inyectado, 400. El superadmin de una empresa cliente recibe 403 en el catálogo; el de Novasis, 200.

**Validado el 24/10 sobre una copia de producción del mismo día** (`novasis_test`, restaurada desde `smartfactpy`; ver §12 para cómo se arma):
- **Verificación.** `verificar.sql` pasa completo, incluido "sin `CREATE` sobre `public`", con dos empresas reales de unos 5.400 documentos cada una.
- **Contraste de privilegios contra el `AuthService.getMe` real del ERP.** El código del ERP corrió sin levantar el backend. La capa de perfil es **idéntica en los 41 usuarios activos**. Los superadmin (casi todos los clientes, con su RUC como usuario) reciben lo que habilita su plan.
- **Barrido del diagnóstico sobre los 14.497 documentos reales.** No quedó ninguno sin clasificar. El barrido corrigió cinco reglas:
  - **Sin enviar:** `Pendiente` en el ERP sin lote. Con el envío en lote, el default por sucursal, el cliente lo envía; **no está trabado**. Eran 70 de los 91 "trabados".
  - **`Inutilizado`.**
  - **"Valores inválidos" sin detalle:** no resuelto.
  - **XML con elementos en otro orden:** falla de plataforma.
  - **Clase del mensaje:** solo en documentos rechazados o con error.
- **Rendimiento.** `v_documentos` entera de la empresa más grande (5.448 documentos) se lee en 38 ms. El camino completo de `estadoDocumento` tarda 1 ms de mediana.
- **Regla del reporte.** En producción el holding es `Novasis PY` y tiene **un solo superadmin**. Él recibe 200 en el catálogo; el superadmin de DOBA, 403.

**Fase 0 cerrada.** No tiene pantalla, así que la cubren `pnpm test:db` y el barrido; la suite Playwright arranca con la fase 1.

**Cómo se trabaja (decisión del 24/10):** se desarrolla **en local, contra `novasis_test`**, que es la copia de producción. El server de test se usa recién para probar el despliegue: Apache con SSE, pm2 y el vhost. Su primer paso es `db/aplicar-en-servidor.sh novasiserp`, que lo corre el usuario porque el clasificador bloquea las escrituras remotas. En test, `PUBLIC` tiene `CREATE` sobre `public` (en prod no): la verificación lo va a marcar, y alinearlo con prod es una decisión aparte.

### 10.2 Estado de la fase 1 (24/10/2026)

**Hecho, en local contra la copia de producción:**
- **Servicio** (commit `b3ca6d9`, repo `novasis-soporte-ia`):
  - base propia `novasis_soporte` (Prisma);
  - turno completo en streaming: extractor → máquina de estados → consultas determinísticas → redactor;
  - conformidad, cierre por inactividad, modo degradado, tope diario y trazas.
  - 94 tests unitarios y 10 contra la base.
- **Frontend** (rama `feat/soporte-ia`, worktree `frontend-soporte-ia`, salida de `main`; commit local `e0263b63`, **sin pushear**):
  - pantalla `/soporte-ia` en **Menú › Sistema › Soporte IA**;
  - suite Playwright con APIs mockeadas, 5 casos;
  - revisada en tema claro, tema oscuro y celular.
- **Conversación real** con un usuario de producción que no es superadmin (CAJA, de Roberto Escobar, asignado a un solo establecimiento):
  - diagnóstico del rechazo y pantalla correcta;
  - verificación después de "ya lo corregí";
  - un documento de otra sucursal no aparece;
  - la pregunta de uso se responde desde las guías;
  - la inyección se rechaza sin mostrar datos de otra empresa.

**Tres cosas de la API que cambiaron el diseño (medidas el 24/10):**
1. **En Claude Opus 5.5, el texto que el modelo escribe antes de llamar a una herramienta vuelve como nota oculta**, no como texto. Con una herramienta de cierre al final, la respuesta al cliente desaparecía. Por eso el cierre es una marca al final del texto (`[[cierre:…]]`, `src/pipeline/cierre.ts`), que el servicio recorta del stream.
2. **Si la conversación tiene un `tool_result`, la API entrega el texto de la respuesta en una sola ráfaga al final**, aunque se le saquen las herramientas. Por eso las consultas previas van como `<datos_del_sistema>` en el turno del cliente, y los casos frecuentes (documento, guías, listado, tickets anteriores) se consultan por código. No van en el mensaje `system`, para que el texto libre de la base no tenga autoridad de operador.
3. **Effort del redactor:** con `medium`, el primer texto tardaba 4,5–9 s y a veces repetía consultas. Con `low`, 1,3 s para un documento, 3,9 s para una pregunta de uso y 3,3 s para un saludo, con la misma calidad en las pruebas. Queda `low` hasta que exista el golden set.

**Latencia restante:** el extractor (Opus 5.5, effort bajo) tarda 2–3 s en los mensajes sin número de documento. Se saltea cuando el mensaje trae un número completo o un CDC. Pasarlo a `claude-haiku-5-5` es el primer candidato del plan (§6.5), pero es una decisión del usuario.

**Falta para cerrar la fase 1** (se hace junto con la fase 2, cuando se pase todo a test; decisión del 24/10: se termina el agente en local y recién después se despliega):
1. **Despliegue en test**, en este orden:
   - `db/aplicar-en-servidor.sh novasiserp` (lo corre el usuario);
   - base `novasis_soporte` y su rol;
   - pm2;
   - vhost con `flushpackets=on` y certificado;
   - frontend con `VITE_SOPORTE_API_URL`.
2. **Suite Playwright contra test** (`soporte-ia-f1.real.spec.js`).
3. **Pushear `feat/soporte-ia`** cuando el usuario lo apruebe.

### 10.3 Fase 2 y cómo probar todo en local (24/10/2026)

**Hecho:**
- **Reporte para Novasis** (servicio: `/reporte`; pantalla: `/soporte-ia/reporte`). El menú lo muestra solo al holding y el servicio exige además superadmin. Incluye:
  - autoresolución honesta: resueltos sobre consultas reales, contando las abandonadas;
  - no resueltos por motivo, por categoría y por empresa;
  - latencia p50/p95 del primer texto, costo y conformidad;
  - **aviso de una misma falla en 2 o más empresas en la última semana**;
  - listado filtrable, detalle con hilo y trazas, y etiqueta correcta/incorrecta por respuesta.
- **Golden set: `pnpm golden`.** Con las respuestas marcadas como correctas, recalcula el plan del turno y el diagnóstico con el código actual, y sale con 1 si algo cambió. En la primera corrida atrapó un cambio real: un listado que antes hacía el modelo y ahora hace el código.
- **Retención:** purga mensual de mensajes y trazas a los 12 meses; los tickets quedan sin texto.
- **E2E del frontend:**
  - mockeado: 5 casos del chat y 3 del reporte;
  - **local contra el servicio real:** 2 casos, la pantalla con el modelo y la copia de producción. Todos pasan.

**Cómo probar en local** (sin levantar el backend del ERP: contra la copia de producción arrancaría sus crons y podría mandar correos reales con la configuración SMTP de los clientes):
```bash
# servicio (lee .env: novasis_test con soporte_ia, novasis_soporte, JWT_SECRET local, ANTHROPIC_API_KEY)
cd novasis-soporte-ia && pnpm build && pnpm start:prod
# pantalla contra el servicio real, con el ERP mockeado
cd frontend-soporte-ia && SOPORTE_E2E_TOKEN=<token firmado con el JWT_SECRET local> \
  SOPORTE_E2E_DOCUMENTO=003-001-0004574 SOPORTE_E2E_AJENO=001-001-0001380 \
  pnpm exec playwright test e2e/soporte-ia-local.real.spec.js
```
El token se firma con `new JwtService().sign({ sub: <usuario_id> }, { secret: JWT_SECRET })`. El usuario CAJA de Roberto Escobar (`bd6a3ee2-…`) sirve como cliente que no es superadmin; `80100753` es el superadmin de Novasis, para el reporte.

**Pendiente para dar el agente por terminado:** pasar todo a test (los pasos de §10.2).

Las fases 3 (análisis automático de los no resueltos) y 4 (WhatsApp) son opcionales.

### 10.4 Comparación de modelos para el extractor (24/10/2026)

Set propio: `evals/extractor-casos.json`, 40 mensajes **sin** número completo (los que sí pasan por el extractor). Cubre números sueltos, "no sé el número", trampas (RUC, cantidades, fechas, montos), preguntas de uso, seguimiento, saludos e inyecciones. Se califica el **plan** que resulta (`pnpm eval:extractor -- <modelos>`).

| Modelo (effort low) | Aciertos | p50 | p95 | Costo cada 1.000 mensajes |
|---|---|---|---|---|
| claude-opus-5-5 | 40/40 (2 corridas) | 1,9–2,2 s | 2,4–5,5 s | US$ 5,1 |
| **claude-haiku-5-5** | **40/40, 39/40, 40/40, 40/40** | **1,3 s** | **1,9–2,3 s** | **US$ 0,13** |

**Decisión: extractor en Haiku 5.5** (el usuario pidió compararlos antes). El redactor sigue en Opus 5.5. Se revierte con `EXTRACTOR_MODEL`.

**Las primeras corridas encontraron 6 fallas, todas del código y no de los modelos.** Las dos versiones fallaban igual:
- se buscaba un número en el texto aunque el extractor dijera que no había referencia ("tengo 3 facturas");
- "no me acuerdo **del** número" no entraba en la regla;
- una consulta de un dato que no es de un documento ("¿cuánto cuesta el plan Pro?") pedía un número de documento;
- "¿tengo alertas?" y "¿mi certificado venció?" no tenían camino propio;
- la referencia con palabras ("punto 002, número 593") no se leía.

Todas corregidas, con tests.

**Latencia de punta a punta después del cambio:** el primer texto aparece en 2,6 s (saludo), 2,8 s ("no sé el número") y 3,9 s (pregunta de uso).

### 10.5 Alcance global (24/10/2026)

Implementado y probado en local sobre `novasis_test`: alcance firmado, 41 vistas de negocio, catálogo, validador, consultor, intención `consulta_negocio` (categoría `datos_negocio`), textos de la pantalla.

- **Extractor**: 55 casos (15 nuevos de negocio). Haiku 5.5, 164/165 en tres corridas. La única falla es un caso viejo ("la del punto 002 es la 593"), sin relación con el cambio. La primera corrida mandaba "tengo 3 facturas rechazadas" al consultor: el prompt ahora separa "cuenta un problema" (incidente) de "pide un conteo o un reporte" (negocio), con ejemplos que **no** son los del eval.
- **Cifras verificadas** contra consultas propias como superusuario (cobrado por semana, total del mes).
- **Turno completo**: primer texto a los 5–7 s (extractor ~1,1 s + consultor 2,2–3,2 s + primer token del redactor 1,8–2,7 s). Todas las etapas ya están en el effort más bajo.
- **Pruebas**: 185 unitarias, 15 contra la base (`pnpm test:db`, incluye que cada vista del catálogo se pueda consultar como `soporte_ia`, y que un `set_config` en el SQL del modelo, sin pasar por el validador, falle por la firma), `verificar.sql` completo, e2e mockeado (8) y contra el servicio real (incluye una pregunta de ventas).
- **Informe en PDF** con capturas y cifras verificadas contra SQL directo: `Portal-ERP/Test-QA/soporte-ia/informe-soporte-ia.pdf` (10 de 10). Se regenera desde el frontend: `E2E_EVIDENCIA=1 pnpm exec playwright test e2e/soporte-ia-evidencia.real.spec.js --workers=1 && pnpm informe:pruebas soporte-ia`, con el servicio levantado.
- Para aplicar en un server: `db/aplicar-en-servidor.sh` ahora genera también `/root/.soporte_alcance_secreto` y corre `negocio.sql`.

## 11. Decisiones abiertas

Ninguna.

---

## 12. Trampas conocidas

- **Las funciones usadas en una vista corren con los permisos de quien consulta**, no del dueño de la vista. Por eso `soporte_ia` tiene `EXECUTE` sobre las guardas.
- **En la base local de desarrollo (`novasis`), `PUBLIC` tiene `CREATE` sobre `public`.** En producción no: su ACL es `{postgres=UC, ia_readonly=U, novasis_ai_readonly=U}`. Es solo un problema del entorno local.
- **La base local `novasis` está atrasada respecto de `pos`** (no tiene `ai_alertas.usuario_destino_id`). Para validar contra datos y esquema reales se usa `novasis_test`.
- **Cómo se arma la copia local de producción** (solo lectura sobre prod, unos 10 s):
  1. Volcar: `ssh Servidor_Digital_Ocean_PROD "sudo -u postgres pg_dump -d smartfactpy -Fc --no-owner --no-acl --exclude-table-data=audit_logs" > dump`.
  2. Restaurar: `pg_restore -d novasis_test --no-owner --no-acl dump`.
  3. **Traer aparte los permisos de prod**, porque `--no-acl` se los saltea y sin ellos `novasis_ai_readonly` no tiene `USAGE` sobre `public`: las líneas `GRANT`/`REVOKE`/`ALTER DEFAULT PRIVILEGES` de un `pg_dump --schema-only`, sin las de `ia_readonly`, que no existe en local.
  4. Correr `roles.sql`, `aplicar.sql` y `verificar.sql`.

  El volcado tiene datos reales de clientes: se borra después de restaurar.
- **"Pendiente" sin datos de SIFEN no es "trabado".** Con `envio_lote_middleware` en `true`, que es el default por sucursal, la factura no se envía sola: la manda el cliente (`facturas.service.ts`, creación de factura).
- **Sin `app.empresa_id`, la RLS existente devuelve 0 filas sin error.** Por eso la guarda de §5.2 lanza excepción: cero filas en silencio es lo peor.
- **`empresas.produccion` está en `false` para todas.** El ambiente real sale de `empresas_timbrado.ambiente`.
- **"Rechazado" con "duplicad" y "Documento firmado" por horas** suelen ser aprobados que el ERP rescata solo. El agente nunca debe sugerir reemitir.
- **El código de SIFEN no está en el documento**: solo `mensaje_sifen`.
- **`certificados` usa la columna `empresa`, no `empresa_id`**, así que la RLS existente no la cubre. Su vista filtra explícitamente.
- **Las columnas usadas por las vistas bloquean migraciones del ERP** que las borren o cambien de tipo: procedimiento en §5.4.
- **SWC compila con errores de tipos:** `tsc --noEmit` antes de cada deploy.
- **`factura_cab.total_factura` viene NULL en la mayoría de las facturas** (13.343 de 14.175 en la copia del 24/10). El total real es la suma de `factura_det.dtotopeitem`, que coincide con los pagos. `v_ventas` y `v_documentos` lo calculan así.
- **Dos formatos de recibo.** `recibos_cobro.modo = 'MULTI'` guarda lo aplicado en `recibo_cobro_facturas` y los medios en `recibo_cobro_medios_pago`; el resto (`LEGACY`, 10.729 de 11.545) guarda los dos en `recibo_cobro_detalle` y deja `total_pagado` en 0. Lo cobrado es `total_pagado || monto_total`, igual que `recibos.service.ts`.
- **`usuario_empresas` está vacía en producción**: la empresa del usuario es `usuario.empresa_id`.
- **Índices sobre `public.f_unaccent`** obligan a dar `USAGE` sobre `public` a quien consulta las vistas (§5.6).
- **psql con locale en español imprime "7,122 ms"**: son 7 milisegundos, no 7 segundos.
- **En test, Kety tiene `sifen_activo = false`.** Para los escenarios SIFEN usar MOVIMIENTO CAFE (80164550). Nunca el RUC 1234567, que es de una persona real.

---

## 13. Pendiente fuera de este plan

**Hueco de aislamiento en el Chat IA del dashboard, en producción.** No afecta a este agente, que usa sus propias vistas y su propio rol, pero hay que arreglarlo:

- **Tablas sin filtro:** `novasis_ai_readonly` tiene `SELECT` sobre todas las tablas, y la RLS solo cubre las que tienen `empresa_id` (176 de 441 modelos quedan sin filtro).
- **Validador textual:** el validador de `chat.service.ts` solo chequea que el SQL contenga `EMPRESA_ID`.
- **Fallback a superusuario:** `AiReadonlyDbService` cae en silencio al superusuario si el rol de solo lectura no conecta.

---

## 14. Bitácora

- **2026-10-26** — Manual del sistema desde el código (§6.9): enrutador, pasajes, lectura de código, capturas en el chat. Progreso sin "guías". En test.
- **2026-10-27** — Caso DOBA «Otro no mueve dinero» replicado en el agente: `v_medios_pago_empresa` suma el mapeo contable del medio (cuenta_al_pagar/cuenta_al_cobrar, de `cont_mapeo_medio_pago`, gated por contabilidad); guía interactiva nueva `medio-pago-no-mueve-dinero` (2 pasos, el interruptor se marca pero NUNCA se toca: cambiaría la config real); y patrón de respuesta en el prompt para causas de configuración: "No es un error…", qué pasó con los datos concretos, solución en dos partes (activar + qué pasa con lo ya cargado) y la guía adjunta. Verificado en test (SOP-20, 27 s, US$ 0,011: q_buscar_codigo + q_consulta_negocio + q_buscar_guia + visor). Pendiente: guía de "corregir un gasto ya cargado" (necesita un gasto editable de demo en Kety; los 3 que hay están anulados).
- **2026-10-26** — Configuración del superadmin, parte 2: los modelos de cada etapa y el teléfono de contacto pasan a ser editables (claves `modelo_*` y `telefono_contacto` en `config`, solo globales, validadas con regex; el `.env` queda como valor por defecto y «Por defecto» borra la fila). `ConfigOperativaModule` ahora es `@Global` con UNA instancia (antes reporte y pipeline tenían cachés distintos y el invalidar no cruzaba). El system prompt del redactor se arma con el teléfono configurado (`sistemaRedactor(telefono)`); sigue siendo estable byte a byte salvo cambio real. Los servicios aceptan el config opcional: los scripts de evals siguen construyéndolos sin él.
- **2026-10-26** — Configuración del superadmin (§9.3): `GET/PUT /reporte/configuracion` + `POST …/youtrack/probar` (NovasisGuard; claves whitelisted y validadas, nunca secretos) y pantalla `/soporte-ia/reporte/configuracion` en el panel: kill switch global y por empresa, tope de tokens global y por empresa (hereda/excepción), youtrack_activo + bandeja + probar conexión, y datos del servicio en solo lectura. Las empresas listadas salen de los tickets (snapshot), sin tocar el ERP.
- **2026-10-26** — El agente investiga como un técnico: `q_buscar_codigo` (grep sobre toda la foto del código) + regla en el redactor para rastrear un posible error de código antes de responder, y cierre `posible_error_sistema` cuando no puede descartarlo.
- **2026-10-26** — Incidencias en YouTrack implementadas (§6.8): bandeja `incidencias` + cron; falta solo el token del bot en test.
- **2026-10-26** — Alcance global ampliado a TODOS los módulos: 42 vistas nuevas en `soporte_ro` (91 en total). Ventas (remisiones, ND, autofacturas, solicitudes de crédito, ofertas, listas de precios), configuración de facturación (condiciones de pago, timbrados, puntos de expedición, medios de pago con no_mueve_dinero), cobranzas (promesas, gestiones, refinanciaciones, retenciones, saldos a favor, cobros online y links de pago), compras (NC de compra, recepciones, órdenes de pago, requisiciones), inventario (depósitos, presentaciones), tesorería (extractos bancarios y sus líneas conciliadas o no, arqueos), contabilidad (períodos, centros de costo, mapeo de cuentas), RRHH (asistencia, legajo y vencimientos; sigue sin montos por empleado), CRM (leads, actividades), ecommerce (tienda), inmobiliario (proyectos, unidades, operaciones, cuotas, cobros), importaciones (despachos) y suscripción (v_modulos_contratados, visible para todos: "¿por qué no veo tal módulo?" se responde con el plan). Fuera a propósito: CSC de timbrados, tokens de links de pago, archivos de legajo, geolocalización de gestiones, montos por empleado. Gotchas: las tablas reales son `listas_precios` y `solicitudes_credito` (el modelo Prisma usa @@map); `rrhh_marcaciones_limpias.hora_entrada` es time, no timestamp.
- **2026-10-26** — Verificar antes de derivar: `v_cuentas_tesoreria` trae la cuenta contable asignada y `v_movimientos_tesoreria` el asiento que contabilizó cada movimiento (propio o de su documento de origen). El redactor consulta eso en vez de mandar al contador a revisarlo (caso DOBA, conciliación banco ↔ contabilidad).
- **2026-10-26** — Capturas del cliente (§6.10): adjuntar/pegar imágenes, lectura con Haiku, el redactor ve la imagen; alertas de login al middleware diagnosticadas.
- **2026-10-26** — Guías paso a paso animadas (§6.9): grabadas con Playwright sin confirmar nada, visor con zoom, recuadros y flecha. Primera: condición de pago a plazo.

- **2026-10-24** — En test: módulo SOPORTE_IA migrado (10 planes, 11 suscripciones, 77 perfiles), servicio en https://soporte-api.factupy.es y panel en facerp. Diseño de incidencias en YouTrack (§6.8).

- **2026-10-24** — Módulo `SOPORTE_IA` en el ERP, activo para todas las empresas (D16); el servicio exige `SOP_IA_USAR`. Script y guía de despliegue para test.

- **2026-10-24** — Alcance global (D14, D15): alcance firmado, 41 vistas de negocio, consultor en Haiku 5.5 que escribe SQL validado sobre ellas (§5.6, §6.7, §10.5).

- **2026-10-14** — v1: adaptación del plan de Futura 1.5, con una mesa de soporte humana dentro del ERP.
- **2026-10-24** — Comparación de modelos para el extractor: Haiku 5.5 (§10.4). Seis fallas de la máquina de estados corregidas.
- **2026-10-24** — Fase 2 implementada en local: reporte, golden set, retención y e2e contra el servicio real.
- **2026-10-24** — Fase 1 implementada en local: servicio, pantalla y e2e mockeado. Hallazgos de la API en §10.2.
- **2026-10-24** — Fase 0 validada sobre una copia de producción del día. Contraste de privilegios 41/41 contra `getMe`, y barrido de 14.497 documentos que corrigió cinco reglas del diagnóstico (§10.1).
- **2026-10-23** — Fase 0 implementada y probada en local (§10.1). El plan se mudó de `backend/docs` a este repo.
- **2026-10-23** — v2: sin intervención humana (los tickets son historial y reporte), totalmente aislado del backend, con conexión propia de solo lectura y vistas `soporte_ro`. El servicio comparte `JWT_SECRET` y relee la sesión en vistas propias. La pantalla del agente es una sección del frontend del ERP.
