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

73 KiB
Raw Blame History

Changelog — s2s-client (Azurian Vocero)

Tutte le modifiche rilevanti al progetto sono documentate in questo file. Formato basato su Keep a Changelog.


[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
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
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
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
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
6 Call site client→upstream forward adesso passa apipilot_context_text_pass1=ctx_apipilot_pass1 al patch 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 · 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
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

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 + 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
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
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
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
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
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
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 + 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
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
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 + server.py:L259-L267 + fetch_tools#L427-L444 + execute_tool#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 + WS INIT#L4094-L4108 + execute 4xx#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
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
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
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
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
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
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

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
2 Helper _apipilot_resolve_execute_url() (calcolo URL finale, priorità env override + append "/execute" se mancante) server.py#L321-L338
3 Regex marker cattura + parse name/arguments (antimeta-commento, JSON con doppie/singole virgolette) 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
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
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
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 · 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
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
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 · L3637-L3640
11 Banner di STARTUP esteso: mostra apipilotExecuteUrlOverride=…(risolto:…) + flag apipilotReasoningPass2=Y se integrazione attiva 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
    • Wrapper fetch globale _installFetchDebugTracer() IIFE — classificazione tag, preview request/response, palette status, performance timing, anti-spam poll/heartbeat: L41-L251
    • fetchConfig() — nuovo flag apipilotEnabled + window.__s2sApipilot: L2526-L2537
    • fetchConfig() — chiamata demo fire-and-forget ApiPilot tools list: 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
    • connect() — contatore #N sessione, log inizio/fine handshake, Promise.all([audioReady, wsReady, apipilotReady]): 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
    • _postSession() — timing handshake grant/queued/402/503: L469-L520
    • _pollQueue() — poll counter + advancement: L523-L573
    • _openWebSocket() — MASCHERAMENTO URL connect params (anti-leak token/JWT): L682-L728
    • _onWsMessage() — high-signal regex, preview response.function_call_arguments.done: L755-L812
    • _onWsClose() — badge status code OK/ERR: L1161-L1180
    • _send() — anti-spam input_audio_buffer.append 250 chunk / 2500 ms: 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

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 (cache+key+invalidazione globale) · 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
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

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.

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) — Sezione APIPILOT completa inserita tra OPENAI_PROJECT e NODE_RED_REASONING_URL:
    1. Definizione 4 env + normalizzazione + TTL/timeout cap range (L236-L266).
    2. Cache in-process _APIPILOT_CACHE: dict[fp → (monotonic, tools[])] + lock asincrona _APIPILOT_CACHE_LOCK (L268-L271).
    3. Helpers pura: _apipilot_auth_fingerprint(auth, demo) (L274-L287) · _apipilot_parse_authorization(Request) (L290-L309) · _apipilot_strip_tool_for_llm(tool_def) (L312-L338).
    4. Discovery: _apipilot_fetch_tools(auth, demo, include_unauthorized, force_refresh) (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) — rimuove extra, check required, messaggi in spagnolo.
    6. Execute: _apipilot_execute_tool(auth, demo, name, arguments) (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).
    8. /api/config: nuova chiave booleana "apipilot": bool(S2S_APIPILOT_TOOLS_URL) + docstring, log esteso (L2544-L2579).
    9. Pydantic model ApipilotExecuteRequest(name:str, arguments:dict={}) (L2582-L2585).
    10. GET /api/apipilot/tools proxy pubblico (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): 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):

    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).
  • ui/chat.js (chat.js):

    1. Helper module-level _safeName(prefix) per sanitizzare nomi file (L38-L50).
    2. Rif this._exportBtn = getElementById("chat-export-btn") + click listener nel ChatView constructor (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).
      • _conversationToText(conv) → pretty-format testo spagnolo con header data, totali, blocchi tool indentati, anteprima immagine dataURL (L584-L621).
      • _downloadBlob(content, filename, mime) → pattern <a download> + revokeObjectURL (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).
  • style.css (style.css):

    1. .chat-header-actions flex-row right-aligned (L4919-L4931).
    2. .chat-export-btn baseline (dark-theme): 32×32 pill, glassy, hover/focus states, transizioni 120-160 ms (L4932-L4968).
    3. Stati feedback: .chat-export-ok (verde) + .chat-export-empty (ámbar + animazione shake 5 step) (L4969-L4989).
    4. Varianti white-theme complete: .chat-header-actions, base, hover, active, OK, empty (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):

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

    1. Definizione nuova env S2S_LATEST_SUMMARY_TEXT + interpretazione escape \n (L128-L140).
    2. Banner [STARTUP] esteso con campo summaryText=%s per conferma visiva a boot (L275-L297).
    3. Ramo override PRIMA di qualsiasi logica HTTP dentro _fetch_latest_summary_for_phone(...) (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).
    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).
    6. Esecuzione summary REASONING: needs_summary and (phone or S2S_LATEST_SUMMARY_TEXT); log "READY" esteso con flag summaryTextOverride=%s (L2240-L2256).
    7. Gate WS connect: medesimo needs_summary esteso con S2S_LATEST_SUMMARY_TEXT (L2644).
    8. Skip WS connect rilassato: anche senza phoneNumber se c'è S2S_LATEST_SUMMARY_TEXT la pipeline parte (L2653-L2664).
    9. Esecuzione summary WS: medesima condizione rilassata + log "READY" con campo summaryTextOverride=%s (L2725-L2746).
  • INJECTIONS.md (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).
    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).

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

    1. Aggiunta costante PODCAST_EPISODE_HINT (L426-L429) per l'hint semantico nel prompt.
    2. Aggiunta funzione _resolve_episode_context(raw_value) (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): 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) 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) con supporto a episode_context_text e logging dedicato [INJECT-EPISODE] / [DEBUG-EPISODE].
    6. WebSocket connect handler (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): 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);
      • nel handler (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):

    1. Lettura da query string: const voceroEpisodeId = pageParams.get("episode_id") (L676-L680).
    2. fetchOpenAiReasoningAnswer (L1337-L1364): campo episode_id aggiunto al body POST /api/reasoning, logKey e console.info estesi.
    3. Costruzione URL WebSocket (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):

    1. Lettura env: S2S_START_SESSION_MODE = os.environ.get(...), .strip().upper() (L150).
    2. Banner [STARTUP] esteso con campo startSessionMode= (L196-L212).
    3. Endpoint GET /api/config → nuovo campo startSessionMode + log [INTERNAL] /api/config (L1383-L1400).
  • index.html (index.html):

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

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

    1. Variabile globale startSessionMode = "" + commento (L628-L632).
    2. Parse dentro fetchConfig() → uppercase, log in reportDebug("B",…) (L2309-L2320).
    3. Passaggio a WS client → startWithResponse: startSessionMode === "TALK" (L2683-L2692).
    4. Blocco scheduling auto-start (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) aggiunto hideTalkUnlocker() in connecting/queued/your-turn/connected.
  • ws/s2s-ws-client.js (s2s-ws-client.js):

    1. Costruttore: nuove proprietà _startWithResponse, _startWithResponseText (default "hola vocero"), _initialResponseSent guardia one-shot (L163-L178).
    2. Handler session.updated (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): aggiunto data-debug-only="true".
  • main.js → applyDebugOnlyVisibilityGate() (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) + 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).

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