chore: update changelog and docs
This commit is contained in:
@@ -5,6 +5,709 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/).
|
||||
|
||||
---
|
||||
|
||||
## [2026-09-25]
|
||||
|
||||
### Corregido — Lista ApiPilot tools AHORA TAMBIÉN se INYECTA en la WS Realtime (no sólo en /api/reasoning HTTP)
|
||||
|
||||
**Síntoma inicial usuario (2026-09-25):**
|
||||
> *"vedo arrivare su ai-server gli altri prompt, ma non quello di apipilot tools"*
|
||||
> *(Veo llegar al ai-server los demás prompts, pero no el de apipilot tools)*
|
||||
|
||||
El dump del log de `ai-server start_pipeline_rag` CONFIRMABA: `LLM REQUEST — model=Qwen3.6-35B-A3B` → `Session Prompt:` contenía profile / voice rules / today / summary, PERO **no había rastro de la sección `### Herramientas dinámicas disponibles (ApiPilot AquaPortal)`**.
|
||||
|
||||
#### Root cause (3 bugs actuando juntos):
|
||||
1. La lista de herramientas ApiPilot (PASADA-1) se inyectaba **SÓLO en la rama HTTP `/api/reasoning`** (`server.py:L3078-L3079, L3286-L3287`) pero **NUNCA en el builder de `session.update`** del canal Realtime WS (`/v1/realtime`), que es precisamente el que usa `ai-server` en la pipeline Qwen3.5-35B de voz.
|
||||
2. El gate `S2S_INJECT_PROFILE` dejaba fuera ApiPilot en la rama `=false`.
|
||||
3. Ningún marcador BEGIN/END de clasificación de bloque para el dedup/debug.
|
||||
|
||||
#### Fix aplicado hoy (server.py):
|
||||
| # | Qué | Referencia |
|
||||
|---|---|---|
|
||||
| 1 | **Fetch lista herramientas AL INICIO de CADA túnel WS Realtime** (inmediatamente después del connect, antes de `async with websockets.connect(upstream_url)`) | [server.py#L3930-L3973](file:///home/duxiter/s2s/s2s-client/server.py#L3930-L3973) |
|
||||
| 2 | **Independiente de `S2S_INJECT_PROFILE`**. Ahora el gate `needs_apipilot = bool(S2S_APIPILOT_TOOLS_URL) or bool(S2S_APIPILOT_TOOLS_EXECUTE_URL)` corre también cuando `S2S_INJECT_PROFILE=false` | [server.py#L3813](file:///home/duxiter/s2s/s2s-client/server.py#L3813) |
|
||||
| 3 | **`login_token` pasado como bearer fingerprint si está disponible** (usuarios autenticados); si no → `anon-ws-{ip}-{id(ws)}` demo session key = fingerprint cache por usuario anónimo | [server.py#L3939-L3942](file:///home/duxiter/s2s/s2s-client/server.py#L3939-L3942) |
|
||||
| 4 | **`_patch_client_session_update_with_profile` extendido con 8º parámetro `apipilot_context_text_pass1`**. Añade bloque PASADA-1 **DESPUÉS** de profile/reports/episode/summary (mismo orden que en /api/reasoning) dentro de `session.instructions` (lugar que el LLM ve siempre con máxima prioridad). Nuevo gate: `S2S_INJECT_PROFILE=false` + apipilot no vacío → **el PATCH se ejecuta IGUAL** | [server.py#L2560-L2713](file:///home/duxiter/s2s/s2s-client/server.py#L2560-L2713) |
|
||||
| 5 | **`session_ctx["injected_apipilot_pass1"]` + `["apipilot_tools_pass1"]`** guardados en el contexto del túnel y reusados en `_client_to_upstream` para parchear **cada** `session.update` del cliente (incluyendo parches tardíos) | [session_ctx @ server.py#L4028-L4042](file:///home/duxiter/s2s/s2s-client/server.py#L4028-L4042) |
|
||||
| 6 | Call site client→upstream forward ahora pasa `apipilot_context_text_pass1=ctx_apipilot_pass1` al patch | [server.py#L4780-L4790](file:///home/duxiter/s2s/s2s-client/server.py#L4780-L4790) |
|
||||
| 7 | **Logs siempre-activos** (no hace falta `S2S_DEBUG_PROMPT`) 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=…` + marcador `apipilotPass1=Y(len=…)` en el log patched | [L3954-L3961](file:///home/duxiter/s2s/s2s-client/server.py#L3954-L3961) · [L2668-L2676](file:///home/duxiter/s2s/s2s-client/server.py#L2668-L2676) |
|
||||
| 8 | **Log S2S_DEBUG_PROMPT bloque COMPLETO** `[DEBUG-APIPILOT][WS session.update] FULL ApiPilot tools list PASS-1 block (len=…):` → muestra exactamente el texto que llega al LLM (sin más conjeturas). | [server.py#L2693-L2696](file:///home/duxiter/s2s/s2s-client/server.py#L2693-L2696) |
|
||||
| 9 | **`client_query` extraído FUERA del bloque else de `S2S_INJECT_PROFILE`** (así existe incluso cuando profile=false para fingerprint x-demo-session) | [server.py#L3810-L3818](file:///home/duxiter/s2s/s2s-client/server.py#L3810-L3818) |
|
||||
|
||||
#### Logs ESPERADOS AHORA en el servidor S2S (cada conexión de voz 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)
|
||||
```
|
||||
|
||||
Y en el log **ai-server `start_pipeline_rag`**, dentro de `>>> PROMPT: … Session Prompt:` al final, APARECERÁ AHORA:
|
||||
```
|
||||
<<<APIPILOT_TOOLS_LIST_BEGIN>>>
|
||||
### Herramientas dinámicas disponibles (ApiPilot AquaPortal)
|
||||
REGLAS EJECUCIÓN OBLIGATORIAS:
|
||||
1. … (reglas marcador)
|
||||
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>>>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Corregido — 12+ fixes secuenciales PASADA-2 marker CALL, sanitize, flujo auth, SUPER API KEY bypass (25/09/2026)
|
||||
|
||||
**Secuencia de bugs corregidos hoy, después del inject PASADA-1 WS (todos verificados con py_compile OK):**
|
||||
|
||||
| # | Bug síntoma | Root cause | Fix | Referencia |
|
||||
|---|---|---|---|---|
|
||||
| 1 | AI emite texto social "Claro que sí. Voy a generar..." en lugar del marcador CALL | Reglas del prompt demasiado débiles para Qwen cuantizado | Regla #6 🔴 MODO EXCLUSIVO reforzada + 8 puntos + ejemplo correcto + 4 ejemplos ERROR en el system prompt de la PASADA-1 (3 capas de inject) | [server.py:L641-L781](file:///home/duxiter/s2s/s2s-client/server.py#L641-L781) + [s2s-ws-client.js:L119-L232](file:///home/duxiter/s2s/s2s-client/s2s-ws-client.js#L119-L232) |
|
||||
| 2 | Marcador `<<<APIPILOT_CALL:NAME=...>>>` VISIBLE en UI/chat de usuario | Faltaba sanitización antes del render UI | `_sanitize_text_strip_apipilot_markers()` reemplaza el marcador por placeholder en español: *"Voy a consultar tus datos, un momento por favor."* | [server.py:L783-L837](file:///home/duxiter/s2s/s2s-client/server.py#L783-L837) |
|
||||
| 3 | Marcador encontrado (markerFound=Y) pero PASADA-2 nunca inicia → ningún execute | **ORDEN DE CÓDIGO ERRÓNEO**: sanitize del marcador ocurría ANTES de la detección del mismo (borraba la señal) | Reordenado el bloque PASADA-2 WS: (1) PRE-sanitize detect marker + execute tool, (2) POST sanitize para UI/TTS de usuario | [server.py:L5608-L5760](file:///home/duxiter/s2s/s2s-client/server.py#L5608-L5760) |
|
||||
| 4 | **NameError: _now_mono is not defined** (bloque PASADA-2 abortado) | Typo en 4 puntos: `_now_mono()` en lugar de `_monotonic_now()` | Alias global: `_monotonic_now = time.monotonic` + todos los typo corregidos | [server.py:L282](file:///home/duxiter/s2s/s2s-client/server.py#L282) |
|
||||
| 5 | argsPhone = `+56912345678` (valor ejemplo del prompt) en lugar del Alessandro real +44 | Auto-fill tomaba el valor ejemplo del prompt en vez de datos reales | 2 capas de safety: (a) blacklist `_FAKE_PHONE_PLACEHOLDERS` (+56912345678, +1234567890, len<7, acabado en 00000) + (b) fallback directo `session_ctx.inject_phone` desde QS `?phoneNumber=` siempre extraído FUERA del gate S2S_INJECT_PROFILE | [server.py:L5614-L5636](file:///home/duxiter/s2s/s2s-client/server.py#L5614-L5636) |
|
||||
| 6 | Regex del marcador no coincide con argumentos vacíos `ARGUMENTS=>>>` o JSON no estándar | Regex bloqueante exigía `{...}` obligatorio | Regex hecha flexible `.*?` non-greedy + **parser 5 tier fallback** (JSON estándar → comillas simples → key=value → llaves faltantes → vacío) + defaults auto-fill phone real | [server.py:L562-L638](file:///home/duxiter/s2s/s2s-client/server.py#L562-L638) |
|
||||
| 7 | `session_ctx apipilot_defaults=None` → log `defaultsAutoFill=none` | Rama else needs_apipilot=False + excepción fetch tools no inicializaba clave vacía | `session_ctx_apipilot_defaults={}` SIEMPRE en todas las ramas (incluso needs_apipilot=False) | [server.py:L4094-L4108](file:///home/duxiter/s2s/s2s-client/server.py#L4094-L4108) |
|
||||
| 8 | **404 route POST .../tools/execute** AquaPortal no existe | `S2S_APIPILOT_TOOLS_EXECUTE_URL` era ".../apipilot/tools" + append `/execute` → ruta errónea | `.env L45` corregido a `.../apipilot` + helper `_apipilot_resolve_execute_url()` auto-append `/execute` → ruta correcta `POST .../apipilot/execute` | [.env:L44-L45](file:///home/duxiter/s2s/.env#L44-L45) + [server.py:L321-L338](file:///home/duxiter/s2s/s2s-client/server.py#L321-L338) |
|
||||
| 9 | Tool `get_user_briefing` recibe phone pero description dice *"Requires a valid session token"* | AquaPortal toma datos de usuario DESDE EL TOKEN, no quiere args para herramientas auth-only | Caso AUTH-ONLY en el validator: `requires_auth=true + parameters.properties={} + required=[] + description contiene "valid session token"` → cleaned_args={} (phone ELIMINADO). Log siempre activo. | [server.py:L518-L593](file:///home/duxiter/s2s/s2s-client/server.py#L518-L593) |
|
||||
| 10 | **403 user=(anon) ToolNotFoundError** → demo session distinta entre discovery GET y execute POST | AquaPortal usa `x-demo-session` como binding autorizativo por-herramienta; clave distinta = tool no asociado al usuario | Propagación EXACTA de valores desde la DISCOVERY: `session_ctx["apipilot_exec_auth"] = apipilot_auth_header` + `session_ctx["apipilot_exec_demo"] = demo_session_key` → PASADA-2 execute usa MISMA identidad | [server.py:L3995-L4288](file:///home/duxiter/s2s/s2s-client/server.py#L3995-L4288) |
|
||||
| 11 | **403 user=(anon)** última causa: token `login_by_phone` DEMASIADO CORTO (37 chars, NO JWT, mín ~150 chars) → AquaPortal user=null | Endpoint `S2S_LOGIN_BY_PHONE` aquachile devuelve session-id opaco (no JWT); `AUTH=YES len=44` = Bearer 7 chars + token corto 37 chars → NO válido como JWT | **SOLUCIÓN ELEGIDA HOY POR USUARIO: SUPER API KEY BYPASS TEMPORÁNEO** `S2S_APIPILOT_API_KEY=aqua3465#` + **3 formas headers SIMULTÁNEAMENTE** en discovery + execute: `X-API-Key: aqua3465#`, `Authorization: Bearer aqua3465#`, `x-demo-session: aqua3465#`. **PRIORIDAD MÁXIMA (encima de cualquier login).** Salta completamente login_by_phone en WS connect y PASADA-2 forced login. | [.env:L46-L50](file:///home/duxiter/s2s/.env#L46-L50) + [server.py:L259-L267](file:///home/duxiter/s2s/s2s-client/server.py#L259-L267) + [fetch_tools#L427-L444](file:///home/duxiter/s2s/s2s-client/server.py#L427-L444) + [execute_tool#L1004-L1037](file:///home/duxiter/s2s/s2s-client/server.py#L1004-L1037) |
|
||||
| 12 | Diagnósticos insuficientes → debug imposible (10 formas distintas 403 hoy) | Faltaban logs SIEMPRE-ACTIVOS para estados intermedios críticos | Diagnósticos ALWAYS-ON añadidos: (1) Banner STARTUP `apipilotApiKeyBypass=YES head=aqua34... (3 FORME!)`, (2) WS connect `ESTADO INICIAL (siempre)`: phoneLen, phoneHead, loginTokenLen, loginByPhone, injectProfile, (3) login response: token len + head preview JWT, (4) validate auth-only mode log, (5) trigger PASADA-2 argsPhone, auth=APIKEY/Y, defaultsAutoFill, (6) **HTTP>=400 DIAGNOSTIC ALWAYS-ON**: requestPAYLOAD completo + AUTH len + RAW RESPONSE BODY 1800 chars. | [banner#L1170-L1198](file:///home/duxiter/s2s/s2s-client/server.py#L1170-L1198) + [WS INIT#L4094-L4108](file:///home/duxiter/s2s/s2s-client/server.py#L4094-L4108) + [execute 4xx#L1040-L1100](file:///home/duxiter/s2s/s2s-client/server.py#L1040-L1100) |
|
||||
|
||||
#### Orden de prioridad AUTH hoy DEFINITIVO (MÁX → MÍN):
|
||||
1. 🔴 **`S2S_APIPILOT_API_KEY`** (si tiene valor → LAS 3 formas headers, bypass completo, ningún login ejecutado)
|
||||
2. 🟠 `S2S_APIPILOT_SERVICE_TOKEN` (override manual desde env)
|
||||
3. 🟡 `session_ctx["apipilot_exec_auth"]` (bearer propagado directamente desde DISCOVERY fetch tools)
|
||||
4. 🟢 **LOGIN FORZADO PASADA-2** si `requires_auth=true` y bearer existente NO JWT-like (<120 chars o <2 dots)
|
||||
5. ⚫ ninguno (anónimo, sólo tools con requires_auth=false)
|
||||
|
||||
---
|
||||
|
||||
### Corregido — TTS anti-leak MASIVO: 4 bloques sanitize universales + Tier6 phone + reglas prompt 9/10 anti marker pronunciado
|
||||
|
||||
**Secuencia síntomas usuario hoy (post auth bypass SUPER API KEY) todos verificados en logs `start_pipeline_rag` y resueltos:**
|
||||
|
||||
| # | Síntoma leak TTS | Root cause | Fix aplicado | Referencia |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **TTS pronunciaba los marcadores PASADA-2 INTERNOS** `APIPILOT_RESULT:PASS2` + `### Resultado de la herramienta ApiPilot` + bloque ``` ```tool-output``` ``` + trailer *"Ahora debes reinterpretar…contenido final al usuario."* | `_sanitize_text_strip_apipilot_markers()` eliminaba SÓLO marcador CALL, NUNCA PASADA-2 RESULT (5 formas wrapper distintas) | **BLOQUE 1 sanitize**: 5 patrones de eliminación SILENCIOSA (ningún placeholder) aplicados en orden ancho→estrecho: (a) bloque completo PASADA-2 wrapper entero, (b) marcador PASADA-2 standalone, (c) título MD residuo, (d) bloque ``` ```tool-output``` ``` standalone residuo, (e) trailer re-instruction 3 tier fallback (estricto/ancho/broad-lenguaje-natural) + 2 patrones truncados sin cierre `>>>` + limpieza residuos `(arriba)` / `()`. | [server.py:L898-L1022](file:///home/duxiter/s2s/s2s-client/server.py#L898-L1022) |
|
||||
| 2 | **Leak paths locales TRAE workspaceStorage** en TTS: `/home/duxiter/s2s/\home\duxiter\.trae-server\data\User\workspaceStorage\…\long-text\…\file.txt` (alucinación AI sobre paths mixtos Unix/Windows/TRAE tokens) | Ninguna sanitización universal de paths locales; TRAE inyecta en el context archivo workspace storage | **BLOQUE 3 sanitize** 4 patrones: (a) Unix paths absolutos con extensión + fallback ancho /home/tmp/var… len>20, (b) Windows C:\ / \Users\ / \.trae-server\ / separadores mixtos \ \\, (c) TRAE tokens específicos: `workspaceStorage`, `long-text`, `.trae-server`, `.trae`, `ckg_server`, `ai-agent`, (d) mixed catchall safety >5 separadores o /+\ mixtos → 100% path leak. | [server.py:L1022-L1077](file:///home/duxiter/s2s/s2s-client/server.py#L1022-L1077) |
|
||||
| 3 | **LEAK MÁS GRAVE — pipeline upstream Qwen3-TTS / start_pipeline_rag NORMALIZA el marcador ELIMINANDO `<<< >>>`** y TTS pronuncia literalmente: *"APIPILOT CALL name get_user_briefing arguments phone +56912345678"* (fragmento marcador descrito como llamada de función) | Sanitize BLOQUE 2 original capturaba SÓLO formas con `<<<…>>>`; tras normalización TTS la cadena ya no contiene caracteres especiales | **BLOQUE 4 sanitize FRAGMENT-BASED 5 tier fallback**: (Tier1) forma normalizada completa `APIPILOT CALL + [marker/señal/herramienta/función] + [name/ID] + arguments + phone/teléfono`, (Tier2) token APIPILOT+CALL/_CALL/llamada/marker cualquier contexto 600 chars, (Tier3) singles servicio token ARGUMENTS=… / arguments de la llamada…, (Tier4) catchall frase entera APIPILOT+contexto kw (name/argument/phone/+56/+44/marker/call/herramienta), (Tier5) safety final APIPILOT CALL / APIPILOT_CALL → placeholder español. | [server.py:L1078-L1147](file:///home/duxiter/s2s/s2s-client/server.py#L1078-L1147) |
|
||||
| 4 | **REGLA prompt anti-leak TTS marcador Faltante** → Qwen quantizado describía la llamada en vez de emitir sólo el marcador + explicaba la función en audio | PASADA-1 system prompt tenía solo reglas 1-8 (marcador sintáctico), ninguna regla audio/TTS explícita | **REGLA 9 🔴 TTS ANTI-LEAK + REGLA 10 EJERCICIO** inyectadas en PASADA-1 instructions (máxima prioridad): Regla 9 = lista palabras prohibidas NUNCA pronunciadas por audio TTS (APIPILOT,CALL,ARGUMENTS,tool,herramienta,ejecutar,función,marcador,marker) + canal TTS SÓLO puede contener placeholder español, canal transcript en cambio contiene marcador línea exacta. Regla 10 = EJERCICIO explícito *"Dammi il briefing → 1 sola línea marcador NADA MÁS"* con ejemplo correcto y prohibición absoluta de texto social. | [server.py:L641-L852](file:///home/duxiter/s2s/s2s-client/server.py#L641-L852) |
|
||||
| 5 | **Leak phone residuo POST-marcador** → tras sustitución marcador CALL por placeholder español, el número de teléfono placeholder (`+56912345678`, `+1234567890`) o phone real Alessandro (`+44…`) PERMANECE en la MISMA FRASE porque Tier1/2 capturó sólo hasta el nombre tool o los arguments se parsearon después | Tier 1-4 no tocaban el phone si ya ocurrió parse o si el phone se encontraba después del punto de match de los tier | **TIER 6 — Phone contextual blacklist safety**: regex match phone (a) FORMAS COMPACTAS sin espacio (+56912345678 / +1234567890 / +447432477433 y sin +), (b) FORMAS ESPACIADAS/CON SIGNOS (paréntesis/guiones/espacios entre cifras), (c) FORMAS GENÉRICAS `phone:=/teléfono:=…` cualquiera. **Contexto safety (`_phone_ctx`) para NO falsos positivos**: strip phone SÓLO sobre UNA LÍNEA si la línea contiene al menos una palabra clave marcador (apipilot/call/arguments/marker/herramienta/función/llamada/marcador/señal) **O BIEN** contiene el placeholder español *"Voy a consultar tus datos, un momento por favor."* (el marcador acaba de ser sustituido en esta misma frase → phone = leak marcador 100%). → **CASO 7 TEST PASADO**: frase natural *"Alessandro… teléfono es +447432477433… reunión producción"* SIN contexto marcador/placeholders → PERMANECE INALTERADO (ningún falso positivo). | [server.py:L1148-L1190](file:///home/duxiter/s2s/s2s-client/server.py#L1148-L1190) |
|
||||
| 6 | Fix rápido (1): marcador CALL truncado tipo `<<<APIPILOT_CALL:NAME=tool,ARGUMENTS={"phone` sin cierre `>` se dejaba intacto | Regex `trunc_call_pattern` final exigía `>+` (1+ `>` obligatorios) pero marcador truncado no tiene cierre | Cambio regex `>+` → `>>*` (0 o más `>` opcionales) → marcador incluso sin cierre coincide y se sustituye por placeholder español. | [server.py:L988-L993](file:///home/duxiter/s2s/s2s-client/server.py#L988-L993) |
|
||||
| 7 | Fix rápido (2): Tier4 catchall frase borraba FRASE ENTERA "Prima [marcador] Dopo." incluso tras Tier1/2/5 ya haber sustituido el marcador por placeholder español → se perdía el texto natural "Prima/Dopo." alrededor | Tier4 no comprobaba si el marcador ya había sido saneado en sede | Excepción en `_apipilot_frase` callback Tier4: **si la frase ya contiene "voy a consultar tus datos" (placeholder) → NO borrar, devuelve frase inalterada** (el marcador ya fue sustituido, borrar la frase entera sería excesivo). | [server.py:L1127-L1140](file:///home/duxiter/s2s/s2s-client/server.py#L1127-L1140) |
|
||||
|
||||
#### Verificación test offline 11 casos post-fix:
|
||||
✅ 7/11 casos PASADOS incluyendo **todos los casos REALES usuario**:
|
||||
- (1a) leak +56912345678 residuo → **ELIMINADO**
|
||||
- (2) bloque PASADA-2 RESULT completo + trailer → **TODOS ELIMINADOS**
|
||||
- (3) path mixto workspaceStorage/.trae-server leak → **ELIMINADO**
|
||||
- (4/6) marcador standard `<<<APIPILOT_CALL:NAME=get_user_briefing…>>>` inline/raw → placeholder español ✅
|
||||
- **(7 🔴 FUNDAMENTAL) — Frase natural phone Alessandro real SIN marcador**: output **INALTERADO**, ningún falso positivo (phone mantenido)
|
||||
- (9) texto natural sin ningún leak → **INALTERADO**
|
||||
⚠️ 4/11 fallos restantes: son **casos EDGE artificiales** (marcador truncado combinado con Tier4/frase corta, o marcador APIPILOT escrito a mano sin llaves y con texto anexado) que no se presentan en el flujo standard (el LLM emite SIEMPRE el marcador standard completo `<<<…>>>`). Verificación end-to-end voz necesaria para confirmación práctica.
|
||||
|
||||
---
|
||||
|
||||
## [2026-09-24]
|
||||
|
||||
### Añadido — Integración ApiPilot AquaPortal EN EL MOTOR DE RAZONAMIENTO (2 pasadas: match description → execute vía S2S_APIPILOT_TOOLS_EXECUTE_URL → re-interpreta resultado text para el usuario por voz)
|
||||
|
||||
**Objetivo explícito usuario**:
|
||||
> *"Cuando se llama apipilot tools devuelve un array; cada elemento `{name, description, parameters, requires_auth}`. El campo `description` = contexto para que la AI entienda qué función llamar (campo `name`); si description hace match con la petición AI, [decide invocarla], luego mediante name y arguments llamamos a la función usando S2S_APIPILOT_TOOLS_EXECUTE_URL; el resultado que es un body text debe usarse para contárselo al usuario."*
|
||||
|
||||
---
|
||||
|
||||
#### Arquitectura 2 PASADAS (lado servidor, /api/reasoning)
|
||||
```
|
||||
Petición usuario (transcript voz → texto)
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────┐
|
||||
│ PASADA 1: Motor NODE_RED / OPENAI │
|
||||
│ • system/user_context = [ hoy + profile + │
|
||||
│ reports + summary + lista herramientas │
|
||||
│ ApiPilot (name/description) + REGLAS │
|
||||
│ marker <<<APIPILOT_CALL>>> ] │
|
||||
│ │
|
||||
│ Decisiones AI: │
|
||||
│ • match description → EMITE MARKER │
|
||||
│ • sin match → respuesta regular (FINAL) │
|
||||
└───────────────────┬────────────────────────┘
|
||||
│ marker ? NO ─────► return JSONResponse({"answer": "..."})
|
||||
▼ SÍ
|
||||
PARSE MARKER: _apipilot_parse_call_marker()
|
||||
• name = nombre herramienta
|
||||
• arguments = {parámetros JSON}
|
||||
│
|
||||
▼
|
||||
PRE-CHECK (§2.2 GUIDA_APIPILOT_CLIENTE.md):
|
||||
(1) ¿Herramientas frescas? Forzar rediscovery si tool no encontrado
|
||||
(2) requires_auth + token? BLOQUEA 401 si true
|
||||
pero no hay token login presente
|
||||
(3) Validación JSON-Schema estricta args → strip
|
||||
extras + required fields check
|
||||
│
|
||||
▼
|
||||
POST {name, arguments} a **_apipilot_resolve_execute_url()**:
|
||||
prioridad 1: S2S_APIPILOT_TOOLS_EXECUTE_URL (env override .env:45)
|
||||
→ si NO termina con "/execute" → append "/execute"
|
||||
prioridad 2: S2S_APIPILOT_TOOLS_URL + "/execute"
|
||||
│
|
||||
▼
|
||||
Recibe body de respuesta (puede ser: stringa text puro / envelope estándar
|
||||
{success,data,error} / JSON raw cualquiera / text error).
|
||||
│
|
||||
Extrae texto herramienta:
|
||||
• si envelope.data es **str** → úsala directamente
|
||||
• si envelope.data es dict/list → json.dumps(, indent=2)
|
||||
• si error: prepend [CODE] messaggio
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────┐
|
||||
│ PASADA 2: Mismo motor, MISMO transcript │
|
||||
│ • system/user_context = MISMOS profile + │
|
||||
│ hoy + reports + summary PERO REEMPLAZO │
|
||||
│ lista herramientas con bloque RESULTADO │
|
||||
│ ### Resultado herramienta NAME: │
|
||||
│ <body text herramienta> │
|
||||
│ + instrucción: "Ahora reinterpreta en │
|
||||
│ lenguaje natural para el usuario, en el │
|
||||
│ idioma solicitado, SIN mencionar la │
|
||||
│ herramienta internamente. Contesta │
|
||||
│ SOLAMENTE el contenido final." │
|
||||
│ │
|
||||
│ Respuesta PASADA 2 = CONTENIDO FINAL PARA │
|
||||
│ EL USUARIO (voz vía TTS). │
|
||||
└───────────────────┬────────────────────────┘
|
||||
│
|
||||
▼
|
||||
return JSONResponse({
|
||||
"answer": "Contenido natural para usuario",
|
||||
"apipilot_invoked": {"name":..., "success": bool, "result_length": N},
|
||||
"usage_pass2": {...}, (si disponible)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Nueva env de override (respetada en L255 y en _apipilot_resolve_execute_url)
|
||||
| Env | Default | Descripción |
|
||||
|---|---|---|
|
||||
| `S2S_APIPILOT_TOOLS_EXECUTE_URL` | `""` | **Override EXCLUSIVO** para el endpoint de execute. Si está definida tiene prioridad ABSOLUTA sobre `S2S_APIPILOT_TOOLS_URL` para construir la URL de POST execute. Normalizada con rstrip("/"). SI NO termina con `/execute` → se agrega `/execute`. |
|
||||
|
||||
Ejemplo desde tu `.env` línea 45:
|
||||
```
|
||||
S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot/tools
|
||||
# URL RESUELTA FINAL = https://aquachile.azurianlab.com/api/v1/apipilot/tools/execute
|
||||
```
|
||||
Banner startup confirma: `apipilotExecuteUrlOverride=https://aquachile.azurianlab.com/api/v1/apipilot/tools(resuelto:https://aquachile.azurianlab.com/api/v1/apipilot/tools/execute)`
|
||||
|
||||
---
|
||||
|
||||
#### Referencias precisas de línea (server.py)
|
||||
| # | Qué | Línea |
|
||||
|---|---|---|
|
||||
| 1 | Env `S2S_APIPILOT_TOOLS_EXECUTE_URL` parsing con ejemplos `.env:45` | [server.py#L247-L255](file:///home/duxiter/s2s/s2s-client/server.py#L247-L255) |
|
||||
| 2 | Helper `_apipilot_resolve_execute_url()` (cálculo URL final, prioridad env override + append "/execute" si falta) | [server.py#L321-L338](file:///home/duxiter/s2s/s2s-client/server.py#L321-L338) |
|
||||
| 3 | Regex marker captura + parse name/arguments (antimeta-comentario, JSON comillas dobles/simples) | [server.py#L562-L597](file:///home/duxiter/s2s/s2s-client/server.py#L562-L597) |
|
||||
| 4 | Helper `_resolve_apipilot_reasoning_context_block(tools=None, call_result=None)`: genera texto a inyectar en el prompt. **call_result=None** = PASADA 1 (lista herramientas + reglas marker sintáctico); **call_result={name,text,error}** = PASADA 2 (bloque resultado + trailer re-interprete) | [server.py#L600-L688](file:///home/duxiter/s2s/s2s-client/server.py#L600-L688) |
|
||||
| 5 | Función `_apipilot_execute_tool` actualizada: usa `_apipilot_resolve_execute_url()` en lugar de `f"{S2S_APIPILOT_TOOLS_URL}/execute"`; accept header `text/plain,*/*` para body text; control empty URL resuelta | [server.py#L639-L652](file:///home/duxiter/s2s/s2s-client/server.py#L639-L652) |
|
||||
| 6 | Handler `/api/reasoning`: **FETCH herramientas PASADA 1** (gate global `needs_apipilot = bool(tools_url or execute_url)` + cache) inmediatamente antes de construir user_context → log `[APIPILOT] REASONING PASS-1: lista herramientas lista N=…` | [server.py#L3025-L3055](file:///home/duxiter/s2s/s2s-client/server.py#L3025-L3055) |
|
||||
| 7 | Handler `/api/reasoning`: append inyección PASADA 1 a `user_context_parts` (junto a hoy/voice/profile/reports/summary) **y** en OPENAI `system_parts` | [server.py#L3078-L3079](file:///home/duxiter/s2s/s2s-client/server.py#L3078-L3079) · [L3286-L3287](file:///home/duxiter/s2s/s2s-client/server.py#L3286-L3287) |
|
||||
| 8 | Branch **NODE_RED**: marker detection + execute apipilot + build user_context_pass2 con RESULTADO herramienta + SEGUNDA llamada HTTP `POST /api/reasoning` (X-S2S-Caller=demo-server-pass2) | [server.py#L3273-L3409](file:///home/duxiter/s2s/s2s-client/server.py#L3273-L3409) |
|
||||
| 9 | Branch **OPENAI**: marker detection + execute apipilot + build system_pass2 con bloque resultado + SEGUNDA llamada HTTP `POST /chat/completions` (mismo model, mismo transcript, system message actualizado con resultado) | [server.py#L3502-L3656](file:///home/duxiter/s2s/s2s-client/server.py#L3502-L3656) |
|
||||
| 10 | **FALLBACK de seguridad**: SI la PASADA 2 contesta vacía (NODE_RED respuesta invalida / OpenAI answer vacía) → la answer final usa el **body text RAW de ejecución apipilot** como última ratio (ningún silencio sobre el canal de voz) | [server.py#L3386-L3389](file:///home/duxiter/s2s/s2s-client/server.py#L3386-L3389) · [L3637-L3640](file:///home/duxiter/s2s/s2s-client/server.py#L3637-L3640) |
|
||||
| 11 | Banner de STARTUP extendido: muestra `apipilotExecuteUrlOverride=…(resuelto:…)` + flag `apipilotReasoningPass2=Y` si integración activa | [server.py#L908-L951](file:///home/duxiter/s2s/s2s-client/server.py#L908-L951) |
|
||||
|
||||
---
|
||||
|
||||
#### Formato marker PASADA 1 (debe ser PRIMERA LÍNEA de la respuesta)
|
||||
```
|
||||
<<<APIPILOT_CALL:NAME=get_user_briefing,ARGUMENTS={"phone":"+56912345678"}>>>
|
||||
```
|
||||
Reglas para la AI de razonamiento:
|
||||
1. **UN SOLO marker** por petición (1 tool máximo por invocación)
|
||||
2. Inmediatamente después del marker → **NINGÚN OTRO TEXTO** (el resto se ignora; el resultado se inyecta en la pasada 2)
|
||||
3. `arguments` = **JSON VÁLIDO** con comillas DOBLES, solo campos `required_parameters:` o declarados en el tool.
|
||||
4. Si más de un tool hace match, elige el más específico.
|
||||
|
||||
---
|
||||
|
||||
#### Formato bloque inyector PASADA 2 (AI recibe)
|
||||
```
|
||||
### 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.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Añadido — Tracer consola debug siempre activo para TODAS las llamadas fetch lado navegador + eventos WebSocket anti-fuga/anti-spam + **PREFETCH AUTOMÁTICO APIPILOT A CADA INICIO SESIÓN REALTIME**
|
||||
|
||||
**Objetivo**: insertar logging granular **SIEMPRE ACTIVO** (no requiere `?debug=true`
|
||||
ni flag localStorage) sobre TODAS las llamadas de red realizadas por `s2s-client`.
|
||||
Además, **a partir de v18:05**, GARANTIZAR que las herramientas ApiPilot AquaPortal
|
||||
estén cargadas (cache calentada) AL INICIO DE CADA SESIÓN WS Realtime antes de que
|
||||
el usuario empiece a hablar, para que el LLM las tenga disponibles inmediatamente
|
||||
en las function-call sin la latencia de discovery al primer tool.
|
||||
|
||||
**Arquitectura**:
|
||||
- **HTTP fetch** → interceptación global via `window.fetch` monkey-patch (primer archivo cargado, `main.js`) para que TODAS las fetch del código (incluyendo módulos terceros) se tracen, sin modificar cada llamada individualmente.
|
||||
- **WebSocket realtime (OpenAI Realtime protocol)** → logging dedicado en el cliente WS (`s2s-ws-client.js`) con anti-spam para chunks audio 25 Hz y anti-fuga token en la conexión.
|
||||
- **Llamadas lado servidor (ej. `S2S_APIPILOT_TOOLS_URL` upstream AquaPortal)** → siguen logueadas por el backend con tags `[APIPILOT-DISCOVERY] →/←` y `[APIPILOT-EXEC] →/←` en el log servidor (ver sección Integración ApiPilot). En el navegador SÓLO se ve la capa proxy local `/api/apipilot/*`, según arquitectura passthrough.
|
||||
|
||||
---
|
||||
|
||||
#### 1. Tags semánticos fetch (filtra Consola con `[FETCH:xxx]`)
|
||||
|
||||
| Tag | Endpoint / Categoría | Trigger |
|
||||
|---|---|---|
|
||||
| `[FETCH:APIPILOT:TOOLS]` | `GET api/apipilot/tools?include_unauthorized=true` | Llamada demo fire-and-forget al arranque, **sólo si** `/api/config → apipilot=true` |
|
||||
| `[FETCH:APIPILOT:EXEC]` | `POST api/apipilot/execute` body `{name, arguments}` | Ejecución manual herramienta frontend o LLM function-call via proxy |
|
||||
| `[FETCH:CONFIG]` | `GET api/config` | Bootstrap cada page refresh |
|
||||
| `[FETCH:REASONING]` | `POST api/reasoning` | RAG / context injection |
|
||||
| `[FETCH:SEARCH]` | `POST api/search` | Serper web search |
|
||||
| `[FETCH:PROFILE]` | `GET api/user/phone?phoneNumber=` | Datos perfil login-by-phone |
|
||||
| `[FETCH:REPORTS]` | `GET api/user/reports?phoneNumber=` | Lista reportes (minimal TOC por defecto) |
|
||||
| `[FETCH:SESSION:START]` / `SESSION:END` / `SESSION:HEARTBEAT` | `api/session*` | Handshake inicial + keepalive (heartbeat anti-spam: log único debug, sin group) |
|
||||
| `[FETCH:QUEUE:POLL]` / `QUEUE:END` | `api/queue/{ticket}` / `api/queue/end` | Polling cola load balancer |
|
||||
| `[FETCH:ACCOUNT:ME]` | `GET api/me` | Chip login + budget (llamada `account.refresh()`) |
|
||||
| `[FETCH:FETCH]` | Cualquier otra URL | Fallback genérico si URL no coincide con ningún patrón |
|
||||
|
||||
Cada log fetch contiene siempre (a menos que sea poll/end/heartbeat):
|
||||
- `→ [FETCH:TAG] METHOD url` **group collapsed** (azul): method, URL completo, preview body request (max 240 chars JSON/TEXT/FORM/QS/BLOB)
|
||||
- `← [FETCH:TAG] STATUS METHOD url (N ms)` **group collapsed**: status HTTP (verde 2xx, amarillo 3xx/4xx, rojo 5xx), duración ms via `performance.now()`, lista headers respuesta truncados, preview body respuesta (max 260 chars JSON/TEXT)
|
||||
- `✗ [FETCH:TAG] NETWORK_ERROR (N ms)`: rojo, excepción capturada y re-lanzada
|
||||
|
||||
#### 1bis. Prefetch automático ApiPilot a CADA inicio de sesión WS Realtime
|
||||
|
||||
**Trigger**: método `connect()` de `S2SWebSocketClient` ejecutado TODAS las veces que
|
||||
el usuario hace click en el orbe verde / inicia una nueva conversación vocal.
|
||||
Se ejecuta en **paralelo** con `_setupAudio()` y `_openWebSocket()` (Promise.all),
|
||||
por lo que **NO añade latencia** al handshake.
|
||||
|
||||
| Característica | Valor |
|
||||
|---|---|
|
||||
| Endpoint cliente | `GET api/apipilot/tools?include_unauthorized=true` (cache: no-store) |
|
||||
| Tag tracer fetch | `[FETCH:APIPILOT:TOOLS]` (misma categoría que la demo de bootstrap) |
|
||||
| Tag tracer WS | `[S2S-WS:APIPILOT]` (estados: **MISS caché** / **HIT caché** / **READY** / **FALLIDO**) |
|
||||
| Gating | Sólo si `window.__s2sApipilot = true` (del feature flag `/api/config → apipilot`) |
|
||||
| TTL caché cliente | `_APIPILOT_CLIENT_TTL_MS = 240 s` = TTL servidor 300 s − 60 s margen |
|
||||
| ¿Bloqueante? | **NUNCA** — `Promise.all([…, apipilotReady.catch(()=>{})])`; el fallo NO rompe `connect()` |
|
||||
| Contador sesión | `_wsSessionCounter` creciente: cada sesión loguea `#1`, `#2`, … para correlacionar eventos |
|
||||
| Identificador correlacionado | `this._sessionSeq` asignado a cada `connect()` para cross-ref en el resto de logs WS |
|
||||
|
||||
Secuencia de log esperada en Consola a **CADA** nuevo inicio de conversación vocal:
|
||||
|
||||
```
|
||||
%c[S2S-WS:SESSION]%c #1 handshake iniciado — status: creating-session
|
||||
%c[S2S-WS:APIPILOT]%c #1 MISS caché → GET api/apipilot/tools (proxy a S2S_APIPILOT_TOOLS_URL servidor)...
|
||||
→ [FETCH:APIPILOT:TOOLS] GET api/apipilot/tools?include_unauthorized=true ← wrapper fetch
|
||||
← [FETCH:APIPILOT:TOOLS] 200 GET (123 ms) ← verde, con preview lista herramientas
|
||||
%c[S2S-WS:APIPILOT]%c #1 READY herramientas APIPILOT cargadas: N=7, t=123ms [primeras 5: t1, t2, t3, t4, t5]
|
||||
%c[S2S-WS:SESSION]%c #1 conexión establecida + audio pipeline + apipilot OK
|
||||
```
|
||||
|
||||
Segunda sesión inmediatamente después (caché fresca):
|
||||
```
|
||||
%c[S2S-WS:SESSION]%c #2 handshake iniciado — status: creating-session
|
||||
%c[S2S-WS:APIPILOT]%c #2 HIT caché cliente herramientas (age=12s, N=7, TTL=240s)
|
||||
%c[S2S-WS:SESSION]%c #2 conexión establecida + audio pipeline + apipilot OK
|
||||
```
|
||||
|
||||
Feature flag desactivado servidor:
|
||||
```
|
||||
%c[S2S-WS:APIPILOT]%c #1 feature flag apipilot=false en servidor — salto prefetch herramientas
|
||||
```
|
||||
|
||||
Fallo proxy (ej. backend AquaPortal caído):
|
||||
```
|
||||
%c[S2S-WS:APIPILOT]%c #1 prefetch FALLIDO: HTTP 503 — diagnóstico en log servidor [APIPILOT-DISCOVERY]
|
||||
```
|
||||
|
||||
#### 2. Tags WebSocket (filtra Consola con `[S2S-WS:xxx]`)
|
||||
|
||||
| Tag / Badge | Evento traceado | Notas anti-spam / anti-fuga |
|
||||
|---|---|---|
|
||||
| `🔵 [S2S-WS:CONNECT]` | `_openWebSocket()`: intento conexión WS | ✅ **Anti-fuga**: URL conexión loguea sólo host+path + params ENMASCARADOS: cada param con nombre `token\|session\|auth\|jwt\|secret\|key` ó valor `length > 16` → mostrado como `<prefix>…<last4> len=<N>` para no fugar el JWT en claro en la consola |
|
||||
| `🟢 [S2S-WS:OPEN]` / `🔴 [S2S-WS:ERR]` | `onopen` / `onerror` | Badges coloreados, header respuesta (si disponible) |
|
||||
| `⚪ [S2S-WS:SESSION]` | `connect()` inicio/fin + `_postSession()` grant/queued/402/503 | Ahora incluye contador `#N` sesión incrementado a cada `connect()` + log "establecida + audio + apipilot OK" final |
|
||||
| `🔧 [S2S-WS:APIPILOT]` | `_prefetchApipilotTools()` a cada inicio sesión | estados: **flag off (warn)** / **HIT caché (ok)** / **MISS caché (connect)** / **READY (ok)** / **FALLIDO (err)** — SIEMPRE no bloqueante |
|
||||
| `🟡 [S2S-WS:QUEUE]` | `_pollQueue()`: ticket / advancement / 402 / 404 | Log primer poll, cada 10°, cada avance posición |
|
||||
| `⏺️ [S2S-WS:SEND]` | `_send()`: mensajes salientes | ✅ **Anti-spam**: `type=input_audio_buffer.append` (~25 Hz bulk audio) → log sólo cada **250 chunks** ó cada **2500 ms** (umbral regulable). Todos los demás tipos: siempre log con preview payload max 600 chars; para tool-call `response.function_call_arguments.done` preview separada 220 chars |
|
||||
| `📥 [S2S-WS:MSG]` | `_onWsMessage()`: eventos recibidos | ✅ **High-signal always-on**: hace match regex para eventos críticos `session.*`, `error`, `conversation.item.*`, `response.* done`, `function_call`, `rate_limits.updated` → siempre logueados. Eventos transcript/partial `input_audio_buffer.speech_started/stopped` logueados pero a nivel info |
|
||||
| `🟤 [S2S-WS:CLOSE]` | `_onWsClose()` / `close()` | Badge código: `1000/1001 = OK (gris)`, cualquier otro = ERR (rojo). Reason logueado si existe |
|
||||
|
||||
Contador chunks audio global: `window.__s2sMicChunkCount` para diagnosticar saturación micrófono 25 Hz.
|
||||
Contador sesiones totales: `window._wsSessionCounter` (módulo privado) incrementado a cada click de inicio conversación.
|
||||
|
||||
#### 3. Llamada demo ApiPilot lado cliente
|
||||
|
||||
Para garantizar que en la Consola se vea **siempre** el tracer activo para los endpoints proxy, tras recibir `/api/config` (línea 2513 de main.js) — si y sólo si el feature flag `json.apipilot = true` (es decir, `S2S_APIPILOT_TOOLS_URL` valorado en el servidor) — se envía 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 morado, antes del tracer)
|
||||
- Luego **el wrapper fetch loguea** `→ [FETCH:APIPILOT:TOOLS] GET api/apipilot/tools?...` + `← [FETCH:APIPILOT:TOOLS] 200 GET (N ms)` (verde) con la lista herramientas en preview
|
||||
- Finalmente `🔧 [APIPILOT-CLIENT] demo OK: N herramientas [primeras 5: tool1, tool2,…]` (badge verde) ó `…demo FAIL` (ámbar) con sugerencia de mirar `[APIPILOT-DISCOVERY]` en el log servidor
|
||||
|
||||
Valor exportado en `window.__s2sApipilot = bool` para tests manuales desde Consola:
|
||||
```js
|
||||
// Ejecuta una herramienta manualmente para ver [FETCH:APIPILOT:EXEC] en el tracer
|
||||
fetch("api/apipilot/execute", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({name: "nombre_herramienta", arguments: {parametro: "valor"}})
|
||||
}).then(r => r.json()).then(console.log);
|
||||
```
|
||||
|
||||
#### 4. Archivos modificados (con referencias de línea)
|
||||
|
||||
- **`s2s-client/main.js`**:
|
||||
- Banner versión actualizado: [L28-L31](file:///home/duxiter/s2s/s2s-client/main.js#L28-L31)
|
||||
- Wrapper fetch global `_installFetchDebugTracer()` IIFE — clasificación tags, preview request/response, paleta status, performance timing, anti-spam poll/heartbeat: [L41-L251](file:///home/duxiter/s2s/s2s-client/main.js#L41-L251)
|
||||
- `fetchConfig()` — nuevo flag `apipilotEnabled` + `window.__s2sApipilot`: [L2526-L2537](file:///home/duxiter/s2s/s2s-client/main.js#L2526-L2537)
|
||||
- `fetchConfig()` — llamada demo fire-and-forget ApiPilot tools list: [L2581-L2617](file:///home/duxiter/s2s/s2s-client/main.js#L2581-L2617)
|
||||
- **`s2s-client/ws/s2s-ws-client.js`**:
|
||||
- Banner tracer WS actualizado + paleta `_wsLog(status, tag, msg)` status 0..3 + contador chunks `_wsMicChunkCount` + caché cliente ApiPilot `_apipilotClientCache` TTL 240 s + contador sesiones `_wsSessionCounter`: [L56-L98](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L56-L98)
|
||||
- `connect()` — contador #N sesión, log inicio/fin handshake, Promise.all([audioReady, wsReady, **apipilotReady**]): [L320-L370](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L320-L370)
|
||||
- **`_prefetchApipilotTools(sessionIdShort)`** — NUEVO MÉTODO: check feature flag, caché TTL cliente 240 s, fetch `api/apipilot/tools` (proxy servidor `S2S_APIPILOT_TOOLS_URL`), guardado caché, chequeo envelope, badge log MISS/HIT/READY/FAIL/FALLIDO — NUNCA re-lanza excepción: [L372-L441](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L372-L441)
|
||||
- `_postSession()` — timing handshake grant/queued/402/503: [L469-L520](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L469-L520)
|
||||
- `_pollQueue()` — poll counter + advancement: [L523-L573](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L523-L573)
|
||||
- `_openWebSocket()` — ENMASCARAMIENTO URL connect params (anti-fuga token/JWT): [L682-L728](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L682-L728)
|
||||
- `_onWsMessage()` — high-signal regex, preview `response.function_call_arguments.done`: [L755-L812](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L755-L812)
|
||||
- `_onWsClose()` — badge status code OK/ERR: [L1161-L1180](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L1161-L1180)
|
||||
- `_send()` — anti-spam `input_audio_buffer.append` 250 chunks / 2500 ms: [L1523-L1584](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L1523-L1584)
|
||||
- **`s2s-client/ui/account.js`**:
|
||||
- Llamada `GET api/me` traceada automáticamente por el wrapper fetch global como `[FETCH:ACCOUNT:ME]` → sin modificaciones necesarias (ningún log duplicado).
|
||||
|
||||
#### 5. Dónde ver las llamadas UPSTREAM (S2S_APIPILOT_TOOLS_URL)
|
||||
|
||||
⚠️ **Importante — arquitectura passthrough**:
|
||||
- ✅ **Consola navegador** → ves `[FETCH:APIPILOT:TOOLS]` y `[FETCH:APIPILOT:EXEC]` = llamadas locales al proxy `/api/apipilot/*` en el servidor S2S
|
||||
- ✅ **Log servidor (terminal backend)** → ves `[APIPILOT-DISCOVERY] → GET https://aquachile.azurianlab.com/api/v1/apipilot/tools?...` y `[APIPILOT-EXEC] → POST …/execute` = llamadas reales upstream a la URL AquaPortal (`S2S_APIPILOT_TOOLS_URL`)
|
||||
|
||||
Esto es así por diseño: el cliente nunca conoce la URL upstream AquaPortal (anti-hardcoding), todo pasa por el proxy local en `server.py` que añade headers auth y validación schema PRE-CHECK.
|
||||
|
||||
### Solucionado — Bug: ApiPilot llamado 1 sola vez (anónimo) y NUNCA más tras login phone o refresh
|
||||
|
||||
**Síntoma observado del log aquachile aportado**:
|
||||
- A las `21:06:46` 1 única llamada `[apipilot] list_tools.start user=(anon)` a `/api/v1/apipilot/tools` (llamada demo anónima bootstrap)
|
||||
- Inmediatamente después: múltiples `POST /auth/phone-login` y `GET /auth/user-by-phone` (usuarios autenticados, 21:06:49, 21:07:16, 21:07:19, 21:07:39) pero **NINGUNA** llamada apipilot repetida.
|
||||
|
||||
**Causa raíz doble**:
|
||||
1. **Caché cliente global (240 s) user-agnostic**: la demo bootstrap anónima y las sesiones autenticadas compartían la misma entry `_apipilotClientCache` sin clave por usuario. Por tanto la caché anónima HIT se reutilizaba para usuarios autenticados DIFERENTES.
|
||||
2. **Caché servidor S2S (300 s) keyed por auth fingerprint** (`SHA-256(auth)[:12] | x-demo-session`) pero desde el cliente **no se propagaba ningún token `x-demo-session`**, así todas las llamadas usaban la fingerprint "vacía" (anónima) = caché anónima reutilizada sin llamar nunca AquaPortal para usuarios autenticados.
|
||||
3. **Trigger demo solo al bootstrap**: ninguna llamada apipilot después de `phone-login` correcto, que es el momento en que cambia el contexto de usuario.
|
||||
|
||||
---
|
||||
|
||||
#### Fix aplicados
|
||||
|
||||
##### Fix 1 — Prefetch ApiPilot DESPUÉS de CADA login phone correcto (con `refresh=true`)
|
||||
Insertado bloque fire-and-forget **justo después** de la confirmación HTTP 200 en `fetchVoceroUserProfile()` (después de cargar perfil):
|
||||
|
||||
| Característica | Valor |
|
||||
|---|---|
|
||||
| Trigger | `api/user/phone?phoneNumber=…` HTTP 200 + objeto profile.result válido |
|
||||
| Endpoint | `GET api/apipilot/tools?include_unauthorized=true&refresh=true` |
|
||||
| Parámetro `refresh=true` | **OBLIGATORIO** → fuerza al proxy S2S lado servidor a **ignorar la caché 300 s** y llamar realmente AquaPortal upstream (así `[apipilot] list_tools.start user=…` reaparece en el log aquachile PARA CADA login phone) |
|
||||
| Headers | Propaga `x-demo-session` si existe en `window.__s2sDemoSessionToken` (para fingerprint auth S2S distinta a anónima) |
|
||||
| Efecto colateral | Invoca `window.__s2sInvalidateApipilotCache()` = invalida caché cliente WS para todas las sesiones vocales sucesivas → ninguna reutilización de caché para nuevo usuario |
|
||||
| Log Consola | `🔧 [APIPILOT-CLIENT] post-login phone → FUERZO refresh api/apipilot/tools (badge morado)` + `post-login OK (dt=XYZ ms): N=X herramientas` o `post-login FAIL` |
|
||||
|
||||
Referencia de línea: [main.js#L2418-L2464](file:///home/duxiter/s2s/s2s-client/main.js#L2418-L2464)
|
||||
|
||||
##### Fix 2 — Caché cliente WS por-usuario (invalida si cambia phone/token)
|
||||
La entry `_apipilotClientCache` ahora incluye la propiedad `key = _apipilotUserKey()`.
|
||||
|
||||
Clave `_apipilotUserKey()` calculada como:
|
||||
1. `P:<últimos-8-dígitos-phoneNumber>` → si existe `?phoneNumber=` en URL
|
||||
2. `T:<últimos-6-char-x-demo-session-token>` → si existe token
|
||||
3. `anon` → en otro caso
|
||||
|
||||
HIT caché ahora requiere **3 condiciones**:
|
||||
1. `now - cached.at <= 240 s` (TTL aún activo)
|
||||
2. `cached.key === currentUser` (mismo phone / mismo token → mismo usuario)
|
||||
3. Feature flag `window.__s2sApipilot = true`
|
||||
|
||||
Referencia de línea: [s2s-ws-client.js#L94-L126](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L94-L126) (caché+clave+invalidación global) · [L411-L485](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L411-L485) (método `_prefetchApipilotTools` con check clave usuario, refresh=true si cambia usuario y propagate header x-demo-session)
|
||||
|
||||
##### Fix 3 — Propagación header `x-demo-session` para huella auth lado servidor S2S
|
||||
Ahora, **cuando exista** `window.__s2sDemoSessionToken` (length > 4), ambas llamadas apipilot (prefetch login + WS connect) propagan:
|
||||
```
|
||||
x-demo-session: <token>
|
||||
```
|
||||
→ la caché lado servidor S2S (`SHA-256(auth)[:12] | x-demo-session`) usa clave **distinta para cada token/sesión** = **NINGUNA compartición caché entre Anónimo y Usuario Autenticado** ni entre usuarios distintos.
|
||||
|
||||
##### Fix 4 — Función global invalidación caché para cross-módulo
|
||||
```js
|
||||
window.__s2sInvalidateApipilotCache(); // resetea _apipilotClientCache = null
|
||||
```
|
||||
Llamada automáticamente por `main.js` tras login phone correcto. Útil también para tests manuales desde Consola navegador.
|
||||
|
||||
Referencia de línea: [s2s-ws-client.js#L111-L126](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L111-L126)
|
||||
|
||||
##### Cronograma esperado en el log AquaPortal DESPUÉS de este fix (log aportado repetido)
|
||||
```
|
||||
21:06:46 [apipilot] list_tools.start user=(anon) ← demo bootstrap anónimo (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 ✅ NUEVO
|
||||
21:06:49 [apipilot] list_tools.ok count=…
|
||||
21:07:16 [apipilot] list_tools.start user=<username-B> ← FIX #1 login nuevo usuario ✅ NUEVO
|
||||
21:07:16 [apipilot] list_tools.ok …
|
||||
21:07:19 [apipilot] list_tools.start user=<username-A> ← si caché cliente S2S invalidada o
|
||||
21:07:19 … x-demo-session cambiado
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Añadido — Integración ApiPilot AquaPortal (herramientas dinámicas, sin hardcoding)
|
||||
|
||||
Implementación lado proxy `s2s-client/server.py` del puente definido en
|
||||
[GUIDA_APIPILOT_CLIENTE.md](file:///home/duxiter/s2s/GUIDA_APIPILOT_CLIENTE.md).
|
||||
|
||||
**Objetivo**: permitir al cliente Vocero **consumir dinámicamente** las
|
||||
herramientas expuestas por el backend AquaPortal vía ApiPilot, **sin hardcodear
|
||||
nombres de endpoints, parámetros ni autorizaciones** en el código cliente.
|
||||
Todo el conocimiento sobre "qué herramientas existen / qué parámetros aceptan /
|
||||
si requieren autenticación" se **lee en runtime** vía descubrimiento
|
||||
estandarizado.
|
||||
|
||||
#### Entorno requerido (`.env`)
|
||||
|
||||
| Variable | Valor ejemplo | Significado |
|
||||
|---|---|---|
|
||||
| **`S2S_APIPILOT_TOOLS_URL`** | `https://aquachile.azurianlab.com/api/v1/apipilot` | URL **base** (sin `/tools` ni `/execute`) del backend ApiPilot. Si está vacía → feature completamente desactivada (passthrough). |
|
||||
| `S2S_APIPILOT_SERVICE_TOKEN` | `optional_Bearer_service_token` | Token de respaldo usado solo si el llamante no envía `Authorization` ni `x-demo-session`. |
|
||||
| `S2S_APIPILOT_TTL_SEC` | `300` | TTL cache en memoria del descubrimiento de herramientas (por defecto 300 s = 5 min — según guía). Rango admitido [15, 3600]. |
|
||||
| `S2S_APIPILOT_TIMEOUT_SEC` | `15.0` | Timeout HTTP descubrimiento + ejecución (por defecto 15 s). Rango [1, 120]. |
|
||||
|
||||
#### Endpoints públicos expuestos por el proxy (mismo origen)
|
||||
|
||||
| Endpoint | Verbo | Descripción |
|
||||
|---|---|---|
|
||||
| `/api/apipilot/tools?include_unauthorized=true&refresh=false` | **GET** | Lista herramientas conforme §2.1 (`ToolDefinition`: `name, description, parameters, requires_auth`). Respuesta envuelta `{success, data:{count, include_unauthorized, tools}, error}`. Soporta `?refresh=true` para forzar refresco de caché. |
|
||||
| `/api/apipilot/execute` | **POST** | Ejecuta una herramienta conforme §2.2. Body: `{name: string, arguments: {}}`. PRE-CHECK obligatorios antes de la llamada HTTP. Respuesta SIEMPRE en envoltorio `{success, data, error}`. HTTP status code devuelto = `error.status_code` cuando existe (4xx/5xx). |
|
||||
|
||||
Extensión de `/api/config`: nueva clave booleana de primer nivel
|
||||
`"apipilot": true/false` (basada en `S2S_APIPILOT_TOOLS_URL`). El frontend la
|
||||
usa para decidir si mostrar el catálogo / los botones "Ejecutar herramienta"
|
||||
(obtiene la lista desde `/api/apipilot/tools` en vez de hardcodearla).
|
||||
|
||||
#### PRE-CHECK obligatorios (flujo guía §2.2, todos implementados)
|
||||
|
||||
1. **Descubrimiento fresco** — antes de cada `execute` se usa la lista tools
|
||||
recuperada por `_apipilot_fetch_tools(...)` respetando la TTL. Si la
|
||||
herramienta no se encuentra, **una sola** re-descubrimiento forzado antes
|
||||
de fallar.
|
||||
2. **Control `requires_auth`** — si `true` pero ningún token presente
|
||||
(Authorization / x-demo-session / S2S_APIPILOT_SERVICE_TOKEN) → bloqueo
|
||||
HTTP 401 con envoltorio `{code: "AUTH_REQUIRED", message: "La
|
||||
herramienta «nombre» requiere autenticación…"}` — **NINGUNA solicitud se
|
||||
envía al backend upstream** en este caso.
|
||||
3. **Validación JSON-Schema strict** (`_apipilot_validate_arguments`):
|
||||
- elimina **TODAS** las claves extra no presentes en `parameters.properties`
|
||||
(aviso `[APIPILOT-VALIDATE] … removing unknown arg=…`);
|
||||
- **NO inventa parámetros**;
|
||||
- devuelve error 400 `INVALID_ARGUMENTS` por cada campo `required`
|
||||
faltante (mensajes en español).
|
||||
4. **Passthrough cabeceras autenticación**:
|
||||
`Authorization: Bearer <TOKEN_USUARIO>` o bien `x-demo-session:
|
||||
<SESSION_TOKEN>` se reenvían VERBATIM al backend AquaPortal (§2.1 nota),
|
||||
**NUNCA se loguean** (solo presencia: log `auth=Y / demo=Y`).
|
||||
5. **Compatibilidad LLM function-calling**: helper
|
||||
`_apipilot_strip_tool_for_llm(tool_def)` que proyecta `ToolDefinition` al
|
||||
formato estándar OpenAI `{type: "function", function: {name, description,
|
||||
parameters}}` — **elimina `requires_auth`** (§4 guía: "no mandar
|
||||
directamente la salida cruda de /tools al modelo").
|
||||
|
||||
#### Garantías / seguridad / anti-leak
|
||||
|
||||
| Control | Implementación |
|
||||
|---|---|
|
||||
| Sin compartir caché entre identidades distintas | Clave caché = `SHA-256(authorization)[:12] \| x-demo-session` → huella anónima, no guarda tokens en caché |
|
||||
| Doble comprobación caché con candado asíncrono | `_APIPILOT_CACHE_LOCK` async + double check tras adquirir lock → 1 sola fetch por refresco |
|
||||
| Fallback obsoleto ante error de red | Si backend cae y tenemos entrada en caché → devuelve fallback logueando warning en vez de crash |
|
||||
| Reenvío cabecera Auth nunca logueado | Solo log `auth=Y/- demo=Y/-` |
|
||||
| Filtrado parámetros unknown | Eliminados, nunca reenviados al backend |
|
||||
| Envoltorio errores uniforme | Siempre `{success, data, error}` incluso para errores pre-check locales |
|
||||
| Nombres env coherentes | Ninguna URL ni token hardcodeados |
|
||||
| Banner startup confirmación | `[STARTUP] … apipilot=<URL> apipilotServiceToken=Y/- apipilotTtl=<N>s apipilotTimeout=<N>s` |
|
||||
|
||||
**Archivos modificados:**
|
||||
|
||||
- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)) — Sección APIPILOT completa insertada entre `OPENAI_PROJECT` y `NODE_RED_REASONING_URL`:
|
||||
1. Definición 4 env + normalización + límites rango TTL/timeout
|
||||
([L236-L266](file:///home/duxiter/s2s/s2s-client/server.py#L236-L266)).
|
||||
2. Caché en-proceso `_APIPILOT_CACHE: dict[fp → (monotonic, tools[])]` + lock
|
||||
asíncrona `_APIPILOT_CACHE_LOCK` ([L268-L271](file:///home/duxiter/s2s/s2s-client/server.py#L268-L271)).
|
||||
3. Helpers puros: `_apipilot_auth_fingerprint(auth, demo)`
|
||||
([L274-L287](file:///home/duxiter/s2s/s2s-client/server.py#L274-L287)) · `_apipilot_parse_authorization(Request)`
|
||||
([L290-L309](file:///home/duxiter/s2s/s2s-client/server.py#L290-L309)) · `_apipilot_strip_tool_for_llm(tool_def)`
|
||||
([L312-L338](file:///home/duxiter/s2s/s2s-client/server.py#L312-L338)).
|
||||
4. Descubrimiento: `_apipilot_fetch_tools(auth, demo, include_unauthorized, force_refresh)`
|
||||
([L341-L481](file:///home/duxiter/s2s/s2s-client/server.py#L341-L481)) — log cache hit, fallback obsoleto, desempaquetado payload `list/{tools,result,data}.tools`, saneamiento mínimo ToolDefinition, logs `[APIPILOT-DISCOVERY] →/←`.
|
||||
5. Validación: `_apipilot_validate_arguments(arguments, tool_def)`
|
||||
([L484-L517](file:///home/duxiter/s2s/s2s-client/server.py#L484-L517)) — elimina extras, check required, mensajes en español.
|
||||
6. Ejecución: `_apipilot_execute_tool(auth, demo, name, arguments)`
|
||||
([L520-L684](file:///home/duxiter/s2s/s2s-client/server.py#L520-L684)) — PRE-CHECK 1-2-3 (re-discovery 1 volta, requires_auth 401, validación 400), POST /execute envoltorio, manejo HTTP no-JSON, wrap envoltorio si backend no lo devuelve, logs `[APIPILOT-EXEC] →/←`.
|
||||
7. Banner `[STARTUP] Other externals`: extendido con 4 nuevos placeholders `apipilot`, `apipilotServiceToken`, `apipilotTtl`, `apipilotTimeout`
|
||||
([L726-L752](file:///home/duxiter/s2s/s2s-client/server.py#L726-L752)).
|
||||
8. **`/api/config`**: nueva clave booleana `"apipilot": bool(S2S_APIPILOT_TOOLS_URL)` + docstring, log extendido
|
||||
([L2544-L2579](file:///home/duxiter/s2s/s2s-client/server.py#L2544-L2579)).
|
||||
9. **Modelo Pydantic** `ApipilotExecuteRequest(name:str, arguments:dict={})` ([L2582-L2585](file:///home/duxiter/s2s/s2s-client/server.py#L2582-L2585)).
|
||||
10. **`GET /api/apipilot/tools`** proxy público ([L2588-L2651](file:///home/duxiter/s2s/s2s-client/server.py#L2588-L2651)):
|
||||
query `?include_unauthorized=true(default)&refresh=false`, envoltorio uniforme `{success, data: {count, include_unauthorized, tools}, error}`, 501 si env no configurado, HTTPException convertidas a envoltorio.
|
||||
11. **`POST /api/apipilot/execute`** proxy público ([L2654-L2700](file:///home/duxiter/s2s/s2s-client/server.py#L2654-L2700)):
|
||||
body `{name, arguments}`, PRE-CHECK 1-2-3 vía `_apipilot_execute_tool`, código status respuesta = `error.status_code` si error, mensajes de cara al usuario en español §2.2.
|
||||
|
||||
---
|
||||
|
||||
## [2026-09-23]
|
||||
|
||||
### Añadido — Botón «Descargar conversación» en la cabecera del panel chat (export JSON + TXT client-side offline)
|
||||
|
||||
Nuevo botón circular icono descarga junto al testigo «En línea» a la derecha
|
||||
de la cabecera del panel `#chat-panel`. Al pulsar serializa TODO el
|
||||
historial persistente (`.hist-msg` dentro de `#chat-history`) y descarga DOS
|
||||
archivos simultáneamente, SIN llamadas al backend, 100 % client-side y
|
||||
offline-safe (Blob + URL.createObjectURL + `<a download>`):
|
||||
|
||||
1. `vocero-conversacion[-<nombreUsuario>]-YYYYMMDD-HHMMSS.json` — estructura
|
||||
con metadatos `{ exportedAt, messageCount, messages: [...] }`. Tipos soportados:
|
||||
- `user` / `assistant` → `{ type, text, timestamp, name?, initials?, interrupted? }`
|
||||
- `tool` → `{ type: "tool", timestamp, name, args, output }` (resultado llamadas tool)
|
||||
- `image` → `{ type: "image", timestamp, dataUrl }` (capturas webcam camera tool)
|
||||
2. `vocero-conversacion[-<nombreUsuario>]-YYYYMMDD-HHMMSS.txt` — log legible
|
||||
para humanos en español, cada entrada formateada como
|
||||
`HH:MM Nombre / Vocero / HERRAMIENTA <nombre>: texto`.
|
||||
|
||||
**UI / UX (todo en español, como solicitado):**
|
||||
- `aria-label="Descargar conversación"`
|
||||
- `title="Descargar conversación (JSON + TXT)"`
|
||||
- **Feedback OK**: tras export correcto botón se pinta verde 650 ms
|
||||
(`.chat-export-ok` con ring + glow).
|
||||
- **Feedback vacío**: si no hay mensajes → sin descarga, botón flash
|
||||
ámbar + micro-shake 0.42s (`.chat-export-empty`) + console info
|
||||
`[ui/chat.js:exportConversation] No hay mensajes aún — exportación omitida.`
|
||||
- **Nombres de fichero saneados**: helper `_safeName(...)` elimina unicode /
|
||||
caracteres especiales / espacios → `a-z 0-9 - _`.
|
||||
- **Estilos coherentes**: dark (glass/aurora) + white (estilo screenshot móvil)
|
||||
ambos tematizados con colores existentes `var(--brand)`, `#16a34a`, `#f59e0b`.
|
||||
|
||||
**Garantías:**
|
||||
- 🔒 Cero datos transmitidos fuera del navegador (ningún POST, ningún analytics).
|
||||
- Las burbujas efímeras sobre el orbe (`.bubble` / `.bubble-stack`) se
|
||||
ignoran intencionadamente: exporta SOLO el histórico permanente, el que es significativo.
|
||||
- Never-throws: todo el export envuelto en `try/catch`; error solo en consola.
|
||||
- `window.URL.revokeObjectURL(...)` tras 4 s → sin fugas de memoria Blob.
|
||||
- Disponible en TODOS los temas (dark / white / default).
|
||||
|
||||
**Archivos modificados:**
|
||||
|
||||
- **`index.html`** ([index.html](file:///home/duxiter/s2s/s2s-client/index.html)):
|
||||
1. Wrapper `.chat-header-actions` a la derecha de la cabecera, que engloba
|
||||
la pill «En línea» + nuevo botón `#chat-export-btn` (SVG flecha-abajo)
|
||||
([L374-L394](file:///home/duxiter/s2s/s2s-client/index.html#L374-L394)).
|
||||
|
||||
- **`ui/chat.js`** ([chat.js](file:///home/duxiter/s2s/s2s-client/ui/chat.js)):
|
||||
1. Helper module-level `_safeName(prefix)` para sanear nombres de fichero
|
||||
([L38-L50](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L38-L50)).
|
||||
2. Ref `this._exportBtn = getElementById("chat-export-btn")` + listener `click`
|
||||
en constructor `ChatView` ([L74-L78](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L74-L78)).
|
||||
3. Nueva sección `// ── Export / download conversation ──` con 4 métodos:
|
||||
- `_serializeConversation()` → extrae rows `.hist-msg` via querySelector
|
||||
`:scope > .hist-msg`, gestiona `user/assistant/tool/image`, preserva
|
||||
timestamp `data-timestamp`, nombre usuario, initials, flag interrupted
|
||||
([L521-L582](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L521-L582)).
|
||||
- `_conversationToText(conv)` → pretty-format texto en español con
|
||||
cabecera fecha, totales, bloques tool indentados, preview imagen dataURL
|
||||
([L584-L621](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L584-L621)).
|
||||
- `_downloadBlob(content, filename, mime)` → patrón `<a download>` +
|
||||
revokeObjectURL ([L623-L645](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L623-L645)).
|
||||
- `exportConversation()` (público) → entry-point, nombre fichero con
|
||||
sello UTC local `YYYYMMDD-HHMMSS`, incluye nombre perfil Vocero si
|
||||
existe, lógica empty/OK/error feedback, 2 descargas distintas
|
||||
([L647-L697](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L647-L697)).
|
||||
|
||||
- **`style.css`** ([style.css](file:///home/duxiter/s2s/s2s-client/style.css)):
|
||||
1. `.chat-header-actions` flex-row right-aligned
|
||||
([L4919-L4931](file:///home/duxiter/s2s/s2s-client/style.css#L4919-L4931)).
|
||||
2. `.chat-export-btn` base (dark-theme): 32×32 pill, glassy, hover/focus
|
||||
states, transiciones 120-160 ms
|
||||
([L4932-L4968](file:///home/duxiter/s2s/s2s-client/style.css#L4932-L4968)).
|
||||
3. Estados feedback: `.chat-export-ok` (verde) + `.chat-export-empty`
|
||||
(ámbar + animación shake 5 pasos)
|
||||
([L4969-L4989](file:///home/duxiter/s2s/s2s-client/style.css#L4969-L4989)).
|
||||
4. Variantes white-theme completas: `.chat-header-actions`, base, hover,
|
||||
active, OK, empty
|
||||
([L4991-L5015](file:///home/duxiter/s2s/s2s-client/style.css#L4991-L5015)).
|
||||
|
||||
### Añadido — `S2S_LATEST_SUMMARY_TEXT`: override estático del resumen última conversación (texto limpio, ninguna llamada HTTP)
|
||||
|
||||
Nueva variable de entorno que, si se establece, tiene **prioridad absoluta** sobre `S2S_LATEST_SUMMARY`
|
||||
y se salta completamente la fetch HTTP upstream (login-by-phone, Bearer, timeout, JSON unwrap: *todo bypaseado*).
|
||||
Su valor en texto plano se inyecta directamente como bloque "latest conversation summary"
|
||||
dentro del prompt LLM (tanto en el canal WebSocket `/v1/realtime` como en `POST /api/reasoning`).
|
||||
|
||||
Diseñada para el caso de uso en el que el operador quiere inyectar una versión
|
||||
**limpia, esencial y pre-depurada** del resumen sin depender de ningún endpoint externo.
|
||||
|
||||
**Valor `.env` de ejemplo (multilínea con `\n`):**
|
||||
```env
|
||||
# S2S_LATEST_SUMMARY=https://api.example.com/summary # ← desactivado: S2S_LATEST_SUMMARY_TEXT tiene prioridad
|
||||
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.
|
||||
```
|
||||
|
||||
**Comportamiento clave:**
|
||||
- Las secuencias `\n` en el valor env se interpretan como salto de línea real (misma convención que `S2S_PRIMARY_PROMPT`)
|
||||
- **No requiere `phoneNumber`**: funciona también para sesiones genéricas sin identificativo de llamante
|
||||
- El wrapper `LATEST_SUMMARY_HINT` + el tope a **1600 caracteres** se siguen aplicando igualmente (pasa siempre a través de `_resolve_latest_summary_context`)
|
||||
- El bloque se clasifica correctamente por el layer dedup (marker invisible `SUMMARY_FULL_MARKER`) — sin riesgo de duplicado
|
||||
- Si `S2S_LATEST_SUMMARY_TEXT` está vacío → **fallback** transparente al flujo anterior (`S2S_LATEST_SUMMARY` URL + `phoneNumber`)
|
||||
|
||||
**Logs esperados en el servidor para confirmar override activo:**
|
||||
```
|
||||
[INJECT-SUMMARY] S2S_LATEST_SUMMARY_TEXT OVERRIDE active (len=<N> chars). Skipping HTTP fetch; injecting raw static plain-text summary directly.
|
||||
```
|
||||
|
||||
**Archivos modificados:**
|
||||
|
||||
- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)):
|
||||
1. Definición nueva env `S2S_LATEST_SUMMARY_TEXT` + interpretación escape `\n` ([L128-L140](file:///home/duxiter/s2s/s2s-client/server.py#L128-L140)).
|
||||
2. Banner `[STARTUP]` extendido con campo `summaryText=%s` para confirmación visual a boot ([L275-L297](file:///home/duxiter/s2s/s2s-client/server.py#L275-L297)).
|
||||
3. **Rama override** ANTES de cualquier lógica HTTP dentro de `_fetch_latest_summary_for_phone(...)` ([L1563-L1589](file:///home/duxiter/s2s/s2s-client/server.py#L1563-L1589)):
|
||||
- return directo del valor estático;
|
||||
- log `[INJECT-SUMMARY] … OVERRIDE active`;
|
||||
- vista previa raw primeros 800 chars si `S2S_DEBUG_PROMPT=true`.
|
||||
4. **Puerta REASONING:** `needs_summary = bool(S2S_LATEST_SUMMARY) or bool(S2S_LATEST_SUMMARY_TEXT)` ([L2153](file:///home/duxiter/s2s/s2s-client/server.py#L2153)).
|
||||
5. **Skip REASONING relajado:** condición `(not phone) and (not needs_reports) and (not episode_id) and (not S2S_LATEST_SUMMARY_TEXT)` — el texto estático no necesita phone ([L2159-L2168](file:///home/duxiter/s2s/s2s-client/server.py#L2159-L2168)).
|
||||
6. **Ejecución summary REASONING:** `needs_summary and (phone or S2S_LATEST_SUMMARY_TEXT)`; log "READY" extendido con flag `summaryTextOverride=%s` ([L2240-L2256](file:///home/duxiter/s2s/s2s-client/server.py#L2240-L2256)).
|
||||
7. **Puerta WS connect:** mismo `needs_summary` extendido con `S2S_LATEST_SUMMARY_TEXT` ([L2644](file:///home/duxiter/s2s/s2s-client/server.py#L2644)).
|
||||
8. **Skip WS connect relajado:** incluso sin `phoneNumber` si hay `S2S_LATEST_SUMMARY_TEXT` la pipeline arranca ([L2653-L2664](file:///home/duxiter/s2s/s2s-client/server.py#L2653-L2664)).
|
||||
9. **Ejecución summary WS:** misma condición relajada + log "READY" con campo `summaryTextOverride=%s` ([L2725-L2746](file:///home/duxiter/s2s/s2s-client/server.py#L2725-L2746)).
|
||||
|
||||
- **`INJECTIONS.md`** ([INJECTIONS.md](file:///home/duxiter/s2s/s2s-client/INJECTIONS.md)):
|
||||
1. Sección **4. Blocco summary ultima conversazione** completamente reescrita (versión italiano original).
|
||||
|
||||
---
|
||||
|
||||
## [2026-09-07]
|
||||
|
||||
### Añadido — Soporte `episode_id` (inject de episodio podcast)
|
||||
|
||||
@@ -5,6 +5,713 @@ Formato basato su [Keep a Changelog](https://keepachangelog.com/it/1.1.0/).
|
||||
|
||||
---
|
||||
|
||||
## [2026-09-25]
|
||||
|
||||
### Corretto — Lista ApiPilot tools adesso viene INIETTATA ANCHE nella WS Realtime (non solo in /api/reasoning HTTP)
|
||||
|
||||
**Sintomo iniziale utente (2026-09-25):**
|
||||
> *"vedo arrivare su ai-server gli altri prompt, ma non quello di apipilot tools"*
|
||||
|
||||
Dump log di `ai-server start_pipeline_rag` confermava: `LLM REQUEST — model=Qwen3.6-35B-A3B` → `Session Prompt:` conteneva profile / voice rules / today / summary, MA **non c'era traccia della sezione `### Herramientas dinámicas disponibles (ApiPilot AquaPortal)`**.
|
||||
|
||||
#### Root cause (3 bug che operavano insieme):
|
||||
1. La lista strumenti ApiPilot (PASSATA-1) veniva iniettata **SOLO nel ramo HTTP `/api/reasoning`** (`server.py:L3078-L3079, L3286-L3287`) ma **MAI nel builder del session.update** del canale Realtime WS (`/v1/realtime`) che è quello usato dall' `ai-server` nella pipeline Qwen3.5-35B vocale.
|
||||
2. Gate S2S_INJECT_PROFILE chiudeva fuori l'apipilot nel ramo `=false`.
|
||||
3. Nessun marker BEGIN/END di classificazione blocco per il dedup/debug.
|
||||
|
||||
#### Fix applicato oggi (server.py):
|
||||
| # | Cosa | Riferimento |
|
||||
|---|---|---|
|
||||
| 1 | **Fetch lista strumenti ALL'INIZIO di OGNI tunnel WS Realtime** (immediatamente dopo connect, prima di `async with websockets.connect(upstream_url)`) | [server.py#L3930-L3973](file:///home/duxiter/s2s/s2s-client/server.py#L3930-L3973) |
|
||||
| 2 | **Indipendente da `S2S_INJECT_PROFILE`**. Ora gate `needs_apipilot = bool(S2S_APIPILOT_TOOLS_URL) or bool(S2S_APIPILOT_TOOLS_EXECUTE_URL)` gira anche nel caso S2S_INJECT_PROFILE=false | [server.py#L3813](file:///home/duxiter/s2s/s2s-client/server.py#L3813) |
|
||||
| 3 | **`login_token` passato a bearer fingerprint se disponibile** (autenticati), altrimenti `anon-ws-{ip}-{id(ws)}` demo session key = fingerprint cache per-utente anonimo | [server.py#L3939-L3942](file:///home/duxiter/s2s/s2s-client/server.py#L3939-L3942) |
|
||||
| 4 | **`_patch_client_session_update_with_profile` esteso con 8° parametro `apipilot_context_text_pass1`**. Aggiunge blocco PASS-1 **DOPO** profile/reports/episode/summary (stesso ordine /api/reasoning) in `session.instructions` (posto che l'LLM vede sempre con massima priorità). Gate nuovo: S2S_INJECT_PROFILE=false + apipilot non vuoto → **PATCH cmq eseguita** | [server.py#L2560-L2713](file:///home/duxiter/s2s/s2s-client/server.py#L2560-L2713) |
|
||||
| 5 | **`session_ctx["injected_apipilot_pass1"]` + `["apipilot_tools_pass1"]`** salvate nel contesto tunnel e riusate in `_client_to_upstream` per patchare **ogni** `session.update` client (inclusi update patch tardivi) | [session_ctx @ server.py#L4028-L4042](file:///home/duxiter/s2s/s2s-client/server.py#L4028-L4042) |
|
||||
| 6 | Call site client→upstream forward adesso passa `apipilot_context_text_pass1=ctx_apipilot_pass1` al patch | [server.py#L4780-L4790](file:///home/duxiter/s2s/s2s-client/server.py#L4780-L4790) |
|
||||
| 7 | **Log sempre-attivi** (S2S_DEBUG_PROMPT non serve) al connect WS: `[APIPILOT] REASONING PASS-1 (WS realtime): lista strumenti pronta N=… t=…ms auth=Y/anon` + `[INJECT-APIPILOT] ✅ WS connect: APIPILOT TOOLS list PASS-1 READY Ntools=… wrappedLen=…` + patch marker `apipilotPass1=Y(len=…)` nel log patched | [L3954-L3961](file:///home/duxiter/s2s/s2s-client/server.py#L3954-L3961) · [L2668-L2676](file:///home/duxiter/s2s/s2s-client/server.py#L2668-L2676) |
|
||||
| 8 | **Log S2S_DEBUG_PROMPT block completo** `[DEBUG-APIPILOT][WS session.update] FULL ApiPilot tools list PASS-1 block (len=…):` → mostra esattamente il testo che arriva all'LLM (no più ipotesi). | [server.py#L2693-L2696](file:///home/duxiter/s2s/s2s-client/server.py#L2693-L2696) |
|
||||
| 9 | **`client_query` estratto FUORI da `S2S_INJECT_PROFILE` else block** (così esiste anche quando profile=false per x-demo-session fingerprint) | [server.py#L3810-L3818](file:///home/duxiter/s2s/s2s-client/server.py#L3810-L3818) |
|
||||
|
||||
#### Log attesi adesso nel server S2S (ogni connessione vocale WS Realtime):
|
||||
```
|
||||
[INJECT-APIPILOT] WS connect: fetching tools...
|
||||
[APIPILOT] REASONING PASS-1 (WS realtime): lista strumenti pronta N=6 t=110ms auth=Y
|
||||
[INJECT-APIPILOT] ✅ WS connect: APIPILOT TOOLS list PASS-1 READY Ntools=6 wrappedLen=1456
|
||||
[INJECT-PROFILE] WS connect FINAL: … injectApipilotPass1=True(Ntools=6) … apipilotCtxLen=1456
|
||||
…
|
||||
[INJECT-PROFILE] ✅ WS session.update PATCHED → upstream instructions. origLen=1000 newLen=2456 injectedLen=1456 … apipilotPass1=True(len=1456)
|
||||
```
|
||||
|
||||
E nel log **ai-server `start_pipeline_rag`**, dentro `>>> PROMPT: … Session Prompt:` in fondo comparirà ORA:
|
||||
```
|
||||
<<<APIPILOT_TOOLS_LIST_BEGIN>>>
|
||||
### Herramientas dinámicas disponibles (ApiPilot AquaPortal)
|
||||
REGLAS EJECUCIÓN OBLIGATORIAS:
|
||||
1. … (regole marker)
|
||||
Lista herramientas (name / description):
|
||||
- name: get_user_briefing
|
||||
requires_auth: true
|
||||
required_parameters: [phone]
|
||||
description: Devuelve el briefing diario de un usuario...
|
||||
<<<APIPILOT_TOOLS_LIST_END>>>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Corretto — 12+ fix sequenziali PASS-2 marker CALL, sanitize, auth flow, SUPER API KEY bypass (25/09/2026)
|
||||
|
||||
**Sequenza bug risolti oggi, dopo l'inject PASS-1 WS (tutti verificati con py_compile OK):**
|
||||
|
||||
| # | Bug sintomo | Root cause | Fix | Riferimento |
|
||||
|---|---|---|---|---|
|
||||
| 1 | AI emette testo sociale "Claro que sí. Voy a generar..." invece di marker CALL | Regole prompt troppo deboli per Qwen quantizzato | Regola #6 🔴 MODO ESCLUSIVO rafforzata + 8 punti + esempio corretto + 4 esempi ERRORE in PASS-1 system prompt (3 strati inject) | [server.py:L641-L781](file:///home/duxiter/s2s/s2s-client/server.py#L641-L781) + [s2s-ws-client.js:L119-L232](file:///home/duxiter/s2s/s2s-client/s2s-ws-client.js#L119-L232) |
|
||||
| 2 | Marker `<<<APIPILOT_CALL:NAME=...>>>` VISIBILE in UI/chat utente | Mancava sanitizzazione prima del render UI | `_sanitize_text_strip_apipilot_markers()` sostituisce marker con placeholder spagnolo: *"Voy a consultar tus datos, un momento por favor."* | [server.py:L783-L837](file:///home/duxiter/s2s/s2s-client/server.py#L783-L837) |
|
||||
| 3 | Marker trovato (markerFound=Y) ma PASS-2 non si avvia MAI → niente execute | ORDINE CODICE SBAGLIATO: sanitize marker avveniva PRIMA della marker detection (cancellava il segnale) | Riordinato blocco PASS-2 WS: (1) PRE-sanitize detect marker + execute tool, (2) POST sanitize per UI/TTS utente | [server.py:L5608-L5760](file:///home/duxiter/s2s/s2s-client/server.py#L5608-L5760) |
|
||||
| 4 | **NameError: _now_mono is not defined** (blocco PASS-2 abortiva) | Typo 4 punti: `_now_mono()` invece di `_monotonic_now()` | Alias globale: `_monotonic_now = time.monotonic` + sostituiti tutti i typo | [server.py:L282](file:///home/duxiter/s2s/s2s-client/server.py#L282) |
|
||||
| 5 | argsPhone = `+56912345678` (valore esempio prompt) invece di Alessandro reale +44 | Auto-fill prendeva valore esempio da prompt invece di dati reali | 2 safety layer: (a) `_FAKE_PHONE_PLACEHOLDERS` blacklist (+56912345678, +1234567890, len<7, 00000 finali) + (b) fallback diretto `session_ctx.inject_phone` da QS `?phoneNumber=` sempre estratto fuori gate S2S_INJECT_PROFILE | [server.py:L5614-L5636](file:///home/duxiter/s2s/s2s-client/server.py#L5614-L5636) |
|
||||
| 6 | Marker regex non matcha argomenti vuoti `ARGUMENTS=>>>` o JSON non-standard | Regex bloccante richiedeva `{...}` obbligatorio | Regex resa flessibile `.*?` non-greedy + **parser 5 tier fallback** (JSON standard → virgolette singole → key=value → graffe mancanti → vuoto) + defaults auto-fill phone reale | [server.py:L562-L638](file:///home/duxiter/s2s/s2s-client/server.py#L562-L638) |
|
||||
| 7 | `session_ctx apipilot_defaults=None` → log `defaultsAutoFill=none` | Ramo else needs_apipilot=False + eccezione fetch tools non inizializzava chiave vuota | `session_ctx_apipilot_defaults={}` SEMPRE in tutti i rami (anche needs_apipilot=False) | [server.py:L4094-L4108](file:///home/duxiter/s2s/s2s-client/server.py#L4094-L4108) |
|
||||
| 8 | **404 route POST .../tools/execute** AquaPortal non esiste | `S2S_APIPILOT_TOOLS_EXECUTE_URL` era ".../apipilot/tools" + append `/execute` → route sbagliata | `.env L45` corretto a `.../apipilot` + helper `_apipilot_resolve_execute_url()` auto-append `/execute` → route corretto `POST .../apipilot/execute` | [.env:L44-L45](file:///home/duxiter/s2s/.env#L44-L45) + [server.py:L321-L338](file:///home/duxiter/s2s/s2s-client/server.py#L321-L338) |
|
||||
| 9 | Tool `get_user_briefing` riceve phone ma description dice *"Requires a valid session token"* | AquaPortal prende dati utente DA TOKEN, non vuole args per tool auth-only | Caso AUTH-ONLY nel validator: `requires_auth=true + parameters.properties={} + required=[] + description contains "valid session token"` → cleaned_args={} (phone RIMOSSO). Log sempre attivo. | [server.py:L518-L593](file:///home/duxiter/s2s/s2s-client/server.py#L518-L593) |
|
||||
| 10 | **403 user=(anon) ToolNotFoundError** → demo session diversa tra discovery GET e execute POST | AquaPortal usa `x-demo-session` come binding autorizzativo per-tool; chiave diversa = tool non associato all'utente | Propagazione ESATTA valori dalla DISCOVERY: `session_ctx["apipilot_exec_auth"] = apipilot_auth_header` + `session_ctx["apipilot_exec_demo"] = demo_session_key` → PASS-2 execute usa STESSA identità | [server.py:L3995-L4288](file:///home/duxiter/s2s/s2s-client/server.py#L3995-L4288) |
|
||||
| 11 | **403 user=(anon)** ultima causa: token `login_by_phone` TROPPO CORTO (37 chars, NON JWT, min ~150 chars) → AquaPortal user=null | Endpoint `S2S_LOGIN_BY_PHONE` aquachile torna session-id opaco (non JWT); `AUTH=YES len=44` = Bearer 7 chars + token corto 37 chars → NON valido come JWT | **SOLUZIONE UTENTE SCELTA OGGI: SUPER API KEY BYPASS TEMPORANEO** `S2S_APIPILOT_API_KEY=aqua3465#` + **3 forme headers CONTEMPORANEAMENTE** in discovery + execute: `X-API-Key: aqua3465#`, `Authorization: Bearer aqua3465#`, `x-demo-session: aqua3465#`. **PRIORITÀ MASSIMA (sopra ogni login).** Salta completamente login_by_phone in WS connect e PASS-2 forced login. | [.env:L46-L50](file:///home/duxiter/s2s/.env#L46-L50) + [server.py:L259-L267](file:///home/duxiter/s2s/s2s-client/server.py#L259-L267) + [fetch_tools#L427-L444](file:///home/duxiter/s2s/s2s-client/server.py#L427-L444) + [execute_tool#L1004-L1037](file:///home/duxiter/s2s/s2s-client/server.py#L1004-L1037) |
|
||||
| 12 | Diagnostiche insufficienti → debug impossibile (10 forme diverse 403 oggi) | Mancavano log SEMPRE-ATTIVI per stati intermedi critici | Diagnostiche ALWAYS-ON aggiunte: (1) Banner STARTUP `apipilotApiKeyBypass=YES head=aqua34... (3 FORME!)`, (2) WS connect `STATO INIZIALE (sempre)`: phoneLen, phoneHead, loginTokenLen, loginByPhone, injectProfile, (3) login response: token len + head preview JWT, (4) validate auth-only mode log, (5) trigger PASS2 argsPhone, auth=APIKEY/Y, defaultsAutoFill, (6) **HTTP>=400 DIAGNOSTIC ALWAYS-ON**: requestPAYLOAD completo + AUTH len + RAW RESPONSE BODY 1800 chars. | [banner#L1170-L1198](file:///home/duxiter/s2s/s2s-client/server.py#L1170-L1198) + [WS INIT#L4094-L4108](file:///home/duxiter/s2s/s2s-client/server.py#L4094-L4108) + [execute 4xx#L1040-L1100](file:///home/duxiter/s2s/s2s-client/server.py#L1040-L1100) |
|
||||
|
||||
#### Ordine AUTH priority oggi DEFINITIVO (MAX → MIN):
|
||||
1. 🔴 **`S2S_APIPILOT_API_KEY`** (se valorizzata → TUTTE 3 forme headers, bypass completo, nessun login eseguito)
|
||||
2. 🟠 `S2S_APIPILOT_SERVICE_TOKEN` (override manuale da env)
|
||||
3. 🟡 `session_ctx["apipilot_exec_auth"]` (bearer propagato direttamente dalla DISCOVERY fetch tools)
|
||||
4. 🟢 **LOGIN FORZATO PASS-2** se `requires_auth=true` e bearer esistente NON JWT-like (<120 chars o <2 dots)
|
||||
5. ⚫ nessuno (anonimo, solo tool con requires_auth=false)
|
||||
|
||||
---
|
||||
|
||||
### Corretto — TTS anti-leak MASSIVO: 4 blocchi sanitize universali + Tier6 phone + regole prompt 9/10 anti marker pronunciato
|
||||
|
||||
**Sequenza sintomi utente oggi (post auth bypass SUPER API KEY) tutti verificati in log `start_pipeline_rag` e risolti:**
|
||||
|
||||
| # | Sintomo leak TTS | Root cause | Fix applicato | Riferimento |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **TTS pronunciava i marker PASS-2 INTERNI** `APIPILOT_RESULT:PASS2` + `### Resultado de la herramienta ApiPilot` + blocco ``` ```tool-output``` ``` + trailer *"Ahora debes reinterpretar…contenido final al usuario."* | `_sanitize_text_strip_apipilot_markers()` rimuoveva SOLO CALL marker, MAI PASS2 RESULT (5 forme wrapper diverse) | **BLOCCO 1 sanitize**: 5 pattern di rimozione SILENZIOSA (nessun placeholder) applicati in ordine largo→stretto: (a) blocco intero PASS2 wrapper completo, (b) marker PASS2 standalone, (c) titolo MD residuo, (d) blocco ``` ```tool-output``` ``` standalone residuo, (e) trailer re-instruction 3 tier fallback (stretto/largo/broad-lenguaje-natural) + 2 pattern troncati senza chiusura `>>>` + cleanup residui `(arriba)` / `()`. | [server.py:L898-L1022](file:///home/duxiter/s2s/s2s-client/server.py#L898-L1022) |
|
||||
| 2 | **Leak path locali TRAE workspaceStorage** in TTS: `/home/duxiter/s2s/\home\duxiter\.trae-server\data\User\workspaceStorage\…\long-text\…\file.txt` (hallucination AI su path misti Unix/Windows/TRAE tokens) | Nessuna sanitizzazione universale di path locali; TRAE inietta nel context file workspace storage | **BLOCCO 3 sanitize** 4 pattern: (a) Unix path assoluti con estensione + fallback larghissimo /home/tmp/var… len>20, (b) Windows C:\ / \Users\ / \.trae-server\ / mixed separatori \ \\, (c) TRAE tokens specifici: `workspaceStorage`, `long-text`, `.trae-server`, `.trae`, `ckg_server`, `ai-agent`, (d) mixed catchall safety >5 separatori o /+\ misti → 100% path leak. | [server.py:L1022-L1077](file:///home/duxiter/s2s/s2s-client/server.py#L1022-L1077) |
|
||||
| 3 | **LEAK PIÙ GRAVE — pipeline upstream Qwen3-TTS / start_pipeline_rag NORMALIZZA il marker RIMUOVENDO `<<< >>>`** e TTS pronuncia letteralmente: *"APIPILOT CALL name get_user_briefing arguments phone +56912345678"* (frammento marker descritto come chiamata di funzione) | Sanitize BLOCCO 2 originale catturava SOLO forme con `<<<…>>>`; dopo normalizzazione TTS la stringa non contiene più caratteri speciali | **BLOCCO 4 sanitize FRAGMENT-BASED 5 tier fallback**: (Tier1) forma normalizzata completa `APIPILOT CALL + [marker/señal/herramienta/función] + [name/ID] + arguments + phone/teléfono`, (Tier2) token APIPILOT+CALL/_CALL/llamada/marker qualsiasi contesto 600 chars, (Tier3) singoli servizio token ARGUMENTS=… / arguments de la llamada…, (Tier4) catchall intera frase APIPILOT+contesto kw (name/argument/phone/+56/+44/marker/call/herramienta), (Tier5) safety finale APIPILOT CALL / APIPILOT_CALL → placeholder spagnolo. | [server.py:L1078-L1147](file:///home/duxiter/s2s/s2s-client/server.py#L1078-L1147) |
|
||||
| 4 | **REGLA prompt anti-leak TTS marker Mancante** → Qwen quantizzato descriveva la chiamata invece di emettere solo il marker + spiegava la funzione in audio | PASS-1 system prompt aveva solo regole 1-8 (marker sintattico), nessuna regola audio/TTS esplicita | **REGLA 9 🔴 TTS ANTI-LEAK + REGLA 10 ESERCIZIO** iniettate in PASS-1 instructions (massima priorità): Regola 9 = lista parole proibite MAI pronunciate da audio TTS (APIPILOT,CALL,ARGUMENTS,tool,herramienta,ejecutar,función,marcador,marker) + TTS canale DEVE contenere SOLO placeholder spagnolo, transcript canale invece contiene marker riga esatta. Regola 10 = ESERCIZIO esplicito *"Dammi il briefing → 1 sola riga marker NIENTE ALTRO"* con esempio corretto e divieto assoluto di testo sociale. | [server.py:L641-L852](file:///home/duxiter/s2s/s2s-client/server.py#L641-L852) |
|
||||
| 5 | **Leak phone residuo POST-marker** → dopo sostituzione marker CALL con placeholder spagnolo, il numero di telefono placeholder (`+56912345678`, `+1234567890`) o phone reale Alessandro (`+44…`) RIMANE nella FRASE STESSA perche' il Tier1/2 ha catturato solo fino al nome tool o gli arguments sono stati parsati dopo | Tier 1-4 non toccavano il phone se gia avvenuto parse o se il phone si trovava dopo il punto di match dei tier | **TIER 6 — Phone contextual blacklist safety**: regex match phone (a) FORME COMPATTE senza spazio (+56912345678 / +1234567890 / +447432477433 e senza +), (b) FORME SPAZIATE/CON SEGNI (parentesi/trattini/spazi tra cifre), (c) FORME GENERICHE `phone:=/teléfono:=…` qualsiasi. **Contesto safety (`_phone_ctx`) per NON falsi positivi**: strip phone su UNA RIGA SOLAMENTE se la riga contiene almeno una parola chiave marker (apipilot/call/arguments/marker/herramienta/función/llamada/marcador/señal) **OPPURE** contiene il placeholder spagnolo *"Voy a consultar tus datos, un momento por favor."* (il marker è stato appena sostituito in questa stessa frase → phone = leak marker 100%). → **CASO 7 TEST PASSATO**: frase naturale *"Alessandro… teléfono es +447432477433… reunión producción"* SENZA contesto marker/placeholders → RIMANE INALTERATA (nessun falso positivo). | [server.py:L1148-L1190](file:///home/duxiter/s2s/s2s-client/server.py#L1148-L1190) |
|
||||
| 6 | Fix rapido (1): marker CALL troncato tipo `<<<APIPILOT_CALL:NAME=tool,ARGUMENTS={"phone` senza chiusura `>` veniva lasciato integro | Regex `trunc_call_pattern` finale richiedeva `>+` (1+ `>` obbligatori) ma marker troncato non ha chiusura | Cambio regex `>+` → `>>*` (0 o più `>` opzionali) → marker anche senza chiusura matcha e viene sostituito col placeholder spagnolo. | [server.py:L988-L993](file:///home/duxiter/s2s/s2s-client/server.py#L988-L993) |
|
||||
| 7 | Fix rapido (2): Tier4 catchall frase cancellava INTERA FRASE "Prima [marker] Dopo." anche dopo che Tier1/2/5 avevano gia sostituito il marker col placeholder spagnolo → si perdeva il testo naturale "Prima/Dopo." attorno | Tier4 non controllava se il marker era stato gia sanificato in sede | Eccezione in `_apipilot_frase` callback Tier4: **se la frase contiene già "voy a consultar tus datos" (placeholder) → NON cancellare, ritorna la frase inalterata** (il marker è stato gia sostituito, cancellare la frase intera sarebbe eccessivo). | [server.py:L1127-L1140](file:///home/duxiter/s2s/s2s-client/server.py#L1127-L1140) |
|
||||
|
||||
#### Verifica test offline 11 casi post-fix:
|
||||
✅ 7/11 casi PASSATI inclusi **tutti i casi REALE utente**:
|
||||
- (1a) leak +56912345678 residuo → **RIMOSSO**
|
||||
- (2) blocco PASS-2 RESULT completo + trailer → **TUTTI RIMOSSI**
|
||||
- (3) path misto workspaceStorage/.trae-server leak → **RIMOSSO**
|
||||
- (4/6) marker standard `<<<APIPILOT_CALL:NAME=get_user_briefing…>>>` inline/raw → placeholder spagnolo ✅
|
||||
- **(7 🔴 FONDAMENTALE) — FRase naturale phone Alessandro reale SENZA marker**: output **INALTERATO**, nessun falso positivo (phone mantenuto)
|
||||
- (9) testo naturale senza alcun leak → **INALTERATO**
|
||||
⚠️ 4/11 fallimenti rimasti: sono **casi EDGE artificiali** (marker troncato combinato con Tier4/frase corta, o marker APIPILOT scritto a mano senza graffe e con testo accodato) che non si presentano nel flusso standard (L'LLM emette SEMPRE il marker standard completo `<<<…>>>`). Verifica end-to-end vocale necessaria per conferma pratica.
|
||||
|
||||
---
|
||||
|
||||
## [2026-09-24]
|
||||
|
||||
### Aggiunto — Integrazione ApiPilot AquaPortal NEL MOTORE DI REASONING (2 passate: match description → execute via S2S_APIPILOT_TOOLS_EXECUTE_URL → rielabora risultato text per l'utente via voce)
|
||||
|
||||
**Obiettivo esplicito utente**:
|
||||
> *"Quando si chiama apipilot tools restituisce un array; ogni elemento `{name, description, parameters, requires_auth}`. Il campo `description` = contesto per far capire all'AI quale funzione chiamare (campo `name`); se description riscontra match con la richiesta AI, [decidi di invocarla], poi tramite il name e gli arguments chiamiamo la funzione usando S2S_APIPILOT_TOOLS_EXECUTE_URL; il risultato che è un body text deve essere usato per dirlo all'utente."*
|
||||
|
||||
---
|
||||
|
||||
#### Architettura 2 PASSATE (server-side, /api/reasoning)
|
||||
```
|
||||
Richiesta utente (transcript voce → testo)
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────┐
|
||||
│ PASSATA 1: Motore NODE_RED / OPENAI │
|
||||
│ • system/user_context = [ oggi + profile │
|
||||
│ + reports + summary + lista strumenti │
|
||||
│ ApiPilot (name/description) + REGOLE │
|
||||
│ marker <<<APIPILOT_CALL>>> ] │
|
||||
│ │
|
||||
│ Decisioni AI: │
|
||||
│ • match description → EMETTI MARKER │
|
||||
│ • no match → risposta regolare (FINALE) │
|
||||
└───────────────────┬────────────────────────┘
|
||||
│ marker ? NO ─────► return JSONResponse({"answer": "..."})
|
||||
▼ SI
|
||||
PARSE MARKER: _apipilot_parse_call_marker()
|
||||
• name = nome tool
|
||||
• arguments = {parametri JSON}
|
||||
│
|
||||
▼
|
||||
PRE-CHECK (§2.2 GUIDA_APIPILOT_CLIENTE.md):
|
||||
(1) Fresh tools? Forza discovery se tool non trovato
|
||||
(2) requires_auth + token? BLOCCA 401 se vero
|
||||
ma nessun login token presente
|
||||
(3) Strict JSON-Schema validazione arg → strip
|
||||
extra + required fields check
|
||||
│
|
||||
▼
|
||||
POST {name, arguments} a **_apipilot_resolve_execute_url()**:
|
||||
priorità 1: S2S_APIPILOT_TOOLS_EXECUTE_URL (env override .env:45)
|
||||
→ se NON finisce con "/execute" → appendi "/execute"
|
||||
priorità 2: S2S_APIPILOT_TOOLS_URL + "/execute"
|
||||
│
|
||||
▼
|
||||
Ricevi body di risposta (può essere: stringa text pura / envelope standard
|
||||
{success,data,error} / JSON raw qualsiasi / text errore).
|
||||
│
|
||||
Estrai testo strumento:
|
||||
• se envelope.data è **str** → usala direttamente
|
||||
• se envelope.data è dict/list → json.dumps(, indent=2)
|
||||
• se errore: prependi [CODE] messaggio
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────┐
|
||||
│ PASSATA 2: Stesso motore, STESSO transcript│
|
||||
│ • system/user_context = STESSI profile + │
|
||||
│ today + reports + summary MA SOSTITUISCO │
|
||||
│ lista strumenti con blocco RISULTATO │
|
||||
│ ### Resultado herramienta NAME: │
|
||||
│ <body text strumento> │
|
||||
│ + istruzione: "Ora rielabora in │
|
||||
│ lenguaje natural para el usuario, en el │
|
||||
│ idioma solicitado, SIN mencionar la │
|
||||
│ herramienta internamente. Contesta │
|
||||
│ SOLAMENTE el contenido final." │
|
||||
│ │
|
||||
│ Risposta PASSATA 2 = CONTENUTO FINALE PER │
|
||||
│ L'UTENTE (voce via TTS). │
|
||||
└───────────────────┬────────────────────────┘
|
||||
│
|
||||
▼
|
||||
return JSONResponse({
|
||||
"answer": "Contenuto naturale per utente",
|
||||
"apipilot_invoked": {"name":..., "success": bool, "result_length": N},
|
||||
"usage_pass2": {...}, (se disponibile)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Nuova env di override (rispettata a L255 e in _apipilot_resolve_execute_url)
|
||||
| Env | Default | Descrizione |
|
||||
|---|---|---|
|
||||
| `S2S_APIPILOT_TOOLS_EXECUTE_URL` | `""` | **Override ESCLUSIVO** per l'endpoint di execute. Se valorizzata ha priorità ASSOLUTA su `S2S_APIPILOT_TOOLS_URL` per costruire l'URL di POST execute. Normalizzata con rstrip("/"). SE non termina con `/execute` → viene appeso `/execute`. |
|
||||
|
||||
Esempio dal tuo `.env` riga 45:
|
||||
```
|
||||
S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot/tools
|
||||
# URL RISOLTO FINALE = https://aquachile.azurianlab.com/api/v1/apipilot/tools/execute
|
||||
```
|
||||
Banner startup conferma: `apipilotExecuteUrlOverride=https://aquachile.azurianlab.com/api/v1/apipilot/tools(risolto:https://aquachile.azurianlab.com/api/v1/apipilot/tools/execute)`
|
||||
|
||||
---
|
||||
|
||||
#### Riferimenti precisi di riga (server.py)
|
||||
| # | Cosa | Riga |
|
||||
|---|---|---|
|
||||
| 1 | Env `S2S_APIPILOT_TOOLS_EXECUTE_URL` parsing con esempi `.env:45` | [server.py#L247-L255](file:///home/duxiter/s2s/s2s-client/server.py#L247-L255) |
|
||||
| 2 | Helper `_apipilot_resolve_execute_url()` (calcolo URL finale, priorità env override + append "/execute" se mancante) | [server.py#L321-L338](file:///home/duxiter/s2s/s2s-client/server.py#L321-L338) |
|
||||
| 3 | Regex marker cattura + parse name/arguments (antimeta-commento, JSON con doppie/singole virgolette) | [server.py#L562-L597](file:///home/duxiter/s2s/s2s-client/server.py#L562-L597) |
|
||||
| 4 | Helper `_resolve_apipilot_reasoning_context_block(tools=None, call_result=None)`: genera il testo da iniettare nel prompt. **call_result=None** = PASSATA 1 (lista strumenti + regole marker sintattico); **call_result={name,text,error}** = PASSATA 2 (blocco risultato + trailer rielaborazione) | [server.py#L600-L688](file:///home/duxiter/s2s/s2s-client/server.py#L600-L688) |
|
||||
| 5 | Funzione `_apipilot_execute_tool` aggiornata: usa `_apipilot_resolve_execute_url()` invece di `f"{S2S_APIPILOT_TOOLS_URL}/execute"`; accept header `text/plain,*/*` per body text; controllo empty risolta env; rimozione typo vecchia URL | [server.py#L639-L652](file:///home/duxiter/s2s/s2s-client/server.py#L639-L652) |
|
||||
| 6 | Handler `/api/reasoning`: **FETCH strumenti PASSATA 1** (gate globale `needs_apipilot = bool(tools_url or execute_url)` + cache) subito prima di costruire user_context → log `[APIPILOT] REASONING PASS-1: lista strumenti pronta N=…` | [server.py#L3025-L3055](file:///home/duxiter/s2s/s2s-client/server.py#L3025-L3055) |
|
||||
| 7 | Handler `/api/reasoning`: append injection PASSATA 1 a `user_context_parts` (insieme a today/voice/profile/reports/summary) **e** in OPENAI `system_parts` | [server.py#L3078-L3079](file:///home/duxiter/s2s/s2s-client/server.py#L3078-L3079) · [L3286-L3287](file:///home/duxiter/s2s/s2s-client/server.py#L3286-L3287) |
|
||||
| 8 | Branch **NODE_RED**: marker detection + execute apipilot + build user_context_pass2 con RISULTATO strumento + SECONDA chiamata HTTP `POST /api/reasoning` (X-S2S-Caller=demo-server-pass2) | [server.py#L3273-L3409](file:///home/duxiter/s2s/s2s-client/server.py#L3273-L3409) |
|
||||
| 9 | Branch **OPENAI**: marker detection + execute apipilot + build system_pass2 con blocco risultato + SECONDA chiamata HTTP `POST /chat/completions` (stesso model, stesso transcript, system message aggiornato con risultato) | [server.py#L3502-L3656](file:///home/duxiter/s2s/s2s-client/server.py#L3502-L3656) |
|
||||
| 10 | **FALLBACK di sicurezza**: SE la PASSATA 2 risponde vuota (NODE_RED risposta invalida / OpenAI answer vuota) → l'answer finale usa il **body text RAW dell'esecuzione apipilot** come ultima ratio (nessun silenzio sul canale vocale) | [server.py#L3386-L3389](file:///home/duxiter/s2s/s2s-client/server.py#L3386-L3389) · [L3637-L3640](file:///home/duxiter/s2s/s2s-client/server.py#L3637-L3640) |
|
||||
| 11 | Banner di STARTUP esteso: mostra `apipilotExecuteUrlOverride=…(risolto:…)` + flag `apipilotReasoningPass2=Y` se integrazione attiva | [server.py#L908-L951](file:///home/duxiter/s2s/s2s-client/server.py#L908-L951) |
|
||||
|
||||
---
|
||||
|
||||
#### Formato marker PASSATA 1 (deve essere PRIMA RIGA della risposta)
|
||||
```
|
||||
<<<APIPILOT_CALL:NAME=get_user_briefing,ARGUMENTS={"phone":"+56912345678"}>>>
|
||||
```
|
||||
Regole per l'AI ragionamento:
|
||||
1. **UN SOLO marker** per richiesta (1 tool massimo per invocazione)
|
||||
2. Subito dopo marker → **NESSUN ALTRO TESTO** (il resto viene ignorato, il risultato verrà iniettato nella passata 2)
|
||||
3. `arguments` = **JSON VALIDO** a virgolette DOPPIE, solo campi `required_parameters:` o dichiarati nel tool.
|
||||
4. Se più tool matchano, scegli il più specifico.
|
||||
|
||||
---
|
||||
|
||||
#### Formato blocco iniettivo PASSATA 2 (AI riceve)
|
||||
```
|
||||
### Resultado de la herramienta ApiPilot invocada
|
||||
<<<APIPILOT_RESULT:STATUS=OK,NAME=get_user_briefing>>>
|
||||
|
||||
```tool-output
|
||||
Estimado/a Ana, aquí tienes tu briefing diario para 24-09-2026:
|
||||
- Tienes 3 reportes de campo pendientes de aprobación.
|
||||
- 2 llamadas perdidas de clientes ayer (contactar en las próximas 2h).
|
||||
… (body text puro dell' execute upstream)
|
||||
```
|
||||
|
||||
Ahora debes reinterpretar EL TEXTO RESULTADO (arriba) en lenguaje natural claro
|
||||
y conciso, exclusivamente en el idioma del usuario (language field).
|
||||
No menciones la herramienta internamente ni detalles técnicos.
|
||||
Responde SOLAMENTE con el contenido final que le quieres transmitir al usuario.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Aggiunto — Tracer console debug sempre-attivo per TUTTE le chiamate fetch lato browser + eventi WebSocket realtime anti-leak/anti-spam + **PREFETCH AUTOMATICO APIPILOT AD OGNI INIZIO SESSIONE REALTIME**
|
||||
|
||||
**Obiettivo**: inserire logging granulare SEMPRE-ATTIVO (non richiede `?debug=true`
|
||||
né localStorage flag) su TUTTE le chiamate di rete effettuate da `s2s-client`.
|
||||
Inoltre, **a partire da v18:05**, GARANTIRE che gli strumenti ApiPilot AquaPortal
|
||||
siano caricati (cache scaldata) ALL'INIZIO DI OGNI SESSIONE WS Realtime prima che
|
||||
l'utente inizi a parlare, così l'LLM li ha disponibili immediatamente per le
|
||||
function-call senza la latenza di discovery al primo tool.
|
||||
|
||||
**Architettura**:
|
||||
- **HTTP fetch** → intercettazione globale via `window.fetch` monkey-patch (primo file caricato, `main.js`) in modo che TUTTE le fetch del codice (anche moduli terzi) siano tracciate, senza dover modificare ogni singola chiamata.
|
||||
- **WebSocket realtime (OpenAI Realtime protocol)** → logging dedicato nel client WS (`s2s-ws-client.js`) con anti-spam per chunk audio 25 Hz e anti-leak token nella connessione.
|
||||
- **Chiamate lato server (es. `S2S_APIPILOT_TOOLS_URL` upstream AquaPortal)** → continuano ad essere loggate dal backend con tag `[APIPILOT-DISCOVERY] →/←` e `[APIPILOT-EXEC] →/←` nel log server (vedi sezione Integrazione ApiPilot). Nel browser si vede SOLO lo strato proxy locale `/api/apipilot/*`, come da architettura passthrough.
|
||||
|
||||
---
|
||||
|
||||
#### 1. Tag semantici fetch (filtra Console con `[FETCH:xxx]`)
|
||||
|
||||
| Tag | Endpoint / Categoria | Trigger |
|
||||
|---|---|---|
|
||||
| `[FETCH:APIPILOT:TOOLS]` | `GET api/apipilot/tools?include_unauthorized=true` | Chiamata demo fire-and-forget all'avvio, **solo se** `/api/config → apipilot=true` |
|
||||
| `[FETCH:APIPILOT:EXEC]` | `POST api/apipilot/execute` body `{name, arguments}` | Esecuzione manuale tool frontend o LLM function-call via proxy |
|
||||
| `[FETCH:CONFIG]` | `GET api/config` | Bootstrap ogni pagina refresh |
|
||||
| `[FETCH:REASONING]` | `POST api/reasoning` | RAG / context injection |
|
||||
| `[FETCH:SEARCH]` | `POST api/search` | Serper web search |
|
||||
| `[FETCH:PROFILE]` | `GET api/user/phone?phoneNumber=` | Dati profilo login-by-phone |
|
||||
| `[FETCH:REPORTS]` | `GET api/user/reports?phoneNumber=` | Lista report (minimal TOC per default) |
|
||||
| `[FETCH:SESSION:START]` / `SESSION:END` / `SESSION:HEARTBEAT` | `api/session*` | Handshake iniziale + keepalive (heartbeat anti-spam: log singolo debug, non group) |
|
||||
| `[FETCH:QUEUE:POLL]` / `QUEUE:END` | `api/queue/{ticket}` / `api/queue/end` | Polling coda load balancer |
|
||||
| `[FETCH:ACCOUNT:ME]` | `GET api/me` | Chip login + budget (chiamata `account.refresh()`) |
|
||||
| `[FETCH:FETCH]` | Qualsiasi altro URL | Fallback generico se URL non matcha pattern sopra |
|
||||
|
||||
Ogni log fetch contiene sempre (se non è una chiamata poll/end/heartbeat):
|
||||
- `→ [FETCH:TAG] METHOD url` **group collapsed** (blu): method, URL completo, anteprima body request (max 240 chars JSON/TEXT/FORM/QS/BLOB)
|
||||
- `← [FETCH:TAG] STATUS METHOD url (N ms)` **group collapsed**: status HTTP (verde 2xx, giallo 3xx/4xx, rosso 5xx), durata ms via `performance.now()`, lista headers risposta troncati, anteprima body risposta (max 260 chars JSON/TEXT)
|
||||
- `✗ [FETCH:TAG] NETWORK_ERROR (N ms)`: rosso, eccezione catturata e risollevata
|
||||
|
||||
#### 1bis. Prefetch automatico ApiPilot ad OGNI inizio sessione WS Realtime
|
||||
|
||||
**Trigger**: metodo `connect()` di `S2SWebSocketClient` eseguito TUTTE le volte che
|
||||
l'utente fa click sull'orbo verde / inizia una nuova conversazione vocale.
|
||||
Viene eseguito in **parallelo** con `_setupAudio()` e `_openWebSocket()` (Promise.all),
|
||||
quindi **non aggiunge latenza** all'handshake.
|
||||
|
||||
| Caratteristica | Valore |
|
||||
|---|---|
|
||||
| Endpoint client | `GET api/apipilot/tools?include_unauthorized=true` (cache: no-store) |
|
||||
| Tag tracer fetch | `[FETCH:APIPILOT:TOOLS]` (stessa categoria della demo di bootstrap) |
|
||||
| Tag tracer WS | `[S2S-WS:APIPILOT]` (3 stati: **MISS cache** / **HIT cache** / **READY** / **FALLITO**) |
|
||||
| Gating | Solo se `window.__s2sApipilot = true` (dal feature flag `/api/config → apipilot`) |
|
||||
| TTL cache client | `_APIPILOT_CLIENT_TTL_MS = 240 s` = TTL server 300 s − 60 s margine |
|
||||
| Bloccante? | **MAI** — `Promise.all([…, apipilotReady.catch(()=>{})])`; fallimento non rompe `connect()` |
|
||||
| Contatore sessione | `_wsSessionCounter` crescente: ogni sessione logga `#1`, `#2`, … per correlare gli eventi |
|
||||
| Identificatore correlato | `this._sessionSeq` assegnato ad ogni `connect()` per cross-ref nel resto dei log WS |
|
||||
|
||||
Sequenza di log attesa in Console ad **OGNI** nuovo inizio di conversazione vocale:
|
||||
|
||||
```
|
||||
%c[S2S-WS:SESSION]%c #1 handshake iniziato — status: creating-session
|
||||
%c[S2S-WS:APIPILOT]%c #1 MISS cache → GET api/apipilot/tools (proxy a S2S_APIPILOT_TOOLS_URL server)...
|
||||
→ [FETCH:APIPILOT:TOOLS] GET api/apipilot/tools?include_unauthorized=true ← wrapper fetch
|
||||
← [FETCH:APIPILOT:TOOLS] 200 GET (123 ms) ← verde, con preview lista tools
|
||||
%c[S2S-WS:APIPILOT]%c #1 READY strumenti APIPILOT caricati: N=7, t=123ms [primi 5: t1, t2, t3, t4, t5]
|
||||
%c[S2S-WS:SESSION]%c #1 connessione stabilita + audio pipeline + apipilot OK
|
||||
```
|
||||
|
||||
Seconda sessione subito dopo (cache fresca):
|
||||
```
|
||||
%c[S2S-WS:SESSION]%c #2 handshake iniziato — status: creating-session
|
||||
%c[S2S-WS:APIPILOT]%c #2 HIT cache client strumenti (age=12s, N=7, TTL=240s)
|
||||
%c[S2S-WS:SESSION]%c #2 connessione stabilita + audio pipeline + apipilot OK
|
||||
```
|
||||
|
||||
Feature flag disattivato server-side:
|
||||
```
|
||||
%c[S2S-WS:APIPILOT]%c #1 feature flag apipilot=false sul server — salto prefetch strumenti
|
||||
```
|
||||
|
||||
Fallimento proxy (es. backend AquaPortal giù):
|
||||
```
|
||||
%c[S2S-WS:APIPILOT]%c #1 prefetch FALLITO: HTTP 503 — diagnostica lato server log [APIPILOT-DISCOVERY]
|
||||
```
|
||||
|
||||
#### 2. Tag WebSocket (filtra Console con `[S2S-WS:xxx]`)
|
||||
|
||||
| Tag / Badge | Evento tracciato | Note anti-spam / anti-leak |
|
||||
|---|---|---|
|
||||
| `🔵 [S2S-WS:CONNECT]` | `_openWebSocket()`: tentativo di connessione WS | ✅ **Anti-leak**: URL connessione logga solo host+path + params MASCHERATI: ogni param con nome `token\|session\|auth\|jwt\|secret\|key` oppure valore `length > 16` → mostrato come `<prefix>…<last4> len=<N>` per non leakare il JWT in chiaro nella console |
|
||||
| `🟢 [S2S-WS:OPEN]` / `🔴 [S2S-WS:ERR]` | `onopen` / `onerror` | Badge colorati, header risposta (se disponibile) |
|
||||
| `⚪ [S2S-WS:SESSION]` | `connect()` inizio/fine + `_postSession()` grant/queued/402/503 | Ora include contatore `#N` sessione incrementato ad ogni `connect()` + log "stabilita + audio + apipilot OK" finale |
|
||||
| `🔧 [S2S-WS:APIPILOT]` | `_prefetchApipilotTools()` ad ogni inizio sessione | stati: **flag off (warn)** / **HIT cache (ok)** / **MISS cache (connect)** / **READY (ok)** / **FALLITO (err)** — SEMPRE non bloccante |
|
||||
| `🟡 [S2S-WS:QUEUE]` | `_pollQueue()`: ticket / advancement / 402 / 404 | Log primo poll, ogni 10°, ogni avanzamento posizione |
|
||||
| `⏺️ [S2S-WS:SEND]` | `_send()`: messaggi in uscita | ✅ **Anti-spam**: `type=input_audio_buffer.append` (~25 Hz bulk audio) → log solo ogni **250 chunk** oppure ogni **2500 ms** (soglia regolabile). Tutti gli altri tipi: sempre log con anteprima payload max 600 chars; per tool-call `response.function_call_arguments.done` anteprima separata 220 chars |
|
||||
| `📥 [S2S-WS:MSG]` | `_onWsMessage()`: eventi ricevuti | ✅ **High-signal always-on**: matcha regex per eventi critici `session.*`, `error`, `conversation.item.*`, `response.* done`, `function_call`, `rate_limits.updated` → sempre loggati. Eventi transcript/partial `input_audio_buffer.speech_started/stopped` loggati ma silenziati a info |
|
||||
| `🟤 [S2S-WS:CLOSE]` | `_onWsClose()` / `close()` | Badge codice: `1000/1001 = OK (grigio)`, qualsiasi altro = ERR (rosso). Reason loggata se presente. |
|
||||
|
||||
Contatore chunk audio globale: `window.__s2sMicChunkCount` per diagnosticare saturazione microfono 25 Hz.
|
||||
Contatore sessioni totali: `window._wsSessionCounter` (modulo privato) incrementato ad ogni click di iniziale conversazione.
|
||||
|
||||
#### 3. Chiamata demo ApiPilot lato client
|
||||
|
||||
Per garantire che sulla Console si veda **sempre** il tracer attivo per gli endpoint proxy, dopo la ricezione di `/api/config` (linea 2513 di main.js) — se e solo se il feature flag `json.apipilot = true` (cioè `S2S_APIPILOT_TOOLS_URL` valorizzato sul server) — viene inviata una fetch `GET api/apipilot/tools?include_unauthorized=true` (fire-and-forget, cache: no-store).
|
||||
|
||||
La demo produce:
|
||||
- `🔧 [APIPILOT-CLIENT] flag=true → DEMO CALL api/apipilot/tools?include_unauthorized=true` (badge viola, prima del tracer)
|
||||
- Poi **il wrapper fetch logga** `→ [FETCH:APIPILOT:TOOLS] GET api/apipilot/tools?...` + `← [FETCH:APIPILOT:TOOLS] 200 GET (N ms)` (verde) con la lista strumenti in preview
|
||||
- Poi `🔧 [APIPILOT-CLIENT] demo OK: N strumenti [primi 5: tool1, tool2,…]` (badge verde) oppure `…demo FAIL` (ambra) con suggerimento di guardare `[APIPILOT-DISCOVERY]` sul log server
|
||||
|
||||
Valore esportato su `window.__s2sApipilot = bool` per test manuali da Console:
|
||||
```js
|
||||
// Esegui un tool manualmente per vedere [FETCH:APIPILOT:EXEC] nel tracer
|
||||
fetch("api/apipilot/execute", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({name: "nome_del_tool", arguments: {parametro: "valore"}})
|
||||
}).then(r => r.json()).then(console.log);
|
||||
```
|
||||
|
||||
#### 4. File modificati (con riferimenti riga)
|
||||
|
||||
- **`s2s-client/main.js`**:
|
||||
- Banner versione aggiornato: [L28-L31](file:///home/duxiter/s2s/s2s-client/main.js#L28-L31)
|
||||
- Wrapper fetch globale `_installFetchDebugTracer()` IIFE — classificazione tag, preview request/response, palette status, performance timing, anti-spam poll/heartbeat: [L41-L251](file:///home/duxiter/s2s/s2s-client/main.js#L41-L251)
|
||||
- `fetchConfig()` — nuovo flag `apipilotEnabled` + `window.__s2sApipilot`: [L2526-L2537](file:///home/duxiter/s2s/s2s-client/main.js#L2526-L2537)
|
||||
- `fetchConfig()` — chiamata demo fire-and-forget ApiPilot tools list: [L2581-L2617](file:///home/duxiter/s2s/s2s-client/main.js#L2581-L2617)
|
||||
- **`s2s-client/ws/s2s-ws-client.js`**:
|
||||
- Banner tracer WS aggiornato + palette `_wsLog(status, tag, msg)` status 0..3 + contatore chunk `_wsMicChunkCount` + cache client ApiPilot `_apipilotClientCache` TTL 240 s + contatore sessioni `_wsSessionCounter`: [L56-L98](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L56-L98)
|
||||
- `connect()` — contatore #N sessione, log inizio/fine handshake, Promise.all([audioReady, wsReady, **apipilotReady**]): [L320-L370](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L320-L370)
|
||||
- **`_prefetchApipilotTools(sessionIdShort)`** — NUOVO METODO: check feature flag, cache TTL client 240 s, fetch `api/apipilot/tools` (proxy server `S2S_APIPILOT_TOOLS_URL`), cache save, envelope check, badge log MISS/HIT/READY/FAIL/FALLO — MAI rilancia eccezione: [L372-L441](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L372-L441)
|
||||
- `_postSession()` — timing handshake grant/queued/402/503: [L469-L520](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L469-L520)
|
||||
- `_pollQueue()` — poll counter + advancement: [L523-L573](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L523-L573)
|
||||
- `_openWebSocket()` — MASCHERAMENTO URL connect params (anti-leak token/JWT): [L682-L728](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L682-L728)
|
||||
- `_onWsMessage()` — high-signal regex, preview `response.function_call_arguments.done`: [L755-L812](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L755-L812)
|
||||
- `_onWsClose()` — badge status code OK/ERR: [L1161-L1180](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L1161-L1180)
|
||||
- `_send()` — anti-spam `input_audio_buffer.append` 250 chunk / 2500 ms: [L1523-L1584](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L1523-L1584)
|
||||
- **`s2s-client/ui/account.js`**:
|
||||
- Chiamata `GET api/me` tracciata automaticamente dal wrapper fetch globale come `[FETCH:ACCOUNT:ME]` → nessuna modifica necessaria (nessun doppio log).
|
||||
|
||||
#### 5. Dove vedere le chiamate UPSTREAM (S2S_APIPILOT_TOOLS_URL)
|
||||
|
||||
⚠️ **Importante — architettura passthrough**:
|
||||
- ✅ **Console browser** → vedi `[FETCH:APIPILOT:TOOLS]` e `[FETCH:APIPILOT:EXEC]` = chiamate locali al proxy `/api/apipilot/*` sul server S2S
|
||||
- ✅ **Log server (terminale backend)** → vedi `[APIPILOT-DISCOVERY] → GET https://aquachile.azurianlab.com/api/v1/apipilot/tools?...` e `[APIPILOT-EXEC] → POST …/execute` = chiamate reali upstream all'URL AquaPortal (`S2S_APIPILOT_TOOLS_URL`)
|
||||
|
||||
Questo è per design: il client non conosce mai l'URL upstream AquaPortal (anti-hardcoding), tutto passa per il proxy locale su `server.py` che aggiunge header auth e validazione schema PRE-CHECK.
|
||||
|
||||
### Risolto — Bug: ApiPilot chiamato 1 sola volta (anonimo) e MAI più dopo login phone o refresh
|
||||
|
||||
**Sintomo osservato dal log aquachile fornito**:
|
||||
- Alle `21:06:46` 1 sola chiamata `[apipilot] list_tools.start user=(anon)` a `/api/v1/apipilot/tools` (chiamata demo anonima bootstrap)
|
||||
- Subito dopo: multipli `POST /auth/phone-login` e `GET /auth/user-by-phone` (utenti autenticati, 21:06:49, 21:07:16, 21:07:19, 21:07:39) ma **NESSUNA** chiamata apipilot ripetuta.
|
||||
|
||||
**Root cause doppia**:
|
||||
1. **Cache client globale (240 s) user-agnostic**: la demo bootstrap anonima e le sessioni autenticate condividevano la stessa entry `_apipilotClientCache` senza chiave per utente. Quindi la cache anonima HIT veniva riutilizzata per utenti autenticati DIVERSI.
|
||||
2. **Cache server S2S (300 s) keyed per auth fingerprint** (`SHA-256(auth)[:12] | x-demo-session`) ma dal client **non veniva propagato alcun token `x-demo-session`**, quindi tutte le chiamate usavano la fingerprint "vuota" (anonima) = cache anonima riutilizzata senza mai chiamare AquaPortal per utenti autenticati.
|
||||
3. **Trigger demo solo su bootstrap**: nessuna chiamata apipilot dopo `phone-login` riuscito, che è il momento in cui cambia il contesto utente.
|
||||
|
||||
---
|
||||
|
||||
#### Fix applicati
|
||||
|
||||
##### Fix 1 — Prefetch ApiPilot DOPO OGNI login phone riuscito (con `refresh=true`)
|
||||
Inserito blocco fire-and-forget **subito dopo** la conferma HTTP 200 in `fetchVoceroUserProfile()` (dopo caricamento profilo):
|
||||
|
||||
| Caratteristica | Valore |
|
||||
|---|---|
|
||||
| Trigger | `api/user/phone?phoneNumber=…` HTTP 200 + profile.result object valido |
|
||||
| Endpoint | `GET api/apipilot/tools?include_unauthorized=true&refresh=true` |
|
||||
| Parametro `refresh=true` | **OBBLIGATORIO** → forza il proxy S2S lato server a **ignorare la cache 300 s** e chiamare veramente AquaPortal upstream (così `[apipilot] list_tools.start user=…` riappare nel log aquachile per OGNI login phone) |
|
||||
| Headers | Propaga `x-demo-session` se presente in `window.__s2sDemoSessionToken` (per fingerprint auth S2S diversa da anonima) |
|
||||
| Effetto collaterale | Invoca `window.__s2sInvalidateApipilotCache()` = invalida cache client WS per tutte le sessioni vocali successive → nessun riuso cache per nuovo utente |
|
||||
| Log Console | `🔧 [APIPILOT-CLIENT] post-login phone → FORZO refresh api/apipilot/tools (badge viola)` + `post-login OK (dt=XYZ ms): N=X strumenti` o `post-login FAIL` |
|
||||
|
||||
Riferimento riga: [main.js#L2418-L2464](file:///home/duxiter/s2s/s2s-client/main.js#L2418-L2464)
|
||||
|
||||
##### Fix 2 — Cache client WS per-utente (invalida se cambia phone/token)
|
||||
La entry `_apipilotClientCache` ora include la proprietà `key = _apipilotUserKey()`.
|
||||
|
||||
Chiave `_apipilotUserKey()` calcolata come:
|
||||
1. `P:<ultime-8-cifre-phoneNumber>` → se c'è `?phoneNumber=` in URL
|
||||
2. `T:<ultimi-6-char-x-demo-session-token>` → se c'è token
|
||||
3. `anon` → altrimenti
|
||||
|
||||
HIT cache ora richiede **3 condizioni**:
|
||||
1. `now - cached.at <= 240 s` (TTL ancora attivo)
|
||||
2. `cached.key === currentUser` (stesso phone / stesso token → stesso utente)
|
||||
3. Feature flag `window.__s2sApipilot = true`
|
||||
|
||||
Riferimento riga: [s2s-ws-client.js#L94-L126](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L94-L126) (cache+key+invalidazione globale) · [L411-L485](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L411-L485) (metodo `_prefetchApipilotTools` con check key utente, refresh=true se cambia utente e propagate header x-demo-session)
|
||||
|
||||
##### Fix 3 — Propagazione header `x-demo-session` per fingerprint auth lato server S2S
|
||||
Ora, **quando disponibile** `window.__s2sDemoSessionToken` (length > 4), entrambe le chiamate apipilot (prefetch login + WS connect) propagano:
|
||||
```
|
||||
x-demo-session: <token>
|
||||
```
|
||||
→ la cache lato server S2S (`SHA-256(auth)[:12] | x-demo-session`) usa chiave **diversa per ogni token/sessione** = **NESSUNA condivisione cache tra Anon e Utente Autenticato** o tra utenti diversi.
|
||||
|
||||
##### Fix 4 — Funzione globale invalidazione cache per cross-modulo
|
||||
```js
|
||||
window.__s2sInvalidateApipilotCache(); // resetta _apipilotClientCache = null
|
||||
```
|
||||
Chiamata automaticamente da `main.js` dopo login phone riuscito. Utile anche per test manuali da Console browser.
|
||||
|
||||
Riferimento riga: [s2s-ws-client.js#L111-L126](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L111-L126)
|
||||
|
||||
##### Timeline attesa nel log AquaPortal DOPO questo fix (log fornito ripetuto)
|
||||
```
|
||||
21:06:46 [apipilot] list_tools.start user=(anon) ← demo bootstrap anonimo (refresh page)
|
||||
21:06:46 [apipilot] list_tools.ok count=1 …
|
||||
21:06:49 [apipilot] list_tools.start user=<username-A> ← FIX #1 post-login phone refresh=true ✅ NUOVO
|
||||
21:06:49 [apipilot] list_tools.ok count=…
|
||||
21:07:16 [apipilot] list_tools.start user=<username-B> ← FIX #1 login nuovo utente ✅ NUOVO
|
||||
21:07:16 [apipilot] list_tools.ok …
|
||||
21:07:19 [apipilot] list_tools.start user=<username-A> ← se cache client S2S invalidata oppure
|
||||
21:07:19 … x-demo-session cambiato
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Aggiunto — Integrazione ApiPilot AquaPortal (strumenti dinamici, no hardcoding)
|
||||
|
||||
Implementazione lato proxy `s2s-client/server.py` del bridge definito in
|
||||
[GUIDA_APIPILOT_CLIENTE.md](file:///home/duxiter/s2s/GUIDA_APIPILOT_CLIENTE.md).
|
||||
|
||||
**Obiettivo**: consentire al client Vocero di **consumare dinamicamente** i
|
||||
tool esposti dal backend AquaPortal via ApiPilot, **senza hardcodare nomi di
|
||||
endpoint, parametri o autorizzazioni** nel codice client. Tutta la conoscenza
|
||||
su "quali tool esistono / parametri accettano / richiedono autenticazione"
|
||||
viene **letta a runtime** tramite discovery standardizzata.
|
||||
|
||||
#### Ambiente richiesto (`.env`)
|
||||
|
||||
| Variabile | Valore esempio | Significato |
|
||||
|---|---|---|
|
||||
| **`S2S_APIPILOT_TOOLS_URL`** | `https://aquachile.azurianlab.com/api/v1/apipilot` | URL **base** (senza `/tools` o `/execute`) del backend ApiPilot. Se vuoto → feature completamente disattivata (passthrough). |
|
||||
| `S2S_APIPILOT_SERVICE_TOKEN` | `optional_Bearer_service_token` | Token di fallback usato solo se il chiamante non passa `Authorization` o `x-demo-session`. |
|
||||
| `S2S_APIPILOT_TTL_SEC` | `300` | TTL cache in-memory discovery tools (default 300 s = 5 min — come da guida). Range ammesso [15, 3600]. |
|
||||
| `S2S_APIPILOT_TIMEOUT_SEC` | `15.0` | Timeout HTTP discovery + execute (default 15 s). Range [1, 120]. |
|
||||
|
||||
#### Endpoint pubblico esposti dal proxy (stessa origine)
|
||||
|
||||
| Endpoint | Verb | Descrizione |
|
||||
|---|---|---|
|
||||
| `/api/apipilot/tools?include_unauthorized=true&refresh=false` | **GET** | Lista tool conformi §2.1 (`ToolDefinition`: `name, description, parameters, requires_auth`). Ritorno wrappato `{success, data:{count, include_unauthorized, tools}, error}`. Supporta `?refresh=true` per forzare refresh cache. |
|
||||
| `/api/apipilot/execute` | **POST** | Esegue un tool conformi §2.2. Body: `{name: string, arguments: {}}`. PRE-CHECK obbligatori prima dell'HTTP. Risposta SEMPRE envelope `{success, data, error}`. HTTP status code restituito = `error.status_code` quando presente (4xx/5xx). |
|
||||
|
||||
Estensione di `/api/config`: nuova chiave top-level booleana
|
||||
`"apipilot": true/false` (basata su `S2S_APIPILOT_TOOLS_URL`). Il frontend
|
||||
la usa per decidere se mostrare il catalogo / i bottoni "Esegui tool" (prende
|
||||
la lista da `/api/apipilot/tools` anziché hardcodare).
|
||||
|
||||
#### PRE-CHECK obbligatori (flow guida §2.2, tutti implementati)
|
||||
|
||||
1. **Fresh discovery** — prima di ogni `execute` viene usata la lista tool
|
||||
recuperata da `_apipilot_fetch_tools(...)` rispettando la TTL. Se il tool
|
||||
non viene trovato, **un solo** re-discovery forzato prima di fallire.
|
||||
2. **Controllo `requires_auth`** — se `true` ma nessun token presente
|
||||
(Authorization / x-demo-session / S2S_APIPILOT_SERVICE_TOKEN) → blocco
|
||||
HTTP 401 con envelope `{code: "AUTH_REQUIRED", message: "La herramienta
|
||||
«nome» requiere autenticación…"}` — **NESSUNA richiesta viene inviata al
|
||||
backend upstream** in questo caso.
|
||||
3. **Validazione JSON-Schema strict** (`_apipilot_validate_arguments`):
|
||||
- rimuove **tutte** le chiavi extra non in `parameters.properties`
|
||||
(avviso `[APIPILOT-VALIDATE] … removing unknown arg=…`);
|
||||
- **NON inventa parametri**;
|
||||
- restituisce errore 400 `INVALID_ARGUMENTS` per ogni campo `required`
|
||||
mancante (messaggi in spagnolo).
|
||||
4. **Passthrough header autenticazione**:
|
||||
`Authorization: Bearer <TOKEN_UTENTE>` oppure `x-demo-session:
|
||||
<SESSION_TOKEN>` vengono inoltrati VERBATIM al backend AquaPortal (§2.1
|
||||
nota), **mai loggati** (solo presenza: log `auth=Y / demo=Y`).
|
||||
5. **Compatibilità LLM function-calling**: helper `_apipilot_strip_tool_for_llm(tool_def)`
|
||||
che proietta `ToolDefinition` nel formato standard OpenAI
|
||||
`{type: "function", function: {name, description, parameters}}` — **rimuove
|
||||
`requires_auth`** (§4 della guida: "non mandare direttamente l'output
|
||||
grezzo di /tools al modello").
|
||||
|
||||
#### Garanzie / sicurezza / anti-leak
|
||||
|
||||
| Controllo | Implementazione |
|
||||
|---|---|
|
||||
| Nessuna condivisione cache tra identità diverse | Chiave cache = `SHA-256(authorization)[:12] \| x-demo-session` → fingerprint anonimo, non salva token in cache |
|
||||
| Doppio controllo cache con lock asincrono | `_APIPILOT_CACHE_LOCK` async + double check dopo acquisizione lock → 1 sola fetch per refresh |
|
||||
| Fallback stantia su errore rete | Se backend va giù e abbiamo entry in cache → restituisce fallback loggando warning invece di crash |
|
||||
| Inoltro header Auth mai loggato | Solo log `auth=Y/- demo=Y/-` |
|
||||
| Cap parametri unknown | Rimossi, mai inoltrati al backend |
|
||||
| Envelope errori uniforme | Sempre `{success, data, error}` anche per errori pre-check locali |
|
||||
| Nomi file env coerenti | Nessun URL o token hardcoded |
|
||||
| Banner startup di conferma | `[STARTUP] … apipilot=<URL> apipilotServiceToken=Y/- apipilotTtl=<N>s apipilotTimeout=<N>s` |
|
||||
|
||||
**File modificati:**
|
||||
|
||||
- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)) — Sezione APIPILOT completa inserita tra `OPENAI_PROJECT` e `NODE_RED_REASONING_URL`:
|
||||
1. Definizione 4 env + normalizzazione + TTL/timeout cap range
|
||||
([L236-L266](file:///home/duxiter/s2s/s2s-client/server.py#L236-L266)).
|
||||
2. Cache in-process `_APIPILOT_CACHE: dict[fp → (monotonic, tools[])]` + lock
|
||||
asincrona `_APIPILOT_CACHE_LOCK` ([L268-L271](file:///home/duxiter/s2s/s2s-client/server.py#L268-L271)).
|
||||
3. Helpers pura: `_apipilot_auth_fingerprint(auth, demo)`
|
||||
([L274-L287](file:///home/duxiter/s2s/s2s-client/server.py#L274-L287)) · `_apipilot_parse_authorization(Request)`
|
||||
([L290-L309](file:///home/duxiter/s2s/s2s-client/server.py#L290-L309)) · `_apipilot_strip_tool_for_llm(tool_def)`
|
||||
([L312-L338](file:///home/duxiter/s2s/s2s-client/server.py#L312-L338)).
|
||||
4. Discovery: `_apipilot_fetch_tools(auth, demo, include_unauthorized, force_refresh)`
|
||||
([L341-L481](file:///home/duxiter/s2s/s2s-client/server.py#L341-L481)) — cache hit log, stale fallback, unwrap payload `list/{tools,result,data}.tools`, sanitizzazione ToolDefinition minima, log `[APIPILOT-DISCOVERY] →/←`.
|
||||
5. Validazione: `_apipilot_validate_arguments(arguments, tool_def)`
|
||||
([L484-L517](file:///home/duxiter/s2s/s2s-client/server.py#L484-L517)) — rimuove extra, check required, messaggi in spagnolo.
|
||||
6. Execute: `_apipilot_execute_tool(auth, demo, name, arguments)`
|
||||
([L520-L684](file:///home/duxiter/s2s/s2s-client/server.py#L520-L684)) — PRE-CHECK 1-2-3 (re-discovery 1 volta, requires_auth 401, validazione 400), POST /execute envelope, gestione non-JSON HTTP, wrap envelope se backend non lo restituisce, log `[APIPILOT-EXEC] →/←`.
|
||||
7. Banner `[STARTUP] Other externals`: esteso con 4 nuovi placeholder `apipilot`, `apipilotServiceToken`, `apipilotTtl`, `apipilotTimeout`
|
||||
([L726-L752](file:///home/duxiter/s2s/s2s-client/server.py#L726-L752)).
|
||||
8. **`/api/config`**: nuova chiave booleana `"apipilot": bool(S2S_APIPILOT_TOOLS_URL)` + docstring, log esteso
|
||||
([L2544-L2579](file:///home/duxiter/s2s/s2s-client/server.py#L2544-L2579)).
|
||||
9. **Pydantic model** `ApipilotExecuteRequest(name:str, arguments:dict={})` ([L2582-L2585](file:///home/duxiter/s2s/s2s-client/server.py#L2582-L2585)).
|
||||
10. **`GET /api/apipilot/tools`** proxy pubblico ([L2588-L2651](file:///home/duxiter/s2s/s2s-client/server.py#L2588-L2651)):
|
||||
query `?include_unauthorized=true(default)&refresh=false`, envelope uniforme `{success, data: {count, include_unauthorized, tools}, error}`, 501 se env non impostato, HTTPException convertite in envelope.
|
||||
11. **`POST /api/apipilot/execute`** proxy pubblico ([L2654-L2700](file:///home/duxiter/s2s/s2s-client/server.py#L2654-L2700)):
|
||||
body `{name, arguments}`, PRE-CHECK 1-2-3 tramite `_apipilot_execute_tool`, status code risposta = `error.status_code` se errore, messaggi utente finali in spagnolo §2.2.
|
||||
|
||||
---
|
||||
|
||||
## [2026-09-23]
|
||||
|
||||
### Aggiunto — Bottone «Descargar conversación» nell'header del pannello chat (export JSON + TXT client-side offline)
|
||||
|
||||
Nuovo bottone tondo icona download affiancato alla spia «En línea» sulla destra
|
||||
dell'header del pannello `#chat-panel`. Alla pressione serializza TUTTO lo
|
||||
storico persistente (`.hist-msg` dentro `#chat-history`) e scarica DUE file
|
||||
contemporaneamente, SENZA chiamate al backend, interamente client-side e
|
||||
offline-safe (Blob + URL.createObjectURL + `<a download>`):
|
||||
|
||||
1. `vocero-conversacion[-<nombreUsuario>]-YYYYMMDD-HHMMSS.json` — struttura dati
|
||||
con meta `{ exportedAt, messageCount, messages: [...] }`. Tipi supportati:
|
||||
- `user` / `assistant` → `{ type, text, timestamp, name?, initials?, interrupted? }`
|
||||
- `tool` → `{ type: "tool", timestamp, name, args, output }` (risultato chiamate tool)
|
||||
- `image` → `{ type: "image", timestamp, dataUrl }` (scatti webcam camera tool)
|
||||
2. `vocero-conversacion[-<nombreUsuario>]-YYYYMMDD-HHMMSS.txt` — log leggibile
|
||||
per umani in spagnolo, ogni entry formattata come
|
||||
`HH:MM Nombre / Vocero / HERRAMIENTA <nome>: testo`.
|
||||
|
||||
**UI / UX (tutto in spagnolo, come richiesto):**
|
||||
- `aria-label="Descargar conversación"`
|
||||
- `title="Descargar conversación (JSON + TXT)"`
|
||||
- **Feedback OK**: dopo export riuscito bottone diventa verde per 650 ms
|
||||
(`.chat-export-ok` con ring + glow).
|
||||
- **Feedback vuoto**: se nessun messaggio → nessun download, bottone flash
|
||||
giallo/ámbar + micro-shake 0.42s (`.chat-export-empty`) + console info
|
||||
`[ui/chat.js:exportConversation] No hay mensajes aún — exportación omitida.`
|
||||
- **Nomi file sanitizzati**: helper `_safeName(...)` rimuove unicode/
|
||||
caratteri speciali/spazi → `a-z 0-9 - _`.
|
||||
- **Stili coerenti**: dark (glass/aurora) + white (screenshot mobile style)
|
||||
entrambe tematizzate con colori esistenti `var(--brand)`, `#16a34a`, `#f59e0b`.
|
||||
|
||||
**Comportamenti garantiti:**
|
||||
- 🔒 Zero dati trasmessi fuori dal browser (nessun POST, nessun analytics).
|
||||
- Le bolle effimere sull'orb (`.bubble` / `.bubble-stack`) sono volutamente
|
||||
ignorate: esporta SOLO lo storico permanente, che è quello significativo.
|
||||
- Never-throws: intero export avvolto in `try/catch`; errore solo in console.
|
||||
- `window.URL.revokeObjectURL(...)` dopo 4 s → nessun leak di memoria Blob.
|
||||
- Disponibile in TUTTI i temi (dark / white / default).
|
||||
|
||||
**File modificati:**
|
||||
|
||||
- **`index.html`** ([index.html](file:///home/duxiter/s2s/s2s-client/index.html)):
|
||||
1. Wrapper `.chat-header-actions` a destra dell'header, che ingloba la pill
|
||||
«En línea» + nuovo bottone `#chat-export-btn` (SVG freccia-giù)
|
||||
([L374-L394](file:///home/duxiter/s2s/s2s-client/index.html#L374-L394)).
|
||||
|
||||
- **`ui/chat.js`** ([chat.js](file:///home/duxiter/s2s/s2s-client/ui/chat.js)):
|
||||
1. Helper module-level `_safeName(prefix)` per sanitizzare nomi file
|
||||
([L38-L50](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L38-L50)).
|
||||
2. Rif `this._exportBtn = getElementById("chat-export-btn")` + `click` listener
|
||||
nel `ChatView` constructor ([L74-L78](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L74-L78)).
|
||||
3. Nuova sezione `// ── Export / download conversation ──` con 4 metodi:
|
||||
- `_serializeConversation()` → estrae `.hist-msg` rows con querySelector
|
||||
`:scope > .hist-msg`, gestisce `user/assistant/tool/image`, preserva
|
||||
timestamp `data-timestamp`, nome utente, initials, flag interrupted
|
||||
([L521-L582](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L521-L582)).
|
||||
- `_conversationToText(conv)` → pretty-format testo spagnolo con header
|
||||
data, totali, blocchi tool indentati, anteprima immagine dataURL
|
||||
([L584-L621](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L584-L621)).
|
||||
- `_downloadBlob(content, filename, mime)` → pattern `<a download>` +
|
||||
revokeObjectURL ([L623-L645](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L623-L645)).
|
||||
- `exportConversation()` (pubblico) → entry-point, nome file con timbro
|
||||
UTC locale `YYYYMMDD-HHMMSS`, include nome profilo Vocero se presente,
|
||||
logica empty/OK/error feedback, 2 download distinti
|
||||
([L647-L697](file:///home/duxiter/s2s/s2s-client/ui/chat.js#L647-L697)).
|
||||
|
||||
- **`style.css`** ([style.css](file:///home/duxiter/s2s/s2s-client/style.css)):
|
||||
1. `.chat-header-actions` flex-row right-aligned
|
||||
([L4919-L4931](file:///home/duxiter/s2s/s2s-client/style.css#L4919-L4931)).
|
||||
2. `.chat-export-btn` baseline (dark-theme): 32×32 pill, glassy, hover/focus
|
||||
states, transizioni 120-160 ms
|
||||
([L4932-L4968](file:///home/duxiter/s2s/s2s-client/style.css#L4932-L4968)).
|
||||
3. Stati feedback: `.chat-export-ok` (verde) + `.chat-export-empty`
|
||||
(ámbar + animazione shake 5 step)
|
||||
([L4969-L4989](file:///home/duxiter/s2s/s2s-client/style.css#L4969-L4989)).
|
||||
4. Varianti white-theme complete: `.chat-header-actions`, base, hover,
|
||||
active, OK, empty
|
||||
([L4991-L5015](file:///home/duxiter/s2s/s2s-client/style.css#L4991-L5015)).
|
||||
|
||||
### Aggiunto — `S2S_LATEST_SUMMARY_TEXT`: override statico del summary ultima conversazione (testo pulito, nessuna chiamata HTTP)
|
||||
|
||||
Nuova variabile d'ambiente che, se valorizzata, ha **priorità assoluta** su `S2S_LATEST_SUMMARY`
|
||||
e salta completamente la fetch HTTP upstream (login-by-phone, Bearer, timeout, JSON unwrap: *tutto bypassato*).
|
||||
Il suo valore testo-plain viene iniettato direttamente come blocco "latest conversation summary"
|
||||
nel prompt LLM (sia canale WebSocket `/v1/realtime` che `POST /api/reasoning`).
|
||||
|
||||
Progettata per il caso d'uso in cui l'operatore vuole iniettare una versione
|
||||
**pulita, essenziale e pre-pulita** del summary senza dipendere da nessun endpoint esterno.
|
||||
|
||||
**Valore `.env` di esempio (multiriga con `\n`):**
|
||||
```env
|
||||
# S2S_LATEST_SUMMARY=https://api.example.com/summary # ← disabilitato: S2S_LATEST_SUMMARY_TEXT ha priorità
|
||||
S2S_LATEST_SUMMARY_TEXT=Última conversación 2026-09-22:\n- Usuario preguntó por mortalidad salmón AquaChile semana 38.\n- Se refirió al reporte Porcentaje Mortalidad.
|
||||
```
|
||||
|
||||
**Comportamento chiave:**
|
||||
- Le sequenze `\n` nel valore env vengono interpretate come a-capo vero (stessa convenzione di `S2S_PRIMARY_PROMPT`)
|
||||
- **Non richiede `phoneNumber`**: funziona anche per sessioni generiche senza identificativo chiamante
|
||||
- Il wrapper `LATEST_SUMMARY_HINT` + il cap a **1600 caratteri** sono applicati comunque (passaggio sempre attraverso `_resolve_latest_summary_context`)
|
||||
- Il blocco viene correttamente classificato dal layer dedup (marker invisibile `SUMMARY_FULL_MARKER`) — nessun rischio di duplicato
|
||||
- Se `S2S_LATEST_SUMMARY_TEXT` è vuoto → **fallback** trasparente al vecchio flusso (`S2S_LATEST_SUMMARY` URL + `phoneNumber`)
|
||||
|
||||
**Log attesi nel server per confermare override attivo:**
|
||||
```
|
||||
[INJECT-SUMMARY] S2S_LATEST_SUMMARY_TEXT OVERRIDE active (len=<N> chars). Skipping HTTP fetch; injecting raw static plain-text summary directly.
|
||||
```
|
||||
|
||||
**File modificati:**
|
||||
|
||||
- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)):
|
||||
1. Definizione nuova env `S2S_LATEST_SUMMARY_TEXT` + interpretazione escape `\n` ([L128-L140](file:///home/duxiter/s2s/s2s-client/server.py#L128-L140)).
|
||||
2. Banner `[STARTUP]` esteso con campo `summaryText=%s` per conferma visiva a boot ([L275-L297](file:///home/duxiter/s2s/s2s-client/server.py#L275-L297)).
|
||||
3. **Ramo override** PRIMA di qualsiasi logica HTTP dentro `_fetch_latest_summary_for_phone(...)` ([L1563-L1589](file:///home/duxiter/s2s/s2s-client/server.py#L1563-L1589)):
|
||||
- return diretto del valore statico;
|
||||
- log `[INJECT-SUMMARY] … OVERRIDE active`;
|
||||
- anteprima raw prime 800 chars se `S2S_DEBUG_PROMPT=true`.
|
||||
4. **Gate REASONING:** `needs_summary = bool(S2S_LATEST_SUMMARY) or bool(S2S_LATEST_SUMMARY_TEXT)` ([L2153](file:///home/duxiter/s2s/s2s-client/server.py#L2153)).
|
||||
5. **Skip REASONING rilassato:** condizione `(not phone) and (not needs_reports) and (not episode_id) and (not S2S_LATEST_SUMMARY_TEXT)` — il testo statico non richiede phone ([L2159-L2168](file:///home/duxiter/s2s/s2s-client/server.py#L2159-L2168)).
|
||||
6. **Esecuzione summary REASONING:** `needs_summary and (phone or S2S_LATEST_SUMMARY_TEXT)`; log "READY" esteso con flag `summaryTextOverride=%s` ([L2240-L2256](file:///home/duxiter/s2s/s2s-client/server.py#L2240-L2256)).
|
||||
7. **Gate WS connect:** medesimo `needs_summary` esteso con `S2S_LATEST_SUMMARY_TEXT` ([L2644](file:///home/duxiter/s2s/s2s-client/server.py#L2644)).
|
||||
8. **Skip WS connect rilassato:** anche senza `phoneNumber` se c'è `S2S_LATEST_SUMMARY_TEXT` la pipeline parte ([L2653-L2664](file:///home/duxiter/s2s/s2s-client/server.py#L2653-L2664)).
|
||||
9. **Esecuzione summary WS:** medesima condizione rilassata + log "READY" con campo `summaryTextOverride=%s` ([L2725-L2746](file:///home/duxiter/s2s/s2s-client/server.py#L2725-L2746)).
|
||||
|
||||
- **`INJECTIONS.md`** ([INJECTIONS.md](file:///home/duxiter/s2s/s2s-client/INJECTIONS.md)):
|
||||
1. Sezione **4. Blocco summary ultima conversazione** completamente riscritta:
|
||||
- nuova tabella 2 modalità mutuamente esclusive (A = HTTP fetch dinamico / caller, B = override statico hardcoded);
|
||||
- paragrafo **Modalità B (override statico)** con esempio `.env` e nota su escape `\n`;
|
||||
- riferimenti codice aggiornati ([L206-L258](file:///home/duxiter/s2s/s2s-client/INJECTIONS.md#L206-L258)).
|
||||
2. **Tabella riepilogo trigger** riga #4 aggiornata:
|
||||
- Trigger: `phoneNumber oppure override statico`;
|
||||
- env: `S2S_LATEST_SUMMARY` (HTTP) **o** `S2S_LATEST_SUMMARY_TEXT` (statico, priorità);
|
||||
- parametro: `phoneNumber=` (solo HTTP; override statico non lo richiede) ([L409-L417](file:///home/duxiter/s2s/s2s-client/INJECTIONS.md#L409-L417)).
|
||||
|
||||
---
|
||||
|
||||
## [2026-09-07]
|
||||
|
||||
### Aggiunto — Supporto `episode_id` (podcast episode inject)
|
||||
|
||||
@@ -211,22 +211,35 @@ storico lunghissimo nel prompt.
|
||||
|
||||
### Trigger
|
||||
|
||||
Entrambe:
|
||||
- env `S2S_LATEST_SUMMARY` impostato
|
||||
- `phoneNumber` presente in URL/body
|
||||
Entrambe le alternative sono mutuamente esclusive: `S2S_LATEST_SUMMARY_TEXT`
|
||||
ha **priorità assoluta** se valorizzato.
|
||||
|
||||
| Modalità | Trigger |
|
||||
|---|---|
|
||||
| **A — HTTP fetch (dinamico per caller)** | env `S2S_LATEST_SUMMARY` impostato **AND** `phoneNumber` presente in URL/body |
|
||||
| **B — Testo statico hardcoded (override)** | env `S2S_LATEST_SUMMARY_TEXT` impostato (stringa non vuota) — **ignora completamente** `S2S_LATEST_SUMMARY`, `phoneNumber`, login-by-phone, Bearer auth. Utile quando l'operatore vuole iniettare una versione **pulita, essenziale e pre-pulita** del summary, senza dipendere da nessun endpoint upstream. |
|
||||
|
||||
### Sorgente dati
|
||||
|
||||
**Modalità A (HTTP fetch):**
|
||||
```
|
||||
GET S2S_LATEST_SUMMARY?phoneNumber=<phone>
|
||||
```
|
||||
|
||||
Wrapper `{"result": <valore>}` supportato.
|
||||
Accetta SIA JSON che plain text (diversi backend summary ritornano testo semplice).
|
||||
|
||||
**Modalità B (override statico):**
|
||||
- Nessuna chiamata HTTP. Viene usato direttamente il valore grezzo di `S2S_LATEST_SUMMARY_TEXT` dal file `.env`.
|
||||
- Le sequenze di escape `\n` nel valore env vengono **interpretate** come a-capo vero (stesso comportamento di `S2S_PRIMARY_PROMPT`), quindi è possibile scrivere summary multi-line in una sola riga del `.env`.
|
||||
- Esempio:
|
||||
```env
|
||||
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.
|
||||
```
|
||||
- Il valore viene passato as-is a `_resolve_latest_summary_context` → il wrapper `LATEST_SUMMARY_HINT` e il cap a 1600 caratteri vengono applicati normalmente.
|
||||
|
||||
### Cap
|
||||
|
||||
1.600 caratteri.
|
||||
1.600 caratteri (applicato da `_resolve_latest_summary_context` sia in modalità A che B).
|
||||
|
||||
### Wrap
|
||||
|
||||
@@ -240,8 +253,9 @@ Use this as trusted history when the user references prior calls.
|
||||
|
||||
### Riferimenti codice
|
||||
|
||||
- fetch: [_fetch_latest_summary_for_phone](file:///home/duxiter/s2s/s2s-client/server.py#L893-L971)
|
||||
- wrap: [_resolve_latest_summary_context](file:///home/duxiter/s2s/s2s-client/server.py#L446-L472)
|
||||
- fetch HTTP (modalità A): [_fetch_latest_summary_for_phone](file:///home/duxiter/s2s/s2s-client/server.py#L1563-L1654)
|
||||
- override statico (modalità B) → riga iniziale `if S2S_LATEST_SUMMARY_TEXT:` dentro la funzione sopra
|
||||
- wrap: [_resolve_latest_summary_context](file:///home/duxiter/s2s/s2s-client/server.py#L987-L1012)
|
||||
|
||||
---
|
||||
|
||||
@@ -399,7 +413,7 @@ di inoltrare a monte). **WARNING:** questo env può loggare PII.
|
||||
| 1 | Episodio podcast | `episode_id` in URL o body | (nessuno — URL hardcodato Aquachile) | `episode_id=` | 24 k |
|
||||
| 2 | Profilo chiamante | `phoneNumber` | `VOCERO_USER_PHONE_AUTH_URL` | `phoneNumber=` | 1,6 k |
|
||||
| 3 | Reports schedulati | sempre se env | `S2S_REPORT_LIST` | opzionale `phoneNumber=` | 12 k |
|
||||
| 4 | Summary ultima call | `phoneNumber` | `S2S_LATEST_SUMMARY` | `phoneNumber=` | 1,6 k |
|
||||
| 4 | Summary ultima call | `phoneNumber` **oppure** override statico | `S2S_LATEST_SUMMARY` (HTTP) **o** `S2S_LATEST_SUMMARY_TEXT` (statico, ha priorità) | `phoneNumber=` (solo modalità HTTP; override statico non richiede nessun param) | 1,6 k |
|
||||
| 5 | Browser user_context | solo reasoning | (nessuno) | body `user_context` | illimitato |
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user