Files
valenti f45a6e9c54
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
before language fix
2026-09-11 09:16:58 +00:00

15 KiB

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:

{"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):

# ---------- 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:

# 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):

{
  "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:

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:

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/:

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:

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.

# 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.:

{"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