74 KiB
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):
- 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 desession.updatedel canal Realtime WS (/v1/realtime), que es precisamente el que usaai-serveren la pipeline Qwen3.5-35B de voz. - El gate
S2S_INJECT_PROFILEdejaba fuera ApiPilot en la rama=false. - 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):
- 🔴
S2S_APIPILOT_API_KEY(si tiene valor → LAS 3 formas headers, bypass completo, ningún login ejecutado) - 🟠
S2S_APIPILOT_SERVICE_TOKEN(override manual desde env) - 🟡
session_ctx["apipilot_exec_auth"](bearer propagado directamente desde DISCOVERY fetch tools) - 🟢 LOGIN FORZADO PASADA-2 si
requires_auth=truey bearer existente NO JWT-like (<120 chars o <2 dots) - ⚫ 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 campodescription= contexto para que la AI entienda qué función llamar (camponame); 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:
- UN SOLO marker por petición (1 tool máximo por invocación)
- Inmediatamente después del marker → NINGÚN OTRO TEXTO (el resto se ignora; el resultado se inyecta en la pasada 2)
arguments= JSON VÁLIDO con comillas DOBLES, solo camposrequired_parameters:o declarados en el tool.- 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 flagapipilotEnabled+window.__s2sApipilot: L2526-L2537fetchConfig()— 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_apipilotClientCacheTTL 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, fetchapi/apipilot/tools(proxy servidorS2S_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, previewresponse.function_call_arguments.done: L755-L812_onWsClose()— badge status code OK/ERR: L1161-L1180_send()— anti-spaminput_audio_buffer.append250 chunks / 2500 ms: L1523-L1584
- Banner tracer WS actualizado + paleta
s2s-client/ui/account.js:- Llamada
GET api/metraceada automáticamente por el wrapper fetch global como[FETCH:ACCOUNT:ME]→ sin modificaciones necesarias (ningún log duplicado).
- Llamada
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:461 ú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-loginyGET /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:
- Caché cliente global (240 s) user-agnostic: la demo bootstrap anónima y las sesiones autenticadas compartían la misma entry
_apipilotClientCachesin clave por usuario. Por tanto la caché anónima HIT se reutilizaba para usuarios autenticados DIFERENTES. - 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 tokenx-demo-session, así todas las llamadas usaban la fingerprint "vacía" (anónima) = caché anónima reutilizada sin llamar nunca AquaPortal para usuarios autenticados. - Trigger demo solo al bootstrap: ninguna llamada apipilot después de
phone-logincorrecto, 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:
P:<últimos-8-dígitos-phoneNumber>→ si existe?phoneNumber=en URLT:<últimos-6-char-x-demo-session-token>→ si existe tokenanon→ en otro caso
HIT caché ahora requiere 3 condiciones:
now - cached.at <= 240 s(TTL aún activo)cached.key === currentUser(mismo phone / mismo token → mismo usuario)- 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)
- Descubrimiento fresco — antes de cada
executese 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. - Control
requires_auth— sitruepero 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. - 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_ARGUMENTSpor cada camporequiredfaltante (mensajes en español).
- elimina TODAS las claves extra no presentes en
- Passthrough cabeceras autenticación:
Authorization: Bearer <TOKEN_USUARIO>o bienx-demo-session: <SESSION_TOKEN>se reenvían VERBATIM al backend AquaPortal (§2.1 nota), NUNCA se loguean (solo presencia: logauth=Y / demo=Y). - Compatibilidad LLM function-calling: helper
_apipilot_strip_tool_for_llm(tool_def)que proyectaToolDefinitional formato estándar OpenAI{type: "function", function: {name, description, parameters}}— eliminarequires_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 entreOPENAI_PROJECTyNODE_RED_REASONING_URL:- Definición 4 env + normalización + límites rango TTL/timeout (L236-L266).
- Caché en-proceso
_APIPILOT_CACHE: dict[fp → (monotonic, tools[])]+ lock asíncrona_APIPILOT_CACHE_LOCK(L268-L271). - Helpers puros:
_apipilot_auth_fingerprint(auth, demo)(L274-L287) ·_apipilot_parse_authorization(Request)(L290-L309) ·_apipilot_strip_tool_for_llm(tool_def)(L312-L338). - Descubrimiento:
_apipilot_fetch_tools(auth, demo, include_unauthorized, force_refresh)(L341-L481) — log cache hit, fallback obsoleto, desempaquetado payloadlist/{tools,result,data}.tools, saneamiento mínimo ToolDefinition, logs[APIPILOT-DISCOVERY] →/←. - Validación:
_apipilot_validate_arguments(arguments, tool_def)(L484-L517) — elimina extras, check required, mensajes en español. - 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] →/←. - Banner
[STARTUP] Other externals: extendido con 4 nuevos placeholdersapipilot,apipilotServiceToken,apipilotTtl,apipilotTimeout(L726-L752). /api/config: nueva clave booleana"apipilot": bool(S2S_APIPILOT_TOOLS_URL)+ docstring, log extendido (L2544-L2579).- Modelo Pydantic
ApipilotExecuteRequest(name:str, arguments:dict={})(L2582-L2585). GET /api/apipilot/toolsproxy 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.POST /api/apipilot/executeproxy público (L2654-L2700): body{name, arguments}, PRE-CHECK 1-2-3 vía_apipilot_execute_tool, código status respuesta =error.status_codesi 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>):
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)
vocero-conversacion[-<nombreUsuario>]-YYYYMMDD-HHMMSS.txt— log legible para humanos en español, cada entrada formateada comoHH: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-okcon 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):- Wrapper
.chat-header-actionsa la derecha de la cabecera, que engloba la pill «En línea» + nuevo botón#chat-export-btn(SVG flecha-abajo) (L374-L394).
- Wrapper
-
ui/chat.js(chat.js):- Helper module-level
_safeName(prefix)para sanear nombres de fichero (L38-L50). - Ref
this._exportBtn = getElementById("chat-export-btn")+ listenerclicken constructorChatView(L74-L78). - Nueva sección
// ── Export / download conversation ──con 4 métodos:_serializeConversation()→ extrae rows.hist-msgvia querySelector:scope > .hist-msg, gestionauser/assistant/tool/image, preserva timestampdata-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 localYYYYMMDD-HHMMSS, incluye nombre perfil Vocero si existe, lógica empty/OK/error feedback, 2 descargas distintas (L647-L697).
- Helper module-level
-
style.css(style.css):.chat-header-actionsflex-row right-aligned (L4919-L4931)..chat-export-btnbase (dark-theme): 32×32 pill, glassy, hover/focus states, transiciones 120-160 ms (L4932-L4968).- Estados feedback:
.chat-export-ok(verde) +.chat-export-empty(ámbar + animación shake 5 pasos) (L4969-L4989). - 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
\nen el valor env se interpretan como salto de línea real (misma convención queS2S_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_TEXTestá vacío → fallback transparente al flujo anterior (S2S_LATEST_SUMMARYURL +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):- Definición nueva env
S2S_LATEST_SUMMARY_TEXT+ interpretación escape\n(L128-L140). - Banner
[STARTUP]extendido con camposummaryText=%spara confirmación visual a boot (L275-L297). - 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.
- Puerta REASONING:
needs_summary = bool(S2S_LATEST_SUMMARY) or bool(S2S_LATEST_SUMMARY_TEXT)(L2153). - 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). - Ejecución summary REASONING:
needs_summary and (phone or S2S_LATEST_SUMMARY_TEXT); log "READY" extendido con flagsummaryTextOverride=%s(L2240-L2256). - Puerta WS connect: mismo
needs_summaryextendido conS2S_LATEST_SUMMARY_TEXT(L2644). - Skip WS connect relajado: incluso sin
phoneNumbersi hayS2S_LATEST_SUMMARY_TEXTla pipeline arranca (L2653-L2664). - Ejecución summary WS: misma condición relajada + log "READY" con campo
summaryTextOverride=%s(L2725-L2746).
- Definición nueva env
-
INJECTIONS.md(INJECTIONS.md):- 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):- Añadida la constante
PODCAST_EPISODE_HINT(L426-L429) para el hint semántico en el prompt. - 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>>>. - 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, devuelveNoneen caso de fallo). - Extendida
_augment_instructions_with_profile(...)(L1137-L1181) con el nuevo parámetroepisode_context_text; el orden de inject ahora es (más específico → más genérico): episode → profile → reports → summary. - Extendida
_patch_client_session_update_with_profile(...)(L1184-L1306) con soporte aepisode_context_texty logging dedicado[INJECT-EPISODE]/[DEBUG-EPISODE]. - WebSocket connect handler (L1791-L1959):
- lee
episode_iddesde query params junto aphoneNumber; - ejecuta fetch episodio + resolve ANTES de profile/reports/summary;
- guarda
injected_episode_contexteinject_episode_iden elsession_ctxcompartido.
- lee
_client_to_upstream(L2649-L2661): pasactx_episode_contextal patch desession.update.- Endpoint
POST /api/reasoning:- extendido el modelo
ReasoningRequestcon el campoepisode_id: str | None = None(L1309-L1323); - en el handler (L1411-L1577) fetch episodio independiente de phone;
inject_episode_textse hace prepend sobreuser_context_parts(primero de todos);- reenvío a NODE_RED / OPENAI con logging extendido (
episodeIdLen).
- extendido el modelo
- Añadida la constante
-
main.js(main.js):- Lectura desde query string:
const voceroEpisodeId = pageParams.get("episode_id")(L676-L680). fetchOpenAiReasoningAnswer(L1337-L1364): campoepisode_idañadido al body POST/api/reasoning, logKey yconsole.infoextendidos.- Construcción URL WebSocket (L2323-L2342):
episode_idañadido a query params tanto con la APIURL.searchParamscomo en el fallback string (concat con&).
- Lectura desde query string:
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):- Lectura env:
S2S_START_SESSION_MODE = os.environ.get(...),.strip().upper()(L150). - Banner
[STARTUP]extendido con el campostartSessionMode=(L196-L212). - Endpoint
GET /api/config→ nuevo campostartSessionMode+ log[INTERNAL] /api/config(L1383-L1400).
- Lectura env:
-
index.html(index.html):- Añadido nuevo overlay
#talk-unlockera pantalla completa (z-index 9999),hiddenpor defecto, con tarjeta "Preparando Vocero… / Toca en cualquier lugar para empezar a hablar" + spinner degradado.
- Añadido nuevo overlay
-
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.
- Clases
-
main.js(main.js):- Variable global
startSessionMode = ""+ comentario (L628-L632). - Parse dentro de
fetchConfig()→ uppercase, log enreportDebug("B",…)(L2309-L2320). - Paso a cliente WS →
startWithResponse: startSessionMode === "TALK"(L2683-L2692). - 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): setpendingWakeWordConfirmationText="hola vocero"+doStart()+ try/catch con teardown y fallback unlocker;- listener unlocker click/touchstart/keydown →
startTalkFromGesture()concreateResumedAudioContext()dentro de la gesture (para iOS silent-free).
onClientStatus()(L2878-L2932) añadidohideTalkUnlocker()enconnecting/queued/your-turn/connected.
- Variable global
-
ws/s2s-ws-client.js(s2s-ws-client.js):- Constructor: nuevas propiedades
_startWithResponse,_startWithResponseText(default"hola vocero"),_initialResponseSentguardia one-shot (L163-L178). - Handler
session.updated(L688-L713): Solo paraTALK(no TALKFIRST, que en cambio usapendingWakeWordConfirmationText):this.sendUserText("hola vocero") + this.requestResponse()— mismo code path identico de la wake-word. requestResponse/_createResponseNowrestaurados limpios (solo payload opcionalimage; ningún shapedresponse.create).
- Constructor: nuevas propiedades
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ñadidodata-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 = falsecondicionado aqs.get("debug") === "true"(L1227-L1246) + log rojo🚫 FORZADO HIDDENcuando 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-panela continuación (historial- form input fijo sobre el footer sticky).
- Actualizada lógica en
main.jsyui/chat.jspara el envío de texto medianteclient.sendUserTexty 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-btnresuelto 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).
- padding incrementado en
- Auto-scroll streaming: implementado
_scrollToBottomcon 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">enindex.htmlque 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):
- Episodio podcast actual (PRIMARIO)
- Perfil llamador (identidad)
- Lista reports / scripts podcast programados
- Summary última conversación
- Todos los bloques están marcados con
<<<NOMBRE_BLOQUE_BEGIN/END>>>para inspección de logs.