# Plan — Ayuda IA "Nova Asistente" (chatbot global de soporte al usuario)

> **Estado:** v1 ENTREGADA y en producción. Este documento refleja el estado final del módulo después de la implementación e iteraciones. Lo dejado para v2 está al final.

## Contexto

Los usuarios siguen abriendo soporte humano por preguntas operativas repetitivas: "¿cómo anulo una factura?", "¿dónde veo el stock?", "¿cómo anulo un cheque?", "¿dónde veo los asientos contables?", etc. Existen `ScreenGuia` colapsables y tours, pero el usuario no los descubre. Nova Asistente es un chatbot accesible **desde cualquier pantalla del ERP** que responde con precisión, indica **dónde** ir, **cómo** hacerlo y **qué validar**, alimentado por una base de conocimiento generada automáticamente desde los `.md` del repo + las guías ya presentes en el frontend.

Decisiones clave de v1:

- Infra LLM **independiente** del Dashboard IA (`Novasis AI`). Mismo `AI_ENCRYPTION_KEY` para cifrar API keys; cero acoplamiento de costos.
- Soporte **Anthropic + OpenAI + Ollama**.
- **Config holding-only**: una sola configuración por grupo de empresas (el panel solo es visible para empresas tipo `holding`).
- Base de conocimiento **auto-generada**: `.md` del backend reescritos por LLM en lenguaje de usuario, + extracción AST de `<ScreenGuia>/<MarangatuGuia>/<RRHHGuia>` del frontend, + catálogo de pantallas. Cero recetas manuales.
- **Seguro por construcción**: el LLM nunca recibe datos del tenant — solo pregunta + chunks de docs + ruta actual.
- **Auditable**: cada pregunta/respuesta queda registrada en `audit_logs` con `action='ayuda_ia_preguntar'`.

---

## Arquitectura global

```
[FAB global + Drawer chat] ────HTTPS────► POST /ayuda-ia/sesiones/:id/preguntar
                                                  │
                                                  ▼
                                          AyudaIaService
                                            ├─ sanitizar pregunta (CI/RUC/montos → [dato])
                                            ├─ embed(pregunta)              ← AyudaProviderService
                                            ├─ topK chunks (pgvector + boost por screen_key)
                                            ├─ build prompt (system + chunks + historial)
                                            ├─ AyudaProviderService.completar(prompt)
                                            ├─ persist user + assistant (ayuda_mensajes)
                                            └─ AuditService.log ← pregunta/respuesta/citas/IP/UA
                                                  │
                                                  ▼
                                          { texto, citas[{titulo, screen_key, ruta_url}] }
```

Pipeline de ingesta (async, con AbortController):

```
[backend/docs/*.md  (SOLO raíz)] ─► transformador LLM "user-friendly" ──┐
[frontend ScreenGuia (AST + regex fallback)]  ─────────────────────────┤─► chunker ─► embeddings ─► ayuda_chunks
[seed-catalogo.ts (pantallas con rutas)]    ───────────────────────────┘
```

---

## Esquema de BD (Prisma)

Tablas (nombres reales en snake*case con prefijo `ayuda*`):

```prisma
model ayuda_ia_config {
  id                           String   @id @default(uuid())
  empresa_id                   String   @unique           // empresa holding owner
  proveedor                    String                     // 'anthropic' | 'openai' | 'ollama'
  modelo                       String
  proveedor_embeddings         String?                    // 'openai' | 'ollama'
  modelo_embeddings            String?
  base_url                     String?                    // ollama / endpoints custom
  api_key_encrypted            String?                    // null para ollama local
  api_key_embeddings_encrypted String?
  activo                       Boolean  @default(false)   // controla visibilidad del FAB
  max_tokens                   Int      @default(800)
  temperatura                  Float    @default(0.2)
  created_at                   DateTime @default(now())
  updated_at                   DateTime @updatedAt
}

model ayuda_documentos {
  id           String   @id @default(uuid())
  fuente       String                                 // 'doc-md' | 'screen-guia' | 'catalogo'
  origen_path  String?
  screen_key   String?                                // p.ej. 'tesoreria/cheques'
  titulo       String
  contenido_md String   @db.Text
  version_hash String                                 // sha256 — skip de re-ingesta
  created_at   DateTime @default(now())
  updated_at   DateTime @updatedAt
  chunks       ayuda_chunks[]
  @@index([screen_key])
}

model ayuda_chunks {
  id            String                @id @default(uuid())
  documento_id  String
  documento     ayuda_documentos      @relation(fields: [documento_id], references: [id], onDelete: Cascade)
  orden         Int
  contenido     String                @db.Text
  embedding     Unsupported("vector(1536)")
  token_count   Int
  @@index([documento_id])
}

model ayuda_sesiones {
  id          String   @id @default(uuid())
  empresa_id  String
  usuario_id  String
  titulo      String?
  created_at  DateTime @default(now())
  updated_at  DateTime @default(now()) @updatedAt
  mensajes    ayuda_mensajes[]
  @@index([empresa_id, usuario_id, created_at])
}

model ayuda_mensajes {
  id           String   @id @default(uuid())
  sesion_id    String
  sesion       ayuda_sesiones @relation(fields: [sesion_id], references: [id], onDelete: Cascade)
  rol          String                                  // 'user' | 'assistant'
  contenido    String   @db.Text
  citas_json   Json?
  tokens_in    Int?
  tokens_out   Int?
  latency_ms   Int?
  feedback     String?                                 // 'up' | 'down' | null
  created_at   DateTime @default(now())
}

model ayuda_ingesta_jobs {
  id           String   @id @default(uuid())
  empresa_id   String
  source       String                                  // 'docs' | 'guias' | 'catalogo' | 'all'
  estado       String   @default("pendiente")          // pendiente|corriendo|cancelando|cancelado|completado|error
  total_docs   Int?
  procesados   Int      @default(0)
  chunks_total Int      @default(0)
  error        String?
  started_at   DateTime?
  finished_at  DateTime?
  created_at   DateTime @default(now())
}
```

**Requisito:** extensión `pgvector` instalada en el PostgreSQL del servidor.

> **⚠️ Deployment caveat:** instalar el paquete que corresponde a la **versión exacta** de Postgres en el host. Ejemplo: `postgresql-17-pgvector` para PG17, **no** `postgresql-18-pgvector` aunque sea más nuevo. Si la migración 0xxx_ayuda_ia_init falla con `extension "vector" is not available`, instalar el paquete correcto y `npx prisma migrate resolve --rolled-back <name>` antes de re-deployar.

Índice IVFFlat para cosine similarity:

```sql
CREATE INDEX ayuda_chunks_embedding_idx ON "ayuda_chunks"
  USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
```

---

## Backend — `src/ayuda-ia/`

### Provider abstracto independiente

`services/ayuda-provider.service.ts` — encapsula Anthropic / OpenAI / Ollama. **NO** reutiliza la instancia de `AiProviderService` del Dashboard IA (config separada).

- `completar({ proveedor, modelo, apiKey, baseUrl, system, prompt, historial, max_tokens, temperatura, signal })`
- `embed({ proveedor, modelo, apiKey, baseUrl, textos, signal })`

Defaults por proveedor:

| Proveedor | Chat default                | Embeddings default                 | Dims    |
| --------- | --------------------------- | ---------------------------------- | ------- |
| anthropic | `claude-haiku-4-5-20251001` | (no soporta — pedir openai/ollama) | —       |
| openai    | `gpt-4o-mini`               | `text-embedding-3-small`           | 1536 ✅ |
| ollama    | `llama3.1`                  | `mxbai-embed-large`                | 1024 ❌ |

**La columna `vector(1536)` es FIJA en el schema.** Solo modelos de 1536 dims funcionan sin romper la tabla:

- `text-embedding-3-small` (OpenAI) — recomendado
- `text-embedding-ada-002` (OpenAI legacy)
- Embeddings de Ollama (768/1024 dims) **NO** son compatibles. La UI lo advierte explícitamente.

**Notas de implementación importantes:**

- `AbortSignal` propagado a Anthropic SDK, OpenAI SDK y `fetch` de Ollama — cancelación real (no solo entre docs).
- OpenAI client constructor **NO** acepta `baseURL` (era un bug que derivaba embeddings al puerto de Ollama). Ollama va por path propio.
- SSRF protection en `validateOllamaBaseUrl`: solo `localhost`, 127.x, 10.x, 192.168.x, 172.16-31.x, `*.local`, `*.lan`, `*.internal`.
- API key desencriptada vive solo en memoria, nunca loggeada.

### Config service & controller

Endpoints (`/ayuda-ia/config`):

| Método | Path                     | Función                                                                                                                                                                       |
| ------ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GET    | `/`                      | Devuelve config sin api_keys; agrega `tiene_api_key` / `tiene_api_key_embeddings` flags.                                                                                      |
| POST   | `/`                      | Upsert (cifra api_keys con `AyudaEncryptionHelper`, mismo `AI_ENCRYPTION_KEY`).                                                                                               |
| POST   | `/test`                  | Prueba chat + embeddings **separadamente**, devuelve `{ ok, chat: {ok,latency_ms,sample,error?}, embeddings: {ok,latency_ms,dims,error?} }`.                                  |
| POST   | `/modelos`               | Lista modelos en vivo. Body `{ proveedor, api_key?, base_url? }`. Si hay key fetch a `/v1/models` (OpenAI/Anthropic) o `/api/tags` (Ollama). Fallback a lista estática si no. |
| POST   | `/ingesta/run`           | Encola job `{ source: 'docs'\|'guias'\|'catalogo'\|'all', force }`. Cancela jobs previos no terminales primero.                                                               |
| GET    | `/ingesta/status/:jobId` | Estado de un job (para barra de progreso).                                                                                                                                    |
| GET    | `/ingesta/activo`        | Devuelve el job no-terminal más reciente (recupera barra al recargar).                                                                                                        |
| POST   | `/ingesta/cancel/:jobId` | Marca `cancelado` inmediato + `controller.abort()`.                                                                                                                           |
| POST   | `/ingesta/cancel-todos`  | Limpieza forzada: mata todos los jobs no terminales (botón "Forzar cancelación").                                                                                             |

Guards: `JwtAuthGuard` + `assertHoldingCaller()` (solo empresas tipo `holding`).

### Ingesta service

`services/ayuda-ingesta.service.ts` — pipeline async con job tracking en `ayuda_ingesta_jobs`.

**Flujo de `correrJob`:**

1. Crea `AbortController` y lo guarda en `Map<jobId, controller>`.
2. Carga config del holding. Si falla → `fallarJob`.
3. Junta docs de las fuentes pedidas (ver abajo).
4. Por cada doc:
   - Antes de procesar, revisa estado en BD. Si es `cancelando`/`cancelado` → break.
   - Si `version_hash` del doc coincide con el existente y `force=false` → skip (cache hit).
   - Si requiere transformación (solo `doc-md`): llama al LLM con el system prompt user-friendly. Si devuelve `SKIP` → omitir.
   - Upsert `ayuda_documentos`, borra chunks anteriores, re-chunkea, embed en batches de 50, inserta `ayuda_chunks` vía SQL raw (`vector` literal).
   - Cada N docs actualiza progreso en BD (para que la UI lo refresque).
5. Si un doc revienta con `HTTP 401/403/404` → **error fatal de configuración**: `controller.abort()` + `fallarJob` (corta el job entero, no churnea 109 docs con el mismo error).
6. Update final del estado, salvo que ya esté en estado terminal (`cancelado`/`error`) puesto por `cancelarJob`.

**`encolarJob` cancela todos los jobs no terminales previos** antes de crear el nuevo — imposible tener 2 jobs activos.

**Fuentes:**

| Fuente     | Lee de                                                                     | Notas                                                                   |
| ---------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `docs`     | `BACKEND_DOCS_PATH/*.md` (SOLO raíz, no recursivo)                         | Subcarpetas como `docs-auxiliar/` están **excluidas** intencionalmente. |
| `guias`    | `POS_VENTAS_FRONTEND_PATH/src/components/**/*.jsx` con `<ScreenGuia>` etc. | Babel AST + regex fallback.                                             |
| `catalogo` | `ingesta/seed-catalogo.ts` (hardcoded)                                     | ~50 entradas de pantallas → rutas + breadcrumbs.                        |

**Transformer user-friendly (`ingesta/transformadores/user-friendly.transformer.ts`):** system prompt explícito de que **SKIP es la excepción**, no la regla. Conserva planes/roadmaps/specs extrayendo la funcionalidad descrita. SKIP solo para migraciones SQL puras, changelogs de libs, configs de CI/CD. En duda → conservar.

### Endpoint de pregunta

`services/ayuda-ia.service.ts` — RAG completo.

`POST /ayuda-ia/sesiones/:id/preguntar` body `{ pregunta, screen_key?, screen_url? }`:

1. Validar pertenencia de sesión (`empresa_id` + `usuario_id`).
2. `sanitizarPregunta` (regex CI/RUC/montos largos/IBAN → `[dato]`) antes de embeber/enviar al LLM.
3. `embed(preguntaSanitizada)`.
4. SQL raw con pgvector (cosine distance, boost +0.1 si `screen_key` coincide), top 6 chunks.
5. Persistir el mensaje del usuario.
6. Cargar últimos 6 mensajes como historial.
7. Construir prompt (`SYSTEM_PROMPT` de "Nova Asistente" + contexto + pregunta).
8. `provider.completar(...)`.
9. Persistir respuesta del asistente con citas (top 3 por score).
10. **`AuditService.log`** ← acción `ayuda_ia_preguntar`, `new_value` con `{ pregunta, pregunta_sanitizada, respuesta, screen_key, screen_url, citas, proveedor, modelo, tokens_in, tokens_out, latency_ms, chunks_recuperados }`, IP + user-agent del request.
11. Devolver `{ mensaje_id, texto, citas, tokens_in, tokens_out, latency_ms }`.

Otros endpoints:

- `POST /ayuda-ia/sesiones` — crea sesión.
- `GET  /ayuda-ia/sesiones` — listar sesiones del usuario actual.
- `GET  /ayuda-ia/sesiones/:id/mensajes` — historial de una sesión.
- `POST /ayuda-ia/mensajes/:id/feedback` — `{ feedback: 'up'|'down' }` → audita acción `ayuda_ia_feedback`.

### Seguridad

- Cero datos de tenant en el prompt. Sanitización antes de embeber/enviar.
- `JwtAuthGuard` global.
- Config/ingesta requieren empresa tipo `holding` (`assertHoldingCaller`).
- API keys cifradas con `AyudaEncryptionHelper` (mismo `AI_ENCRYPTION_KEY`).
- SSRF protection en base_url de Ollama.
- Rate limit: TODO con `@nestjs/throttler` cuando se priorice (no bloqueante para v1).

---

## Frontend — `src/components/ayuda-ia/`

### Componentes entregados

```
AyudaIaFab.jsx            // FAB fixed bottom-right; icono mdi:robot-happy-outline; atajo Ctrl+/
AyudaIaDrawer.jsx         // Drawer derecho (420px desktop / 100% mobile)
AyudaIaChat.jsx           // Mensajes + input. Render markdown inline (bold, code) theme-aware.
AyudaIaCitas.jsx          // Chips con deeplink (navigate(ruta_url))
AyudaIaConfigPanel.jsx    // Form config (admin holding) con live model fetch + test separado chat/emb
useAyudaIa.js             // TanStack: mutations preguntar/feedback, queries sesiones/mensajes
screenKeyMap.js           // pathname → screen_key
```

Montado en `App.jsx` después del auth gate. El FAB se muestra solo si el módulo está `activo=true` en la config del holding.

### Config UI (admin holding)

**No es una tab dentro de `ConfiguracionTemplate`** — vive dentro del menú reorganizado `ConfiguracionNew.jsx` bajo "Asistente IA → Nova Asistente (Holding)", con flag `holdingOnly: true` (oculto para empresas subsidiarias).

Features destacados:

- **Live model fetch** debounced (600ms) — al pegar la API key, los selects de chat/embeddings se autopopulan con los modelos reales disponibles del proveedor.
- **Chip "Configurada"/"Sin guardar"** en cada API key (chat y embeddings) — indica si ya hay key persistida en el servidor.
- **Botón "Probar conexión (chat + embeddings)"** muestra dos paneles separados con resultado, latencia, sample/dims, y un ✓ "compatible" si los embeddings dan 1536 dims.
- **Switch "Forzar reprocesamiento"** — ignora cache de `version_hash` para reprocesar todos los docs (uso típico: tras cambiar el prompt del transformer).
- **Barra de progreso** poll a `/ingesta/status/:jobId` cada 2s, se detiene en estados terminales (`completado`/`error`/`cancelado`).
- **Recovery al reload** — al montar, `getIngestaActiva` retoma el job no terminal más reciente.
- **Cancelar / Forzar cancelación** — botón principal llama a `/cancel/:jobId`; si queda zombie en `cancelando`, el botón cambia a "Forzar cancelación" y llama a `/cancel-todos`.
- **Alert de embeddings** explicando límite de 1536 dims y modelos compatibles.

### Service layer

`src/api/ayuda-ia.service.js` con axios. Endpoints expuestos: getAyudaConfig, upsertAyudaConfig, testAyudaConexion, listarModelosAyuda, ejecutarIngesta, getIngestaStatus, getIngestaActiva, cancelarIngesta, cancelarTodasIngestas, crearSesionAyuda, listarSesionesAyuda, listarMensajesAyuda, preguntarAyuda, enviarFeedbackAyuda.

### Branding final

- Marca: **"Nova Asistente"** (rebrand de "Novasis Soporte" del plan original).
- FAB: `mdi:robot-happy-outline` (rebrand de `mdi:lifebuoy` por feedback del usuario — más IA, menos "salvavidas").
- Avatar del bot en mensajes: `mdi:help-circle` con color primary.
- Tagline: _"Estoy para ayudarte con el sistema"_.

---

## Configuración en producción

### Variables de entorno requeridas

| Variable                   | Default (dev)                                         | En prod                                                                                  |
| -------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `AI_ENCRYPTION_KEY`        | (compartida con Dashboard IA)                         | Obligatoria                                                                              |
| `BACKEND_DOCS_PATH`        | `/var/www/html/proyectos/smartfactvoice-backend/docs` | `/var/www/html/smartfactvoice-backend/docs`                                              |
| `POS_VENTAS_FRONTEND_PATH` | `/var/www/html/proyectos/pos-ventas`                  | path absoluto al checkout del frontend en el servidor (ej. `/var/www/html/facvoice-erp`) |

> ⚠️ Los defaults apuntan al filesystem de la máquina de desarrollo. **Prod requiere ambas vars explícitas** o la ingesta no encontrará nada y devolverá pocos docs.

### Setup de proveedor recomendado

| Caso de uso             | Chat                                      | Embeddings                      | Costo aprox                      |
| ----------------------- | ----------------------------------------- | ------------------------------- | -------------------------------- |
| Cliente con presupuesto | Anthropic `claude-sonnet-4-6`             | OpenAI `text-embedding-3-small` | Bajo (embeddings) + medio (chat) |
| Cliente cost-sensitive  | OpenAI `gpt-4o-mini`                      | OpenAI `text-embedding-3-small` | Bajo                             |
| On-premise / aislado    | Ollama local (chat) + OpenAI (embeddings) | Mixto                           | Solo embeddings online           |

---

## Auditoría

Todas las preguntas/respuestas se registran en `audit_logs` reutilizando la infra global. Filtrar por:

- `action = ayuda_ia_preguntar` — incluye `new_value` con pregunta, respuesta completa, citas, tokens, latency, screen_key, IP, user-agent.
- `action = ayuda_ia_feedback` — incluye `old_value` (feedback anterior) y `new_value` (nuevo).
- `entity_type = ayuda_ia_mensaje` — agrupa ambas acciones.

Esto permite revisar qué responde el bot, identificar respuestas flojas (👎), y auditar exposición de datos sensibles si llegara a pasar.

---

## Verificación end-to-end (entregada y probada)

- [x] Migración corre con `pgvector` instalado en la versión correcta de PG.
- [x] Admin del holding ve la opción en Configuración → Asistente IA. Otros no.
- [x] Live model fetch al pegar API key (debounced).
- [x] Test conexión muestra chat OK + embeddings OK con compatibilidad de dims.
- [x] Re-indexar todo procesa docs + guías + catálogo. Progreso en tiempo real.
- [x] Cancelación funcional: estado cambia a `cancelado` inmediato.
- [x] Job sobrevive al reload: barra se recupera al volver a entrar.
- [x] Error fatal (401/404) en LLM aborta el job entero, no churnea 100+ docs.
- [x] FAB visible para usuarios cuando `activo=true`.
- [x] Pregunta "¿cómo anulo una factura?" devuelve respuesta con pasos + citas clickeables.
- [x] Pregunta "¿cuánto facturé este mes?" redirige al Dashboard sin filtrar datos.
- [x] Sanitización: pregunta con CI muestra `[dato]` en el prompt enviado.
- [x] Feedback 👍/👎 persistido y auditado.
- [x] Auditoría: cada pregunta queda en `audit_logs` con pregunta/respuesta/citas.
- [x] Code styling del chat legible en dark mode.

---

## v1.1 — Mejora del retriever con re-ranking (2026-06-25)

### Problema observado en producción

El retriever vectorial puro (similitud coseno sobre embeddings) devuelve **contexto irrelevante con frecuencia** cuando la pregunta contiene palabras polisémicas dentro del dominio del ERP. Ejemplo real:

- Pregunta: *"¿Dónde puedo darle pagado a las compras que hacemos a crédito?"*
- Top 30 chunks recuperados: mayoría de `guia-solicitud-credito.md` (porque "crédito" aparece en ambos lados).
- Chunks correctos de `guia-pagos-a-proveedores.md` quedan en posición 15-20.
- LLM ve mucho ruido y responde con disclaimers tipo *"Esta consulta no es de Solicitudes de Crédito"*.

Causa raíz: la similitud coseno no entiende **intención**. Mide cercanía semántica superficial, no relevancia para responder la pregunta.

### Solución: 4 capas de mejora (priorizadas por ROI)

| Capa | Solución | Impacto | Costo dev | Costo runtime |
|---|---|---|---|---|
| 1 | **Cross-encoder re-ranking** | 70-80% mejora precisión | 2-3 horas | ~10 USD/mes |
| 2 | **Query intent classifier** | 20-30% mejora adicional | 1 día | ~1 USD/mes |
| 3 | **Hybrid search** (vectorial + BM25) | 10-15% adicional | 1 día | gratis |
| 4 | **Confidence threshold** + Self-RAG | Evita respuestas malas | 2-3 días | +50% LLM cost |

**Decisión 2026-06-25:** implementar **Capa 1 (cross-encoder re-ranking)** como solución definitiva. Cubre el 80% de los casos de mal contexto. Capas 2-4 quedan diferidas a v1.2+ según necesidad.

### Arquitectura propuesta

```
Antes (v1):
  Pregunta → Embedding → Top 30 chunks (similitud coseno) → LLM (todos los 30)

Después (v1.1):
  Pregunta → Embedding → Top 30 chunks (recall amplio)
                              ↓
                         Cross-encoder reranker (Cohere o local)
                              ↓
                       Top 5 chunks REALMENTE relevantes
                              ↓
                         (Opcional) Threshold check
                              ↓
                            LLM (solo 5 chunks de alta calidad)
```

### Implementación técnica

#### Backend — Nuevo servicio `RerankerService`

Ubicación: `src/ayuda-ia/services/reranker.service.ts`

Interfaz:
```typescript
export interface RerankResult {
  index: number;          // índice original en el array recibido
  score: number;          // score de relevancia 0-1 del reranker
  chunk: AyudaChunk;      // payload original
}

@Injectable()
export class RerankerService {
  /**
   * Re-ordena chunks por relevancia real a la pregunta usando un cross-encoder.
   * El reranker EVALÚA pregunta+chunk juntos, no por similitud vectorial.
   */
  async rerank(params: {
    query: string;
    chunks: AyudaChunk[];
    topK?: number;          // default 5
    minScore?: number;      // default 0.3 (umbral mínimo)
    proveedor: 'cohere' | 'local';
  }): Promise<RerankResult[]>
}
```

#### Cambios en `ayuda-ia.service.ts`

```typescript
// Antes
const chunks = await this.buscarChunksSimilares(vector, 30);
const contexto = this.armarContexto(chunks);
const respuesta = await this.llm.completar(promptCompleto, contexto);

// Después
const chunks = await this.buscarChunksSimilares(vector, 30);   // recall amplio
const reranked = await this.reranker.rerank({
  query: preguntaSanitizada,
  chunks,
  topK: 5,
  minScore: 0.3,
});

if (reranked.length === 0) {
  // Ningún chunk superó el threshold → respuesta directa sin LLM
  return this.respuestaSinContexto();
}

const contexto = this.armarContexto(reranked.map(r => r.chunk));
const respuesta = await this.llm.completar(promptCompleto, contexto);
```

#### Config en `ayuda_config` (DB)

Agregar columnas:
```sql
ALTER TABLE ayuda_config
  ADD COLUMN reranker_proveedor VARCHAR(20) DEFAULT 'cohere',  -- 'cohere' | 'local' | 'none'
  ADD COLUMN reranker_modelo VARCHAR(50) DEFAULT 'rerank-multilingual-v3.0',
  ADD COLUMN reranker_api_key_enc TEXT,
  ADD COLUMN reranker_top_k SMALLINT DEFAULT 5,
  ADD COLUMN reranker_min_score DECIMAL(3,2) DEFAULT 0.30;
```

Cifrado de `reranker_api_key_enc` reusa `AI_ENCRYPTION_KEY`.

#### UI admin

En la pantalla de configuración (`AyudaIAConfig`):
- Toggle "Activar re-ranking" (default ON).
- Selector de proveedor: Cohere / Local / Desactivado.
- Si Cohere → input de API key.
- Top K (slider 3-10, default 5).
- Min score (slider 0.1-0.6, default 0.3).

### Proveedores de reranker — comparativa

| Proveedor | Modelo | Costo | Latencia | Calidad ES | Setup |
|---|---|---|---|---|---|
| **Cohere Rerank** | `rerank-multilingual-v3.0` | $2 USD por 1k búsquedas | ~150ms | ⭐⭐⭐⭐⭐ | API key |
| **bge-reranker-v2-m3 (local)** | open source (BAAI) | gratis | ~300ms con CPU, ~50ms con GPU | ⭐⭐⭐⭐ | Servidor + RAM |
| **jina-reranker-v2** | API | $0.50 USD por 1k | ~200ms | ⭐⭐⭐⭐ | API key |
| **mixedbread mxbai-rerank-large-v1** | open source | gratis | ~250ms CPU | ⭐⭐⭐⭐ | Servidor |

**Recomendación inicial:** Cohere por simplicidad. Cuando el volumen pase 100k búsquedas/mes (~200 USD/mes), evaluar self-hosted con `bge-reranker-v2-m3`.

### Métricas para validar la mejora

Antes/después comparar:
1. **Tasa de respuestas correctas** sobre 50 preguntas-test reales (medición manual con feedback 👍/👎).
2. **% de chunks irrelevantes pasados al LLM** (contar manualmente sobre 10 muestras).
3. **Latencia total** (esperada: +200ms por la llamada al reranker).
4. **Costo por mes** (estimado: +10 USD/mes a volumen actual).

### Criterio de aceptación v1.1

- Pregunta "donde pago compras a crédito" responde con ruta `Finanzas → Orden de Pago` sin mencionar Solicitud de Crédito.
- Pregunta "como solicito un crédito para un cliente" responde con Solicitud de Crédito (no Pagos a Proveedor).
- Threshold detecta preguntas fuera del dominio y responde "no tengo esa información" sin alucinar.

### Roadmap

| Fase | Tarea | Duración |
|---|---|---|
| 1 | Crear cuenta Cohere + obtener API key gratuita (trial 100k búsquedas/mes) | 30 min |
| 2 | Crear `RerankerService` con implementación Cohere | 2 horas |
| 3 | Integrar en `ayuda-ia.service.preguntar()` | 1 hora |
| 4 | Migración de schema + UI config | 1 hora |
| 5 | Testing con 20 preguntas reales antes/después | 1 hora |
| 6 | Deploy + monitoreo de latencia y costo | 1 hora |
| **Total** | | **~7 horas** |

---

## Fuera de alcance v1 (candidatos a v2)

- **Streaming SSE** de la respuesta del bot (hoy es request/response).
- **Rate limiting** con `@nestjs/throttler` (30 preguntas/usuario/hora).
- **Multi-idioma** (hoy solo español paraguayo).
- **Voz / audio** (input por micrófono, TTS de respuestas).
- **Sugerencias proactivas** push según pantalla actual.
- **Auto-tickets de soporte** cuando la IA dice "no sé".
- **Re-ingesta en cron** o GitHub Action al cambiar `docs/`.
- **Memoria de largo plazo** del usuario (preferencias, historial cross-sesión).
- **Export de KB precomputada** entre entornos (`pg_dump` de `ayuda_documentos`+`ayuda_chunks`) para no recalcular embeddings en prod si los `.md` no cambian.
- **Dashboard de calidad** con métricas de feedback 👍/👎 + cost tracking.

---

## Relación con módulos IA existentes

- **NO modificar** `src/ai-dashboard/*` (backend) ni `src/components/organismos/AIDashboard/*` (frontend). Son módulos independientes con su propia config (`ai_empresa_config`).
- **Reusar solo**: `AI_ENCRYPTION_KEY` (mismo cifrado de keys), `AuditService` (registro global), patrones de DTOs/Guards.
- Branding diferenciado: **"Novasis AI"** (analítica conversacional sobre datos del tenant) vs **"Nova Asistente"** (ayuda operativa sobre cómo usar el sistema).
- Configuraciones 100% independientes: un cliente puede tener Anthropic en Dashboard y Ollama local en Nova Asistente, o viceversa.
