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

864 lines
73 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```
<<<APIPILOT_TOOLS_LIST_BEGIN>>>
### 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...
<<<APIPILOT_TOOLS_LIST_END>>>
```
---
### 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 `<<<APIPILOT_CALL:NAME=...>>>` 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 `<<<APIPILOT_CALL:NAME=tool,ARGUMENTS={"phone` senza chiusura `>` 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 `<<<APIPILOT_CALL:NAME=get_user_briefing…>>>` 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 <<<APIPILOT_CALL>>> ] │
│ │
│ 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: │
│ <body text strumento> │
│ + 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)
```
<<<APIPILOT_CALL:NAME=get_user_briefing,ARGUMENTS={"phone":"+56912345678"}>>>
```
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
<<<APIPILOT_RESULT:STATUS=OK,NAME=get_user_briefing>>>
```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 `<prefix>…<last4> len=<N>` 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:<ultime-8-cifre-phoneNumber>` → se c'è `?phoneNumber=` in URL
2. `T:<ultimi-6-char-x-demo-session-token>` → 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: <token>
```
→ 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=<username-A> ← FIX #1 post-login phone refresh=true ✅ NUOVO
21:06:49 [apipilot] list_tools.ok count=…
21:07:16 [apipilot] list_tools.start user=<username-B> ← FIX #1 login nuovo utente ✅ NUOVO
21:07:16 [apipilot] list_tools.ok …
21:07:19 [apipilot] list_tools.start user=<username-A> ← 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 <TOKEN_UTENTE>` oppure `x-demo-session:
<SESSION_TOKEN>` 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=<URL> apipilotServiceToken=Y/- apipilotTtl=<N>s apipilotTimeout=<N>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 + `<a download>`):
1. `vocero-conversacion[-<nombreUsuario>]-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[-<nombreUsuario>]-YYYYMMDD-HHMMSS.txt` — log leggibile
per umani in spagnolo, ogni entry formattata come
`HH:MM Nombre / Vocero / HERRAMIENTA <nome>: 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 `<a download>` +
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=<N> 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 `<<<PODCAST_EPISODE_BEGIN/END>>>`.
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 `<dialog id="user-profile-dialog">` 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 `<<<NOME_BLOCCO_BEGIN/END>>>` per ispezione log.