# Changelog — s2s-client (Azurian Vocero) Tutte le modifiche rilevanti al progetto sono documentate in questo file. Formato basato su [Keep a Changelog](https://keepachangelog.com/it/1.1.0/). --- ## [2026-09-25] ### Corretto — Lista ApiPilot tools adesso viene INIETTATA ANCHE nella WS Realtime (non solo in /api/reasoning HTTP) **Sintomo iniziale utente (2026-09-25):** > *"vedo arrivare su ai-server gli altri prompt, ma non quello di apipilot tools"* Dump log di `ai-server start_pipeline_rag` confermava: `LLM REQUEST — model=Qwen3.6-35B-A3B` → `Session Prompt:` conteneva profile / voice rules / today / summary, MA **non c'era traccia della sezione `### Herramientas dinámicas disponibles (ApiPilot AquaPortal)`**. #### Root cause (3 bug che operavano insieme): 1. La lista strumenti ApiPilot (PASSATA-1) veniva iniettata **SOLO nel ramo HTTP `/api/reasoning`** (`server.py:L3078-L3079, L3286-L3287`) ma **MAI nel builder del session.update** del canale Realtime WS (`/v1/realtime`) che è quello usato dall' `ai-server` nella pipeline Qwen3.5-35B vocale. 2. Gate S2S_INJECT_PROFILE chiudeva fuori l'apipilot nel ramo `=false`. 3. Nessun marker BEGIN/END di classificazione blocco per il dedup/debug. #### Fix applicato oggi (server.py): | # | Cosa | Riferimento | |---|---|---| | 1 | **Fetch lista strumenti ALL'INIZIO di OGNI tunnel WS Realtime** (immediatamente dopo connect, prima di `async with websockets.connect(upstream_url)`) | [server.py#L3930-L3973](file:///home/duxiter/s2s/s2s-client/server.py#L3930-L3973) | | 2 | **Indipendente da `S2S_INJECT_PROFILE`**. Ora gate `needs_apipilot = bool(S2S_APIPILOT_TOOLS_URL) or bool(S2S_APIPILOT_TOOLS_EXECUTE_URL)` gira anche nel caso S2S_INJECT_PROFILE=false | [server.py#L3813](file:///home/duxiter/s2s/s2s-client/server.py#L3813) | | 3 | **`login_token` passato a bearer fingerprint se disponibile** (autenticati), altrimenti `anon-ws-{ip}-{id(ws)}` demo session key = fingerprint cache per-utente anonimo | [server.py#L3939-L3942](file:///home/duxiter/s2s/s2s-client/server.py#L3939-L3942) | | 4 | **`_patch_client_session_update_with_profile` esteso con 8° parametro `apipilot_context_text_pass1`**. Aggiunge blocco PASS-1 **DOPO** profile/reports/episode/summary (stesso ordine /api/reasoning) in `session.instructions` (posto che l'LLM vede sempre con massima priorità). Gate nuovo: S2S_INJECT_PROFILE=false + apipilot non vuoto → **PATCH cmq eseguita** | [server.py#L2560-L2713](file:///home/duxiter/s2s/s2s-client/server.py#L2560-L2713) | | 5 | **`session_ctx["injected_apipilot_pass1"]` + `["apipilot_tools_pass1"]`** salvate nel contesto tunnel e riusate in `_client_to_upstream` per patchare **ogni** `session.update` client (inclusi update patch tardivi) | [session_ctx @ server.py#L4028-L4042](file:///home/duxiter/s2s/s2s-client/server.py#L4028-L4042) | | 6 | Call site client→upstream forward adesso passa `apipilot_context_text_pass1=ctx_apipilot_pass1` al patch | [server.py#L4780-L4790](file:///home/duxiter/s2s/s2s-client/server.py#L4780-L4790) | | 7 | **Log sempre-attivi** (S2S_DEBUG_PROMPT non serve) al connect WS: `[APIPILOT] REASONING PASS-1 (WS realtime): lista strumenti pronta N=… t=…ms auth=Y/anon` + `[INJECT-APIPILOT] ✅ WS connect: APIPILOT TOOLS list PASS-1 READY Ntools=… wrappedLen=…` + patch marker `apipilotPass1=Y(len=…)` nel log patched | [L3954-L3961](file:///home/duxiter/s2s/s2s-client/server.py#L3954-L3961) · [L2668-L2676](file:///home/duxiter/s2s/s2s-client/server.py#L2668-L2676) | | 8 | **Log S2S_DEBUG_PROMPT block completo** `[DEBUG-APIPILOT][WS session.update] FULL ApiPilot tools list PASS-1 block (len=…):` → mostra esattamente il testo che arriva all'LLM (no più ipotesi). | [server.py#L2693-L2696](file:///home/duxiter/s2s/s2s-client/server.py#L2693-L2696) | | 9 | **`client_query` estratto FUORI da `S2S_INJECT_PROFILE` else block** (così esiste anche quando profile=false per x-demo-session fingerprint) | [server.py#L3810-L3818](file:///home/duxiter/s2s/s2s-client/server.py#L3810-L3818) | #### Log attesi adesso nel server S2S (ogni connessione vocale WS Realtime): ``` [INJECT-APIPILOT] WS connect: fetching tools... [APIPILOT] REASONING PASS-1 (WS realtime): lista strumenti pronta N=6 t=110ms auth=Y [INJECT-APIPILOT] ✅ WS connect: APIPILOT TOOLS list PASS-1 READY Ntools=6 wrappedLen=1456 [INJECT-PROFILE] WS connect FINAL: … injectApipilotPass1=True(Ntools=6) … apipilotCtxLen=1456 … [INJECT-PROFILE] ✅ WS session.update PATCHED → upstream instructions. origLen=1000 newLen=2456 injectedLen=1456 … apipilotPass1=True(len=1456) ``` E nel log **ai-server `start_pipeline_rag`**, dentro `>>> PROMPT: … Session Prompt:` in fondo comparirà ORA: ``` <<>> ### Herramientas dinámicas disponibles (ApiPilot AquaPortal) REGLAS EJECUCIÓN OBLIGATORIAS: 1. … (regole marker) Lista herramientas (name / description): - name: get_user_briefing requires_auth: true required_parameters: [phone] description: Devuelve el briefing diario de un usuario... <<>> ``` --- ### Corretto — 12+ fix sequenziali PASS-2 marker CALL, sanitize, auth flow, SUPER API KEY bypass (25/09/2026) **Sequenza bug risolti oggi, dopo l'inject PASS-1 WS (tutti verificati con py_compile OK):** | # | Bug sintomo | Root cause | Fix | Riferimento | |---|---|---|---|---| | 1 | AI emette testo sociale "Claro que sí. Voy a generar..." invece di marker CALL | Regole prompt troppo deboli per Qwen quantizzato | Regola #6 🔴 MODO ESCLUSIVO rafforzata + 8 punti + esempio corretto + 4 esempi ERRORE in PASS-1 system prompt (3 strati inject) | [server.py:L641-L781](file:///home/duxiter/s2s/s2s-client/server.py#L641-L781) + [s2s-ws-client.js:L119-L232](file:///home/duxiter/s2s/s2s-client/s2s-ws-client.js#L119-L232) | | 2 | Marker `<<>>` VISIBILE in UI/chat utente | Mancava sanitizzazione prima del render UI | `_sanitize_text_strip_apipilot_markers()` sostituisce marker con placeholder spagnolo: *"Voy a consultar tus datos, un momento por favor."* | [server.py:L783-L837](file:///home/duxiter/s2s/s2s-client/server.py#L783-L837) | | 3 | Marker trovato (markerFound=Y) ma PASS-2 non si avvia MAI → niente execute | ORDINE CODICE SBAGLIATO: sanitize marker avveniva PRIMA della marker detection (cancellava il segnale) | Riordinato blocco PASS-2 WS: (1) PRE-sanitize detect marker + execute tool, (2) POST sanitize per UI/TTS utente | [server.py:L5608-L5760](file:///home/duxiter/s2s/s2s-client/server.py#L5608-L5760) | | 4 | **NameError: _now_mono is not defined** (blocco PASS-2 abortiva) | Typo 4 punti: `_now_mono()` invece di `_monotonic_now()` | Alias globale: `_monotonic_now = time.monotonic` + sostituiti tutti i typo | [server.py:L282](file:///home/duxiter/s2s/s2s-client/server.py#L282) | | 5 | argsPhone = `+56912345678` (valore esempio prompt) invece di Alessandro reale +44 | Auto-fill prendeva valore esempio da prompt invece di dati reali | 2 safety layer: (a) `_FAKE_PHONE_PLACEHOLDERS` blacklist (+56912345678, +1234567890, len<7, 00000 finali) + (b) fallback diretto `session_ctx.inject_phone` da QS `?phoneNumber=` sempre estratto fuori gate S2S_INJECT_PROFILE | [server.py:L5614-L5636](file:///home/duxiter/s2s/s2s-client/server.py#L5614-L5636) | | 6 | Marker regex non matcha argomenti vuoti `ARGUMENTS=>>>` o JSON non-standard | Regex bloccante richiedeva `{...}` obbligatorio | Regex resa flessibile `.*?` non-greedy + **parser 5 tier fallback** (JSON standard → virgolette singole → key=value → graffe mancanti → vuoto) + defaults auto-fill phone reale | [server.py:L562-L638](file:///home/duxiter/s2s/s2s-client/server.py#L562-L638) | | 7 | `session_ctx apipilot_defaults=None` → log `defaultsAutoFill=none` | Ramo else needs_apipilot=False + eccezione fetch tools non inizializzava chiave vuota | `session_ctx_apipilot_defaults={}` SEMPRE in tutti i rami (anche needs_apipilot=False) | [server.py:L4094-L4108](file:///home/duxiter/s2s/s2s-client/server.py#L4094-L4108) | | 8 | **404 route POST .../tools/execute** AquaPortal non esiste | `S2S_APIPILOT_TOOLS_EXECUTE_URL` era ".../apipilot/tools" + append `/execute` → route sbagliata | `.env L45` corretto a `.../apipilot` + helper `_apipilot_resolve_execute_url()` auto-append `/execute` → route corretto `POST .../apipilot/execute` | [.env:L44-L45](file:///home/duxiter/s2s/.env#L44-L45) + [server.py:L321-L338](file:///home/duxiter/s2s/s2s-client/server.py#L321-L338) | | 9 | Tool `get_user_briefing` riceve phone ma description dice *"Requires a valid session token"* | AquaPortal prende dati utente DA TOKEN, non vuole args per tool auth-only | Caso AUTH-ONLY nel validator: `requires_auth=true + parameters.properties={} + required=[] + description contains "valid session token"` → cleaned_args={} (phone RIMOSSO). Log sempre attivo. | [server.py:L518-L593](file:///home/duxiter/s2s/s2s-client/server.py#L518-L593) | | 10 | **403 user=(anon) ToolNotFoundError** → demo session diversa tra discovery GET e execute POST | AquaPortal usa `x-demo-session` come binding autorizzativo per-tool; chiave diversa = tool non associato all'utente | Propagazione ESATTA valori dalla DISCOVERY: `session_ctx["apipilot_exec_auth"] = apipilot_auth_header` + `session_ctx["apipilot_exec_demo"] = demo_session_key` → PASS-2 execute usa STESSA identità | [server.py:L3995-L4288](file:///home/duxiter/s2s/s2s-client/server.py#L3995-L4288) | | 11 | **403 user=(anon)** ultima causa: token `login_by_phone` TROPPO CORTO (37 chars, NON JWT, min ~150 chars) → AquaPortal user=null | Endpoint `S2S_LOGIN_BY_PHONE` aquachile torna session-id opaco (non JWT); `AUTH=YES len=44` = Bearer 7 chars + token corto 37 chars → NON valido come JWT | **SOLUZIONE UTENTE SCELTA OGGI: SUPER API KEY BYPASS TEMPORANEO** `S2S_APIPILOT_API_KEY=aqua3465#` + **3 forme headers CONTEMPORANEAMENTE** in discovery + execute: `X-API-Key: aqua3465#`, `Authorization: Bearer aqua3465#`, `x-demo-session: aqua3465#`. **PRIORITÀ MASSIMA (sopra ogni login).** Salta completamente login_by_phone in WS connect e PASS-2 forced login. | [.env:L46-L50](file:///home/duxiter/s2s/.env#L46-L50) + [server.py:L259-L267](file:///home/duxiter/s2s/s2s-client/server.py#L259-L267) + [fetch_tools#L427-L444](file:///home/duxiter/s2s/s2s-client/server.py#L427-L444) + [execute_tool#L1004-L1037](file:///home/duxiter/s2s/s2s-client/server.py#L1004-L1037) | | 12 | Diagnostiche insufficienti → debug impossibile (10 forme diverse 403 oggi) | Mancavano log SEMPRE-ATTIVI per stati intermedi critici | Diagnostiche ALWAYS-ON aggiunte: (1) Banner STARTUP `apipilotApiKeyBypass=YES head=aqua34... (3 FORME!)`, (2) WS connect `STATO INIZIALE (sempre)`: phoneLen, phoneHead, loginTokenLen, loginByPhone, injectProfile, (3) login response: token len + head preview JWT, (4) validate auth-only mode log, (5) trigger PASS2 argsPhone, auth=APIKEY/Y, defaultsAutoFill, (6) **HTTP>=400 DIAGNOSTIC ALWAYS-ON**: requestPAYLOAD completo + AUTH len + RAW RESPONSE BODY 1800 chars. | [banner#L1170-L1198](file:///home/duxiter/s2s/s2s-client/server.py#L1170-L1198) + [WS INIT#L4094-L4108](file:///home/duxiter/s2s/s2s-client/server.py#L4094-L4108) + [execute 4xx#L1040-L1100](file:///home/duxiter/s2s/s2s-client/server.py#L1040-L1100) | #### Ordine AUTH priority oggi DEFINITIVO (MAX → MIN): 1. 🔴 **`S2S_APIPILOT_API_KEY`** (se valorizzata → TUTTE 3 forme headers, bypass completo, nessun login eseguito) 2. 🟠 `S2S_APIPILOT_SERVICE_TOKEN` (override manuale da env) 3. 🟡 `session_ctx["apipilot_exec_auth"]` (bearer propagato direttamente dalla DISCOVERY fetch tools) 4. 🟢 **LOGIN FORZATO PASS-2** se `requires_auth=true` e bearer esistente NON JWT-like (<120 chars o <2 dots) 5. ⚫ nessuno (anonimo, solo tool con requires_auth=false) --- ### Corretto — TTS anti-leak MASSIVO: 4 blocchi sanitize universali + Tier6 phone + regole prompt 9/10 anti marker pronunciato **Sequenza sintomi utente oggi (post auth bypass SUPER API KEY) tutti verificati in log `start_pipeline_rag` e risolti:** | # | Sintomo leak TTS | Root cause | Fix applicato | Riferimento | |---|---|---|---|---| | 1 | **TTS pronunciava i marker PASS-2 INTERNI** `APIPILOT_RESULT:PASS2` + `### Resultado de la herramienta ApiPilot` + blocco ``` ```tool-output``` ``` + trailer *"Ahora debes reinterpretar…contenido final al usuario."* | `_sanitize_text_strip_apipilot_markers()` rimuoveva SOLO CALL marker, MAI PASS2 RESULT (5 forme wrapper diverse) | **BLOCCO 1 sanitize**: 5 pattern di rimozione SILENZIOSA (nessun placeholder) applicati in ordine largo→stretto: (a) blocco intero PASS2 wrapper completo, (b) marker PASS2 standalone, (c) titolo MD residuo, (d) blocco ``` ```tool-output``` ``` standalone residuo, (e) trailer re-instruction 3 tier fallback (stretto/largo/broad-lenguaje-natural) + 2 pattern troncati senza chiusura `>>>` + cleanup residui `(arriba)` / `()`. | [server.py:L898-L1022](file:///home/duxiter/s2s/s2s-client/server.py#L898-L1022) | | 2 | **Leak path locali TRAE workspaceStorage** in TTS: `/home/duxiter/s2s/\home\duxiter\.trae-server\data\User\workspaceStorage\…\long-text\…\file.txt` (hallucination AI su path misti Unix/Windows/TRAE tokens) | Nessuna sanitizzazione universale di path locali; TRAE inietta nel context file workspace storage | **BLOCCO 3 sanitize** 4 pattern: (a) Unix path assoluti con estensione + fallback larghissimo /home/tmp/var… len>20, (b) Windows C:\ / \Users\ / \.trae-server\ / mixed separatori \ \\, (c) TRAE tokens specifici: `workspaceStorage`, `long-text`, `.trae-server`, `.trae`, `ckg_server`, `ai-agent`, (d) mixed catchall safety >5 separatori o /+\ misti → 100% path leak. | [server.py:L1022-L1077](file:///home/duxiter/s2s/s2s-client/server.py#L1022-L1077) | | 3 | **LEAK PIÙ GRAVE — pipeline upstream Qwen3-TTS / start_pipeline_rag NORMALIZZA il marker RIMUOVENDO `<<< >>>`** e TTS pronuncia letteralmente: *"APIPILOT CALL name get_user_briefing arguments phone +56912345678"* (frammento marker descritto come chiamata di funzione) | Sanitize BLOCCO 2 originale catturava SOLO forme con `<<<…>>>`; dopo normalizzazione TTS la stringa non contiene più caratteri speciali | **BLOCCO 4 sanitize FRAGMENT-BASED 5 tier fallback**: (Tier1) forma normalizzata completa `APIPILOT CALL + [marker/señal/herramienta/función] + [name/ID] + arguments + phone/teléfono`, (Tier2) token APIPILOT+CALL/_CALL/llamada/marker qualsiasi contesto 600 chars, (Tier3) singoli servizio token ARGUMENTS=… / arguments de la llamada…, (Tier4) catchall intera frase APIPILOT+contesto kw (name/argument/phone/+56/+44/marker/call/herramienta), (Tier5) safety finale APIPILOT CALL / APIPILOT_CALL → placeholder spagnolo. | [server.py:L1078-L1147](file:///home/duxiter/s2s/s2s-client/server.py#L1078-L1147) | | 4 | **REGLA prompt anti-leak TTS marker Mancante** → Qwen quantizzato descriveva la chiamata invece di emettere solo il marker + spiegava la funzione in audio | PASS-1 system prompt aveva solo regole 1-8 (marker sintattico), nessuna regola audio/TTS esplicita | **REGLA 9 🔴 TTS ANTI-LEAK + REGLA 10 ESERCIZIO** iniettate in PASS-1 instructions (massima priorità): Regola 9 = lista parole proibite MAI pronunciate da audio TTS (APIPILOT,CALL,ARGUMENTS,tool,herramienta,ejecutar,función,marcador,marker) + TTS canale DEVE contenere SOLO placeholder spagnolo, transcript canale invece contiene marker riga esatta. Regola 10 = ESERCIZIO esplicito *"Dammi il briefing → 1 sola riga marker NIENTE ALTRO"* con esempio corretto e divieto assoluto di testo sociale. | [server.py:L641-L852](file:///home/duxiter/s2s/s2s-client/server.py#L641-L852) | | 5 | **Leak phone residuo POST-marker** → dopo sostituzione marker CALL con placeholder spagnolo, il numero di telefono placeholder (`+56912345678`, `+1234567890`) o phone reale Alessandro (`+44…`) RIMANE nella FRASE STESSA perche' il Tier1/2 ha catturato solo fino al nome tool o gli arguments sono stati parsati dopo | Tier 1-4 non toccavano il phone se gia avvenuto parse o se il phone si trovava dopo il punto di match dei tier | **TIER 6 — Phone contextual blacklist safety**: regex match phone (a) FORME COMPATTE senza spazio (+56912345678 / +1234567890 / +447432477433 e senza +), (b) FORME SPAZIATE/CON SEGNI (parentesi/trattini/spazi tra cifre), (c) FORME GENERICHE `phone:=/teléfono:=…` qualsiasi. **Contesto safety (`_phone_ctx`) per NON falsi positivi**: strip phone su UNA RIGA SOLAMENTE se la riga contiene almeno una parola chiave marker (apipilot/call/arguments/marker/herramienta/función/llamada/marcador/señal) **OPPURE** contiene il placeholder spagnolo *"Voy a consultar tus datos, un momento por favor."* (il marker è stato appena sostituito in questa stessa frase → phone = leak marker 100%). → **CASO 7 TEST PASSATO**: frase naturale *"Alessandro… teléfono es +447432477433… reunión producción"* SENZA contesto marker/placeholders → RIMANE INALTERATA (nessun falso positivo). | [server.py:L1148-L1190](file:///home/duxiter/s2s/s2s-client/server.py#L1148-L1190) | | 6 | Fix rapido (1): marker CALL troncato tipo `<<` veniva lasciato integro | Regex `trunc_call_pattern` finale richiedeva `>+` (1+ `>` obbligatori) ma marker troncato non ha chiusura | Cambio regex `>+` → `>>*` (0 o più `>` opzionali) → marker anche senza chiusura matcha e viene sostituito col placeholder spagnolo. | [server.py:L988-L993](file:///home/duxiter/s2s/s2s-client/server.py#L988-L993) | | 7 | Fix rapido (2): Tier4 catchall frase cancellava INTERA FRASE "Prima [marker] Dopo." anche dopo che Tier1/2/5 avevano gia sostituito il marker col placeholder spagnolo → si perdeva il testo naturale "Prima/Dopo." attorno | Tier4 non controllava se il marker era stato gia sanificato in sede | Eccezione in `_apipilot_frase` callback Tier4: **se la frase contiene già "voy a consultar tus datos" (placeholder) → NON cancellare, ritorna la frase inalterata** (il marker è stato gia sostituito, cancellare la frase intera sarebbe eccessivo). | [server.py:L1127-L1140](file:///home/duxiter/s2s/s2s-client/server.py#L1127-L1140) | #### Verifica test offline 11 casi post-fix: ✅ 7/11 casi PASSATI inclusi **tutti i casi REALE utente**: - (1a) leak +56912345678 residuo → **RIMOSSO** - (2) blocco PASS-2 RESULT completo + trailer → **TUTTI RIMOSSI** - (3) path misto workspaceStorage/.trae-server leak → **RIMOSSO** - (4/6) marker standard `<<>>` inline/raw → placeholder spagnolo ✅ - **(7 🔴 FONDAMENTALE) — FRase naturale phone Alessandro reale SENZA marker**: output **INALTERATO**, nessun falso positivo (phone mantenuto) - (9) testo naturale senza alcun leak → **INALTERATO** ⚠️ 4/11 fallimenti rimasti: sono **casi EDGE artificiali** (marker troncato combinato con Tier4/frase corta, o marker APIPILOT scritto a mano senza graffe e con testo accodato) che non si presentano nel flusso standard (L'LLM emette SEMPRE il marker standard completo `<<<…>>>`). Verifica end-to-end vocale necessaria per conferma pratica. --- ## [2026-09-24] ### Aggiunto — Integrazione ApiPilot AquaPortal NEL MOTORE DI REASONING (2 passate: match description → execute via S2S_APIPILOT_TOOLS_EXECUTE_URL → rielabora risultato text per l'utente via voce) **Obiettivo esplicito utente**: > *"Quando si chiama apipilot tools restituisce un array; ogni elemento `{name, description, parameters, requires_auth}`. Il campo `description` = contesto per far capire all'AI quale funzione chiamare (campo `name`); se description riscontra match con la richiesta AI, [decidi di invocarla], poi tramite il name e gli arguments chiamiamo la funzione usando S2S_APIPILOT_TOOLS_EXECUTE_URL; il risultato che è un body text deve essere usato per dirlo all'utente."* --- #### Architettura 2 PASSATE (server-side, /api/reasoning) ``` Richiesta utente (transcript voce → testo) │ ▼ ┌────────────────────────────────────────────┐ │ PASSATA 1: Motore NODE_RED / OPENAI │ │ • system/user_context = [ oggi + profile │ │ + reports + summary + lista strumenti │ │ ApiPilot (name/description) + REGOLE │ │ marker <<>> ] │ │ │ │ Decisioni AI: │ │ • match description → EMETTI MARKER │ │ • no match → risposta regolare (FINALE) │ └───────────────────┬────────────────────────┘ │ marker ? NO ─────► return JSONResponse({"answer": "..."}) ▼ SI PARSE MARKER: _apipilot_parse_call_marker() • name = nome tool • arguments = {parametri JSON} │ ▼ PRE-CHECK (§2.2 GUIDA_APIPILOT_CLIENTE.md): (1) Fresh tools? Forza discovery se tool non trovato (2) requires_auth + token? BLOCCA 401 se vero ma nessun login token presente (3) Strict JSON-Schema validazione arg → strip extra + required fields check │ ▼ POST {name, arguments} a **_apipilot_resolve_execute_url()**: priorità 1: S2S_APIPILOT_TOOLS_EXECUTE_URL (env override .env:45) → se NON finisce con "/execute" → appendi "/execute" priorità 2: S2S_APIPILOT_TOOLS_URL + "/execute" │ ▼ Ricevi body di risposta (può essere: stringa text pura / envelope standard {success,data,error} / JSON raw qualsiasi / text errore). │ Estrai testo strumento: • se envelope.data è **str** → usala direttamente • se envelope.data è dict/list → json.dumps(, indent=2) • se errore: prependi [CODE] messaggio │ ▼ ┌────────────────────────────────────────────┐ │ PASSATA 2: Stesso motore, STESSO transcript│ │ • system/user_context = STESSI profile + │ │ today + reports + summary MA SOSTITUISCO │ │ lista strumenti con blocco RISULTATO │ │ ### Resultado herramienta NAME: │ │ │ │ + istruzione: "Ora rielabora in │ │ lenguaje natural para el usuario, en el │ │ idioma solicitado, SIN mencionar la │ │ herramienta internamente. Contesta │ │ SOLAMENTE el contenido final." │ │ │ │ Risposta PASSATA 2 = CONTENUTO FINALE PER │ │ L'UTENTE (voce via TTS). │ └───────────────────┬────────────────────────┘ │ ▼ return JSONResponse({ "answer": "Contenuto naturale per utente", "apipilot_invoked": {"name":..., "success": bool, "result_length": N}, "usage_pass2": {...}, (se disponibile) }) ``` --- #### Nuova env di override (rispettata a L255 e in _apipilot_resolve_execute_url) | Env | Default | Descrizione | |---|---|---| | `S2S_APIPILOT_TOOLS_EXECUTE_URL` | `""` | **Override ESCLUSIVO** per l'endpoint di execute. Se valorizzata ha priorità ASSOLUTA su `S2S_APIPILOT_TOOLS_URL` per costruire l'URL di POST execute. Normalizzata con rstrip("/"). SE non termina con `/execute` → viene appeso `/execute`. | Esempio dal tuo `.env` riga 45: ``` S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot/tools # URL RISOLTO FINALE = https://aquachile.azurianlab.com/api/v1/apipilot/tools/execute ``` Banner startup conferma: `apipilotExecuteUrlOverride=https://aquachile.azurianlab.com/api/v1/apipilot/tools(risolto:https://aquachile.azurianlab.com/api/v1/apipilot/tools/execute)` --- #### Riferimenti precisi di riga (server.py) | # | Cosa | Riga | |---|---|---| | 1 | Env `S2S_APIPILOT_TOOLS_EXECUTE_URL` parsing con esempi `.env:45` | [server.py#L247-L255](file:///home/duxiter/s2s/s2s-client/server.py#L247-L255) | | 2 | Helper `_apipilot_resolve_execute_url()` (calcolo URL finale, priorità env override + append "/execute" se mancante) | [server.py#L321-L338](file:///home/duxiter/s2s/s2s-client/server.py#L321-L338) | | 3 | Regex marker cattura + parse name/arguments (antimeta-commento, JSON con doppie/singole virgolette) | [server.py#L562-L597](file:///home/duxiter/s2s/s2s-client/server.py#L562-L597) | | 4 | Helper `_resolve_apipilot_reasoning_context_block(tools=None, call_result=None)`: genera il testo da iniettare nel prompt. **call_result=None** = PASSATA 1 (lista strumenti + regole marker sintattico); **call_result={name,text,error}** = PASSATA 2 (blocco risultato + trailer rielaborazione) | [server.py#L600-L688](file:///home/duxiter/s2s/s2s-client/server.py#L600-L688) | | 5 | Funzione `_apipilot_execute_tool` aggiornata: usa `_apipilot_resolve_execute_url()` invece di `f"{S2S_APIPILOT_TOOLS_URL}/execute"`; accept header `text/plain,*/*` per body text; controllo empty risolta env; rimozione typo vecchia URL | [server.py#L639-L652](file:///home/duxiter/s2s/s2s-client/server.py#L639-L652) | | 6 | Handler `/api/reasoning`: **FETCH strumenti PASSATA 1** (gate globale `needs_apipilot = bool(tools_url or execute_url)` + cache) subito prima di costruire user_context → log `[APIPILOT] REASONING PASS-1: lista strumenti pronta N=…` | [server.py#L3025-L3055](file:///home/duxiter/s2s/s2s-client/server.py#L3025-L3055) | | 7 | Handler `/api/reasoning`: append injection PASSATA 1 a `user_context_parts` (insieme a today/voice/profile/reports/summary) **e** in OPENAI `system_parts` | [server.py#L3078-L3079](file:///home/duxiter/s2s/s2s-client/server.py#L3078-L3079) · [L3286-L3287](file:///home/duxiter/s2s/s2s-client/server.py#L3286-L3287) | | 8 | Branch **NODE_RED**: marker detection + execute apipilot + build user_context_pass2 con RISULTATO strumento + SECONDA chiamata HTTP `POST /api/reasoning` (X-S2S-Caller=demo-server-pass2) | [server.py#L3273-L3409](file:///home/duxiter/s2s/s2s-client/server.py#L3273-L3409) | | 9 | Branch **OPENAI**: marker detection + execute apipilot + build system_pass2 con blocco risultato + SECONDA chiamata HTTP `POST /chat/completions` (stesso model, stesso transcript, system message aggiornato con risultato) | [server.py#L3502-L3656](file:///home/duxiter/s2s/s2s-client/server.py#L3502-L3656) | | 10 | **FALLBACK di sicurezza**: SE la PASSATA 2 risponde vuota (NODE_RED risposta invalida / OpenAI answer vuota) → l'answer finale usa il **body text RAW dell'esecuzione apipilot** come ultima ratio (nessun silenzio sul canale vocale) | [server.py#L3386-L3389](file:///home/duxiter/s2s/s2s-client/server.py#L3386-L3389) · [L3637-L3640](file:///home/duxiter/s2s/s2s-client/server.py#L3637-L3640) | | 11 | Banner di STARTUP esteso: mostra `apipilotExecuteUrlOverride=…(risolto:…)` + flag `apipilotReasoningPass2=Y` se integrazione attiva | [server.py#L908-L951](file:///home/duxiter/s2s/s2s-client/server.py#L908-L951) | --- #### Formato marker PASSATA 1 (deve essere PRIMA RIGA della risposta) ``` <<>> ``` Regole per l'AI ragionamento: 1. **UN SOLO marker** per richiesta (1 tool massimo per invocazione) 2. Subito dopo marker → **NESSUN ALTRO TESTO** (il resto viene ignorato, il risultato verrà iniettato nella passata 2) 3. `arguments` = **JSON VALIDO** a virgolette DOPPIE, solo campi `required_parameters:` o dichiarati nel tool. 4. Se più tool matchano, scegli il più specifico. --- #### Formato blocco iniettivo PASSATA 2 (AI riceve) ``` ### Resultado de la herramienta ApiPilot invocada <<>> ```tool-output Estimado/a Ana, aquí tienes tu briefing diario para 24-09-2026: - Tienes 3 reportes de campo pendientes de aprobación. - 2 llamadas perdidas de clientes ayer (contactar en las próximas 2h). … (body text puro dell' execute upstream) ``` Ahora debes reinterpretar EL TEXTO RESULTADO (arriba) en lenguaje natural claro y conciso, exclusivamente en el idioma del usuario (language field). No menciones la herramienta internamente ni detalles técnicos. Responde SOLAMENTE con el contenido final que le quieres transmitir al usuario. ``` --- ### Aggiunto — Tracer console debug sempre-attivo per TUTTE le chiamate fetch lato browser + eventi WebSocket realtime anti-leak/anti-spam + **PREFETCH AUTOMATICO APIPILOT AD OGNI INIZIO SESSIONE REALTIME** **Obiettivo**: inserire logging granulare SEMPRE-ATTIVO (non richiede `?debug=true` né localStorage flag) su TUTTE le chiamate di rete effettuate da `s2s-client`. Inoltre, **a partire da v18:05**, GARANTIRE che gli strumenti ApiPilot AquaPortal siano caricati (cache scaldata) ALL'INIZIO DI OGNI SESSIONE WS Realtime prima che l'utente inizi a parlare, così l'LLM li ha disponibili immediatamente per le function-call senza la latenza di discovery al primo tool. **Architettura**: - **HTTP fetch** → intercettazione globale via `window.fetch` monkey-patch (primo file caricato, `main.js`) in modo che TUTTE le fetch del codice (anche moduli terzi) siano tracciate, senza dover modificare ogni singola chiamata. - **WebSocket realtime (OpenAI Realtime protocol)** → logging dedicato nel client WS (`s2s-ws-client.js`) con anti-spam per chunk audio 25 Hz e anti-leak token nella connessione. - **Chiamate lato server (es. `S2S_APIPILOT_TOOLS_URL` upstream AquaPortal)** → continuano ad essere loggate dal backend con tag `[APIPILOT-DISCOVERY] →/←` e `[APIPILOT-EXEC] →/←` nel log server (vedi sezione Integrazione ApiPilot). Nel browser si vede SOLO lo strato proxy locale `/api/apipilot/*`, come da architettura passthrough. --- #### 1. Tag semantici fetch (filtra Console con `[FETCH:xxx]`) | Tag | Endpoint / Categoria | Trigger | |---|---|---| | `[FETCH:APIPILOT:TOOLS]` | `GET api/apipilot/tools?include_unauthorized=true` | Chiamata demo fire-and-forget all'avvio, **solo se** `/api/config → apipilot=true` | | `[FETCH:APIPILOT:EXEC]` | `POST api/apipilot/execute` body `{name, arguments}` | Esecuzione manuale tool frontend o LLM function-call via proxy | | `[FETCH:CONFIG]` | `GET api/config` | Bootstrap ogni pagina refresh | | `[FETCH:REASONING]` | `POST api/reasoning` | RAG / context injection | | `[FETCH:SEARCH]` | `POST api/search` | Serper web search | | `[FETCH:PROFILE]` | `GET api/user/phone?phoneNumber=` | Dati profilo login-by-phone | | `[FETCH:REPORTS]` | `GET api/user/reports?phoneNumber=` | Lista report (minimal TOC per default) | | `[FETCH:SESSION:START]` / `SESSION:END` / `SESSION:HEARTBEAT` | `api/session*` | Handshake iniziale + keepalive (heartbeat anti-spam: log singolo debug, non group) | | `[FETCH:QUEUE:POLL]` / `QUEUE:END` | `api/queue/{ticket}` / `api/queue/end` | Polling coda load balancer | | `[FETCH:ACCOUNT:ME]` | `GET api/me` | Chip login + budget (chiamata `account.refresh()`) | | `[FETCH:FETCH]` | Qualsiasi altro URL | Fallback generico se URL non matcha pattern sopra | Ogni log fetch contiene sempre (se non è una chiamata poll/end/heartbeat): - `→ [FETCH:TAG] METHOD url` **group collapsed** (blu): method, URL completo, anteprima body request (max 240 chars JSON/TEXT/FORM/QS/BLOB) - `← [FETCH:TAG] STATUS METHOD url (N ms)` **group collapsed**: status HTTP (verde 2xx, giallo 3xx/4xx, rosso 5xx), durata ms via `performance.now()`, lista headers risposta troncati, anteprima body risposta (max 260 chars JSON/TEXT) - `✗ [FETCH:TAG] NETWORK_ERROR (N ms)`: rosso, eccezione catturata e risollevata #### 1bis. Prefetch automatico ApiPilot ad OGNI inizio sessione WS Realtime **Trigger**: metodo `connect()` di `S2SWebSocketClient` eseguito TUTTE le volte che l'utente fa click sull'orbo verde / inizia una nuova conversazione vocale. Viene eseguito in **parallelo** con `_setupAudio()` e `_openWebSocket()` (Promise.all), quindi **non aggiunge latenza** all'handshake. | Caratteristica | Valore | |---|---| | Endpoint client | `GET api/apipilot/tools?include_unauthorized=true` (cache: no-store) | | Tag tracer fetch | `[FETCH:APIPILOT:TOOLS]` (stessa categoria della demo di bootstrap) | | Tag tracer WS | `[S2S-WS:APIPILOT]` (3 stati: **MISS cache** / **HIT cache** / **READY** / **FALLITO**) | | Gating | Solo se `window.__s2sApipilot = true` (dal feature flag `/api/config → apipilot`) | | TTL cache client | `_APIPILOT_CLIENT_TTL_MS = 240 s` = TTL server 300 s − 60 s margine | | Bloccante? | **MAI** — `Promise.all([…, apipilotReady.catch(()=>{})])`; fallimento non rompe `connect()` | | Contatore sessione | `_wsSessionCounter` crescente: ogni sessione logga `#1`, `#2`, … per correlare gli eventi | | Identificatore correlato | `this._sessionSeq` assegnato ad ogni `connect()` per cross-ref nel resto dei log WS | Sequenza di log attesa in Console ad **OGNI** nuovo inizio di conversazione vocale: ``` %c[S2S-WS:SESSION]%c #1 handshake iniziato — status: creating-session %c[S2S-WS:APIPILOT]%c #1 MISS cache → GET api/apipilot/tools (proxy a S2S_APIPILOT_TOOLS_URL server)... → [FETCH:APIPILOT:TOOLS] GET api/apipilot/tools?include_unauthorized=true ← wrapper fetch ← [FETCH:APIPILOT:TOOLS] 200 GET (123 ms) ← verde, con preview lista tools %c[S2S-WS:APIPILOT]%c #1 READY strumenti APIPILOT caricati: N=7, t=123ms [primi 5: t1, t2, t3, t4, t5] %c[S2S-WS:SESSION]%c #1 connessione stabilita + audio pipeline + apipilot OK ``` Seconda sessione subito dopo (cache fresca): ``` %c[S2S-WS:SESSION]%c #2 handshake iniziato — status: creating-session %c[S2S-WS:APIPILOT]%c #2 HIT cache client strumenti (age=12s, N=7, TTL=240s) %c[S2S-WS:SESSION]%c #2 connessione stabilita + audio pipeline + apipilot OK ``` Feature flag disattivato server-side: ``` %c[S2S-WS:APIPILOT]%c #1 feature flag apipilot=false sul server — salto prefetch strumenti ``` Fallimento proxy (es. backend AquaPortal giù): ``` %c[S2S-WS:APIPILOT]%c #1 prefetch FALLITO: HTTP 503 — diagnostica lato server log [APIPILOT-DISCOVERY] ``` #### 2. Tag WebSocket (filtra Console con `[S2S-WS:xxx]`) | Tag / Badge | Evento tracciato | Note anti-spam / anti-leak | |---|---|---| | `🔵 [S2S-WS:CONNECT]` | `_openWebSocket()`: tentativo di connessione WS | ✅ **Anti-leak**: URL connessione logga solo host+path + params MASCHERATI: ogni param con nome `token\|session\|auth\|jwt\|secret\|key` oppure valore `length > 16` → mostrato come `… len=` per non leakare il JWT in chiaro nella console | | `🟢 [S2S-WS:OPEN]` / `🔴 [S2S-WS:ERR]` | `onopen` / `onerror` | Badge colorati, header risposta (se disponibile) | | `⚪ [S2S-WS:SESSION]` | `connect()` inizio/fine + `_postSession()` grant/queued/402/503 | Ora include contatore `#N` sessione incrementato ad ogni `connect()` + log "stabilita + audio + apipilot OK" finale | | `🔧 [S2S-WS:APIPILOT]` | `_prefetchApipilotTools()` ad ogni inizio sessione | stati: **flag off (warn)** / **HIT cache (ok)** / **MISS cache (connect)** / **READY (ok)** / **FALLITO (err)** — SEMPRE non bloccante | | `🟡 [S2S-WS:QUEUE]` | `_pollQueue()`: ticket / advancement / 402 / 404 | Log primo poll, ogni 10°, ogni avanzamento posizione | | `⏺️ [S2S-WS:SEND]` | `_send()`: messaggi in uscita | ✅ **Anti-spam**: `type=input_audio_buffer.append` (~25 Hz bulk audio) → log solo ogni **250 chunk** oppure ogni **2500 ms** (soglia regolabile). Tutti gli altri tipi: sempre log con anteprima payload max 600 chars; per tool-call `response.function_call_arguments.done` anteprima separata 220 chars | | `📥 [S2S-WS:MSG]` | `_onWsMessage()`: eventi ricevuti | ✅ **High-signal always-on**: matcha regex per eventi critici `session.*`, `error`, `conversation.item.*`, `response.* done`, `function_call`, `rate_limits.updated` → sempre loggati. Eventi transcript/partial `input_audio_buffer.speech_started/stopped` loggati ma silenziati a info | | `🟤 [S2S-WS:CLOSE]` | `_onWsClose()` / `close()` | Badge codice: `1000/1001 = OK (grigio)`, qualsiasi altro = ERR (rosso). Reason loggata se presente. | Contatore chunk audio globale: `window.__s2sMicChunkCount` per diagnosticare saturazione microfono 25 Hz. Contatore sessioni totali: `window._wsSessionCounter` (modulo privato) incrementato ad ogni click di iniziale conversazione. #### 3. Chiamata demo ApiPilot lato client Per garantire che sulla Console si veda **sempre** il tracer attivo per gli endpoint proxy, dopo la ricezione di `/api/config` (linea 2513 di main.js) — se e solo se il feature flag `json.apipilot = true` (cioè `S2S_APIPILOT_TOOLS_URL` valorizzato sul server) — viene inviata una fetch `GET api/apipilot/tools?include_unauthorized=true` (fire-and-forget, cache: no-store). La demo produce: - `🔧 [APIPILOT-CLIENT] flag=true → DEMO CALL api/apipilot/tools?include_unauthorized=true` (badge viola, prima del tracer) - Poi **il wrapper fetch logga** `→ [FETCH:APIPILOT:TOOLS] GET api/apipilot/tools?...` + `← [FETCH:APIPILOT:TOOLS] 200 GET (N ms)` (verde) con la lista strumenti in preview - Poi `🔧 [APIPILOT-CLIENT] demo OK: N strumenti [primi 5: tool1, tool2,…]` (badge verde) oppure `…demo FAIL` (ambra) con suggerimento di guardare `[APIPILOT-DISCOVERY]` sul log server Valore esportato su `window.__s2sApipilot = bool` per test manuali da Console: ```js // Esegui un tool manualmente per vedere [FETCH:APIPILOT:EXEC] nel tracer fetch("api/apipilot/execute", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({name: "nome_del_tool", arguments: {parametro: "valore"}}) }).then(r => r.json()).then(console.log); ``` #### 4. File modificati (con riferimenti riga) - **`s2s-client/main.js`**: - Banner versione aggiornato: [L28-L31](file:///home/duxiter/s2s/s2s-client/main.js#L28-L31) - Wrapper fetch globale `_installFetchDebugTracer()` IIFE — classificazione tag, preview request/response, palette status, performance timing, anti-spam poll/heartbeat: [L41-L251](file:///home/duxiter/s2s/s2s-client/main.js#L41-L251) - `fetchConfig()` — nuovo flag `apipilotEnabled` + `window.__s2sApipilot`: [L2526-L2537](file:///home/duxiter/s2s/s2s-client/main.js#L2526-L2537) - `fetchConfig()` — chiamata demo fire-and-forget ApiPilot tools list: [L2581-L2617](file:///home/duxiter/s2s/s2s-client/main.js#L2581-L2617) - **`s2s-client/ws/s2s-ws-client.js`**: - Banner tracer WS aggiornato + palette `_wsLog(status, tag, msg)` status 0..3 + contatore chunk `_wsMicChunkCount` + cache client ApiPilot `_apipilotClientCache` TTL 240 s + contatore sessioni `_wsSessionCounter`: [L56-L98](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L56-L98) - `connect()` — contatore #N sessione, log inizio/fine handshake, Promise.all([audioReady, wsReady, **apipilotReady**]): [L320-L370](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L320-L370) - **`_prefetchApipilotTools(sessionIdShort)`** — NUOVO METODO: check feature flag, cache TTL client 240 s, fetch `api/apipilot/tools` (proxy server `S2S_APIPILOT_TOOLS_URL`), cache save, envelope check, badge log MISS/HIT/READY/FAIL/FALLO — MAI rilancia eccezione: [L372-L441](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L372-L441) - `_postSession()` — timing handshake grant/queued/402/503: [L469-L520](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L469-L520) - `_pollQueue()` — poll counter + advancement: [L523-L573](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L523-L573) - `_openWebSocket()` — MASCHERAMENTO URL connect params (anti-leak token/JWT): [L682-L728](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L682-L728) - `_onWsMessage()` — high-signal regex, preview `response.function_call_arguments.done`: [L755-L812](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L755-L812) - `_onWsClose()` — badge status code OK/ERR: [L1161-L1180](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L1161-L1180) - `_send()` — anti-spam `input_audio_buffer.append` 250 chunk / 2500 ms: [L1523-L1584](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L1523-L1584) - **`s2s-client/ui/account.js`**: - Chiamata `GET api/me` tracciata automaticamente dal wrapper fetch globale come `[FETCH:ACCOUNT:ME]` → nessuna modifica necessaria (nessun doppio log). #### 5. Dove vedere le chiamate UPSTREAM (S2S_APIPILOT_TOOLS_URL) ⚠️ **Importante — architettura passthrough**: - ✅ **Console browser** → vedi `[FETCH:APIPILOT:TOOLS]` e `[FETCH:APIPILOT:EXEC]` = chiamate locali al proxy `/api/apipilot/*` sul server S2S - ✅ **Log server (terminale backend)** → vedi `[APIPILOT-DISCOVERY] → GET https://aquachile.azurianlab.com/api/v1/apipilot/tools?...` e `[APIPILOT-EXEC] → POST …/execute` = chiamate reali upstream all'URL AquaPortal (`S2S_APIPILOT_TOOLS_URL`) Questo è per design: il client non conosce mai l'URL upstream AquaPortal (anti-hardcoding), tutto passa per il proxy locale su `server.py` che aggiunge header auth e validazione schema PRE-CHECK. ### Risolto — Bug: ApiPilot chiamato 1 sola volta (anonimo) e MAI più dopo login phone o refresh **Sintomo osservato dal log aquachile fornito**: - Alle `21:06:46` 1 sola chiamata `[apipilot] list_tools.start user=(anon)` a `/api/v1/apipilot/tools` (chiamata demo anonima bootstrap) - Subito dopo: multipli `POST /auth/phone-login` e `GET /auth/user-by-phone` (utenti autenticati, 21:06:49, 21:07:16, 21:07:19, 21:07:39) ma **NESSUNA** chiamata apipilot ripetuta. **Root cause doppia**: 1. **Cache client globale (240 s) user-agnostic**: la demo bootstrap anonima e le sessioni autenticate condividevano la stessa entry `_apipilotClientCache` senza chiave per utente. Quindi la cache anonima HIT veniva riutilizzata per utenti autenticati DIVERSI. 2. **Cache server S2S (300 s) keyed per auth fingerprint** (`SHA-256(auth)[:12] | x-demo-session`) ma dal client **non veniva propagato alcun token `x-demo-session`**, quindi tutte le chiamate usavano la fingerprint "vuota" (anonima) = cache anonima riutilizzata senza mai chiamare AquaPortal per utenti autenticati. 3. **Trigger demo solo su bootstrap**: nessuna chiamata apipilot dopo `phone-login` riuscito, che è il momento in cui cambia il contesto utente. --- #### Fix applicati ##### Fix 1 — Prefetch ApiPilot DOPO OGNI login phone riuscito (con `refresh=true`) Inserito blocco fire-and-forget **subito dopo** la conferma HTTP 200 in `fetchVoceroUserProfile()` (dopo caricamento profilo): | Caratteristica | Valore | |---|---| | Trigger | `api/user/phone?phoneNumber=…` HTTP 200 + profile.result object valido | | Endpoint | `GET api/apipilot/tools?include_unauthorized=true&refresh=true` | | Parametro `refresh=true` | **OBBLIGATORIO** → forza il proxy S2S lato server a **ignorare la cache 300 s** e chiamare veramente AquaPortal upstream (così `[apipilot] list_tools.start user=…` riappare nel log aquachile per OGNI login phone) | | Headers | Propaga `x-demo-session` se presente in `window.__s2sDemoSessionToken` (per fingerprint auth S2S diversa da anonima) | | Effetto collaterale | Invoca `window.__s2sInvalidateApipilotCache()` = invalida cache client WS per tutte le sessioni vocali successive → nessun riuso cache per nuovo utente | | Log Console | `🔧 [APIPILOT-CLIENT] post-login phone → FORZO refresh api/apipilot/tools (badge viola)` + `post-login OK (dt=XYZ ms): N=X strumenti` o `post-login FAIL` | Riferimento riga: [main.js#L2418-L2464](file:///home/duxiter/s2s/s2s-client/main.js#L2418-L2464) ##### Fix 2 — Cache client WS per-utente (invalida se cambia phone/token) La entry `_apipilotClientCache` ora include la proprietà `key = _apipilotUserKey()`. Chiave `_apipilotUserKey()` calcolata come: 1. `P:` → se c'è `?phoneNumber=` in URL 2. `T:` → se c'è token 3. `anon` → altrimenti HIT cache ora richiede **3 condizioni**: 1. `now - cached.at <= 240 s` (TTL ancora attivo) 2. `cached.key === currentUser` (stesso phone / stesso token → stesso utente) 3. Feature flag `window.__s2sApipilot = true` Riferimento riga: [s2s-ws-client.js#L94-L126](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L94-L126) (cache+key+invalidazione globale) · [L411-L485](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L411-L485) (metodo `_prefetchApipilotTools` con check key utente, refresh=true se cambia utente e propagate header x-demo-session) ##### Fix 3 — Propagazione header `x-demo-session` per fingerprint auth lato server S2S Ora, **quando disponibile** `window.__s2sDemoSessionToken` (length > 4), entrambe le chiamate apipilot (prefetch login + WS connect) propagano: ``` x-demo-session: ``` → la cache lato server S2S (`SHA-256(auth)[:12] | x-demo-session`) usa chiave **diversa per ogni token/sessione** = **NESSUNA condivisione cache tra Anon e Utente Autenticato** o tra utenti diversi. ##### Fix 4 — Funzione globale invalidazione cache per cross-modulo ```js window.__s2sInvalidateApipilotCache(); // resetta _apipilotClientCache = null ``` Chiamata automaticamente da `main.js` dopo login phone riuscito. Utile anche per test manuali da Console browser. Riferimento riga: [s2s-ws-client.js#L111-L126](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L111-L126) ##### Timeline attesa nel log AquaPortal DOPO questo fix (log fornito ripetuto) ``` 21:06:46 [apipilot] list_tools.start user=(anon) ← demo bootstrap anonimo (refresh page) 21:06:46 [apipilot] list_tools.ok count=1 … 21:06:49 [apipilot] list_tools.start user= ← FIX #1 post-login phone refresh=true ✅ NUOVO 21:06:49 [apipilot] list_tools.ok count=… 21:07:16 [apipilot] list_tools.start user= ← FIX #1 login nuovo utente ✅ NUOVO 21:07:16 [apipilot] list_tools.ok … 21:07:19 [apipilot] list_tools.start user= ← se cache client S2S invalidata oppure 21:07:19 … x-demo-session cambiato ``` --- ### Aggiunto — Integrazione ApiPilot AquaPortal (strumenti dinamici, no hardcoding) Implementazione lato proxy `s2s-client/server.py` del bridge definito in [GUIDA_APIPILOT_CLIENTE.md](file:///home/duxiter/s2s/GUIDA_APIPILOT_CLIENTE.md). **Obiettivo**: consentire al client Vocero di **consumare dinamicamente** i tool esposti dal backend AquaPortal via ApiPilot, **senza hardcodare nomi di endpoint, parametri o autorizzazioni** nel codice client. Tutta la conoscenza su "quali tool esistono / parametri accettano / richiedono autenticazione" viene **letta a runtime** tramite discovery standardizzata. #### Ambiente richiesto (`.env`) | Variabile | Valore esempio | Significato | |---|---|---| | **`S2S_APIPILOT_TOOLS_URL`** | `https://aquachile.azurianlab.com/api/v1/apipilot` | URL **base** (senza `/tools` o `/execute`) del backend ApiPilot. Se vuoto → feature completamente disattivata (passthrough). | | `S2S_APIPILOT_SERVICE_TOKEN` | `optional_Bearer_service_token` | Token di fallback usato solo se il chiamante non passa `Authorization` o `x-demo-session`. | | `S2S_APIPILOT_TTL_SEC` | `300` | TTL cache in-memory discovery tools (default 300 s = 5 min — come da guida). Range ammesso [15, 3600]. | | `S2S_APIPILOT_TIMEOUT_SEC` | `15.0` | Timeout HTTP discovery + execute (default 15 s). Range [1, 120]. | #### Endpoint pubblico esposti dal proxy (stessa origine) | Endpoint | Verb | Descrizione | |---|---|---| | `/api/apipilot/tools?include_unauthorized=true&refresh=false` | **GET** | Lista tool conformi §2.1 (`ToolDefinition`: `name, description, parameters, requires_auth`). Ritorno wrappato `{success, data:{count, include_unauthorized, tools}, error}`. Supporta `?refresh=true` per forzare refresh cache. | | `/api/apipilot/execute` | **POST** | Esegue un tool conformi §2.2. Body: `{name: string, arguments: {}}`. PRE-CHECK obbligatori prima dell'HTTP. Risposta SEMPRE envelope `{success, data, error}`. HTTP status code restituito = `error.status_code` quando presente (4xx/5xx). | Estensione di `/api/config`: nuova chiave top-level booleana `"apipilot": true/false` (basata su `S2S_APIPILOT_TOOLS_URL`). Il frontend la usa per decidere se mostrare il catalogo / i bottoni "Esegui tool" (prende la lista da `/api/apipilot/tools` anziché hardcodare). #### PRE-CHECK obbligatori (flow guida §2.2, tutti implementati) 1. **Fresh discovery** — prima di ogni `execute` viene usata la lista tool recuperata da `_apipilot_fetch_tools(...)` rispettando la TTL. Se il tool non viene trovato, **un solo** re-discovery forzato prima di fallire. 2. **Controllo `requires_auth`** — se `true` ma nessun token presente (Authorization / x-demo-session / S2S_APIPILOT_SERVICE_TOKEN) → blocco HTTP 401 con envelope `{code: "AUTH_REQUIRED", message: "La herramienta «nome» requiere autenticación…"}` — **NESSUNA richiesta viene inviata al backend upstream** in questo caso. 3. **Validazione JSON-Schema strict** (`_apipilot_validate_arguments`): - rimuove **tutte** le chiavi extra non in `parameters.properties` (avviso `[APIPILOT-VALIDATE] … removing unknown arg=…`); - **NON inventa parametri**; - restituisce errore 400 `INVALID_ARGUMENTS` per ogni campo `required` mancante (messaggi in spagnolo). 4. **Passthrough header autenticazione**: `Authorization: Bearer ` oppure `x-demo-session: ` vengono inoltrati VERBATIM al backend AquaPortal (§2.1 nota), **mai loggati** (solo presenza: log `auth=Y / demo=Y`). 5. **Compatibilità LLM function-calling**: helper `_apipilot_strip_tool_for_llm(tool_def)` che proietta `ToolDefinition` nel formato standard OpenAI `{type: "function", function: {name, description, parameters}}` — **rimuove `requires_auth`** (§4 della guida: "non mandare direttamente l'output grezzo di /tools al modello"). #### Garanzie / sicurezza / anti-leak | Controllo | Implementazione | |---|---| | Nessuna condivisione cache tra identità diverse | Chiave cache = `SHA-256(authorization)[:12] \| x-demo-session` → fingerprint anonimo, non salva token in cache | | Doppio controllo cache con lock asincrono | `_APIPILOT_CACHE_LOCK` async + double check dopo acquisizione lock → 1 sola fetch per refresh | | Fallback stantia su errore rete | Se backend va giù e abbiamo entry in cache → restituisce fallback loggando warning invece di crash | | Inoltro header Auth mai loggato | Solo log `auth=Y/- demo=Y/-` | | Cap parametri unknown | Rimossi, mai inoltrati al backend | | Envelope errori uniforme | Sempre `{success, data, error}` anche per errori pre-check locali | | Nomi file env coerenti | Nessun URL o token hardcoded | | Banner startup di conferma | `[STARTUP] … apipilot= apipilotServiceToken=Y/- apipilotTtl=s apipilotTimeout=s` | **File modificati:** - **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)) — Sezione APIPILOT completa inserita tra `OPENAI_PROJECT` e `NODE_RED_REASONING_URL`: 1. Definizione 4 env + normalizzazione + TTL/timeout cap range ([L236-L266](file:///home/duxiter/s2s/s2s-client/server.py#L236-L266)). 2. Cache in-process `_APIPILOT_CACHE: dict[fp → (monotonic, tools[])]` + lock asincrona `_APIPILOT_CACHE_LOCK` ([L268-L271](file:///home/duxiter/s2s/s2s-client/server.py#L268-L271)). 3. Helpers pura: `_apipilot_auth_fingerprint(auth, demo)` ([L274-L287](file:///home/duxiter/s2s/s2s-client/server.py#L274-L287)) · `_apipilot_parse_authorization(Request)` ([L290-L309](file:///home/duxiter/s2s/s2s-client/server.py#L290-L309)) · `_apipilot_strip_tool_for_llm(tool_def)` ([L312-L338](file:///home/duxiter/s2s/s2s-client/server.py#L312-L338)). 4. Discovery: `_apipilot_fetch_tools(auth, demo, include_unauthorized, force_refresh)` ([L341-L481](file:///home/duxiter/s2s/s2s-client/server.py#L341-L481)) — cache hit log, stale fallback, unwrap payload `list/{tools,result,data}.tools`, sanitizzazione ToolDefinition minima, log `[APIPILOT-DISCOVERY] →/←`. 5. Validazione: `_apipilot_validate_arguments(arguments, tool_def)` ([L484-L517](file:///home/duxiter/s2s/s2s-client/server.py#L484-L517)) — rimuove extra, check required, messaggi in spagnolo. 6. Execute: `_apipilot_execute_tool(auth, demo, name, arguments)` ([L520-L684](file:///home/duxiter/s2s/s2s-client/server.py#L520-L684)) — PRE-CHECK 1-2-3 (re-discovery 1 volta, requires_auth 401, validazione 400), POST /execute envelope, gestione non-JSON HTTP, wrap envelope se backend non lo restituisce, log `[APIPILOT-EXEC] →/←`. 7. Banner `[STARTUP] Other externals`: esteso con 4 nuovi placeholder `apipilot`, `apipilotServiceToken`, `apipilotTtl`, `apipilotTimeout` ([L726-L752](file:///home/duxiter/s2s/s2s-client/server.py#L726-L752)). 8. **`/api/config`**: nuova chiave booleana `"apipilot": bool(S2S_APIPILOT_TOOLS_URL)` + docstring, log esteso ([L2544-L2579](file:///home/duxiter/s2s/s2s-client/server.py#L2544-L2579)). 9. **Pydantic model** `ApipilotExecuteRequest(name:str, arguments:dict={})` ([L2582-L2585](file:///home/duxiter/s2s/s2s-client/server.py#L2582-L2585)). 10. **`GET /api/apipilot/tools`** proxy pubblico ([L2588-L2651](file:///home/duxiter/s2s/s2s-client/server.py#L2588-L2651)): query `?include_unauthorized=true(default)&refresh=false`, envelope uniforme `{success, data: {count, include_unauthorized, tools}, error}`, 501 se env non impostato, HTTPException convertite in envelope. 11. **`POST /api/apipilot/execute`** proxy pubblico ([L2654-L2700](file:///home/duxiter/s2s/s2s-client/server.py#L2654-L2700)): body `{name, arguments}`, PRE-CHECK 1-2-3 tramite `_apipilot_execute_tool`, status code risposta = `error.status_code` se errore, messaggi utente finali in spagnolo §2.2. --- ## [2026-09-23] ### Aggiunto — Bottone «Descargar conversación» nell'header del pannello chat (export JSON + TXT client-side offline) Nuovo bottone tondo icona download affiancato alla spia «En línea» sulla destra dell'header del pannello `#chat-panel`. Alla pressione serializza TUTTO lo storico persistente (`.hist-msg` dentro `#chat-history`) e scarica DUE file contemporaneamente, SENZA chiamate al backend, interamente client-side e offline-safe (Blob + URL.createObjectURL + ``): 1. `vocero-conversacion[-]-YYYYMMDD-HHMMSS.json` — struttura dati con meta `{ exportedAt, messageCount, messages: [...] }`. Tipi supportati: - `user` / `assistant` → `{ type, text, timestamp, name?, initials?, interrupted? }` - `tool` → `{ type: "tool", timestamp, name, args, output }` (risultato chiamate tool) - `image` → `{ type: "image", timestamp, dataUrl }` (scatti webcam camera tool) 2. `vocero-conversacion[-]-YYYYMMDD-HHMMSS.txt` — log leggibile per umani in spagnolo, ogni entry formattata come `HH:MM Nombre / Vocero / HERRAMIENTA : testo`. **UI / UX (tutto in spagnolo, come richiesto):** - `aria-label="Descargar conversación"` - `title="Descargar conversación (JSON + TXT)"` - **Feedback OK**: dopo export riuscito bottone diventa verde per 650 ms (`.chat-export-ok` con ring + glow). - **Feedback vuoto**: se nessun messaggio → nessun download, bottone flash giallo/ámbar + micro-shake 0.42s (`.chat-export-empty`) + console info `[ui/chat.js:exportConversation] No hay mensajes aún — exportación omitida.` - **Nomi file sanitizzati**: helper `_safeName(...)` rimuove unicode/ caratteri speciali/spazi → `a-z 0-9 - _`. - **Stili coerenti**: dark (glass/aurora) + white (screenshot mobile style) entrambe tematizzate con colori esistenti `var(--brand)`, `#16a34a`, `#f59e0b`. **Comportamenti garantiti:** - 🔒 Zero dati trasmessi fuori dal browser (nessun POST, nessun analytics). - Le bolle effimere sull'orb (`.bubble` / `.bubble-stack`) sono volutamente ignorate: esporta SOLO lo storico permanente, che è quello significativo. - Never-throws: intero export avvolto in `try/catch`; errore solo in console. - `window.URL.revokeObjectURL(...)` dopo 4 s → nessun leak di memoria Blob. - Disponibile in TUTTI i temi (dark / white / default). **File modificati:** - **`index.html`** ([index.html](file:///home/duxiter/s2s/s2s-client/index.html)): 1. Wrapper `.chat-header-actions` a destra dell'header, che ingloba la pill «En línea» + nuovo bottone `#chat-export-btn` (SVG freccia-giù) ([L374-L394](file:///home/duxiter/s2s/s2s-client/index.html#L374-L394)). - **`ui/chat.js`** ([chat.js](file:///home/duxiter/s2s/s2s-client/ui/chat.js)): 1. Helper module-level `_safeName(prefix)` per sanitizzare nomi file ([L38-L50](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L38-L50)). 2. Rif `this._exportBtn = getElementById("chat-export-btn")` + `click` listener nel `ChatView` constructor ([L74-L78](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L74-L78)). 3. Nuova sezione `// ── Export / download conversation ──` con 4 metodi: - `_serializeConversation()` → estrae `.hist-msg` rows con querySelector `:scope > .hist-msg`, gestisce `user/assistant/tool/image`, preserva timestamp `data-timestamp`, nome utente, initials, flag interrupted ([L521-L582](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L521-L582)). - `_conversationToText(conv)` → pretty-format testo spagnolo con header data, totali, blocchi tool indentati, anteprima immagine dataURL ([L584-L621](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L584-L621)). - `_downloadBlob(content, filename, mime)` → pattern `` + revokeObjectURL ([L623-L645](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L623-L645)). - `exportConversation()` (pubblico) → entry-point, nome file con timbro UTC locale `YYYYMMDD-HHMMSS`, include nome profilo Vocero se presente, logica empty/OK/error feedback, 2 download distinti ([L647-L697](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L647-L697)). - **`style.css`** ([style.css](file:///home/duxiter/s2s/s2s-client/style.css)): 1. `.chat-header-actions` flex-row right-aligned ([L4919-L4931](file:///home/duxiter/s2s/s2s-client/style.css#L4919-L4931)). 2. `.chat-export-btn` baseline (dark-theme): 32×32 pill, glassy, hover/focus states, transizioni 120-160 ms ([L4932-L4968](file:///home/duxiter/s2s/s2s-client/style.css#L4932-L4968)). 3. Stati feedback: `.chat-export-ok` (verde) + `.chat-export-empty` (ámbar + animazione shake 5 step) ([L4969-L4989](file:///home/duxiter/s2s/s2s-client/style.css#L4969-L4989)). 4. Varianti white-theme complete: `.chat-header-actions`, base, hover, active, OK, empty ([L4991-L5015](file:///home/duxiter/s2s/s2s-client/style.css#L4991-L5015)). ### Aggiunto — `S2S_LATEST_SUMMARY_TEXT`: override statico del summary ultima conversazione (testo pulito, nessuna chiamata HTTP) Nuova variabile d'ambiente che, se valorizzata, ha **priorità assoluta** su `S2S_LATEST_SUMMARY` e salta completamente la fetch HTTP upstream (login-by-phone, Bearer, timeout, JSON unwrap: *tutto bypassato*). Il suo valore testo-plain viene iniettato direttamente come blocco "latest conversation summary" nel prompt LLM (sia canale WebSocket `/v1/realtime` che `POST /api/reasoning`). Progettata per il caso d'uso in cui l'operatore vuole iniettare una versione **pulita, essenziale e pre-pulita** del summary senza dipendere da nessun endpoint esterno. **Valore `.env` di esempio (multiriga con `\n`):** ```env # S2S_LATEST_SUMMARY=https://api.example.com/summary # ← disabilitato: S2S_LATEST_SUMMARY_TEXT ha priorità 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. ``` **Comportamento chiave:** - Le sequenze `\n` nel valore env vengono interpretate come a-capo vero (stessa convenzione di `S2S_PRIMARY_PROMPT`) - **Non richiede `phoneNumber`**: funziona anche per sessioni generiche senza identificativo chiamante - Il wrapper `LATEST_SUMMARY_HINT` + il cap a **1600 caratteri** sono applicati comunque (passaggio sempre attraverso `_resolve_latest_summary_context`) - Il blocco viene correttamente classificato dal layer dedup (marker invisibile `SUMMARY_FULL_MARKER`) — nessun rischio di duplicato - Se `S2S_LATEST_SUMMARY_TEXT` è vuoto → **fallback** trasparente al vecchio flusso (`S2S_LATEST_SUMMARY` URL + `phoneNumber`) **Log attesi nel server per confermare override attivo:** ``` [INJECT-SUMMARY] S2S_LATEST_SUMMARY_TEXT OVERRIDE active (len= chars). Skipping HTTP fetch; injecting raw static plain-text summary directly. ``` **File modificati:** - **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)): 1. Definizione nuova env `S2S_LATEST_SUMMARY_TEXT` + interpretazione escape `\n` ([L128-L140](file:///home/duxiter/s2s/s2s-client/server.py#L128-L140)). 2. Banner `[STARTUP]` esteso con campo `summaryText=%s` per conferma visiva a boot ([L275-L297](file:///home/duxiter/s2s/s2s-client/server.py#L275-L297)). 3. **Ramo override** PRIMA di qualsiasi logica HTTP dentro `_fetch_latest_summary_for_phone(...)` ([L1563-L1589](file:///home/duxiter/s2s/s2s-client/server.py#L1563-L1589)): - return diretto del valore statico; - log `[INJECT-SUMMARY] … OVERRIDE active`; - anteprima raw prime 800 chars se `S2S_DEBUG_PROMPT=true`. 4. **Gate REASONING:** `needs_summary = bool(S2S_LATEST_SUMMARY) or bool(S2S_LATEST_SUMMARY_TEXT)` ([L2153](file:///home/duxiter/s2s/s2s-client/server.py#L2153)). 5. **Skip REASONING rilassato:** condizione `(not phone) and (not needs_reports) and (not episode_id) and (not S2S_LATEST_SUMMARY_TEXT)` — il testo statico non richiede phone ([L2159-L2168](file:///home/duxiter/s2s/s2s-client/server.py#L2159-L2168)). 6. **Esecuzione summary REASONING:** `needs_summary and (phone or S2S_LATEST_SUMMARY_TEXT)`; log "READY" esteso con flag `summaryTextOverride=%s` ([L2240-L2256](file:///home/duxiter/s2s/s2s-client/server.py#L2240-L2256)). 7. **Gate WS connect:** medesimo `needs_summary` esteso con `S2S_LATEST_SUMMARY_TEXT` ([L2644](file:///home/duxiter/s2s/s2s-client/server.py#L2644)). 8. **Skip WS connect rilassato:** anche senza `phoneNumber` se c'è `S2S_LATEST_SUMMARY_TEXT` la pipeline parte ([L2653-L2664](file:///home/duxiter/s2s/s2s-client/server.py#L2653-L2664)). 9. **Esecuzione summary WS:** medesima condizione rilassata + log "READY" con campo `summaryTextOverride=%s` ([L2725-L2746](file:///home/duxiter/s2s/s2s-client/server.py#L2725-L2746)). - **`INJECTIONS.md`** ([INJECTIONS.md](file:///home/duxiter/s2s/s2s-client/INJECTIONS.md)): 1. Sezione **4. Blocco summary ultima conversazione** completamente riscritta: - nuova tabella 2 modalità mutuamente esclusive (A = HTTP fetch dinamico / caller, B = override statico hardcoded); - paragrafo **Modalità B (override statico)** con esempio `.env` e nota su escape `\n`; - riferimenti codice aggiornati ([L206-L258](file:///home/duxiter/s2s/s2s-client/INJECTIONS.md#L206-L258)). 2. **Tabella riepilogo trigger** riga #4 aggiornata: - Trigger: `phoneNumber oppure override statico`; - env: `S2S_LATEST_SUMMARY` (HTTP) **o** `S2S_LATEST_SUMMARY_TEXT` (statico, priorità); - parametro: `phoneNumber=` (solo HTTP; override statico non lo richiede) ([L409-L417](file:///home/duxiter/s2s/s2s-client/INJECTIONS.md#L409-L417)). --- ## [2026-09-07] ### Aggiunto — Supporto `episode_id` (podcast episode inject) Nuovo parametro query string `?episode_id=` affiancato a `?phoneNumber=` che abilita il caricamento e l'iniezione automatica di un episodio podcast come contesto PRIMARIO nel prompt LLM. **URL di esempio:** ``` /?phoneNumber=%2B447432477433&episode_id=episode-19569f77-a06e-5ce5-a52f-6299dbd67d03 ``` **File modificati:** - **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)): 1. Aggiunta costante `PODCAST_EPISODE_HINT` ([L426-L429](file:///home/duxiter/s2s/s2s-client/server.py#L426-L429)) per l'hint semantico nel prompt. 2. Aggiunta funzione `_resolve_episode_context(raw_value)` ([L475-L504](file:///home/duxiter/s2s/s2s-client/server.py#L475-L504)): normalizza la risposta API (dict/list/str, eventuale wrapper `{"result": …}`), la serializza in JSON (cap 24.000 chars) e la avvolge con i marker `<<>>`. 3. Aggiunta funzione asincrona `_fetch_podcast_episode(episode_id)` ([L1010-L1078](file:///home/duxiter/s2s/s2s-client/server.py#L1010-L1078)): GET plain verso `https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}` (timeout 15s, follow redirects, error handling soft — non rompe mai il flusso, restituisce `None` su fail). 4. Estesa `_augment_instructions_with_profile(...)` ([L1137-L1181](file:///home/duxiter/s2s/s2s-client/server.py#L1137-L1181)) con nuovo parametro `episode_context_text`; l'ordine di inject ora è (più specifico → più generico): **episode → profile → reports → summary**. 5. Estesa `_patch_client_session_update_with_profile(...)` ([L1184-L1306](file:///home/duxiter/s2s/s2s-client/server.py#L1184-L1306)) con supporto a `episode_context_text` e logging dedicato `[INJECT-EPISODE]` / `[DEBUG-EPISODE]`. 6. WebSocket connect handler ([L1791-L1959](file:///home/duxiter/s2s/s2s-client/server.py#L1791-L1959)): - legge `episode_id` da query params assieme a `phoneNumber`; - esegue fetch episodio + resolve PRIMA di profile/reports/summary; - salva `injected_episode_context` e `inject_episode_id` nel `session_ctx` condiviso. 7. `_client_to_upstream` ([L2649-L2661](file:///home/duxiter/s2s/s2s-client/server.py#L2649-L2661)): passa `ctx_episode_context` al patch di `session.update`. 8. Endpoint `POST /api/reasoning`: - esteso modello `ReasoningRequest` con campo `episode_id: str | None = None` ([L1309-L1323](file:///home/duxiter/s2s/s2s-client/server.py#L1309-L1323)); - nel handler ([L1411-L1577](file:///home/duxiter/s2s/s2s-client/server.py#L1411-L1577)) fetch episodio indipendente da phone; - `inject_episode_text` viene **prepend** a `user_context_parts` (prima di tutti); - inoltro a NODE_RED / OPENAI con logging esteso (`episodeIdLen`). - **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)): 1. Lettura da query string: `const voceroEpisodeId = pageParams.get("episode_id")` ([L676-L680](file:///home/duxiter/s2s/s2s-client/main.js#L676-L680)). 2. `fetchOpenAiReasoningAnswer` ([L1337-L1364](file:///home/duxiter/s2s/s2s-client/main.js#L1337-L1364)): campo `episode_id` aggiunto al body POST `/api/reasoning`, logKey e `console.info` estesi. 3. Costruzione URL WebSocket ([L2323-L2342](file:///home/duxiter/s2s/s2s-client/main.js#L2323-L2342)): `episode_id` appeso a query params sia con API `URL.searchParams` sia nel fallback stringa (concat con `&`). --- ### Aggiunto — `S2S_START_SESSION_MODE`: 3 modalità di start sessione Env var letta dal server e propagata al client via `GET /api/config.startSessionMode`. Scopo: governare se e come il microfono si auto-accende e se il modello saluta per primo (flow simile a wake-word). Valori supportati: | Valore | Descrizione | |---|---| | *(vuoto) / default* | Start manuale: solo tap sull'Orb / pulsante Start | | **`TALK`** | Dopo 2s overlay fullscreen "Tocca ovunque per iniziare" (policy safe per Safari/iOS `getUserMedia`). Click/touch/tasto dentro la user gesture → `doStart()`. WS client inietta bootstrap `"hola vocero"` + `requestResponse()`. | | **`TALKFIRST`** | Dopo 2s **tenta `doStart()` DIRETTAMENTE SENZA click**, pre-caricando `pendingWakeWordConfirmationText = "hola vocero"` (stessa variabile della wake-word vera). Se i permessi mic sono già "allow always" funziona senza click. Se fallisce (policy Safari/iOS) → fallback sullo stesso unlocker di TALK con messaggio "permesso microfono". | **File modificati:** - **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)): 1. Lettura env: `S2S_START_SESSION_MODE = os.environ.get(...)`, `.strip().upper()` ([L150](file:///home/duxiter/s2s/s2s-client/server.py#L150)). 2. Banner `[STARTUP]` esteso con campo `startSessionMode=` ([L196-L212](file:///home/duxiter/s2s/s2s-client/server.py#L196-L212)). 3. Endpoint `GET /api/config` → nuovo campo `startSessionMode` + log `[INTERNAL] /api/config` ([L1383-L1400](file:///home/duxiter/s2s/s2s-client/server.py#L1383-L1400)). - **`index.html`** ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L32-L39)): - Aggiunto nuovo `#talk-unlocker` overlay fullscreen (z-index 9999), `hidden` di default, con card "Preparando Vocero… / Toca en cualquier lugar para empezar a hablar" + spinner gradient. - **`style.css`** ([style.css](file:///home/duxiter/s2s/s2s-client/style.css#L145-L215)): - Classi `.talk-unlocker`, `.talk-unlocker-hint`, `.talk-unlocker-spinner`, `.talk-unlocker-title`, `.talk-unlocker-sub` + animazioni fade-in e pop-up card. - **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)): 1. Variabile globale `startSessionMode = ""` + commento ([L628-L632](file:///home/duxiter/s2s/s2s-client/main.js#L628-L632)). 2. Parse dentro `fetchConfig()` → uppercase, log in `reportDebug("B",…)` ([L2309-L2320](file:///home/duxiter/s2s/s2s-client/main.js#L2309-L2320)). 3. Passaggio a WS client → `startWithResponse: startSessionMode === "TALK"` ([L2683-L2692](file:///home/duxiter/s2s/s2s-client/main.js#L2683-L2692)). 4. Blocco scheduling auto-start ([L2976-L3083](file:///home/duxiter/s2s/s2s-client/main.js#L2976-L3083)): - `bootstrapWithAutoStart()`: `await fetchConfig()` → `maybeScheduleAutoStart()`; - `setTimeout(2000 ms)` warmup per finire fetch di episode/profile/reports; - `showTalkUnlocker(fallbackHint)`: gestisce label custom nel caso fallback TALKFIRST; - **`tryTalkFirstDirect()` (TALKFIRST)**: set `pendingWakeWordConfirmationText="hola vocero"` + `doStart()` + try/catch con teardown e fallback unlocker; - listener unlocker click/touchstart/keydown → `startTalkFromGesture()` con `createResumedAudioContext()` dentro la gesture (per iOS silent-free). 5. `onClientStatus()` ([L2878-L2932](file:///home/duxiter/s2s/s2s-client/main.js#L2878-L2932)) aggiunto `hideTalkUnlocker()` in `connecting/queued/your-turn/connected`. - **`ws/s2s-ws-client.js`** ([s2s-ws-client.js](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js)): 1. Costruttore: nuove proprietà `_startWithResponse`, `_startWithResponseText` (default `"hola vocero"`), `_initialResponseSent` guardia one-shot ([L163-L178](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L163-L178)). 2. Handler `session.updated` ([L688-L713](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L688-L713)): **Solo per `TALK`** (non TALKFIRST, che usa invece `pendingWakeWordConfirmationText`): `this.sendUserText("hola vocero") + this.requestResponse()` — stesso identico code path della wake-word. 3. `requestResponse` / `_createResponseNow` ripristinati puliti (solo payload opzionale `image`; nessun shaped `response.create`). --- ### Modificato — `#reports-sidebar-toggle` visibile solo con `?debug=true` Il bottone "reports" nella topbar (toggle del pannello laterale reports) è ora `data-debug-only="true"` — viene quindi mostrato solo se l'URL contiene `?debug=true`, analogamente al cluster `.topbar-right`. **File modificati:** - **`index.html`** → `#reports-sidebar-toggle` ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L47-L50)): aggiunto `data-debug-only="true"`. - **`main.js`** → `applyDebugOnlyVisibilityGate()` ([L48-L109](file:///home/duxiter/s2s/s2s-client/main.js#L48-L109)): esteso per iterare *tutti* gli elementi `[data-debug-only="true"]` (non solo `.topbar-right`); riga 1227: `reportsSidebarToggle.hidden = false` gated a `qs.get("debug") === "true"` ([L1227-L1246](file:///home/duxiter/s2s/s2s-client/main.js#L1227-L1246)) + log rosso `🚫 FORZATO HIDDEN` quando manca debug=true. - **`style.css`** → regola di sicurezza già presente: `[data-debug-only]:not([data-debug-enabled]) { display:none !important; visibility:hidden; opacity:0 }` ([L532-L537](file:///home/duxiter/s2s/s2s-client/style.css#L532-L537)). --- ## [2026-09-04] ### Modificato — Chat sempre visibile + integrazione seamless Riorganizzato il layout per mostrare la chat testuale in modo permanente e integrato visivamente con l'Orb nella parte superiore dello stage. - Rimosso pulsante toggle chat nella topbar (`#chat-btn`) e relativo overlay. - Stage con l'Orb nella parte superiore; nuovo `#chat-panel` a seguire (cronologia + form input fisso sopra il footer sticky). - Aggiornata logica in `main.js` e `ui/chat.js` per l'invio testo tramite `client.sendUserText` e auto-scroll senza controlli di visibilità pannello. - Versione banner deploy: **2026-09-03 14:40**. ### Risolto — Stabilità integrazione chat mobile - Errore `Missing element: #chat-btn` risolto con selettori non-strict (`document.querySelector`) per elementi opzionali rimossi. - Refactoring estetico "seamless": chat senza cornici, ombre o sfondi (`background:transparent`, `box-shadow:none`). - **Layout mobile:** - padding incrementato in `#app` (56px) e `.chat-history` (96px) per evitare sovrapposizioni col footer sticky; - margini fissi bolle: `margin-right:22px` (Assistant), `margin-left:6px` (User). - **Auto-scroll streaming:** implementato `_scrollToBottom` con catena di retry multipli (`requestAnimationFrame` + `setTimeout`) per compensare il line-wrap asincrono durante l'arrivo dei token; `overflow-anchor:none` + `scroll-behavior:auto !important`. ### Aggiunto — Avatar utente iniziali + modale profilo - Sostituito testo nome utente nell'header con avatar circolare 40×40px (gradient brand) contenente le iniziali calcolate (es. "AV"). - Nuova `` in `index.html` che si apre al click sull'avatar: mostra email, telefono, ruolo, azienda, con supporto a shape annidate del payload profilo. - Chiusura modale: tasto ESC, click fuori card, pulsante `×` dedicato. --- ## [Note di formato] - **Cap campo prompt episodio:** 24.000 caratteri (transcript lunghi). - **Cap profilo / summary:** 1.600 caratteri ciascuno. - **Cap reports list:** 12.000 caratteri. - **Ordine inject prompt LLM (WS session.update + reasoning):** 1. Episodio podcast corrente (PRIMARIO) 2. Profilo chiamante (identity) 3. Lista reports / script podcast schedulati 4. Summary ultima conversazione - Tutti i blocchi sono marcati con `<<>>` per ispezione log.