73 KiB
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):
- 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-servernella pipeline Qwen3.5-35B vocale. - Gate S2S_INJECT_PROFILE chiudeva fuori l'apipilot nel ramo
=false. - 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):
- 🔴
S2S_APIPILOT_API_KEY(se valorizzata → TUTTE 3 forme headers, bypass completo, nessun login eseguito) - 🟠
S2S_APIPILOT_SERVICE_TOKEN(override manuale da env) - 🟡
session_ctx["apipilot_exec_auth"](bearer propagato direttamente dalla DISCOVERY fetch tools) - 🟢 LOGIN FORZATO PASS-2 se
requires_auth=truee bearer esistente NON JWT-like (<120 chars o <2 dots) - ⚫ 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 campodescription= contesto per far capire all'AI quale funzione chiamare (camponame); 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:
- UN SOLO marker per richiesta (1 tool massimo per invocazione)
- Subito dopo marker → NESSUN ALTRO TESTO (il resto viene ignorato, il risultato verrà iniettato nella passata 2)
arguments= JSON VALIDO a virgolette DOPPIE, solo campirequired_parameters:o dichiarati nel tool.- 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 flagapipilotEnabled+window.__s2sApipilot: L2526-L2537fetchConfig()— 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_apipilotClientCacheTTL 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, fetchapi/apipilot/tools(proxy serverS2S_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, previewresponse.function_call_arguments.done: L755-L812_onWsClose()— badge status code OK/ERR: L1161-L1180_send()— anti-spaminput_audio_buffer.append250 chunk / 2500 ms: L1523-L1584
- Banner tracer WS aggiornato + palette
s2s-client/ui/account.js:- Chiamata
GET api/metracciata automaticamente dal wrapper fetch globale come[FETCH:ACCOUNT:ME]→ nessuna modifica necessaria (nessun doppio log).
- Chiamata
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:461 sola chiamata[apipilot] list_tools.start user=(anon)a/api/v1/apipilot/tools(chiamata demo anonima bootstrap) - Subito dopo: multipli
POST /auth/phone-logineGET /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:
- Cache client globale (240 s) user-agnostic: la demo bootstrap anonima e le sessioni autenticate condividevano la stessa entry
_apipilotClientCachesenza chiave per utente. Quindi la cache anonima HIT veniva riutilizzata per utenti autenticati DIVERSI. - Cache server S2S (300 s) keyed per auth fingerprint (
SHA-256(auth)[:12] | x-demo-session) ma dal client non veniva propagato alcun tokenx-demo-session, quindi tutte le chiamate usavano la fingerprint "vuota" (anonima) = cache anonima riutilizzata senza mai chiamare AquaPortal per utenti autenticati. - Trigger demo solo su bootstrap: nessuna chiamata apipilot dopo
phone-loginriuscito, 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:
P:<ultime-8-cifre-phoneNumber>→ se c'è?phoneNumber=in URLT:<ultimi-6-char-x-demo-session-token>→ se c'è tokenanon→ altrimenti
HIT cache ora richiede 3 condizioni:
now - cached.at <= 240 s(TTL ancora attivo)cached.key === currentUser(stesso phone / stesso token → stesso utente)- 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)
- Fresh discovery — prima di ogni
executeviene 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. - Controllo
requires_auth— setruema 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. - 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_ARGUMENTSper ogni camporequiredmancante (messaggi in spagnolo).
- rimuove tutte le chiavi extra non in
- Passthrough header autenticazione:
Authorization: Bearer <TOKEN_UTENTE>oppurex-demo-session: <SESSION_TOKEN>vengono inoltrati VERBATIM al backend AquaPortal (§2.1 nota), mai loggati (solo presenza: logauth=Y / demo=Y). - Compatibilità LLM function-calling: helper
_apipilot_strip_tool_for_llm(tool_def)che proiettaToolDefinitionnel formato standard OpenAI{type: "function", function: {name, description, parameters}}— rimuoverequires_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 traOPENAI_PROJECTeNODE_RED_REASONING_URL:- Definizione 4 env + normalizzazione + TTL/timeout cap range (L236-L266).
- Cache in-process
_APIPILOT_CACHE: dict[fp → (monotonic, tools[])]+ lock asincrona_APIPILOT_CACHE_LOCK(L268-L271). - Helpers pura:
_apipilot_auth_fingerprint(auth, demo)(L274-L287) ·_apipilot_parse_authorization(Request)(L290-L309) ·_apipilot_strip_tool_for_llm(tool_def)(L312-L338). - Discovery:
_apipilot_fetch_tools(auth, demo, include_unauthorized, force_refresh)(L341-L481) — cache hit log, stale fallback, unwrap payloadlist/{tools,result,data}.tools, sanitizzazione ToolDefinition minima, log[APIPILOT-DISCOVERY] →/←. - Validazione:
_apipilot_validate_arguments(arguments, tool_def)(L484-L517) — rimuove extra, check required, messaggi in spagnolo. - 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] →/←. - Banner
[STARTUP] Other externals: esteso con 4 nuovi placeholderapipilot,apipilotServiceToken,apipilotTtl,apipilotTimeout(L726-L752). /api/config: nuova chiave booleana"apipilot": bool(S2S_APIPILOT_TOOLS_URL)+ docstring, log esteso (L2544-L2579).- Pydantic model
ApipilotExecuteRequest(name:str, arguments:dict={})(L2582-L2585). GET /api/apipilot/toolsproxy 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.POST /api/apipilot/executeproxy pubblico (L2654-L2700): body{name, arguments}, PRE-CHECK 1-2-3 tramite_apipilot_execute_tool, status code risposta =error.status_codese 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>):
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)
vocero-conversacion[-<nombreUsuario>]-YYYYMMDD-HHMMSS.txt— log leggibile per umani in spagnolo, ogni entry formattata comeHH: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-okcon 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):- Wrapper
.chat-header-actionsa destra dell'header, che ingloba la pill «En línea» + nuovo bottone#chat-export-btn(SVG freccia-giù) (L374-L394).
- Wrapper
-
ui/chat.js(chat.js):- Helper module-level
_safeName(prefix)per sanitizzare nomi file (L38-L50). - Rif
this._exportBtn = getElementById("chat-export-btn")+clicklistener nelChatViewconstructor (L74-L78). - Nuova sezione
// ── Export / download conversation ──con 4 metodi:_serializeConversation()→ estrae.hist-msgrows con querySelector:scope > .hist-msg, gestisceuser/assistant/tool/image, preserva timestampdata-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 localeYYYYMMDD-HHMMSS, include nome profilo Vocero se presente, logica empty/OK/error feedback, 2 download distinti (L647-L697).
- Helper module-level
-
style.css(style.css):.chat-header-actionsflex-row right-aligned (L4919-L4931)..chat-export-btnbaseline (dark-theme): 32×32 pill, glassy, hover/focus states, transizioni 120-160 ms (L4932-L4968).- Stati feedback:
.chat-export-ok(verde) +.chat-export-empty(ámbar + animazione shake 5 step) (L4969-L4989). - 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
\nnel valore env vengono interpretate come a-capo vero (stessa convenzione diS2S_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_SUMMARYURL +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):- Definizione nuova env
S2S_LATEST_SUMMARY_TEXT+ interpretazione escape\n(L128-L140). - Banner
[STARTUP]esteso con camposummaryText=%sper conferma visiva a boot (L275-L297). - 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.
- Gate REASONING:
needs_summary = bool(S2S_LATEST_SUMMARY) or bool(S2S_LATEST_SUMMARY_TEXT)(L2153). - 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). - Esecuzione summary REASONING:
needs_summary and (phone or S2S_LATEST_SUMMARY_TEXT); log "READY" esteso con flagsummaryTextOverride=%s(L2240-L2256). - Gate WS connect: medesimo
needs_summaryesteso conS2S_LATEST_SUMMARY_TEXT(L2644). - Skip WS connect rilassato: anche senza
phoneNumberse c'èS2S_LATEST_SUMMARY_TEXTla pipeline parte (L2653-L2664). - Esecuzione summary WS: medesima condizione rilassata + log "READY" con campo
summaryTextOverride=%s(L2725-L2746).
- Definizione nuova env
-
INJECTIONS.md(INJECTIONS.md):- 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
.enve nota su escape\n; - riferimenti codice aggiornati (L206-L258).
- Tabella riepilogo trigger riga #4 aggiornata:
- Trigger:
phoneNumber oppure override statico; - env:
S2S_LATEST_SUMMARY(HTTP) oS2S_LATEST_SUMMARY_TEXT(statico, priorità); - parametro:
phoneNumber=(solo HTTP; override statico non lo richiede) (L409-L417).
- Trigger:
- Sezione 4. Blocco summary ultima conversazione completamente riscritta:
[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):- Aggiunta costante
PODCAST_EPISODE_HINT(L426-L429) per l'hint semantico nel prompt. - 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>>>. - 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, restituisceNonesu fail). - Estesa
_augment_instructions_with_profile(...)(L1137-L1181) con nuovo parametroepisode_context_text; l'ordine di inject ora è (più specifico → più generico): episode → profile → reports → summary. - Estesa
_patch_client_session_update_with_profile(...)(L1184-L1306) con supporto aepisode_context_texte logging dedicato[INJECT-EPISODE]/[DEBUG-EPISODE]. - WebSocket connect handler (L1791-L1959):
- legge
episode_idda query params assieme aphoneNumber; - esegue fetch episodio + resolve PRIMA di profile/reports/summary;
- salva
injected_episode_contexteinject_episode_idnelsession_ctxcondiviso.
- legge
_client_to_upstream(L2649-L2661): passactx_episode_contextal patch disession.update.- Endpoint
POST /api/reasoning:- esteso modello
ReasoningRequestcon campoepisode_id: str | None = None(L1309-L1323); - nel handler (L1411-L1577) fetch episodio indipendente da phone;
inject_episode_textviene prepend auser_context_parts(prima di tutti);- inoltro a NODE_RED / OPENAI con logging esteso (
episodeIdLen).
- esteso modello
- Aggiunta costante
-
main.js(main.js):- Lettura da query string:
const voceroEpisodeId = pageParams.get("episode_id")(L676-L680). fetchOpenAiReasoningAnswer(L1337-L1364): campoepisode_idaggiunto al body POST/api/reasoning, logKey econsole.infoestesi.- Costruzione URL WebSocket (L2323-L2342):
episode_idappeso a query params sia con APIURL.searchParamssia nel fallback stringa (concat con&).
- Lettura da query string:
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):- Lettura env:
S2S_START_SESSION_MODE = os.environ.get(...),.strip().upper()(L150). - Banner
[STARTUP]esteso con campostartSessionMode=(L196-L212). - Endpoint
GET /api/config→ nuovo campostartSessionMode+ log[INTERNAL] /api/config(L1383-L1400).
- Lettura env:
-
index.html(index.html):- Aggiunto nuovo
#talk-unlockeroverlay fullscreen (z-index 9999),hiddendi default, con card "Preparando Vocero… / Toca en cualquier lugar para empezar a hablar" + spinner gradient.
- Aggiunto nuovo
-
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.
- Classi
-
main.js(main.js):- Variabile globale
startSessionMode = ""+ commento (L628-L632). - Parse dentro
fetchConfig()→ uppercase, log inreportDebug("B",…)(L2309-L2320). - Passaggio a WS client →
startWithResponse: startSessionMode === "TALK"(L2683-L2692). - 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): setpendingWakeWordConfirmationText="hola vocero"+doStart()+ try/catch con teardown e fallback unlocker;- listener unlocker click/touchstart/keydown →
startTalkFromGesture()concreateResumedAudioContext()dentro la gesture (per iOS silent-free).
onClientStatus()(L2878-L2932) aggiuntohideTalkUnlocker()inconnecting/queued/your-turn/connected.
- Variabile globale
-
ws/s2s-ws-client.js(s2s-ws-client.js):- Costruttore: nuove proprietà
_startWithResponse,_startWithResponseText(default"hola vocero"),_initialResponseSentguardia one-shot (L163-L178). - Handler
session.updated(L688-L713): Solo perTALK(non TALKFIRST, che usa invecependingWakeWordConfirmationText):this.sendUserText("hola vocero") + this.requestResponse()— stesso identico code path della wake-word. requestResponse/_createResponseNowripristinati puliti (solo payload opzionaleimage; nessun shapedresponse.create).
- Costruttore: nuove proprietà
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): aggiuntodata-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 = falsegated aqs.get("debug") === "true"(L1227-L1246) + log rosso🚫 FORZATO HIDDENquando 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-panela seguire (cronologia- form input fisso sopra il footer sticky).
- Aggiornata logica in
main.jseui/chat.jsper l'invio testo tramiteclient.sendUserTexte auto-scroll senza controlli di visibilità pannello. - Versione banner deploy: 2026-09-03 14:40.
Risolto — Stabilità integrazione chat mobile
- Errore
Missing element: #chat-btnrisolto 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).
- padding incrementato in
- Auto-scroll streaming: implementato
_scrollToBottomcon 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">inindex.htmlche 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):
- Episodio podcast corrente (PRIMARIO)
- Profilo chiamante (identity)
- Lista reports / script podcast schedulati
- Summary ultima conversazione
- Tutti i blocchi sono marcati con
<<<NOME_BLOCCO_BEGIN/END>>>per ispezione log.