Files
vocero-client/s2s-client/INJECTIONS.md
T

499 lines
20 KiB
Markdown

# Inject pipeline — caller context & reference material
Documento ufficiale di tutti i blocchi di contesto che il server inietta
automaticamente nel prompt LLM **all'inizio di ogni sessione**, sia per il
canale **WebSocket realtime** (`/v1/realtime`, patch di `session.update`) sia
per l'endpoint **HTTP reasoning** (`POST /api/reasoning`, augmentation di
`instructions` e `user_context`).
Tutto il sistema è **generico / deploy-agnostic** per scelta architetturale:
nessun campo viene hardcodato. Ogni blocco accetta payload arbitrari e li
serializza in JSON direttamente. Qualsiasi errore di rete / JSON non
interrompe mai il flusso: il blocco problematico viene solo saltato con
una riga `[INJECT-*] ⏭️ skipped` nel log server.
---
## 0. Master flag: `S2S_INJECT_PROFILE`
Tutta la pipeline è sotto **un unico master flag** lato server.
| Valore env `S2S_INJECT_PROFILE` | Comportamento |
|---|---|
| `false` / non impostato | **Pure passthrough.** Nessuna fetch upstream, nessuna modifica a `session.update` o al body `/api/reasoning`. Il proxy passa tutto invariato. |
| `true` / `1` / `yes` / `on` | Pipeline inject attiva. I singoli blocchi poi dipendono dagli env specifici e dai query params della URL del client. |
Perché esiste: su deploy generici (nessuna integrazione con AzurianLab /
Aquachile / sistemi interni) il client rimane un puro speech-to-speech
passthrough, senza dipendere da endpoint esterni.
---
## 1. Blocco episodio podcast — `episode_id`
**Priorità massima.** Quando c'è, viene considerato il riferimento PRINCIPALE
e più fidato per tutta la sessione.
### Trigger (quando si attiva)
Una qualunque delle due:
1. URL pagina browser → query string `?episode_id=episode-xxx`
2. Body `POST /api/reasoning` → campo `episode_id: "episode-xxx"`
### Sorgente dati
GET plain verso l'endpoint Aquachile AzurianLab:
```
https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}
```
Parametri fetch:
- timeout 15 s
- `follow_redirects: true`
- **Nessuna auth** (endpoint pubblico)
Formato risposta accettato:
- `Content-Type: application/json` → oggetto arbitrario o array
- `{"result": <payload>}` → wrapper supportato, il contenuto viene "spacchettato"
- `text/plain` → usato come stringa raw
### Cap
24.000 caratteri dopo serializzazione JSON (transcript lunghi).
Se supera → troncato con `...` append.
### Wrap applicato
```
< PODCAST_EPISODE_HINT >
<<<PODCAST_EPISODE_BEGIN>>>
{ <TUTTI I CAMPI DELL'EPISODIO, JSON> }
<<<PODCAST_EPISODE_END>>>. This is the episode the caller is currently interacting with — answer with reference to it first.
```
**Hint testuale** (in testa, letto dal modello come primissima frase del blocco):
> Podcast episode context loaded from the page `episode_id` query param (current episode the user is referencing). Treat the episode fields (title, description, transcript, script, show notes, guests, audio metadata, etc.) as the **PRIMARY trusted reference material** for this session.
### Campi supportati (esempi — lista NON esaustiva, qualsiasi campo extra viene incluso)
- metadati base: `id`, `title`, `slug`, `description`, `summary`, `publishedAt`, `durationSeconds`
- contenuti: `transcript`, `podcastScript`, `script`, `chapters[]`, `showNotes`
- persone: `host`, `guests[]`, `author`, `guestsInfo[]`
- audio: `audioUrl`, `audioFile`, `waveform`, `sampleRate`
- classificazione: `tags[]`, `topics[]`, `category`, `season`, `episodeNumber`
- riferimenti incrociati: `reportId`, `scheduleId`, `company`, `product`
### Riferimenti codice
- fetch upstream: [_fetch_podcast_episode](file:///home/duxiter/s2s/s2s-client/server.py#L1010-L1078)
- wrap + serialize + cap 24k: [_resolve_episode_context](file:///home/duxiter/s2s/s2s-client/server.py#L475-L504)
- hint costante: [PODCAST_EPISODE_HINT](file:///home/duxiter/s2s/s2s-client/server.py#L426-L429)
---
## 2. Blocco profilo chiamante — `phoneNumber`
Contesto identità del chiamante. Viene usato per il modello quando risponde
(riferirsi all'utente per nome, conoscere la sua azienda, ruolo, ecc.).
### Trigger
1. URL browser → `?phoneNumber=%2B<numero-internazionale>`
2. Body `/api/reasoning` → `phoneNumber: "+…"`
### Sorgente dati
```
GET VOCERO_USER_PHONE_AUTH_URL?phoneNumber=<phone>
```
Se è configurato anche `S2S_LOGIN_BY_PHONE`, PRIMA della profile fetch:
```
POST S2S_LOGIN_BY_PHONE body { "phoneNumber": "..." }
```
Da questa risposta il proxy estrae un bearer token e lo inoltra come
`Authorization: Bearer <token>` sulle chiamate successive (profile, reports,
summary).
Formato risposta: JSON arbitrario, wrapper `{"result": ...}` supportato.
### Cap
1.600 caratteri dopo JSON serialize.
### Wrap
```
Caller profile resolved from the page phoneNumber:
{ <profilo JSON troncato> }.
Use this as trusted caller context when relevant.
```
### Riferimenti codice
- login by phone: [_login_by_phone](file:///home/duxiter/s2s/s2s-client/server.py#L812-L890)
- fetch profile: [_fetch_user_profile_for_phone](file:///home/duxiter/s2s/s2s-client/server.py#L1081-L1134)
- wrap serialize: [_resolve_user_context_text](file:///home/duxiter/s2s/s2s-client/server.py#L432-L444)
---
## 3. Blocco lista reports / script schedulati
Riferimento "storico" dei contenuti preparati prima della sessione. Non sono
contesto live, ma materiale che il modello deve sapere esiste (e a cui
riferirsi se l'utente chiede di report pregressi, script, podcast schedulati).
### Trigger
Solo env: `S2S_REPORT_LIST` deve essere valorizzato a un URL valido.
**Funziona anche SENZA `phoneNumber`** (lista reports generica per tutti).
Se `phoneNumber` è presente lo passa come query param all'endpoint.
### Sorgente dati
```
GET S2S_REPORT_LIST[?phoneNumber=<phone>]
```
Wrapper `{"result": [...]}` supportato.
### Cap
12.000 caratteri sul blocco COMPLETO (ogni podcast script può essere lungo).
### Wrap + filtri + sort
```
Scheduled reports / podcast scripts available for this caller before the current session.
Each entry is a scheduled report; use the podcastScript contents as trusted reference material
when the user asks about scheduled topics, reports, or prior content.
<<<REPORTS_LIST_BEGIN>>>
[
{
"sourceScheduleLabel": "...",
"date": "2026-09-06T…",
"podcastScript": "…"
},
… ordinati per data DESC (nuovi prima) …
]
<<<REPORTS_LIST_END>>>
```
Regole per ogni entry:
1. **Solo dict** vengono tenuti (stringhe/int scartati).
2. Per ogni entry estrae 3 campi con fallback a nested shape:
- Label: `sourceScheduleLabel` oppure `sourceSchedule.label`
- Date: `date` oppure `sourceSchedule.date`
- Script: `podcastScript`
3. Ordinamento **data DESC** (nuovi prima) con parser robusto:
- ISO datetime / date strings
- `Date` / `DateTime` Python (se deserializzati)
- Unix timestamp int/float
- Lexicografico DESC come fallback per stringhe ISO-ish
- Valori nulli / non-parse-abili finiscono in fondo.
### Riferimenti codice
- fetch: [_fetch_reports_list](file:///home/duxiter/s2s/s2s-client/server.py#L668-L745)
- parse data sort key: [_parse_date_key](file:///home/duxiter/s2s/s2s-client/server.py#L507-L515)
- wrap + filtro + sort: [_resolve_reports_context](file:///home/duxiter/s2s/s2s-client/server.py#L518-L666)
---
## 4. Blocco summary ultima conversazione
Sintesi della chiamata/chat IMMEDIATAMENTE precedente a questa. Usato dal
modello per "ricordarsi" cosa si è detto l'ultima volta senza conservare
storico lunghissimo nel prompt.
### Trigger
Entrambe le alternative sono mutuamente esclusive: `S2S_LATEST_SUMMARY_TEXT`
ha **priorità assoluta** se valorizzato.
| Modalità | Trigger |
|---|---|
| **A — HTTP fetch (dinamico per caller)** | env `S2S_LATEST_SUMMARY` impostato **AND** `phoneNumber` presente in URL/body |
| **B — Testo statico hardcoded (override)** | env `S2S_LATEST_SUMMARY_TEXT` impostato (stringa non vuota) — **ignora completamente** `S2S_LATEST_SUMMARY`, `phoneNumber`, login-by-phone, Bearer auth. Utile quando l'operatore vuole iniettare una versione **pulita, essenziale e pre-pulita** del summary, senza dipendere da nessun endpoint upstream. |
### Sorgente dati
**Modalità A (HTTP fetch):**
```
GET S2S_LATEST_SUMMARY?phoneNumber=<phone>
```
Wrapper `{"result": <valore>}` supportato.
Accetta SIA JSON che plain text (diversi backend summary ritornano testo semplice).
**Modalità B (override statico):**
- Nessuna chiamata HTTP. Viene usato direttamente il valore grezzo di `S2S_LATEST_SUMMARY_TEXT` dal file `.env`.
- Le sequenze di escape `\n` nel valore env vengono **interpretate** come a-capo vero (stesso comportamento di `S2S_PRIMARY_PROMPT`), quindi è possibile scrivere summary multi-line in una sola riga del `.env`.
- Esempio:
```env
S2S_LATEST_SUMMARY_TEXT=Última conversación 2026-09-22:\n- Usuario preguntó por mortalidad salmón AquaChile semana 38.\n- Se refirió al reporte Porcentaje Mortalidad.
```
- Il valore viene passato as-is a `_resolve_latest_summary_context` → il wrapper `LATEST_SUMMARY_HINT` e il cap a 1600 caratteri vengono applicati normalmente.
### Cap
1.600 caratteri (applicato da `_resolve_latest_summary_context` sia in modalità A che B).
### Wrap
```
Latest conversation summary for this caller (most recent before the current session):
<<<LATEST_SUMMARY_BEGIN>>>
<summary, stringa o JSON serializzato>
<<<LATEST_SUMMARY_END>>>.
Use this as trusted history when the user references prior calls.
```
### Riferimenti codice
- fetch HTTP (modalità A): [_fetch_latest_summary_for_phone](file:///home/duxiter/s2s/s2s-client/server.py#L1563-L1654)
- override statico (modalità B) → riga iniziale `if S2S_LATEST_SUMMARY_TEXT:` dentro la funzione sopra
- wrap: [_resolve_latest_summary_context](file:///home/duxiter/s2s/s2s-client/server.py#L987-L1012)
---
## 5. Blocco `user_context` custom dal browser (SOLO `/api/reasoning`)
**Canale solo HTTP reasoning.**
Non è nel WS: il WS non ha campo user_context nel protocollo realtime
OpenAI (tutto passa per `session.instructions`).
Composizione lato client in [main.js fetchOpenAiReasoningAnswer](file:///home/duxiter/s2s/s2s-client/main.js#L1337-L1364):
```js
const profileCtx = voceroUserContextText();
const ragCtx = customRagContextText();
const user_context = [profileCtx, ragCtx]
.map(s => s.trim()).filter(Boolean).join("\n\n");
```
### Dove va a finire
Nel body a monte sia NODE_RED sia OPENAI, questo blocco viene **APPESO DOPO**
tutti i blocchi iniettati server-side:
```
user_context finale =
[1. episode] ← solo se episode_id
[2. profile] ← solo se phoneNumber + env profilatura
[3. reports] ← solo se env S2S_REPORT_LIST
[4. summary] ← solo se phoneNumber + S2S_LATEST_SUMMARY
+ browser user_context (profileCtx + ragCtx)
```
---
## Ordine finale nel prompt LLM
Questa è la sequenza **letterale** con cui i blocchi vengono concatenati
dentro `session.instructions` (WS) e in `instructions` / `user_context`
(reasoning HTTP).
```
1. EPISODIO PODCAST ← PRIORITÀ PIÙ ALTA (primary trusted reference)
2. PROFILO CHIAMANTE ← identity / caller profile
3. REPORTS SCHEDULATI ← scheduled / prior content
4. SUMMARY ULTIMA CALL ← history più recente
5. Base instructions (prompt predefinito o primary prompt)
6. (solo reasoning) user_context custom inviato dal browser
```
Perché questo ordine:
- più in alto nel prompt = più peso / attenzione dal modello.
- l'episodio attuale è il materiale che l'utente ha esplicitamente richiesto
aprendo la pagina con `?episode_id=`, quindi merita massimo peso.
- il profilo chiamante va sempre prima di "contenuti generici" (reports e
summary) perché serve per coerenza nel riferirsi all'utente.
- reports = contenuti schedulati PRIMA della sessione corrente → più vecchi
dell'ultima chiamata, quindi messi PRIMA del summary ultima chiamata che
è il contenuto più "vivo".
Implementazione ordine: [_augment_instructions_with_profile](file:///home/duxiter/s2s/s2s-client/server.py#L1149-L1159)
```python
contexts = [c for c in [episode_ctx, profile_ctx, reports_ctx, summary_ctx] if c]
```
---
## Punti di innesco (quando viene calcolato / applicato)
### Canale WebSocket — `/v1/realtime`
**MOMENTO 1 (solo una volta):** apertura WS.
Nel handler [@app.websocket("/v1/realtime")](file:///home/duxiter/s2s/s2s-client/server.py#L1791-L1959)
appena la WS viene accettata:
1. parsa `ws.url.query` per `phoneNumber` e `episode_id`
2. esegue login (se attivo) → profile → episode → reports → summary
3. salva TUTTI i blocchi già wrappati e serializzati nel dict `session_ctx`:
```python
session_ctx = {
"injected_episode_context": "...",
"injected_profile_context": "...",
"injected_reports_context": "...",
"injected_summary_context": "...",
...
}
```
**MOMENTO 2 (ogni `session.update` client→upstream):** nel task
[_client_to_upstream](file:///home/duxiter/s2s/s2s-client/server.py#L2649-L2661)
ogni frame in uscita viene passato a:
```python
_patch_client_session_update_with_profile(
frame_text,
user_context_text = profile_ctx,
reports_context_text = reports_ctx,
summary_context_text = summary_ctx,
episode_context_text = episode_ctx, # ← nuovo
)
```
Se il frame NON è `session.update` → ritorna **identico** e non viene loggato
(per evitare spam).
Se è `session.update` → patch del campo `payload["session"]["instructions"]`.
### Canale HTTP — `POST /api/reasoning`
**Ogni chiamata** ricostruisce i blocchi da zero nel
[handler `/api/reasoning`](file:///home/duxiter/s2s/s2s-client/server.py#L1411-L1577):
1. legge `req.phoneNumber` + `req.episode_id`
2. fetch episodio (se episode_id) + login + profile + reports + summary
3. costruisce `user_context_parts` e `effective_instructions`
4. inoltra al backend (NODE_RED o OPENAI)
---
## Quando un blocco viene SKIPPATO (niente inject)
Tutti i blocchi hanno 5 possibili ragioni di skip. Nessuna rompe mai il
flusso: ogni caso produce una riga `[INJECT-*] ⏭️ skipped` nei log e basta.
| Causa | Log |
|---|---|
| Master flag `S2S_INJECT_PROFILE=false` | `[INJECT-PROFILE] ⏭️ skipped (S2S_INJECT_PROFILE=false)` |
| Parametro mancante (es. `phoneNumber` non in URL) | `[INJECT-*] ⏭️ skipped (<parametro> missing)` |
| Endpoint env non configurato | `[INJECT-*] ⏭️ skipped (env for ... empty)` |
| Upstream HTTP ≠ 200 | `[INJECT-*] ⛔ upstream HTTP <status_code>` |
| Upstream ha ritornato vuoto / null / `{}` / `[]` | `[INJECT-*] ⏭️ upstream returned empty` |
| Eccezione di rete (timeout, DNS, SSL) | `[INJECT-*] ⛔ fetch unreachable error=<exc>` |
---
## Marcatori log / marker nel prompt
Ogni blocco è **auto-documentante** per ispezione dei log con coppie di
marker:
```
<<<PODCAST_EPISODE_BEGIN>>> / <<<PODCAST_EPISODE_END>>>
<<<REPORTS_LIST_BEGIN>>> / <<<REPORTS_LIST_END>>>
<<<LATEST_SUMMARY_BEGIN>>> / <<<LATEST_SUMMARY_END>>>
```
Quando `S2S_DEBUG_PROMPT=true` viene loggato il contenuto COMPLETO di ogni
blocco con i tag `[DEBUG-EPISODE]`, `[DEBUG-REPORTS]`, `[DEBUG-SUMMARY]` sia
all'atto del load (WS connect) sia dopo l'augment delle instructions (prima
di inoltrare a monte). **WARNING:** questo env può loggare PII.
---
## Tabella riepilogo: trigger per ogni blocco
| # | Blocco | Trigger | env richiesto | parametro | cap |
|---|---|---|---|---|---|
| 1 | Episodio podcast | `episode_id` in URL o body | (nessuno — URL hardcodato Aquachile) | `episode_id=` | 24 k |
| 2 | Profilo chiamante | `phoneNumber` | `VOCERO_USER_PHONE_AUTH_URL` | `phoneNumber=` | 1,6 k |
| 3 | Reports schedulati | sempre se env | `S2S_REPORT_LIST` | opzionale `phoneNumber=` | 12 k |
| 4 | Summary ultima call | `phoneNumber` **oppure** override statico | `S2S_LATEST_SUMMARY` (HTTP) **o** `S2S_LATEST_SUMMARY_TEXT` (statico, ha priorità) | `phoneNumber=` (solo modalità HTTP; override statico non richiede nessun param) | 1,6 k |
| 5 | Browser user_context | solo reasoning | (nessuno) | body `user_context` | illimitato |
---
## Error handling strategico
Perché tutti gli errori sono silenziosi (log + skip) e non abortiscono:
> Se il backend dei profili è giù per manutenzione 5 minuti non vogliamo
> che il Voice Agent smetta di funzionare. Semplicemente l'assistente non
> conosce l'identità del chiamante per 5 minuti, ma parla normalmente.
L'utente non si accorge di niente. L'operatore legge i log e vede le righe
`⛔`; può risolvere offline.
Se volessi forzare il fail (raro, su deploy dedicato), si trasformerebbero
le `return None` finali in `raise HTTPException(...)` — ma per default
non è il comportamento voluto.
---
## Appendice A — Start-session modes (`S2S_START_SESSION_MODE`)
Oltre alla inject pipeline lato server, è presente una feature lato client che
governa **come e se la sessione vocale inizia in automatico** e se il modello
saluta per primo (in modo identico alla wake-word "Hola Vocero").
### Valori supportati
| `S2S_START_SESSION_MODE` | Chi inizia? | Richiede click utente? | Trigger greet iniziale |
|---|---|---|---|
| *(vuoto) / default* | Utente, tap manuale Orb | ✅ | No |
| `TALK` | Auto dopo 2s con overlay unlocker | ✅ (1 click policy-safe su Safari/iOS) | WS client: `sendUserText("hola vocero") + requestResponse()` dopo `session.updated` |
| `TALKFIRST` | Auto dopo 2s **SENZA click** (se possibile) | ❌ se mic già "allow always"; fallback unlocker se `getUserMedia` fallisce | **Stesso identico codice della wake-word vera:** `pendingWakeWordConfirmationText = "hola vocero"` (main.js `case "connected"` → `sendUserText + requestResponse`) |
### Warmup 2s
Il timer non parte prima di 2000 ms per lasciare terminare:
- `fetchVoceroUserProfile()` (identity chiamante)
- `fetchVoceroReportsList()` (storico script podcast)
- fetch episode (`episode_id`) se presente
- `GET /api/config` (con la quale il client apprende `startSessionMode`)
Senza warmup le injection lato server potrebbero ancora non essere completate e
il greet iniziale arriverebbe "cieco" (senza contesto episodio/profilo).
### Fallback di `TALKFIRST`
Nel 99% dei casi `getUserMedia()` fuori da una user gesture fallisce
(NotAllowedError / NotReadableError su Safari, Chrome con policy strict, iOS).
In tal caso:
1. si chiude il client WS (`teardown()`)
2. stato torna `idle`
3. si mostra `#talk-unlocker` con la dicitura:
- **"Se requiere permiso de micrófono"**
- **"Toca en cualquier lugar para permitir el audio y empezar"**
4. `pendingWakeWordConfirmationText` NON viene svuotato → quando l'utente
toccherà, `case "connected"` triggerà comunque la greet come wake-word.
### Riferimenti codice
| Componente | File | Range righe |
|---|---|---|
| Lettura env + banner startup + `/api/config` exposure | [server.py](file:///home/duxiter/s2s/s2s-client/server.py) | [L150](file:///home/duxiter/s2s/s2s-client/server.py#L150), [L196-L212](file:///home/duxiter/s2s/s2s-client/server.py#L196-L212), [L1383-L1400](file:///home/duxiter/s2s/s2s-client/server.py#L1383-L1400) |
| Overlay HTML unlocker | [index.html](file:///home/duxiter/s2s/s2s-client/index.html#L32-L39) | L32-L39 |
| Stili overlay | [style.css](file:///home/duxiter/s2s/s2s-client/style.css#L145-L215) | L145-L215 |
| Variabile globale + parse config client | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L628-L632) | L628-L632 |
| `fetchConfig()` parsing + `reportDebug("B")` | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2309-L2320) | L2309-L2320 |
| `new S2sWsRealtimeClient({startWithResponse})` | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2683-L2692) | L2683-L2692 |
| Scheduling + `tryTalkFirstDirect()` + unlocker gesture | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2976-L3083) | L2976-L3083 |
| `onClientStatus("connected")` → wake-word inject flow | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2897-L2914) | L2897-L2914 |
| WS client → `TALK` mode sendUserText("hola vocero") + `requestResponse()` | [s2s-ws-client.js](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L688-L713) | L688-L713 |
---
## Riferimenti file
| File | Ruolo |
|---|---|
| [server.py](file:///home/duxiter/s2s/s2s-client/server.py) | Tutta la pipeline inject: fetch, wrap, patch, handler WS + reasoning |
| [main.js](file:///home/duxiter/s2s/s2s-client/main.js) | Passaggio client `?phoneNumber` + `?episode_id` → WS query params e POST `/api/reasoning` body |
| [CHANGELOG.md](file:///home/duxiter/s2s/s2s-client/CHANGELOG.md) | History rilasci / modifiche alla inject pipeline |