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

74 KiB
Raw Blame History

Changelog — s2s-client (Azurian Vocero)

Todos los cambios relevantes del proyecto están documentados en este archivo. Formato basado en Keep a Changelog.


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

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

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
2 Helper _apipilot_resolve_execute_url() (cálculo URL final, prioridad env override + append "/execute" si falta) server.py#L321-L338
3 Regex marker captura + parse name/arguments (antimeta-comentario, JSON comillas dobles/simples) 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
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
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
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 · 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
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
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 · L3637-L3640
11 Banner de STARTUP extendido: muestra apipilotExecuteUrlOverride=…(resuelto:…) + flag apipilotReasoningPass2=Y si integración activa 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
    • Wrapper fetch global _installFetchDebugTracer() IIFE — clasificación tags, preview request/response, paleta status, performance timing, anti-spam poll/heartbeat: L41-L251
    • fetchConfig() — nuevo flag apipilotEnabled + window.__s2sApipilot: L2526-L2537
    • fetchConfig() — llamada demo fire-and-forget ApiPilot tools list: 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
    • connect() — contador #N sesión, log inicio/fin handshake, Promise.all([audioReady, wsReady, apipilotReady]): 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
    • _postSession() — timing handshake grant/queued/402/503: L469-L520
    • _pollQueue() — poll counter + advancement: L523-L573
    • _openWebSocket() — ENMASCARAMIENTO URL connect params (anti-fuga token/JWT): L682-L728
    • _onWsMessage() — high-signal regex, preview response.function_call_arguments.done: L755-L812
    • _onWsClose() — badge status code OK/ERR: L1161-L1180
    • _send() — anti-spam input_audio_buffer.append 250 chunks / 2500 ms: 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

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 (caché+clave+invalidación global) · 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
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

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.

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) — 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).
    2. Caché en-proceso _APIPILOT_CACHE: dict[fp → (monotonic, tools[])] + lock asíncrona _APIPILOT_CACHE_LOCK (L268-L271).
    3. Helpers puros: _apipilot_auth_fingerprint(auth, demo) (L274-L287) · _apipilot_parse_authorization(Request) (L290-L309) · _apipilot_strip_tool_for_llm(tool_def) (L312-L338).
    4. Descubrimiento: _apipilot_fetch_tools(auth, demo, include_unauthorized, force_refresh) (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) — elimina extras, check required, mensajes en español.
    6. Ejecución: _apipilot_execute_tool(auth, demo, name, arguments) (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).
    8. /api/config: nueva clave booleana "apipilot": bool(S2S_APIPILOT_TOOLS_URL) + docstring, log extendido (L2544-L2579).
    9. Modelo Pydantic ApipilotExecuteRequest(name:str, arguments:dict={}) (L2582-L2585).
    10. GET /api/apipilot/tools proxy público (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): 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):

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

    1. Helper module-level _safeName(prefix) para sanear nombres de fichero (L38-L50).
    2. Ref this._exportBtn = getElementById("chat-export-btn") + listener click en constructor ChatView (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).
      • _conversationToText(conv) → pretty-format texto en español con cabecera fecha, totales, bloques tool indentados, preview imagen dataURL (L584-L621).
      • _downloadBlob(content, filename, mime) → patrón <a download> + revokeObjectURL (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).
  • style.css (style.css):

    1. .chat-header-actions flex-row right-aligned (L4919-L4931).
    2. .chat-export-btn base (dark-theme): 32×32 pill, glassy, hover/focus states, transiciones 120-160 ms (L4932-L4968).
    3. Estados feedback: .chat-export-ok (verde) + .chat-export-empty (ámbar + animación shake 5 pasos) (L4969-L4989).
    4. Variantes white-theme completas: .chat-header-actions, base, hover, active, OK, empty (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):

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

    1. Definición nueva env S2S_LATEST_SUMMARY_TEXT + interpretación escape \n (L128-L140).
    2. Banner [STARTUP] extendido con campo summaryText=%s para confirmación visual a boot (L275-L297).
    3. Rama override ANTES de cualquier lógica HTTP dentro de _fetch_latest_summary_for_phone(...) (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).
    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).
    6. Ejecución summary REASONING: needs_summary and (phone or S2S_LATEST_SUMMARY_TEXT); log "READY" extendido con flag summaryTextOverride=%s (L2240-L2256).
    7. Puerta WS connect: mismo needs_summary extendido con S2S_LATEST_SUMMARY_TEXT (L2644).
    8. Skip WS connect relajado: incluso sin phoneNumber si hay S2S_LATEST_SUMMARY_TEXT la pipeline arranca (L2653-L2664).
    9. Ejecución summary WS: misma condición relajada + log "READY" con campo summaryTextOverride=%s (L2725-L2746).
  • INJECTIONS.md (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):

    1. Añadida la constante PODCAST_EPISODE_HINT (L426-L429) para el hint semántico en el prompt.
    2. Añadida la función _resolve_episode_context(raw_value) (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 <<<PODCAST_EPISODE_BEGIN/END>>>.
    3. Añadida la función asíncrona _fetch_podcast_episode(episode_id) (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) 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) con soporte a episode_context_text y logging dedicado [INJECT-EPISODE] / [DEBUG-EPISODE].
    6. WebSocket connect handler (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): 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);
      • en el handler (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):

    1. Lectura desde query string: const voceroEpisodeId = pageParams.get("episode_id") (L676-L680).
    2. fetchOpenAiReasoningAnswer (L1337-L1364): campo episode_id añadido al body POST /api/reasoning, logKey y console.info extendidos.
    3. Construcción URL WebSocket (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):

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

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

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

    1. Variable global startSessionMode = "" + comentario (L628-L632).
    2. Parse dentro de fetchConfig() → uppercase, log en reportDebug("B",…) (L2309-L2320).
    3. Paso a cliente WS → startWithResponse: startSessionMode === "TALK" (L2683-L2692).
    4. Bloque scheduling auto-start (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) añadido hideTalkUnlocker() en connecting/queued/your-turn/connected.
  • ws/s2s-ws-client.js (s2s-ws-client.js):

    1. Constructor: nuevas propiedades _startWithResponse, _startWithResponseText (default "hola vocero"), _initialResponseSent guardia one-shot (L163-L178).
    2. Handler session.updated (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): añadido data-debug-only="true".
  • main.js → applyDebugOnlyVisibilityGate() (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) + 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).

[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 <dialog id="user-profile-dialog"> 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 <<<NOMBRE_BLOQUE_BEGIN/END>>> para inspección de logs.