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

This commit is contained in:
valenti
2026-09-11 09:16:58 +00:00
parent ecb24e916d
commit f45a6e9c54
38 changed files with 3036 additions and 62 deletions
+378
View File
@@ -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)