18 KiB
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:
- URL pagina browser → query string
?episode_id=episode-xxx - Body
POST /api/reasoning→ campoepisode_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_idquery 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
- wrap + serialize + cap 24k: _resolve_episode_context
- hint costante: PODCAST_EPISODE_HINT
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
- URL browser →
?phoneNumber=%2B<numero-internazionale> - 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
- fetch profile: _fetch_user_profile_for_phone
- wrap serialize: _resolve_user_context_text
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:
- Solo dict vengono tenuti (stringhe/int scartati).
- Per ogni entry estrae 3 campi con fallback a nested shape:
- Label:
sourceScheduleLabeloppuresourceSchedule.label - Date:
dateoppuresourceSchedule.date - Script:
podcastScript
- Label:
- Ordinamento data DESC (nuovi prima) con parser robusto:
- ISO datetime / date strings
Date/DateTimePython (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
- parse data sort key: _parse_date_key
- wrap + filtro + sort: _resolve_reports_context
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:
- env
S2S_LATEST_SUMMARYimpostato phoneNumberpresente in URL/body
Sorgente dati
GET S2S_LATEST_SUMMARY?phoneNumber=<phone>
Wrapper {"result": <valore>} supportato.
Accetta SIA JSON che plain text (diversi backend summary ritornano testo semplice).
Cap
1.600 caratteri.
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
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:
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
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") appena la WS viene accettata:
- parsa
ws.url.queryperphoneNumbereepisode_id - esegue login (se attivo) → profile → episode → reports → summary
- salva TUTTI i blocchi già wrappati e serializzati nel dict
session_ctx: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
ogni frame in uscita viene passato a:
_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:
- legge
req.phoneNumber+req.episode_id - fetch episodio (se episode_id) + login + profile + reports + summary
- costruisce
user_context_partseeffective_instructions - 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 |
S2S_LATEST_SUMMARY |
phoneNumber= |
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 apprendestartSessionMode)
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:
- si chiude il client WS (
teardown()) - stato torna
idle - si mostra
#talk-unlockercon la dicitura:- "Se requiere permiso de micrófono"
- "Toca en cualquier lugar para permitir el audio y empezar"
pendingWakeWordConfirmationTextNON 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 | L150, L196-L212, L1383-L1400 |
| Overlay HTML unlocker | index.html | L32-L39 |
| Stili overlay | style.css | L145-L215 |
| Variabile globale + parse config client | main.js | L628-L632 |
fetchConfig() parsing + reportDebug("B") |
main.js | L2309-L2320 |
new S2sWsRealtimeClient({startWithResponse}) |
main.js | L2683-L2692 |
Scheduling + tryTalkFirstDirect() + unlocker gesture |
main.js | L2976-L3083 |
onClientStatus("connected") → wake-word inject flow |
main.js | L2897-L2914 |
WS client → TALK mode sendUserText("hola vocero") + requestResponse() |
s2s-ws-client.js | L688-L713 |
Riferimenti file
| File | Ruolo |
|---|---|
| server.py | Tutta la pipeline inject: fetch, wrap, patch, handler WS + reasoning |
| main.js | Passaggio client ?phoneNumber + ?episode_id → WS query params e POST /api/reasoning body |
| CHANGELOG.md | History rilasci / modifiche alla inject pipeline |