before language fix
CI / ruff (push) Has been cancelled
CI / mypy (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / package (push) Has been cancelled
CI / Install smoke (${{ matrix.label }}) (linux, ubuntu-latest) (push) Has been cancelled
CI / Install smoke (${{ matrix.label }}) (macos-arm64, macos-14) (push) Has been cancelled
CI / ruff (push) Has been cancelled
CI / mypy (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / package (push) Has been cancelled
CI / Install smoke (${{ matrix.label }}) (linux, ubuntu-latest) (push) Has been cancelled
CI / Install smoke (${{ matrix.label }}) (macos-arm64, macos-14) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
# RAG Multi-Tenant / Alcance Usuario & Rol
|
||||
## Documentación Técnica — Vocero S2S v0.2.11+
|
||||
|
||||
> Fecha: 01/09/2026
|
||||
> Ámbito: Módulo RAG Server-Side (`speech_to_speech.RAG`) + WebSocket en Tiempo Real
|
||||
|
||||
---
|
||||
|
||||
## 1. Objetivo
|
||||
|
||||
Esta modificación introduce un **mecanismo de aislamiento del alcance (scope)** en
|
||||
el RAG del pipeline Speech-to-Speech, para permitir que distintos clientes
|
||||
(usuarios autenticados, roles) reciban **solo los chunks de la Knowledge Base
|
||||
autorizados**, sin compartir información privada entre sesiones distintas.
|
||||
|
||||
Antes de este release el RAG era **único y global**: cada sesión WS podía ver
|
||||
toda la KB compartida. Ahora se puede filtrar la búsqueda por:
|
||||
- **`user_id`** (usuario lógico individual)
|
||||
- **`roles`** (lista de roles: agente, supervisor, backoffice…)
|
||||
- **`allowed_source_prefixes`** (prefijos personalizados en el campo `source`)
|
||||
- **Metadatos arbitrarios** (`metadata_filter` con coincidencia exacta y
|
||||
sufijo `*_in` para listas OR)
|
||||
|
||||
Los filtros se aplican **ANTES del ranking** de similaridad del coseno (mediante
|
||||
máscara booleana NumPy), así que no hay pérdida de precisión ni degradación de
|
||||
rendimiento apreciable.
|
||||
|
||||
---
|
||||
|
||||
## 2. Comparativa: sistema anterior vs sistema nuevo
|
||||
|
||||
| Criterio | Antes (≤ v0.2.11 vanilla) | Después (v0.2.11 + RAG scoping) |
|
||||
|---|---|---|
|
||||
| **Alcance retrieval** | Singleton `_GLOBAL_RAG` → todo el mundo ve todo | Sigue siendo singleton, pero `search()` aplica máscara de scope por sesión |
|
||||
| **Aislamiento datos** | ❌ Ninguno: clientes distintos comparten chunks privados (PII) | ✅ Cada sesión WS declara `user_id`/`roles`/`prefijos`; solo los chunks autorizados son candidatos al ranking |
|
||||
| **Uso memoria** | 1 índice, 1 modelo embedding | 1 índice, 1 modelo embedding (sin aumento — el filtro es O(n) con máscara numpy) |
|
||||
| **Población KB** | `source` libre, `metadata` opcional | `source` y `metadata` deben seguir una convención para los filtros (véase §4) |
|
||||
| **Declarar el scope** | ❌ No existía | ✅ **Query-string del WebSocket** en el momento de la conexión |
|
||||
| **Probar filtros por HTTP** | ❌ `/v1/rag/search` solo aceptaba query/top_k/threshold | ✅ `/v1/rag/search` ahora acepta también `allowed_source_prefixes` y `metadata_filter` |
|
||||
| **Cambiar scope en caliente** | — | ⚠️ Solo modificando `runtime_config` (no expuesto por `session.update` — véase §9.1) |
|
||||
| **Hook LLM** | `_inject_rag_context` llamaba `search(query, top_k, threshold)` | El mismo hook, pero pasa además `allowed_prefixes` y `meta_filter` deducidos de `runtime_config` |
|
||||
| **Logging retrieval** | El log contenía `count` + `sources_summary` | El log incluye también `scope_prefixes` y `scope_meta` para auditoría y depuración |
|
||||
|
||||
---
|
||||
|
||||
## 3. Cambios en el codebase
|
||||
|
||||
| Archivo | Tipo | Descripción |
|
||||
|---|---|---|
|
||||
| `src/speech_to_speech/RAG/retriever.py` | Core búsqueda | Firma de `search(...)` extendida con `allowed_source_prefixes` y `metadata_filter`. Aplica máscara booleana numpy antes del ranking. Soporte `*_in` para coincidencia OR sobre listas en metadata. |
|
||||
| `src/speech_to_speech/RAG/router.py` | API HTTP | `SearchRequest` y endpoint `POST /v1/rag/search` propagan los nuevos filtros. |
|
||||
| `src/speech_to_speech/api/openai_realtime/runtime_config.py` | Sesión | Añadidos 3 campos en `RuntimeConfig`: `rag_user_id`, `rag_roles` (CSV → list), `rag_allowed_source_prefixes` (CSV → list). |
|
||||
| `src/speech_to_speech/api/openai_realtime/websocket_router.py` | WS claim | Dentro de `realtime_endpoint`, justo después de `register()`, lee los parámetros de scope desde la query-string del WS y los escribe en `ConnState.runtime_config` de la sesión. |
|
||||
| `src/speech_to_speech/LLM/base_openai_compatible_language_model.py` | Hook LLM | `_inject_rag_context(...)` ahora recibe `runtime_config` y construye `allowed_prefixes` (shared + roles + user + custom) y `metadata_filter` (`user_id`, `roles_in`), pasándolos a `search()`. El log de inject incluye ahora también el scope. |
|
||||
|
||||
Ningún archivo eliminado; la compatibilidad con versiones anteriores está
|
||||
garantizada (todos los nuevos parámetros tienen valor `None` por defecto, así
|
||||
que los clientes que no pasan scope siguen viendo **TODA** la KB igual que
|
||||
antes — comportamiento "legacy").
|
||||
|
||||
---
|
||||
|
||||
## 4. Convenciones: cómo etiquetar chunks por usuario/rol
|
||||
|
||||
### 4.1 Convención en el campo `source` (OBLIGATORIA si usas prefijos)
|
||||
|
||||
La parte inicial de `source` determina el alcance de visibilidad a través del
|
||||
filtro `allowed_source_prefixes`. Usa estos prefijos estándar:
|
||||
|
||||
```
|
||||
users/<id>/<ruta> # ← solo el usuario <id> ve estos chunks
|
||||
roles/<nombre_rol>/<ruta> # ← solo usuarios con el rol <nombre_rol>
|
||||
shared/<ruta> # ← todo el mundo lo ve (equivale a public/)
|
||||
public/<ruta> # ← todo el mundo lo ve
|
||||
<cualquier_otro>/<ruta> # ← visible SOLO si se pasa explícitamente en
|
||||
# rag_allowed_source_prefixes al conectar el WS
|
||||
```
|
||||
|
||||
**Ejemplos válidos:**
|
||||
```
|
||||
users/123/preferencias_usuario
|
||||
users/123/anamnesis_2026
|
||||
roles/agente/procedimiento/abrir_ticket
|
||||
roles/supervisor/informes/kpi_mensuales
|
||||
shared/intro_general
|
||||
public/horarios_helpdesk
|
||||
kb_cliente_alpha/contratos/clausula_23 # ← necesita prefixes=kb_cliente_alpha/ en WS
|
||||
```
|
||||
|
||||
### 4.2 Convención en `metadata` (recomendada, usada por `metadata_filter`)
|
||||
|
||||
| Clave | Tipo | Semántica |
|
||||
|---|---|---|
|
||||
| `user_id` | `str`/`int` | Identificador del usuario propietario. El filtro automático del WS usa coincidencia EXACTA. |
|
||||
| `roles` | `list[str]` | Roles autorizados. El filtro automático del WS usa `roles_in: [...]` (coincidencia OR: basta uno). |
|
||||
| `scopes` | `list[str]` | Opcional (no usado por el filtro automático — para lógicas personalizadas). |
|
||||
|
||||
**Ejemplos JSON válidos:**
|
||||
```json
|
||||
{"user_id": "123", "nota": "datos sensibles"}
|
||||
{"roles": ["agente", "supervisor"], "version": 3}
|
||||
```
|
||||
|
||||
### 4.3 Los dos filtros no se excluyen — trabajan en AND
|
||||
|
||||
`allowed_source_prefixes` y `metadata_filter` se combinan con AND (lógico):
|
||||
un chunk pasa si **ambos** coinciden.
|
||||
|
||||
> 💡 Consejo práctico: usa **solo uno de los dos** para no complicarte.
|
||||
> El sistema WS por defecto usa **ambos** para mayor seguridad: si te
|
||||
> equivocas con el prefijo, el metadata filter te protege igualmente contra
|
||||
> fugas de información, y viceversa.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cómo usarlo
|
||||
|
||||
### 5.1 Cargar chunks con alcance
|
||||
|
||||
Usar `/v1/rag/add/document` (splittea automáticamente) o
|
||||
`/v1/rag/add/chunks` (pre-chunkizados):
|
||||
|
||||
```bash
|
||||
# ---------- Usuario individual 123 ----------
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"text": "Preferencia usuario 123: café sin azúcar, alergia a los frutos secos.",
|
||||
"source": "users/123/preferencias_usuario",
|
||||
"metadata": {"user_id": "123"}
|
||||
}'
|
||||
|
||||
# ---------- Visible a roles agente + supervisor ----------
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"text": "Procedimiento: para abrir ticket enviar mail a support@example.com con ID cliente.",
|
||||
"source": "roles/agente/procedimiento/abrir_ticket",
|
||||
"metadata": {"roles": ["agente", "supervisor"]}
|
||||
}'
|
||||
|
||||
# ---------- Visible a TODOS ----------
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"text": "Horarios helpdesk: lun-vie 9-18.",
|
||||
"source": "shared/horarios_helpdesk",
|
||||
"metadata": {"scopes": ["public"]}
|
||||
}'
|
||||
|
||||
# ---------- Alcance custom (ej. cliente alpha completo) ----------
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"text": "Cláusula 23 contrato Alpha: penalización 5% por retraso > 10 días.",
|
||||
"source": "kb_cliente_alpha/contratos/clausula_23",
|
||||
"metadata": {"customer": "alpha"}
|
||||
}'
|
||||
```
|
||||
|
||||
### 5.2 Conectar el WebSocket declarando el alcance
|
||||
|
||||
El sistema de scope se rellena **en el momento de la conexión WS** mediante
|
||||
**query-string** (más sencillo y estándar que meter campos custom dentro de
|
||||
`session.update`).
|
||||
|
||||
| Parámetro WS | Alias | Ejemplo | Explicación |
|
||||
|---|---|---|---|
|
||||
| `user_id` | `rag_user_id` | `user_id=123` | Id usuario. Genera prefijo `users/123/` y filtro `metadata.user_id=="123"`. |
|
||||
| `roles` | `rag_roles` | `roles=agente,supervisor` | Lista CSV de roles. Genera prefijos `roles/agente/`, `roles/supervisor/` y filtro `metadata.roles ∩ [agente,supervisor] ≠ ∅`. |
|
||||
| `prefixes` | `allowed_source_prefixes` / `rag_allowed_source_prefixes` | `prefixes=shared/,kb_cliente_alpha/` | Lista CSV de prefijos custom ADICIONALES (además de user/rol/shared) que quieres forzar visibles. |
|
||||
|
||||
**Ejemplo URL WS — Usuario 123, rol agente, quiere ver también kb_cliente_alpha/**
|
||||
|
||||
```
|
||||
ws://host:12345/v1/realtime?user_id=123&roles=agente&prefixes=kb_cliente_alpha/
|
||||
```
|
||||
|
||||
Qué se incluye AUTOMÁTICAMENTE con esta URL:
|
||||
|
||||
```
|
||||
allowed_source_prefixes = [
|
||||
"shared/", # siempre (público builtin)
|
||||
"public/", # siempre (público builtin)
|
||||
"roles/agente/", # de roles=agente
|
||||
"users/123/", # de user_id=123
|
||||
"kb_cliente_alpha/" # de prefixes=
|
||||
]
|
||||
|
||||
metadata_filter = {
|
||||
"roles_in": ["agente"], # de roles=agente
|
||||
"user_id": "123" # de user_id=123
|
||||
}
|
||||
```
|
||||
|
||||
Ambos filtros se pasan a `search()`. Resultado: la búsqueda solo puede pescar
|
||||
chunks autorizados.
|
||||
|
||||
### 5.3 Pruebas por HTTP /v1/rag/search
|
||||
|
||||
Para depurar sin abrir un WS, usa el endpoint HTTP pasando explícitamente
|
||||
los filtros:
|
||||
|
||||
```bash
|
||||
# Busca como si fuese "usuario 123 con rol agente"
|
||||
curl -sS -X POST http://127.0.0.1:12345/v1/rag/search \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"query": "cómo abro un ticket",
|
||||
"top_k": 5,
|
||||
"threshold": 0.05,
|
||||
"allowed_source_prefixes": ["shared/", "users/123/", "roles/agente/"],
|
||||
"metadata_filter": {
|
||||
"user_id": "123",
|
||||
"roles_in": ["agente", "supervisor"]
|
||||
}
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
Respuesta (estructura igual; solo cambia el pool de chunks sobre el que se busca):
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "cómo abro un ticket",
|
||||
"count": 1,
|
||||
"results": [
|
||||
{
|
||||
"text": "Procedimiento: para abrir ticket enviar mail a support@example.com...",
|
||||
"source": "roles/agente/procedimiento/abrir_ticket",
|
||||
"chunk_index": 0,
|
||||
"score": 0.71,
|
||||
"metadata": {"roles": ["agente", "supervisor"]}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Limpieza
|
||||
|
||||
Eliminar todos los chunks de un usuario:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/remove \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"source_prefix": "users/123/", "exact": false, "persist": true}'
|
||||
```
|
||||
|
||||
Eliminar todos los chunks de un rol:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/remove \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"source_prefix": "roles/backoffice/", "exact": false, "persist": true}'
|
||||
```
|
||||
|
||||
Recargar el índice tras editar archivos en `kb/`:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/reload \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"force_rebuild": true}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Logging & Depuración
|
||||
|
||||
Qué buscar en los logs PM2 de la pipeline:
|
||||
|
||||
| Momento | Nivel | Verás |
|
||||
|---|---|---|
|
||||
| Conexión WS con scope | INFO | `RAG scope cargado desde WS — user_id=X roles=Y prefixes=Z` |
|
||||
| Fallo al parsear el scope | WARNING | `error al aplicar scope RAG desde query-string: ...` |
|
||||
| Ningún chunk para ese scope | DEBUG | `ningún chunk relevante para turn=... scope_prefixes=... scope_meta=...` |
|
||||
| Retrieval OK (inject prompt) | INFO | `inyectados N chunks para turn=... (scope_prefixes=..., scope_meta=...)` |
|
||||
|
||||
Útil para ver los scopes activos en ejecución:
|
||||
```bash
|
||||
pm2 logs start_pipeline_rag --lines 200 | grep -i "RAG scope"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Ejemplo completo end-to-end
|
||||
|
||||
**Escenario:** Un CRM externo abre 2 sesiones WS: una para el usuario 123
|
||||
(con email privado en el RAG) y otra para el usuario 456.
|
||||
|
||||
```bash
|
||||
# 1) Poblar KB
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/upsert/document -H 'Content-Type: application/json' \
|
||||
-d '{"text":"Cuenta 123: mario.rossi@secretmail.example","source":"users/123/contactos","metadata":{"user_id":"123"}}'
|
||||
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/upsert/document -H 'Content-Type: application/json' \
|
||||
-d '{"text":"Cuenta 456: lucia.bianchi@otro.example","source":"users/456/contactos","metadata":{"user_id":"456"}}'
|
||||
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/upsert/document -H 'Content-Type: application/json' \
|
||||
-d '{"text":"Horarios secretaría 9-18","source":"shared/horarios"}'
|
||||
```
|
||||
|
||||
```
|
||||
# 2) Usuario 123 se conecta
|
||||
ws://127.0.0.1:12345/v1/realtime?user_id=123
|
||||
```
|
||||
|
||||
El usuario 123 pregunta por voz: *"¿Cuál es mi correo?"* → el RAG devuelve el
|
||||
suyo, **NO** devuelve el correo del 456 (filtrado).
|
||||
|
||||
```
|
||||
# 3) Usuario 456 se conecta
|
||||
ws://127.0.0.1:12345/v1/realtime?user_id=456
|
||||
```
|
||||
|
||||
Misma pregunta → RAG devuelve el correo del 456, no ve el del 123.
|
||||
|
||||
Ambos ven `shared/horarios` porque se incluye por defecto.
|
||||
|
||||
---
|
||||
|
||||
## 8. Compatibilidad con versiones anteriores
|
||||
|
||||
- **Sin parámetros WS**: sigue funcionando igual que antes — no se aplica
|
||||
filtro alguno, toda la KB es visible. Útil para escenarios legacy.
|
||||
- **API `/v1/rag/*` existentes**: firma invariable; los nuevos campos son
|
||||
opcionales (`Optional[...]`), así que scripts preexistentes **no se rompen**.
|
||||
- **Archivos persistidos en disco**: ningún cambio a `_dynamic.jsonl`,
|
||||
`_chunks.jsonl`, `_index.npz` — formato idéntico.
|
||||
|
||||
---
|
||||
|
||||
## 9. Límites actuales y posibles evoluciones
|
||||
|
||||
### 9.1 Scope inmutable durante la sesión
|
||||
|
||||
Actualmente el scope solo se puede fijar en `ws.accept()` por query-string.
|
||||
Una vez conectado el WS no puedes cambiar `user_id`/`roles` a través de
|
||||
`session.update` — tendrías que desconectar y reconectar.
|
||||
|
||||
**Motivo:** `session.update` está tipado como `RealtimeSessionCreateRequest`
|
||||
del paquete `openai-python`, así que no queremos romper compatibilidad con los
|
||||
SDKs adjuntando campos custom no oficiales.
|
||||
|
||||
**Cómo desbloquearlo (opción futura):** crear un evento custom
|
||||
`session.scope_update` (no estándar) en `RealtimeService.parse_client_event`
|
||||
que actualizaría `ConnState.runtime_config.rag_*`.
|
||||
|
||||
### 9.2 Aislamiento soft (lógico), no hard
|
||||
|
||||
Esta es la "Solución A": un solo índice compartido, con filtro vectorizado
|
||||
Python/NumPy. Suficiente para el 90 % de los casos. **No** protege de bugs que
|
||||
se salten el filtro (p. ej. una parche futuro que olvide pasarlo).
|
||||
|
||||
Si hiciera falta aislamiento a nivel de proceso/índice (datos sanitarios o
|
||||
financieros), hace falta la "Solución B": registro de múltiples
|
||||
`RAGRetriever`, uno por tenant, con índices NPZ separados.
|
||||
|
||||
### 9.3 Roles múltiples y permisos jerárquicos
|
||||
|
||||
Hoy el filtro `roles_in` es un OR plano. No existe la noción de jerarquía
|
||||
(p. ej. `supervisor` hereda todos los permisos de `agente`).
|
||||
|
||||
**Solución alternativa:** al rellenar `metadata.roles` escribe todos los roles
|
||||
que pueden ver el chunk, no solo el rol "base". Ej.:
|
||||
|
||||
```json
|
||||
{"roles": ["agente", "supervisor", "admin"]} # en vez de solo ["agente"]
|
||||
```
|
||||
|
||||
### 9.4 Cache de embeddings
|
||||
|
||||
La máscara se recalculo en cada `search()`. Es extremadamente rápida para
|
||||
`n < 500.000 chunks`; por encima, se podría precalcular una bitmask por scope
|
||||
y reutilizarla para sesiones del mismo tipo.
|
||||
|
||||
---
|
||||
|
||||
## 10. Referencias de código
|
||||
|
||||
- Filtro core: [RAGRetriever.search()](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/retriever.py#L380-L489)
|
||||
- Modelo petición HTTP: [SearchRequest](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/router.py#L71-L82)
|
||||
- Campos RuntimeConfig: [RuntimeConfig.rag_*](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/runtime_config.py#L40-L78)
|
||||
- Query-string handler WS: [realtime_endpoint parsing scope](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/websocket_router.py#L626-L667)
|
||||
- Hook LLM con inyección: [_inject_rag_context](file:///home/azurian/speech-to-speech/src/speech_to_speech/LLM/base_openai_compatible_language_model.py#L292-L389)
|
||||
@@ -0,0 +1,378 @@
|
||||
# RAG Multi-Tenant / Scope Utente & Ruolo
|
||||
## Documentazione Tecnica — Vocero S2S v0.2.11+
|
||||
|
||||
> Data: 01/09/2026
|
||||
> Ambito: Modulo RAG Server-Side (`speech_to_speech.RAG`) + Realtime WebSocket
|
||||
|
||||
---
|
||||
|
||||
## 1. Obiettivo
|
||||
|
||||
Questa modifica introduce un **meccanismo di isolamento dello scope** nel RAG del pipeline
|
||||
Speech-to-Speech, per permettere a clienti diversi (utenti loggati, ruoli) di ricevere
|
||||
**solo i chunk di Knowledge Base a loro autorizzati**, senza condividere informazioni
|
||||
private tra sessioni diverse.
|
||||
|
||||
Prima di questo rilascio il RAG era **unico e globale**: ogni sessione WS poteva
|
||||
vedere l'intera KB condivisa. Ora è possibile filtrare il retrieval per:
|
||||
- **`user_id`** (utente logico singolo)
|
||||
- **`roles`** (lista ruoli: agente, supervisore, backoffice…)
|
||||
- **`allowed_source_prefixes`** (prefissi custom sulle `source` dei chunk)
|
||||
- **Metadati arbitrari** (`metadata_filter` con match esatto e `*_in` per liste OR)
|
||||
|
||||
I filtri vengono applicati **PRIMA del ranking** della similarità del coseno (tramite
|
||||
maschera booleana NumPy), quindi non c'è perdita di accuratezza né decadimento
|
||||
prestazioni apprezzabile.
|
||||
|
||||
---
|
||||
|
||||
## 2. Confronto: sistema precedente vs sistema nuovo
|
||||
|
||||
| Criterio | Prima (≤ v0.2.11 vanilla) | Dopo (v0.2.11 + RAG scoping) |
|
||||
|---|---|---|
|
||||
| **Retrieval scope** | Singleton `_GLOBAL_RAG` → tutti vedono tutto | Sempre singleton, ma con `search()` che applica maschera di scope per-sessione |
|
||||
| **Isolamento dati** | ❌ Nessuno: clienti diversi condividono chunk privati (PII) | ✅ Ogni sessione WS dichiara `user_id`/`roles`/`prefixes`; solo chunk autorizzati sono candidati al ranking |
|
||||
| **Uso memoria** | 1 indice, 1 modello embedding | 1 indice, 1 modello embedding (nessun aumento — filtro è O(n) con numpy mask) |
|
||||
| **Popolamento KB** | `source` libera, `metadata` opzionale | `source` e `metadata` devono rispettare una convenzione per i filtri (vedi §4) |
|
||||
| **Modo per dichiarare scope** | ❌ Non esisteva | ✅ **WebSocket query-string** al momento della connessione |
|
||||
| **Test filtri via HTTP** | ❌ `/v1/rag/search` accettava solo query/top_k/threshold | ✅ `/v1/rag/search` ora accetta anche `allowed_source_prefixes` e `metadata_filter` |
|
||||
| **Aggiornamento scope durante sessione** | — | ⚠️ Possibile solo modificando runtime_config (non esposto da `session.update` — vedi §9.1) |
|
||||
| **Hook LLM** | `_inject_rag_context` chiamava `search(query, top_k, threshold)` | Stesso hook, ma passa anche `allowed_prefixes` e `meta_filter` dedotti da `runtime_config` |
|
||||
| **Logging retrieval** | Log conteneva `count` + `sources_summary` | Log include anche `scope_prefixes` e `scope_meta` per audit e debug |
|
||||
|
||||
---
|
||||
|
||||
## 3. Modifiche al codebase
|
||||
|
||||
| File | Tipo | Descrizione |
|
||||
|---|---|---|
|
||||
| `src/speech_to_speech/RAG/retriever.py` | Core search | Firma `search(...)` estesa con `allowed_source_prefixes` e `metadata_filter`. Applicazione maschera numpy booleana prima del ranking. Supporto `*_in` per match OR su liste metadata. |
|
||||
| `src/speech_to_speech/RAG/router.py` | HTTP API | `SearchRequest` e endpoint `POST /v1/rag/search` propagano i nuovi filtri. |
|
||||
| `src/speech_to_speech/api/openai_realtime/runtime_config.py` | Sessione | Aggiunti 3 campi in `RuntimeConfig`: `rag_user_id`, `rag_roles` (CSV coerced → list), `rag_allowed_source_prefixes` (CSV coerced → list). |
|
||||
| `src/speech_to_speech/api/openai_realtime/websocket_router.py` | WS claim | In `realtime_endpoint`, subito dopo `register()` legge i parametri di scope dalla query-string del WS e li scrive nel `ConnState.runtime_config` della sessione. |
|
||||
| `src/speech_to_speech/LLM/base_openai_compatible_language_model.py` | LLM hook | `_inject_rag_context(...)` ora riceve `runtime_config` e costruisce `allowed_prefixes` (shared + roles + user + custom) e `metadata_filter` (`user_id`, `roles_in`), passandoli a `search()`. Log di inject include ora lo scope. |
|
||||
|
||||
Nessun file eliminato; backward-compatibility garantita (tutti i nuovi parametri hanno
|
||||
default `None`, quindi i clienti che non passano scope continuano a vedere TUTTA la KB
|
||||
come prima — comportamento "legacy").
|
||||
|
||||
---
|
||||
|
||||
## 4. Convenzioni: come taggare chunk per utente/ruolo
|
||||
|
||||
### 4.1 Convenzione sul campo `source` (OBBLIGATORIA se usi i prefissi)
|
||||
|
||||
La parte iniziale di `source` determina lo scope di visibilità tramite il filtro
|
||||
`allowed_source_prefixes`. Usa questi prefissi standard:
|
||||
|
||||
```
|
||||
users/<id>/<resto_del_path> # ← solo l'utente <id> vede questi chunk
|
||||
roles/<nome_ruolo>/<resto_del_path> # ← solo utenti con ruolo <nome_ruolo>
|
||||
shared/<resto_del_path> # ← tutti vedono (equivalente a public/)
|
||||
public/<resto_del_path> # ← tutti vedono
|
||||
<altro_qualsiasi>/<path> # ← visibile SOLO se passato esplicitamente in
|
||||
# rag_allowed_source_prefixes al WS connect
|
||||
```
|
||||
|
||||
**Esempi validi:**
|
||||
```
|
||||
users/123/preferenze_utente
|
||||
users/123/anamnesi_2026
|
||||
roles/agente/procedure/apri_ticket
|
||||
roles/supervisore/report/kpi_mensili
|
||||
shared/intro_generale
|
||||
public/orari_helpdesk
|
||||
kb_cliente_alpha/contratti/clusola_23 # ← richiede prefixes=kb_cliente_alpha/ in WS
|
||||
```
|
||||
|
||||
### 4.2 Convenzione su `metadata` (consigliata, usata da `metadata_filter`)
|
||||
|
||||
| Chiave | Tipo | Semantica |
|
||||
|---|---|---|
|
||||
| `user_id` | `str`/`int` | Identificatore utente proprietario. Il filtro automatico WS usa match ESATTO. |
|
||||
| `roles` | `list[str]` | Ruoli autorizzati. Il filtro automatico WS usa `roles_in: [...]` (match OR: basta uno). |
|
||||
| `scopes` | `list[str]` | Opzionale (non usato dal filtro automatico — per logiche custom). |
|
||||
|
||||
**Esempi JSON validi:**
|
||||
```json
|
||||
{"user_id": "123", "note": "dati sensibili"}
|
||||
{"roles": ["agente", "supervisore"], "version": 3}
|
||||
```
|
||||
|
||||
### 4.3 I due filtri non si escludono — lavorano in AND
|
||||
|
||||
`allowed_source_prefixes` e `metadata_filter` vengono uniti con AND
|
||||
(logico): un chunk passa se **sia** inizia con almeno un prefisso consentito
|
||||
**sia** soddisfa il metadata filter (se entrambi sono valorizzati).
|
||||
|
||||
> 💡 Consiglio pratico: usa **solo uno dei due** per non complicarti.
|
||||
> Il sistema WS di default usa **entrambi** per maggiore safety: se sbagli il
|
||||
> prefisso il metadata filter ti protegge comunque da leak, e viceversa.
|
||||
|
||||
---
|
||||
|
||||
## 5. Come si usa
|
||||
|
||||
### 5.1 Caricare chunk con scope
|
||||
|
||||
Usare `/v1/rag/add/document` (splitta automaticamente) o `/v1/rag/add/chunks`
|
||||
(pre-chunkizzati):
|
||||
|
||||
```bash
|
||||
# ---------- Utente singolo 123 ----------
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"text": "Preferenza utente 123: caffè senza zucchero, allergia alle noci.",
|
||||
"source": "users/123/preferenze_utente",
|
||||
"metadata": {"user_id": "123"}
|
||||
}'
|
||||
|
||||
# ---------- Visibile a ruoli agente + supervisore ----------
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"text": "Procedura: per aprire ticket mail a supporto@example.com con ID cliente.",
|
||||
"source": "roles/agente/procedure/apri_ticket",
|
||||
"metadata": {"roles": ["agente", "supervisore"]}
|
||||
}'
|
||||
|
||||
# ---------- Visibile a TUTTI ----------
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"text": "Orari helpdesk: lun-ven 9-18.",
|
||||
"source": "shared/orari_helpdesk",
|
||||
"metadata": {"scopes": ["public"]}
|
||||
}'
|
||||
|
||||
# ---------- Scope custom (es. un intero cliente alpha) ----------
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"text": "Clausola 23 del contratto Alpha: penale 5% per ritardo > 10gg.",
|
||||
"source": "kb_cliente_alpha/contratti/clausola_23",
|
||||
"metadata": {"customer": "alpha"}
|
||||
}'
|
||||
```
|
||||
|
||||
### 5.2 Connettere il WebSocket dichiarando lo scope
|
||||
|
||||
Il sistema di scope viene popolato **al momento della connessione WS** tramite
|
||||
**query-string** (più semplice e standard rispetto a custom field dentro
|
||||
`session.update`).
|
||||
|
||||
| Parametro WS | Alias | Esempio | Spiegazione |
|
||||
|---|---|---|---|
|
||||
| `user_id` | `rag_user_id` | `user_id=123` | Id utente. Genera prefisso `users/123/` e filtro `metadata.user_id=="123"`. |
|
||||
| `roles` | `rag_roles` | `roles=agente,supervisore` | Lista CSV ruoli. Genera prefissi `roles/agente/`, `roles/supervisore/` e filtro `metadata.roles ∩ [agente,supervisore] ≠ ∅`. |
|
||||
| `prefixes` | `allowed_source_prefixes` / `rag_allowed_source_prefixes` | `prefixes=shared/,kb_cliente_alpha/` | Lista CSV prefissi custom AGGIUNTIVI (oltre a user/ruolo/shared) che vuoi forzare visibili. |
|
||||
|
||||
**Esempio URL WS — Utente 123, ruolo agente, deve vedere anche kb_cliente_alpha/**
|
||||
|
||||
```
|
||||
ws://host:12345/v1/realtime?user_id=123&roles=agente&prefixes=kb_cliente_alpha/
|
||||
```
|
||||
|
||||
Cosa viene incluso AUTOMATICAMENTE da questo URL:
|
||||
|
||||
```
|
||||
allowed_source_prefixes = [
|
||||
"shared/", # sempre (builtin pubblico)
|
||||
"public/", # sempre (builtin pubblico)
|
||||
"roles/agente/", # da roles=agente
|
||||
"users/123/", # da user_id=123
|
||||
"kb_cliente_alpha/" # da prefixes=
|
||||
]
|
||||
|
||||
metadata_filter = {
|
||||
"roles_in": ["agente"], # da roles=agente
|
||||
"user_id": "123" # da user_id=123
|
||||
}
|
||||
```
|
||||
|
||||
I due filtri vengono passati al `search()`. Risultato: il retrieval può solo
|
||||
pescare chunk autorizzati.
|
||||
|
||||
### 5.3 Test via HTTP /v1/rag/search
|
||||
|
||||
Per debuggare senza aprire un WS, usa l'endpoint HTTP passando esplicitamente
|
||||
i filtri:
|
||||
|
||||
```bash
|
||||
# Cerca come se fossi "utente 123 con ruolo agente"
|
||||
curl -sS -X POST http://127.0.0.1:12345/v1/rag/search \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"query": "come apro un ticket",
|
||||
"top_k": 5,
|
||||
"threshold": 0.05,
|
||||
"allowed_source_prefixes": ["shared/", "users/123/", "roles/agente/"],
|
||||
"metadata_filter": {
|
||||
"user_id": "123",
|
||||
"roles_in": ["agente", "supervisore"]
|
||||
}
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
Risposta (struttura invariata, solo il pool di chunk su cui viene cercato cambia):
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "come apro un ticket",
|
||||
"count": 1,
|
||||
"results": [
|
||||
{
|
||||
"text": "Procedura: per aprire ticket mail a supporto@example.com...",
|
||||
"source": "roles/agente/procedure/apri_ticket",
|
||||
"chunk_index": 0,
|
||||
"score": 0.71,
|
||||
"metadata": {"roles": ["agente", "supervisore"]}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Cleanup
|
||||
|
||||
Rimuovere tutti i chunk di un utente:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/remove \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"source_prefix": "users/123/", "exact": false, "persist": true}'
|
||||
```
|
||||
|
||||
Rimuovere tutti i chunk di un ruolo:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/remove \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"source_prefix": "roles/backoffice/", "exact": false, "persist": true}'
|
||||
```
|
||||
|
||||
Ricaricare l'indice dopo modifiche a file in `kb/`:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/reload \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"force_rebuild": true}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Logging & Debug
|
||||
|
||||
Cosa cercare nei log PM2 della pipeline:
|
||||
|
||||
| Momento | Livello | Cosa vedrai |
|
||||
|---|---|---|
|
||||
| WS connessione con scope | INFO | `RAG scope caricato da WS — user_id=X roles=Y prefixes=Z` |
|
||||
| Scope parsing fallisce | WARNING | `errore applicazione scope RAG da query-string: ...` |
|
||||
| Nessun chunk trovato per lo scope | DEBUG | `nessun chunk rilevante per turn=... scope_prefixes=... scope_meta=...` |
|
||||
| Retrieval OK (inject nel prompt) | INFO | `iniettati N chunk per turn=... (scope_prefixes=..., scope_meta=...)` |
|
||||
|
||||
Comodo per vedere gli scope attuali in esecuzione:
|
||||
```bash
|
||||
pm2 logs start_pipeline_rag --lines 200 | grep -i "RAG scope"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Esempio end-to-end completo
|
||||
|
||||
**Scenario:** Un CRM esterno apre 2 sessioni WS: una per l'utente 123 (con email
|
||||
privata nel RAG) e una per utente 456.
|
||||
|
||||
```bash
|
||||
# 1) Popolo KB
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/upsert/document -H 'Content-Type: application/json' \
|
||||
-d '{"text":"Utenza 123: mario.rossi@secretmail.example","source":"users/123/contatti","metadata":{"user_id":"123"}}'
|
||||
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/upsert/document -H 'Content-Type: application/json' \
|
||||
-d '{"text":"Utenza 456: lucia.bianchi@altro.example","source":"users/456/contatti","metadata":{"user_id":"456"}}'
|
||||
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/upsert/document -H 'Content-Type: application/json' \
|
||||
-d '{"text":"Orari segreteria 9-18","source":"shared/orari"}'
|
||||
```
|
||||
|
||||
```
|
||||
# 2) Utente 123 si connette
|
||||
ws://127.0.0.1:12345/v1/realtime?user_id=123
|
||||
```
|
||||
|
||||
Utente 123 dice a voce: *"Qual è la mia email?"* → RAG restituisce la sua,
|
||||
**non** restituisce quella di 456 (filtrata).
|
||||
|
||||
```
|
||||
# 3) Utente 456 si connette
|
||||
ws://127.0.0.1:12345/v1/realtime?user_id=456
|
||||
```
|
||||
|
||||
Stessa domanda → RAG restituisce la mail di 456, non vede 123.
|
||||
|
||||
Entrambe vedono `shared/orari` perché viene incluso di default.
|
||||
|
||||
---
|
||||
|
||||
## 8. Backward compatibility
|
||||
|
||||
- **Senza parametri WS**: continua a funzionare come prima — nessun filtro viene
|
||||
applicato, tutta la KB è visibile. Utile per scenari legacy.
|
||||
- **API `/v1/rag/*` esistenti**: invariata la loro firma; i nuovi campi sono
|
||||
opzionali (`Optional[...]`), quindi script pre-esistenti non si rompono.
|
||||
- **File persistiti su disco**: nessun cambiamento a `_dynamic.jsonl`,
|
||||
`_chunks.jsonl`, `_index.npz` — formato identico.
|
||||
|
||||
---
|
||||
|
||||
## 9. Limiti attuali & possibili evoluzioni
|
||||
|
||||
### 9.1 Scope non modificabile durante la sessione
|
||||
|
||||
Lo scope oggi viene impostato **solo al `ws.accept()`** tramite query-string.
|
||||
Una volta connesso il WS, non puoi cambiare `user_id`/`roles` con `session.update`
|
||||
— dovresti disconnettere e riconnettere.
|
||||
|
||||
**Perché:** `session.update` è tipizzato `RealtimeSessionCreateRequest` di
|
||||
`openai-python`, quindi non vogliamo rompere la compatibilità con gli SDK
|
||||
attaccando campi custom non ufficiali.
|
||||
|
||||
**Come sbloccarlo (opzione futura):** creare un evento custom
|
||||
`session.scope_update` (non standard) in `RealtimeService.parse_client_event`
|
||||
che aggiornarebbe `ConnState.runtime_config.rag_*`.
|
||||
|
||||
### 9.2 Isolamento soft (logico), non hard
|
||||
|
||||
Questa è la "Soluzione A": un solo indice condiviso, con filtro vettorizzato
|
||||
Python/NumPy. È sufficiente per il 90% dei casi. **Non** protegge da bug che
|
||||
saltano il filtro (es. una patch futura che dimentica di passarlo).
|
||||
|
||||
Se servisse l'isolamento a livello di processo/indice (es. dati sanitari o
|
||||
finanziari), serve la "Soluzione B": registry di `RAGRetriever` multipli,
|
||||
uno per tenant, con indici NPZ separati.
|
||||
|
||||
### 9.3 Ruoli multipli e permessi gerarchici
|
||||
|
||||
Oggi il filtro `roles_in` è un OR piatto. Non esiste la nozione di gerarchia
|
||||
(es. `supervisore` eredita tutti i permessi di `agente`).
|
||||
|
||||
**Workaround:** quando popoli `metadata.roles` scrivi tutti i ruoli che
|
||||
possono vedere il chunk, non solo il ruolo "base". Es.:
|
||||
|
||||
```json
|
||||
{"roles": ["agente", "supervisore", "admin"]} # invece di solo ["agente"]
|
||||
```
|
||||
|
||||
### 9.4 Cache embeddings
|
||||
|
||||
La mask viene calcolata ad ogni `search()`. È estremamente veloce per
|
||||
`n < 500.000 chunk`; oltre, si potrebbe precalcolare una bitmask per scope
|
||||
e riutilizzarla per sessioni dello stesso tipo.
|
||||
|
||||
---
|
||||
|
||||
## 10. Riferimenti codice
|
||||
|
||||
- Core filtro: [RAGRetriever.search()](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/retriever.py#L380-L489)
|
||||
- Modello richiesta HTTP: [SearchRequest](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/router.py#L71-L82)
|
||||
- Campi RuntimeConfig: [RuntimeConfig.rag_*](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/runtime_config.py#L40-L78)
|
||||
- Query-string WS handler: [realtime_endpoint scope parsing](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/websocket_router.py#L626-L667)
|
||||
- Hook LLM con injection: [_inject_rag_context](file:///home/azurian/speech-to-speech/src/speech_to_speech/LLM/base_openai_compatible_language_model.py#L292-L389)
|
||||
@@ -0,0 +1,377 @@
|
||||
# Multi-Tenant RAG / User & Role Scoping
|
||||
## Technical Documentation — Vocero S2S v0.2.11+
|
||||
|
||||
> Date: 01/09/2026
|
||||
> Scope: RAG Server-Side module (`speech_to_speech.RAG`) + Realtime WebSocket
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
This change introduces a **scope isolation mechanism** into the Speech-to-Speech
|
||||
pipeline RAG, enabling different clients (logged-in users, roles) to receive
|
||||
**only the Knowledge Base chunks they are authorised to access**, without
|
||||
sharing private information across different sessions.
|
||||
|
||||
Before this release the RAG was **single and global**: every WS session could
|
||||
see the entire shared KB. Now the retrieval can be filtered by:
|
||||
- **`user_id`** (single logical user)
|
||||
- **`roles`** (role list: agent, supervisor, backoffice…)
|
||||
- **`allowed_source_prefixes`** (custom prefixes on chunk `source` fields)
|
||||
- **Arbitrary metadata** (`metadata_filter` with exact match + `*_in` suffix
|
||||
for OR-list matches)
|
||||
|
||||
Filters are applied **BEFORE** the cosine-similarity ranking (via NumPy boolean
|
||||
mask), so there is no accuracy loss or measurable performance penalty.
|
||||
|
||||
---
|
||||
|
||||
## 2. Comparison: old system vs new system
|
||||
|
||||
| Criterion | Before (≤ v0.2.11 vanilla) | After (v0.2.11 + RAG scoping) |
|
||||
|---|---|---|
|
||||
| **Retrieval scope** | `_GLOBAL_RAG` singleton → everybody sees everything | Still a singleton, but `search()` applies a per-session scope mask |
|
||||
| **Data isolation** | ❌ None: different clients share private chunks (PII) | ✅ Each WS session declares `user_id`/`roles`/`prefixes`; only authorised chunks become ranking candidates |
|
||||
| **Memory usage** | 1 index, 1 embedding model | 1 index, 1 embedding model (no increase — filter is O(n) with a numpy mask) |
|
||||
| **KB population** | `source` is free-form, `metadata` is optional | `source` and `metadata` must follow a convention for filters (see §4) |
|
||||
| **How to declare scope** | ❌ Did not exist | ✅ **WebSocket query-string** at connection time |
|
||||
| **Test filters via HTTP** | ❌ `/v1/rag/search` only accepted query/top_k/threshold | ✅ `/v1/rag/search` now also accepts `allowed_source_prefixes` and `metadata_filter` |
|
||||
| **Hot-patch scope mid-session** | — | ⚠️ Possible only by mutating `runtime_config` directly (not exposed via `session.update` — see §9.1) |
|
||||
| **LLM hook** | `_inject_rag_context` called `search(query, top_k, threshold)` | Same hook, but additionally passes `allowed_prefixes` and `meta_filter` derived from `runtime_config` |
|
||||
| **Retrieval logging** | Log contained `count` + `sources_summary` | Log now also includes `scope_prefixes` and `scope_meta` for audit & debugging |
|
||||
|
||||
---
|
||||
|
||||
## 3. Codebase changes
|
||||
|
||||
| File | Layer | Description |
|
||||
|---|---|---|
|
||||
| `src/speech_to_speech/RAG/retriever.py` | Search core | Extended `search(...)` signature with `allowed_source_prefixes` and `metadata_filter`. Applies a NumPy boolean mask before ranking. Supports `*_in` OR-list semantics on metadata. |
|
||||
| `src/speech_to_speech/RAG/router.py` | HTTP API | `SearchRequest` schema + `POST /v1/rag/search` endpoint now forward the new filters. |
|
||||
| `src/speech_to_speech/api/openai_realtime/runtime_config.py` | Session state | Added 3 fields on `RuntimeConfig`: `rag_user_id`, `rag_roles` (CSV coerced → list), `rag_allowed_source_prefixes` (CSV coerced → list). |
|
||||
| `src/speech_to_speech/api/openai_realtime/websocket_router.py` | WS claim | Inside `realtime_endpoint`, right after `register()`, scope parameters are read from the WS query-string and written into the session's `ConnState.runtime_config`. |
|
||||
| `src/speech_to_speech/LLM/base_openai_compatible_language_model.py` | LLM hook | `_inject_rag_context(...)` now receives `runtime_config`, builds `allowed_prefixes` (shared + roles + user + custom) and `metadata_filter` (`user_id`, `roles_in`), then forwards them to `search()`. The inject log now also reports the actual scope. |
|
||||
|
||||
No files were removed; backward compatibility is preserved (all new parameters
|
||||
default to `None`, so clients that send no scope continue to see the **entire**
|
||||
KB exactly like before — "legacy" behaviour).
|
||||
|
||||
---
|
||||
|
||||
## 4. Conventions: how to tag chunks per user/role
|
||||
|
||||
### 4.1 `source` field convention (MANDATORY if you use prefix-based scoping)
|
||||
|
||||
The leading part of `source` controls visibility through the
|
||||
`allowed_source_prefixes` filter. Use these standard prefixes:
|
||||
|
||||
```
|
||||
users/<id>/<rest_of_path> # ← only user <id> sees these chunks
|
||||
roles/<role_name>/<rest_of_path> # ← only users with <role_name> see them
|
||||
shared/<rest_of_path> # ← everyone sees them (same as public/)
|
||||
public/<rest_of_path> # ← everyone sees them
|
||||
<anything_else>/<rest_of_path> # ← visible ONLY if explicitly passed into
|
||||
# rag_allowed_source_prefixes at WS connect
|
||||
```
|
||||
|
||||
**Valid examples:**
|
||||
```
|
||||
users/123/user_preferences
|
||||
users/123/anamnesis_2026
|
||||
roles/agent/procedure/open_ticket
|
||||
roles/supervisor/reports/monthly_kpis
|
||||
shared/general_intro
|
||||
public/helpdesk_hours
|
||||
kb_customer_alpha/contracts/clause_23 # ← needs prefixes=kb_customer_alpha/ in WS
|
||||
```
|
||||
|
||||
### 4.2 `metadata` convention (recommended — used by `metadata_filter`)
|
||||
|
||||
| Key | Type | Semantics |
|
||||
|---|---|---|
|
||||
| `user_id` | `str`/`int` | Owner user identifier. The WS auto-filter uses EXACT match. |
|
||||
| `roles` | `list[str]` | Allowed roles. The WS auto-filter uses `roles_in: [...]` (OR-match: any match passes). |
|
||||
| `scopes` | `list[str]` | Optional (not used by the automatic filter — for custom business logic). |
|
||||
|
||||
**Valid JSON examples:**
|
||||
```json
|
||||
{"user_id": "123", "note": "sensitive data"}
|
||||
{"roles": ["agent", "supervisor"], "version": 3}
|
||||
```
|
||||
|
||||
### 4.3 The two filters don't exclude — they AND together
|
||||
|
||||
`allowed_source_prefixes` and `metadata_filter` are combined with logical AND:
|
||||
a chunk passes only if **both** coincide.
|
||||
|
||||
> 💡 Practical tip: stick to **only one mechanism** to keep things simple.
|
||||
> The default WS plumbing uses **both** for defence in depth — if you mess up
|
||||
> the prefix, the metadata filter still prevents leakage, and vice versa.
|
||||
|
||||
---
|
||||
|
||||
## 5. How to use it
|
||||
|
||||
### 5.1 Upload scoped chunks
|
||||
|
||||
Use `/v1/rag/add/document` (auto-chunked) or `/v1/rag/add/chunks`
|
||||
(pre-chunked):
|
||||
|
||||
```bash
|
||||
# ---------- Single user 123 ----------
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"text": "User 123 preferences: coffee without sugar, nut allergy.",
|
||||
"source": "users/123/user_preferences",
|
||||
"metadata": {"user_id": "123"}
|
||||
}'
|
||||
|
||||
# ---------- Visible to roles agent + supervisor ----------
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"text": "Procedure: to open a ticket, email support@example.com with the customer ID.",
|
||||
"source": "roles/agent/procedure/open_ticket",
|
||||
"metadata": {"roles": ["agent", "supervisor"]}
|
||||
}'
|
||||
|
||||
# ---------- Visible to EVERYBODY ----------
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"text": "Helpdesk hours: Mon-Fri 9-18.",
|
||||
"source": "shared/helpdesk_hours",
|
||||
"metadata": {"scopes": ["public"]}
|
||||
}'
|
||||
|
||||
# ---------- Custom scope (e.g. a whole customer tenant) ----------
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"text": "Alpha contract clause 23: 5% penalty for delay > 10 days.",
|
||||
"source": "kb_customer_alpha/contracts/clause_23",
|
||||
"metadata": {"customer": "alpha"}
|
||||
}'
|
||||
```
|
||||
|
||||
### 5.2 Connect the WebSocket declaring the scope
|
||||
|
||||
Scope is populated **at WS connection time** via **query-string** (simpler and
|
||||
more standard than stuffing custom fields inside `session.update`).
|
||||
|
||||
| WS param | Aliases | Example | Explanation |
|
||||
|---|---|---|---|
|
||||
| `user_id` | `rag_user_id` | `user_id=123` | User id. Produces prefix `users/123/` + filter `metadata.user_id=="123"`. |
|
||||
| `roles` | `rag_roles` | `roles=agent,supervisor` | CSV role list. Produces prefixes `roles/agent/`, `roles/supervisor/` + filter `metadata.roles ∩ [agent,supervisor] ≠ ∅`. |
|
||||
| `prefixes` | `allowed_source_prefixes` / `rag_allowed_source_prefixes` | `prefixes=shared/,kb_customer_alpha/` | CSV list of EXTRA custom prefixes to add on top of user/role/shared. |
|
||||
|
||||
**Example WS URL — User 123, role agent, also needs kb_customer_alpha/**
|
||||
|
||||
```
|
||||
ws://host:12345/v1/realtime?user_id=123&roles=agent&prefixes=kb_customer_alpha/
|
||||
```
|
||||
|
||||
What the system AUTOMATICALLY includes from this URL:
|
||||
|
||||
```
|
||||
allowed_source_prefixes = [
|
||||
"shared/", # always (builtin public)
|
||||
"public/", # always (builtin public)
|
||||
"roles/agent/", # from roles=agent
|
||||
"users/123/", # from user_id=123
|
||||
"kb_customer_alpha/" # from prefixes=
|
||||
]
|
||||
|
||||
metadata_filter = {
|
||||
"roles_in": ["agent"], # from roles=agent
|
||||
"user_id": "123" # from user_id=123
|
||||
}
|
||||
```
|
||||
|
||||
Both filters are passed to `search()`. Result: retrieval can only pick from
|
||||
authorised chunks.
|
||||
|
||||
### 5.3 Test via HTTP /v1/rag/search
|
||||
|
||||
To debug without opening a WS, hit the HTTP endpoint by explicitly sending the
|
||||
filters:
|
||||
|
||||
```bash
|
||||
# Search as if we were "user 123 with role agent"
|
||||
curl -sS -X POST http://127.0.0.1:12345/v1/rag/search \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"query": "how do I open a ticket",
|
||||
"top_k": 5,
|
||||
"threshold": 0.05,
|
||||
"allowed_source_prefixes": ["shared/", "users/123/", "roles/agent/"],
|
||||
"metadata_filter": {
|
||||
"user_id": "123",
|
||||
"roles_in": ["agent", "supervisor"]
|
||||
}
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
Response (structure is unchanged — only the candidate chunk pool changes):
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "how do I open a ticket",
|
||||
"count": 1,
|
||||
"results": [
|
||||
{
|
||||
"text": "Procedure: to open a ticket, email support@example.com...",
|
||||
"source": "roles/agent/procedure/open_ticket",
|
||||
"chunk_index": 0,
|
||||
"score": 0.71,
|
||||
"metadata": {"roles": ["agent", "supervisor"]}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Cleanup
|
||||
|
||||
Delete every chunk for one user:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/remove \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"source_prefix": "users/123/", "exact": false, "persist": true}'
|
||||
```
|
||||
|
||||
Delete every chunk for one role:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/remove \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"source_prefix": "roles/backoffice/", "exact": false, "persist": true}'
|
||||
```
|
||||
|
||||
Reload the index after editing files inside `kb/`:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/reload \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"force_rebuild": true}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Logging & Debugging
|
||||
|
||||
What to look for in the PM2 pipeline logs:
|
||||
|
||||
| Event | Level | You'll see |
|
||||
|---|---|---|
|
||||
| WS connection with scope | INFO | `RAG scope loaded from WS — user_id=X roles=Y prefixes=Z` |
|
||||
| Scope parsing failed | WARNING | `error applying RAG scope from query-string: ...` |
|
||||
| No chunk matched the scope | DEBUG | `no relevant chunks for turn=... scope_prefixes=... scope_meta=...` |
|
||||
| Retrieval OK (prompt injection) | INFO | `injected N chunks for turn=... (scope_prefixes=..., scope_meta=...)` |
|
||||
|
||||
Handy one-liner to watch active scopes live:
|
||||
```bash
|
||||
pm2 logs start_pipeline_rag --lines 200 | grep -i "RAG scope"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Full end-to-end example
|
||||
|
||||
**Scenario:** An external CRM opens 2 WS sessions: one for user 123 (private
|
||||
email stored in the RAG) and one for user 456.
|
||||
|
||||
```bash
|
||||
# 1) Seed the KB
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/upsert/document -H 'Content-Type: application/json' \
|
||||
-d '{"text":"Account 123: mario.rossi@secretmail.example","source":"users/123/contacts","metadata":{"user_id":"123"}}'
|
||||
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/upsert/document -H 'Content-Type: application/json' \
|
||||
-d '{"text":"Account 456: lucia.bianchi@other.example","source":"users/456/contacts","metadata":{"user_id":"456"}}'
|
||||
|
||||
curl -X POST http://127.0.0.1:12345/v1/rag/upsert/document -H 'Content-Type: application/json' \
|
||||
-d '{"text":"Secretary office hours 9-18","source":"shared/hours"}'
|
||||
```
|
||||
|
||||
```
|
||||
# 2) User 123 connects
|
||||
ws://127.0.0.1:12345/v1/realtime?user_id=123
|
||||
```
|
||||
|
||||
User 123 asks by voice: *"What is my email address?"* → the RAG returns theirs,
|
||||
**NOT** user 456's (filtered out).
|
||||
|
||||
```
|
||||
# 3) User 456 connects
|
||||
ws://127.0.0.1:12345/v1/realtime?user_id=456
|
||||
```
|
||||
|
||||
Same question → the RAG returns user 456's email, never sees user 123's.
|
||||
|
||||
Both users see `shared/hours` because it is force-included by default.
|
||||
|
||||
---
|
||||
|
||||
## 8. Backward compatibility
|
||||
|
||||
- **With no WS params**: continues to behave exactly like before — no filter is
|
||||
applied, the entire KB is visible. Good for legacy scenarios.
|
||||
- **Existing `/v1/rag/*` APIs**: signatures unchanged; new fields are
|
||||
optional (`Optional[...]`) so pre-existing scripts **do not break**.
|
||||
- **On-disk persisted files**: no changes to `_dynamic.jsonl`,
|
||||
`_chunks.jsonl`, `_index.npz` — format is byte-identical.
|
||||
|
||||
---
|
||||
|
||||
## 9. Current limits & possible evolutions
|
||||
|
||||
### 9.1 Scope cannot be changed mid-session
|
||||
|
||||
Today scope can only be fixed at `ws.accept()` via the query-string. Once the
|
||||
WS is connected you cannot update `user_id`/`roles` through `session.update`
|
||||
— you'd have to disconnect and reconnect.
|
||||
|
||||
**Why:** `session.update` is typed as `RealtimeSessionCreateRequest` from the
|
||||
`openai-python` package, and we don't want to break SDK compatibility by
|
||||
attaching unofficial custom fields to it.
|
||||
|
||||
**How to unlock it (future option):** add a custom event
|
||||
`session.scope_update` (non-standard) inside
|
||||
`RealtimeService.parse_client_event` that mutates
|
||||
`ConnState.runtime_config.rag_*`.
|
||||
|
||||
### 9.2 Soft (logical) isolation, not hard
|
||||
|
||||
This is "Solution A": one shared index with a Python/NumPy vectorised filter.
|
||||
Sufficient for 90% of scenarios. It **does not** protect you from bugs that
|
||||
skip the filter (e.g. a future patch that forgets to thread it through).
|
||||
|
||||
If you need process-level / index-level isolation (healthcare or finance data),
|
||||
you need "Solution B": a registry of multiple `RAGRetriever` instances, one
|
||||
per tenant, with separate NPZ indexes.
|
||||
|
||||
### 9.3 Multiple roles, but no permission hierarchy
|
||||
|
||||
Today the `roles_in` filter is a flat OR. There is no concept of hierarchy
|
||||
(e.g. `supervisor` inherits every `agent` permission).
|
||||
|
||||
**Workaround:** when writing `metadata.roles` list every role that should be
|
||||
allowed to read that chunk, not only the "base" one. E.g.:
|
||||
|
||||
```json
|
||||
{"roles": ["agent", "supervisor", "admin"]} # instead of just ["agent"]
|
||||
```
|
||||
|
||||
### 9.4 Embeddings cache
|
||||
|
||||
The mask is recomputed on every `search()`. It is overwhelmingly fast up to
|
||||
`n < 500.000 chunks`; beyond that you could pre-compute one bitmask per
|
||||
discrete scope type and reuse it across sessions of the same kind.
|
||||
|
||||
---
|
||||
|
||||
## 10. Code references
|
||||
|
||||
- Core filter: [RAGRetriever.search()](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/retriever.py#L380-L489)
|
||||
- HTTP request schema: [SearchRequest](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/router.py#L71-L82)
|
||||
- RuntimeConfig scope fields: [RuntimeConfig.rag_*](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/runtime_config.py#L40-L78)
|
||||
- WS query-string handler: [realtime_endpoint scope parsing](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/websocket_router.py#L626-L667)
|
||||
- LLM hook with injection: [_inject_rag_context](file:///home/azurian/speech-to-speech/src/speech_to_speech/LLM/base_openai_compatible_language_model.py#L292-L389)
|
||||
Reference in New Issue
Block a user