Files
vocero-client/s2s-client/INJECTIONS.md
T
2026-09-10 15:57:48 -04:00

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:

  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


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


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


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_SUMMARY impostato
  • phoneNumber presente 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:

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

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