# Changelog — s2s-client (Azurian Vocero) Todos los cambios relevantes del proyecto están documentados en este archivo. 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: ``` <<>> ### 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... <<>> ``` --- ### 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 `<<>>` 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 `<<` 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 `<<>>` 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 <<>> ] │ │ │ │ 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: │ │ │ │ + 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) ``` <<>> ``` 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 <<>> ```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 `… len=` 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: ``` → 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= ← FIX #1 post-login phone refresh=true ✅ NUEVO 21:06:49 [apipilot] list_tools.ok count=… 21:07:16 [apipilot] list_tools.start user= ← FIX #1 login nuevo usuario ✅ NUEVO 21:07:16 [apipilot] list_tools.ok … 21:07:19 [apipilot] list_tools.start user= ← 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 ` o bien `x-demo-session: ` 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= apipilotServiceToken=Y/- apipilotTtl=s apipilotTimeout=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 + ``): 1. `vocero-conversacion[-]-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[-]-YYYYMMDD-HHMMSS.txt` — log legible para humanos en español, cada entrada formateada como `HH:MM Nombre / Vocero / HERRAMIENTA : 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 `` + 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= 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) Nuevo parámetro query string `?episode_id=` junto a `?phoneNumber=` que habilita la carga y la inyección automática de un episodio podcast como contexto PRIMARIO en el prompt LLM. **URL de ejemplo:** ``` /?phoneNumber=%2B447432477433&episode_id=episode-19569f77-a06e-5ce5-a52f-6299dbd67d03 ``` **Archivos modificados:** - **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)): 1. Añadida la constante `PODCAST_EPISODE_HINT` ([L426-L429](file:///home/duxiter/s2s/s2s-client/server.py#L426-L429)) para el hint semántico en el prompt. 2. Añadida la función `_resolve_episode_context(raw_value)` ([L475-L504](file:///home/duxiter/s2s/s2s-client/server.py#L475-L504)): normaliza la respuesta API (dict/list/str, posible wrapper `{"result": …}`), la serializa en JSON (tope 24.000 chars) y la envuelve con los marcadores `<<>>`. 3. Añadida la función asíncrona `_fetch_podcast_episode(episode_id)` ([L1010-L1078](file:///home/duxiter/s2s/s2s-client/server.py#L1010-L1078)): GET directo a `https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}` (timeout 15s, follow redirects, manejo de errores tolerante — nunca rompe el flujo, devuelve `None` en caso de fallo). 4. Extendida `_augment_instructions_with_profile(...)` ([L1137-L1181](file:///home/duxiter/s2s/s2s-client/server.py#L1137-L1181)) con el nuevo parámetro `episode_context_text`; el orden de inject ahora es (más específico → más genérico): **episode → profile → reports → summary**. 5. Extendida `_patch_client_session_update_with_profile(...)` ([L1184-L1306](file:///home/duxiter/s2s/s2s-client/server.py#L1184-L1306)) con soporte a `episode_context_text` y logging dedicado `[INJECT-EPISODE]` / `[DEBUG-EPISODE]`. 6. WebSocket connect handler ([L1791-L1959](file:///home/duxiter/s2s/s2s-client/server.py#L1791-L1959)): - lee `episode_id` desde query params junto a `phoneNumber`; - ejecuta fetch episodio + resolve ANTES de profile/reports/summary; - guarda `injected_episode_context` e `inject_episode_id` en el `session_ctx` compartido. 7. `_client_to_upstream` ([L2649-L2661](file:///home/duxiter/s2s/s2s-client/server.py#L2649-L2661)): pasa `ctx_episode_context` al patch de `session.update`. 8. Endpoint `POST /api/reasoning`: - extendido el modelo `ReasoningRequest` con el campo `episode_id: str | None = None` ([L1309-L1323](file:///home/duxiter/s2s/s2s-client/server.py#L1309-L1323)); - en el handler ([L1411-L1577](file:///home/duxiter/s2s/s2s-client/server.py#L1411-L1577)) fetch episodio independiente de phone; - `inject_episode_text` se hace **prepend** sobre `user_context_parts` (primero de todos); - reenvío a NODE_RED / OPENAI con logging extendido (`episodeIdLen`). - **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)): 1. Lectura desde query string: `const voceroEpisodeId = pageParams.get("episode_id")` ([L676-L680](file:///home/duxiter/s2s/s2s-client/main.js#L676-L680)). 2. `fetchOpenAiReasoningAnswer` ([L1337-L1364](file:///home/duxiter/s2s/s2s-client/main.js#L1337-L1364)): campo `episode_id` añadido al body POST `/api/reasoning`, logKey y `console.info` extendidos. 3. Construcción URL WebSocket ([L2323-L2342](file:///home/duxiter/s2s/s2s-client/main.js#L2323-L2342)): `episode_id` añadido a query params tanto con la API `URL.searchParams` como en el fallback string (concat con `&`). --- ### Añadido — `S2S_START_SESSION_MODE`: 3 modos de inicio de sesión Variable de entorno leída por el servidor y propagada al cliente vía `GET /api/config.startSessionMode`. Objetivo: controlar si y cómo se enciende automáticamente el micrófono y si el modelo saluda primero (flujo similar al wake-word). Valores soportados: | Valor | Descripción | |---|---| | *(vacío) / default* | Inicio manual: solo tap sobre el Orb / botón Start | | **`TALK`** | Después de 2s overlay a pantalla completa "Toca en cualquier lugar para empezar" (policy safe para Safari/iOS `getUserMedia`). Click/touch/tecla dentro de la user gesture → `doStart()`. El cliente WS inyecta bootstrap `"hola vocero"` + `requestResponse()`. | | **`TALKFIRST`** | Después de 2s **intenta `doStart()` DIRECTAMENTE SIN click**, precargando `pendingWakeWordConfirmationText = "hola vocero"` (misma variable de la wake-word real). Si los permisos de mic ya están "allow always" funciona sin click. Si falla (policy Safari/iOS) → fallback sobre el mismo unlocker de TALK con mensaje "permiso micrófono". | **Archivos modificados:** - **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)): 1. Lectura env: `S2S_START_SESSION_MODE = os.environ.get(...)`, `.strip().upper()` ([L150](file:///home/duxiter/s2s/s2s-client/server.py#L150)). 2. Banner `[STARTUP]` extendido con el campo `startSessionMode=` ([L196-L212](file:///home/duxiter/s2s/s2s-client/server.py#L196-L212)). 3. Endpoint `GET /api/config` → nuevo campo `startSessionMode` + log `[INTERNAL] /api/config` ([L1383-L1400](file:///home/duxiter/s2s/s2s-client/server.py#L1383-L1400)). - **`index.html`** ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L32-L39)): - Añadido nuevo overlay `#talk-unlocker` a pantalla completa (z-index 9999), `hidden` por defecto, con tarjeta "Preparando Vocero… / Toca en cualquier lugar para empezar a hablar" + spinner degradado. - **`style.css`** ([style.css](file:///home/duxiter/s2s/s2s-client/style.css#L145-L215)): - Clases `.talk-unlocker`, `.talk-unlocker-hint`, `.talk-unlocker-spinner`, `.talk-unlocker-title`, `.talk-unlocker-sub` + animaciones fade-in y pop-up de tarjeta. - **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)): 1. Variable global `startSessionMode = ""` + comentario ([L628-L632](file:///home/duxiter/s2s/s2s-client/main.js#L628-L632)). 2. Parse dentro de `fetchConfig()` → uppercase, log en `reportDebug("B",…)` ([L2309-L2320](file:///home/duxiter/s2s/s2s-client/main.js#L2309-L2320)). 3. Paso a cliente WS → `startWithResponse: startSessionMode === "TALK"` ([L2683-L2692](file:///home/duxiter/s2s/s2s-client/main.js#L2683-L2692)). 4. Bloque scheduling auto-start ([L2976-L3083](file:///home/duxiter/s2s/s2s-client/main.js#L2976-L3083)): - `bootstrapWithAutoStart()`: `await fetchConfig()` → `maybeScheduleAutoStart()`; - `setTimeout(2000 ms)` warmup para finalizar fetch de episode/profile/reports; - `showTalkUnlocker(fallbackHint)`: gestiona labels personalizadas en caso de fallback TALKFIRST; - **`tryTalkFirstDirect()` (TALKFIRST)**: set `pendingWakeWordConfirmationText="hola vocero"` + `doStart()` + try/catch con teardown y fallback unlocker; - listener unlocker click/touchstart/keydown → `startTalkFromGesture()` con `createResumedAudioContext()` dentro de la gesture (para iOS silent-free). 5. `onClientStatus()` ([L2878-L2932](file:///home/duxiter/s2s/s2s-client/main.js#L2878-L2932)) añadido `hideTalkUnlocker()` en `connecting/queued/your-turn/connected`. - **`ws/s2s-ws-client.js`** ([s2s-ws-client.js](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js)): 1. Constructor: nuevas propiedades `_startWithResponse`, `_startWithResponseText` (default `"hola vocero"`), `_initialResponseSent` guardia one-shot ([L163-L178](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L163-L178)). 2. Handler `session.updated` ([L688-L713](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L688-L713)): **Solo para `TALK`** (no TALKFIRST, que en cambio usa `pendingWakeWordConfirmationText`): `this.sendUserText("hola vocero") + this.requestResponse()` — mismo code path identico de la wake-word. 3. `requestResponse` / `_createResponseNow` restaurados limpios (solo payload opcional `image`; ningún shaped `response.create`). --- ### Modificado — `#reports-sidebar-toggle` visible solo con `?debug=true` El botón "reports" en la topbar (toggle del panel lateral reports) ahora es `data-debug-only="true"` — por lo tanto solo se muestra si la URL contiene `?debug=true`, de manera análoga al cluster `.topbar-right`. **Archivos modificados:** - **`index.html`** → `#reports-sidebar-toggle` ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L47-L50)): añadido `data-debug-only="true"`. - **`main.js`** → `applyDebugOnlyVisibilityGate()` ([L48-L109](file:///home/duxiter/s2s/s2s-client/main.js#L48-L109)): extendido para iterar *todos* los elementos `[data-debug-only="true"]` (no solo `.topbar-right`); línea 1227: `reportsSidebarToggle.hidden = false` condicionado a `qs.get("debug") === "true"` ([L1227-L1246](file:///home/duxiter/s2s/s2s-client/main.js#L1227-L1246)) + log rojo `🚫 FORZADO HIDDEN` cuando falta debug=true. - **`style.css`** → regla de seguridad ya presente: `[data-debug-only]:not([data-debug-enabled]) { display:none !important; visibility:hidden; opacity:0 }` ([L532-L537](file:///home/duxiter/s2s/s2s-client/style.css#L532-L537)). --- ## [2026-09-04] ### Modificado — Chat siempre visible + integración seamless Reorganizado el layout para mostrar el chat textual de forma permanente e integrado visualmente con el Orb en la parte superior del stage. - Eliminado botón toggle chat en la topbar (`#chat-btn`) y relativo overlay. - Stage con el Orb en la parte superior; nuevo `#chat-panel` a continuación (historial + form input fijo sobre el footer sticky). - Actualizada lógica en `main.js` y `ui/chat.js` para el envío de texto mediante `client.sendUserText` y auto-scroll sin controles de visibilidad del panel. - Versión banner deploy: **2026-09-03 14:40**. ### Resuelto — Estabilidad integración chat mobile - Error `Missing element: #chat-btn` resuelto con selectores non-strict (`document.querySelector`) para elementos opcionales eliminados. - Refactorización estética "seamless": chat sin marcos, sombras ni fondos (`background:transparent`, `box-shadow:none`). - **Layout mobile:** - padding incrementado en `#app` (56px) y `.chat-history` (96px) para evitar superposiciones con el footer sticky; - márgenes fijos en las burbujas: `margin-right:22px` (Assistant), `margin-left:6px` (User). - **Auto-scroll streaming:** implementado `_scrollToBottom` con cadena de retry múltiples (`requestAnimationFrame` + `setTimeout`) para compensar el line-wrap asíncrono durante la llegada de los tokens; `overflow-anchor:none` + `scroll-behavior:auto !important`. ### Añadido — Avatar usuario con iniciales + modal de perfil - Sustituido el texto del nombre de usuario en el header por avatar circular 40×40px (gradient de marca) conteniendo las iniciales calculadas (ej. "AV"). - Nueva `` en `index.html` que se abre al click sobre el avatar: muestra email, teléfono, rol, empresa, con soporte a shapes anidadas del payload de perfil. - Cierre del modal: tecla ESC, click fuera de la tarjeta, botón `×` dedicado. --- ## [Notas de formato] - **Tope campo prompt episodio:** 24.000 caracteres (transcript largos). - **Tope profile / summary:** 1.600 caracteres cada uno. - **Tope reports list:** 12.000 caracteres. - **Orden inject prompt LLM (WS session.update + reasoning):** 1. Episodio podcast actual (PRIMARIO) 2. Perfil llamador (identidad) 3. Lista reports / scripts podcast programados 4. Summary última conversación - Todos los bloques están marcados con `<<>>` para inspección de logs.