# 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": }` → 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 > <<>> { } <<>>. 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` 2. Body `/api/reasoning` → `phoneNumber: "+…"` ### Sorgente dati ``` GET VOCERO_USER_PHONE_AUTH_URL?phoneNumber= ``` 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 ` 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: { }. 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=] ``` 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. <<>> [ { "sourceScheduleLabel": "...", "date": "2026-09-06T…", "podcastScript": "…" }, … ordinati per data DESC (nuovi prima) … ] <<>> ``` 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= ``` Wrapper `{"result": }` 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): <<>> <<>>. 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 ( missing)` | | Endpoint env non configurato | `[INJECT-*] ⏭️ skipped (env for ... empty)` | | Upstream HTTP ≠ 200 | `[INJECT-*] ⛔ upstream HTTP ` | | Upstream ha ritornato vuoto / null / `{}` / `[]` | `[INJECT-*] ⏭️ upstream returned empty` | | Eccezione di rete (timeout, DNS, SSL) | `[INJECT-*] ⛔ fetch unreachable error=` | --- ## Marcatori log / marker nel prompt Ogni blocco è **auto-documentante** per ispezione dei log con coppie di marker: ``` <<>> / <<>> <<>> / <<>> <<>> / <<>> ``` 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 |