From 413ba08c217d30bbf2bffd52b43621a12a5a103f Mon Sep 17 00:00:00 2001 From: valenti Date: Thu, 10 Sep 2026 15:57:48 -0400 Subject: [PATCH] pretheme --- .gitignore | 10 +- _old_to_del/index copy.html | 401 ++++++++ s2s-client/CHANGELOG.es.md | 156 +++ s2s-client/CHANGELOG.md | 156 +++ s2s-client/INJECTIONS.md | 484 ++++++++++ s2s-client/README.md | 82 +- s2s-client/index.html | 112 ++- s2s-client/main.js | 1011 ++++++++++++++++++- s2s-client/server.py | 710 +++++++++++--- s2s-client/style.css | 1656 +++++++++++++++++++++++++++++--- s2s-client/ui/chat.js | 157 ++- s2s-client/ws/s2s-ws-client.js | 40 + start_ui.sh | 8 +- 13 files changed, 4649 insertions(+), 334 deletions(-) create mode 100644 _old_to_del/index copy.html create mode 100644 s2s-client/CHANGELOG.es.md create mode 100644 s2s-client/CHANGELOG.md create mode 100644 s2s-client/INJECTIONS.md diff --git a/.gitignore b/.gitignore index 1fd9795..3893c32 100644 --- a/.gitignore +++ b/.gitignore @@ -129,12 +129,12 @@ s2s-reasoning-engine/lib/flows_cred.json s2s-reasoning-engine/.config.* # ───────────────────────────────────────────────────────────── -# Demo static artifacts generated at runtime by browser / proxy logs +# s2s-client static artifacts generated at runtime by browser / proxy logs # ───────────────────────────────────────────────────────────── -demo/.cache/ -demo/uploads/ -demo/sessions/ -demo/*.log +s2s-client/.cache/ +s2s-client/uploads/ +s2s-client/sessions/ +s2s-client/*.log speech_to_speech/*.log speech_to_speech/sessions/ speech_to_speech/.cache/ diff --git a/_old_to_del/index copy.html b/_old_to_del/index copy.html new file mode 100644 index 0000000..6522f14 --- /dev/null +++ b/_old_to_del/index copy.html @@ -0,0 +1,401 @@ + + + + + + + + + Minimal Conversation · S2S backend (WebSocket) + + + + + + + +
+
+ +
+ + + + + + + +
+
+ +
+
+
+ + +
+ + + + +
+ +

Tap to start

+ + + +
+ +
+ + +
+ + + + + +
+
+
+
+

Conversation

+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/s2s-client/CHANGELOG.es.md b/s2s-client/CHANGELOG.es.md new file mode 100644 index 0000000..e0d3902 --- /dev/null +++ b/s2s-client/CHANGELOG.es.md @@ -0,0 +1,156 @@ +# Changelog — s2s-client (Azurian Vocero) + +Todos los cambios relevantes del proyecto están documentados en este archivo. +Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/). + +--- + +## [2026-09-07] + +### Añadido — Soporte `episode_id` (inject de episodio podcast) + +Nuevo parámetro query string `?episode_id=` junto a `?phoneNumber=` que +habilita la carga y la inyección automática de un episodio podcast como +contexto PRIMARIO en el prompt LLM. + +**URL de ejemplo:** +``` +/?phoneNumber=%2B447432477433&episode_id=episode-19569f77-a06e-5ce5-a52f-6299dbd67d03 +``` + +**Archivos modificados:** + +- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)): + 1. Añadida la constante `PODCAST_EPISODE_HINT` ([L426-L429](file:///home/duxiter/s2s/s2s-client/server.py#L426-L429)) para el hint semántico en el prompt. + 2. Añadida la función `_resolve_episode_context(raw_value)` ([L475-L504](file:///home/duxiter/s2s/s2s-client/server.py#L475-L504)): normaliza la respuesta API (dict/list/str, posible wrapper `{"result": …}`), la serializa en JSON (tope 24.000 chars) y la envuelve con los marcadores `<<>>`. + 3. Añadida la función asíncrona `_fetch_podcast_episode(episode_id)` ([L1010-L1078](file:///home/duxiter/s2s/s2s-client/server.py#L1010-L1078)): GET directo a + `https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}` + (timeout 15s, follow redirects, manejo de errores tolerante — nunca rompe el flujo, devuelve `None` en caso de fallo). + 4. Extendida `_augment_instructions_with_profile(...)` ([L1137-L1181](file:///home/duxiter/s2s/s2s-client/server.py#L1137-L1181)) con el nuevo parámetro `episode_context_text`; el orden de inject ahora es (más específico → más genérico): **episode → profile → reports → summary**. + 5. Extendida `_patch_client_session_update_with_profile(...)` ([L1184-L1306](file:///home/duxiter/s2s/s2s-client/server.py#L1184-L1306)) con soporte a `episode_context_text` y logging dedicado `[INJECT-EPISODE]` / `[DEBUG-EPISODE]`. + 6. WebSocket connect handler ([L1791-L1959](file:///home/duxiter/s2s/s2s-client/server.py#L1791-L1959)): + - lee `episode_id` desde query params junto a `phoneNumber`; + - ejecuta fetch episodio + resolve ANTES de profile/reports/summary; + - guarda `injected_episode_context` e `inject_episode_id` en el `session_ctx` compartido. + 7. `_client_to_upstream` ([L2649-L2661](file:///home/duxiter/s2s/s2s-client/server.py#L2649-L2661)): pasa `ctx_episode_context` al patch de `session.update`. + 8. Endpoint `POST /api/reasoning`: + - extendido el modelo `ReasoningRequest` con el campo `episode_id: str | None = None` ([L1309-L1323](file:///home/duxiter/s2s/s2s-client/server.py#L1309-L1323)); + - en el handler ([L1411-L1577](file:///home/duxiter/s2s/s2s-client/server.py#L1411-L1577)) fetch episodio independiente de phone; + - `inject_episode_text` se hace **prepend** sobre `user_context_parts` (primero de todos); + - reenvío a NODE_RED / OPENAI con logging extendido (`episodeIdLen`). + +- **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)): + 1. Lectura desde query string: `const voceroEpisodeId = pageParams.get("episode_id")` ([L676-L680](file:///home/duxiter/s2s/s2s-client/main.js#L676-L680)). + 2. `fetchOpenAiReasoningAnswer` ([L1337-L1364](file:///home/duxiter/s2s/s2s-client/main.js#L1337-L1364)): campo `episode_id` añadido al body POST `/api/reasoning`, logKey y `console.info` extendidos. + 3. Construcción URL WebSocket ([L2323-L2342](file:///home/duxiter/s2s/s2s-client/main.js#L2323-L2342)): `episode_id` añadido a query params tanto con la API `URL.searchParams` como en el fallback string (concat con `&`). + +--- + +### Añadido — `S2S_START_SESSION_MODE`: 3 modos de inicio de sesión + +Variable de entorno leída por el servidor y propagada al cliente vía `GET /api/config.startSessionMode`. +Objetivo: controlar si y cómo se enciende automáticamente el micrófono y si el modelo saluda +primero (flujo similar al wake-word). Valores soportados: + +| Valor | Descripción | +|---|---| +| *(vacío) / default* | Inicio manual: solo tap sobre el Orb / botón Start | +| **`TALK`** | Después de 2s overlay a pantalla completa "Toca en cualquier lugar para empezar" (policy safe para Safari/iOS `getUserMedia`). Click/touch/tecla dentro de la user gesture → `doStart()`. El cliente WS inyecta bootstrap `"hola vocero"` + `requestResponse()`. | +| **`TALKFIRST`** | Después de 2s **intenta `doStart()` DIRECTAMENTE SIN click**, precargando `pendingWakeWordConfirmationText = "hola vocero"` (misma variable de la wake-word real). Si los permisos de mic ya están "allow always" funciona sin click. Si falla (policy Safari/iOS) → fallback sobre el mismo unlocker de TALK con mensaje "permiso micrófono". | + +**Archivos modificados:** + +- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)): + 1. Lectura env: `S2S_START_SESSION_MODE = os.environ.get(...)`, `.strip().upper()` ([L150](file:///home/duxiter/s2s/s2s-client/server.py#L150)). + 2. Banner `[STARTUP]` extendido con el campo `startSessionMode=` ([L196-L212](file:///home/duxiter/s2s/s2s-client/server.py#L196-L212)). + 3. Endpoint `GET /api/config` → nuevo campo `startSessionMode` + log `[INTERNAL] /api/config` ([L1383-L1400](file:///home/duxiter/s2s/s2s-client/server.py#L1383-L1400)). + +- **`index.html`** ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L32-L39)): + - Añadido nuevo overlay `#talk-unlocker` a pantalla completa (z-index 9999), `hidden` por defecto, con tarjeta "Preparando Vocero… / Toca en cualquier lugar para empezar a hablar" + spinner degradado. + +- **`style.css`** ([style.css](file:///home/duxiter/s2s/s2s-client/style.css#L145-L215)): + - Clases `.talk-unlocker`, `.talk-unlocker-hint`, `.talk-unlocker-spinner`, `.talk-unlocker-title`, `.talk-unlocker-sub` + animaciones fade-in y pop-up de tarjeta. + +- **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)): + 1. Variable global `startSessionMode = ""` + comentario ([L628-L632](file:///home/duxiter/s2s/s2s-client/main.js#L628-L632)). + 2. Parse dentro de `fetchConfig()` → uppercase, log en `reportDebug("B",…)` ([L2309-L2320](file:///home/duxiter/s2s/s2s-client/main.js#L2309-L2320)). + 3. Paso a cliente WS → `startWithResponse: startSessionMode === "TALK"` ([L2683-L2692](file:///home/duxiter/s2s/s2s-client/main.js#L2683-L2692)). + 4. Bloque scheduling auto-start ([L2976-L3083](file:///home/duxiter/s2s/s2s-client/main.js#L2976-L3083)): + - `bootstrapWithAutoStart()`: `await fetchConfig()` → `maybeScheduleAutoStart()`; + - `setTimeout(2000 ms)` warmup para finalizar fetch de episode/profile/reports; + - `showTalkUnlocker(fallbackHint)`: gestiona labels personalizadas en caso de fallback TALKFIRST; + - **`tryTalkFirstDirect()` (TALKFIRST)**: set `pendingWakeWordConfirmationText="hola vocero"` + `doStart()` + try/catch con teardown y fallback unlocker; + - listener unlocker click/touchstart/keydown → `startTalkFromGesture()` con `createResumedAudioContext()` dentro de la gesture (para iOS silent-free). + 5. `onClientStatus()` ([L2878-L2932](file:///home/duxiter/s2s/s2s-client/main.js#L2878-L2932)) añadido `hideTalkUnlocker()` en `connecting/queued/your-turn/connected`. + +- **`ws/s2s-ws-client.js`** ([s2s-ws-client.js](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js)): + 1. Constructor: nuevas propiedades `_startWithResponse`, `_startWithResponseText` (default `"hola vocero"`), `_initialResponseSent` guardia one-shot ([L163-L178](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L163-L178)). + 2. Handler `session.updated` ([L688-L713](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L688-L713)): **Solo para `TALK`** (no TALKFIRST, que en cambio usa `pendingWakeWordConfirmationText`): `this.sendUserText("hola vocero") + this.requestResponse()` — mismo code path identico de la wake-word. + 3. `requestResponse` / `_createResponseNow` restaurados limpios (solo payload opcional `image`; ningún shaped `response.create`). + +--- + +### Modificado — `#reports-sidebar-toggle` visible solo con `?debug=true` + +El botón "reports" en la topbar (toggle del panel lateral reports) ahora es +`data-debug-only="true"` — por lo tanto solo se muestra si la URL contiene +`?debug=true`, de manera análoga al cluster `.topbar-right`. + +**Archivos modificados:** + +- **`index.html`** → `#reports-sidebar-toggle` ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L47-L50)): añadido `data-debug-only="true"`. +- **`main.js`** → `applyDebugOnlyVisibilityGate()` ([L48-L109](file:///home/duxiter/s2s/s2s-client/main.js#L48-L109)): extendido para iterar *todos* los elementos `[data-debug-only="true"]` (no solo `.topbar-right`); línea 1227: `reportsSidebarToggle.hidden = false` condicionado a `qs.get("debug") === "true"` ([L1227-L1246](file:///home/duxiter/s2s/s2s-client/main.js#L1227-L1246)) + log rojo `🚫 FORZADO HIDDEN` cuando falta debug=true. +- **`style.css`** → regla de seguridad ya presente: `[data-debug-only]:not([data-debug-enabled]) { display:none !important; visibility:hidden; opacity:0 }` ([L532-L537](file:///home/duxiter/s2s/s2s-client/style.css#L532-L537)). + +--- + +## [2026-09-04] + +### Modificado — Chat siempre visible + integración seamless + +Reorganizado el layout para mostrar el chat textual de forma permanente e +integrado visualmente con el Orb en la parte superior del stage. + +- Eliminado botón toggle chat en la topbar (`#chat-btn`) y relativo overlay. +- Stage con el Orb en la parte superior; nuevo `#chat-panel` a continuación (historial + + form input fijo sobre el footer sticky). +- Actualizada lógica en `main.js` y `ui/chat.js` para el envío de texto mediante + `client.sendUserText` y auto-scroll sin controles de visibilidad del panel. +- Versión banner deploy: **2026-09-03 14:40**. + +### Resuelto — Estabilidad integración chat mobile + +- Error `Missing element: #chat-btn` resuelto con selectores non-strict + (`document.querySelector`) para elementos opcionales eliminados. +- Refactorización estética "seamless": chat sin marcos, sombras ni fondos + (`background:transparent`, `box-shadow:none`). +- **Layout mobile:** + - padding incrementado en `#app` (56px) y `.chat-history` (96px) para evitar + superposiciones con el footer sticky; + - márgenes fijos en las burbujas: `margin-right:22px` (Assistant), `margin-left:6px` (User). +- **Auto-scroll streaming:** implementado `_scrollToBottom` con cadena de retry + múltiples (`requestAnimationFrame` + `setTimeout`) para compensar el line-wrap + asíncrono durante la llegada de los tokens; `overflow-anchor:none` + `scroll-behavior:auto !important`. + +### Añadido — Avatar usuario con iniciales + modal de perfil + +- Sustituido el texto del nombre de usuario en el header por avatar circular 40×40px + (gradient de marca) conteniendo las iniciales calculadas (ej. "AV"). +- Nueva `` en `index.html` que se abre al click + sobre el avatar: muestra email, teléfono, rol, empresa, con soporte a shapes + anidadas del payload de perfil. +- Cierre del modal: tecla ESC, click fuera de la tarjeta, botón `×` dedicado. + +--- + +## [Notas de formato] + +- **Tope campo prompt episodio:** 24.000 caracteres (transcript largos). +- **Tope profile / summary:** 1.600 caracteres cada uno. +- **Tope reports list:** 12.000 caracteres. +- **Orden inject prompt LLM (WS session.update + reasoning):** + 1. Episodio podcast actual (PRIMARIO) + 2. Perfil llamador (identidad) + 3. Lista reports / scripts podcast programados + 4. Summary última conversación +- Todos los bloques están marcados con `<<>>` para inspección de logs. diff --git a/s2s-client/CHANGELOG.md b/s2s-client/CHANGELOG.md new file mode 100644 index 0000000..8634bae --- /dev/null +++ b/s2s-client/CHANGELOG.md @@ -0,0 +1,156 @@ +# Changelog — s2s-client (Azurian Vocero) + +Tutte le modifiche rilevanti al progetto sono documentate in questo file. +Formato basato su [Keep a Changelog](https://keepachangelog.com/it/1.1.0/). + +--- + +## [2026-09-07] + +### Aggiunto — Supporto `episode_id` (podcast episode inject) + +Nuovo parametro query string `?episode_id=` affiancato a `?phoneNumber=` che +abilita il caricamento e l'iniezione automatica di un episodio podcast come +contesto PRIMARIO nel prompt LLM. + +**URL di esempio:** +``` +/?phoneNumber=%2B447432477433&episode_id=episode-19569f77-a06e-5ce5-a52f-6299dbd67d03 +``` + +**File modificati:** + +- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)): + 1. Aggiunta costante `PODCAST_EPISODE_HINT` ([L426-L429](file:///home/duxiter/s2s/s2s-client/server.py#L426-L429)) per l'hint semantico nel prompt. + 2. Aggiunta funzione `_resolve_episode_context(raw_value)` ([L475-L504](file:///home/duxiter/s2s/s2s-client/server.py#L475-L504)): normalizza la risposta API (dict/list/str, eventuale wrapper `{"result": …}`), la serializza in JSON (cap 24.000 chars) e la avvolge con i marker `<<>>`. + 3. Aggiunta funzione asincrona `_fetch_podcast_episode(episode_id)` ([L1010-L1078](file:///home/duxiter/s2s/s2s-client/server.py#L1010-L1078)): GET plain verso + `https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}` + (timeout 15s, follow redirects, error handling soft — non rompe mai il flusso, restituisce `None` su fail). + 4. Estesa `_augment_instructions_with_profile(...)` ([L1137-L1181](file:///home/duxiter/s2s/s2s-client/server.py#L1137-L1181)) con nuovo parametro `episode_context_text`; l'ordine di inject ora è (più specifico → più generico): **episode → profile → reports → summary**. + 5. Estesa `_patch_client_session_update_with_profile(...)` ([L1184-L1306](file:///home/duxiter/s2s/s2s-client/server.py#L1184-L1306)) con supporto a `episode_context_text` e logging dedicato `[INJECT-EPISODE]` / `[DEBUG-EPISODE]`. + 6. WebSocket connect handler ([L1791-L1959](file:///home/duxiter/s2s/s2s-client/server.py#L1791-L1959)): + - legge `episode_id` da query params assieme a `phoneNumber`; + - esegue fetch episodio + resolve PRIMA di profile/reports/summary; + - salva `injected_episode_context` e `inject_episode_id` nel `session_ctx` condiviso. + 7. `_client_to_upstream` ([L2649-L2661](file:///home/duxiter/s2s/s2s-client/server.py#L2649-L2661)): passa `ctx_episode_context` al patch di `session.update`. + 8. Endpoint `POST /api/reasoning`: + - esteso modello `ReasoningRequest` con campo `episode_id: str | None = None` ([L1309-L1323](file:///home/duxiter/s2s/s2s-client/server.py#L1309-L1323)); + - nel handler ([L1411-L1577](file:///home/duxiter/s2s/s2s-client/server.py#L1411-L1577)) fetch episodio indipendente da phone; + - `inject_episode_text` viene **prepend** a `user_context_parts` (prima di tutti); + - inoltro a NODE_RED / OPENAI con logging esteso (`episodeIdLen`). + +- **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)): + 1. Lettura da query string: `const voceroEpisodeId = pageParams.get("episode_id")` ([L676-L680](file:///home/duxiter/s2s/s2s-client/main.js#L676-L680)). + 2. `fetchOpenAiReasoningAnswer` ([L1337-L1364](file:///home/duxiter/s2s/s2s-client/main.js#L1337-L1364)): campo `episode_id` aggiunto al body POST `/api/reasoning`, logKey e `console.info` estesi. + 3. Costruzione URL WebSocket ([L2323-L2342](file:///home/duxiter/s2s/s2s-client/main.js#L2323-L2342)): `episode_id` appeso a query params sia con API `URL.searchParams` sia nel fallback stringa (concat con `&`). + +--- + +### Aggiunto — `S2S_START_SESSION_MODE`: 3 modalità di start sessione + +Env var letta dal server e propagata al client via `GET /api/config.startSessionMode`. +Scopo: governare se e come il microfono si auto-accende e se il modello saluta +per primo (flow simile a wake-word). Valori supportati: + +| Valore | Descrizione | +|---|---| +| *(vuoto) / default* | Start manuale: solo tap sull'Orb / pulsante Start | +| **`TALK`** | Dopo 2s overlay fullscreen "Tocca ovunque per iniziare" (policy safe per Safari/iOS `getUserMedia`). Click/touch/tasto dentro la user gesture → `doStart()`. WS client inietta bootstrap `"hola vocero"` + `requestResponse()`. | +| **`TALKFIRST`** | Dopo 2s **tenta `doStart()` DIRETTAMENTE SENZA click**, pre-caricando `pendingWakeWordConfirmationText = "hola vocero"` (stessa variabile della wake-word vera). Se i permessi mic sono già "allow always" funziona senza click. Se fallisce (policy Safari/iOS) → fallback sullo stesso unlocker di TALK con messaggio "permesso microfono". | + +**File modificati:** + +- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)): + 1. Lettura env: `S2S_START_SESSION_MODE = os.environ.get(...)`, `.strip().upper()` ([L150](file:///home/duxiter/s2s/s2s-client/server.py#L150)). + 2. Banner `[STARTUP]` esteso con campo `startSessionMode=` ([L196-L212](file:///home/duxiter/s2s/s2s-client/server.py#L196-L212)). + 3. Endpoint `GET /api/config` → nuovo campo `startSessionMode` + log `[INTERNAL] /api/config` ([L1383-L1400](file:///home/duxiter/s2s/s2s-client/server.py#L1383-L1400)). + +- **`index.html`** ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L32-L39)): + - Aggiunto nuovo `#talk-unlocker` overlay fullscreen (z-index 9999), `hidden` di default, con card "Preparando Vocero… / Toca en cualquier lugar para empezar a hablar" + spinner gradient. + +- **`style.css`** ([style.css](file:///home/duxiter/s2s/s2s-client/style.css#L145-L215)): + - Classi `.talk-unlocker`, `.talk-unlocker-hint`, `.talk-unlocker-spinner`, `.talk-unlocker-title`, `.talk-unlocker-sub` + animazioni fade-in e pop-up card. + +- **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)): + 1. Variabile globale `startSessionMode = ""` + commento ([L628-L632](file:///home/duxiter/s2s/s2s-client/main.js#L628-L632)). + 2. Parse dentro `fetchConfig()` → uppercase, log in `reportDebug("B",…)` ([L2309-L2320](file:///home/duxiter/s2s/s2s-client/main.js#L2309-L2320)). + 3. Passaggio a WS client → `startWithResponse: startSessionMode === "TALK"` ([L2683-L2692](file:///home/duxiter/s2s/s2s-client/main.js#L2683-L2692)). + 4. Blocco scheduling auto-start ([L2976-L3083](file:///home/duxiter/s2s/s2s-client/main.js#L2976-L3083)): + - `bootstrapWithAutoStart()`: `await fetchConfig()` → `maybeScheduleAutoStart()`; + - `setTimeout(2000 ms)` warmup per finire fetch di episode/profile/reports; + - `showTalkUnlocker(fallbackHint)`: gestisce label custom nel caso fallback TALKFIRST; + - **`tryTalkFirstDirect()` (TALKFIRST)**: set `pendingWakeWordConfirmationText="hola vocero"` + `doStart()` + try/catch con teardown e fallback unlocker; + - listener unlocker click/touchstart/keydown → `startTalkFromGesture()` con `createResumedAudioContext()` dentro la gesture (per iOS silent-free). + 5. `onClientStatus()` ([L2878-L2932](file:///home/duxiter/s2s/s2s-client/main.js#L2878-L2932)) aggiunto `hideTalkUnlocker()` in `connecting/queued/your-turn/connected`. + +- **`ws/s2s-ws-client.js`** ([s2s-ws-client.js](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js)): + 1. Costruttore: nuove proprietà `_startWithResponse`, `_startWithResponseText` (default `"hola vocero"`), `_initialResponseSent` guardia one-shot ([L163-L178](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L163-L178)). + 2. Handler `session.updated` ([L688-L713](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L688-L713)): **Solo per `TALK`** (non TALKFIRST, che usa invece `pendingWakeWordConfirmationText`): `this.sendUserText("hola vocero") + this.requestResponse()` — stesso identico code path della wake-word. + 3. `requestResponse` / `_createResponseNow` ripristinati puliti (solo payload opzionale `image`; nessun shaped `response.create`). + +--- + +### Modificato — `#reports-sidebar-toggle` visibile solo con `?debug=true` + +Il bottone "reports" nella topbar (toggle del pannello laterale reports) è ora +`data-debug-only="true"` — viene quindi mostrato solo se l'URL contiene +`?debug=true`, analogamente al cluster `.topbar-right`. + +**File modificati:** + +- **`index.html`** → `#reports-sidebar-toggle` ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L47-L50)): aggiunto `data-debug-only="true"`. +- **`main.js`** → `applyDebugOnlyVisibilityGate()` ([L48-L109](file:///home/duxiter/s2s/s2s-client/main.js#L48-L109)): esteso per iterare *tutti* gli elementi `[data-debug-only="true"]` (non solo `.topbar-right`); riga 1227: `reportsSidebarToggle.hidden = false` gated a `qs.get("debug") === "true"` ([L1227-L1246](file:///home/duxiter/s2s/s2s-client/main.js#L1227-L1246)) + log rosso `🚫 FORZATO HIDDEN` quando manca debug=true. +- **`style.css`** → regola di sicurezza già presente: `[data-debug-only]:not([data-debug-enabled]) { display:none !important; visibility:hidden; opacity:0 }` ([L532-L537](file:///home/duxiter/s2s/s2s-client/style.css#L532-L537)). + +--- + +## [2026-09-04] + +### Modificato — Chat sempre visibile + integrazione seamless + +Riorganizzato il layout per mostrare la chat testuale in modo permanente e +integrato visivamente con l'Orb nella parte superiore dello stage. + +- Rimosso pulsante toggle chat nella topbar (`#chat-btn`) e relativo overlay. +- Stage con l'Orb nella parte superiore; nuovo `#chat-panel` a seguire (cronologia + + form input fisso sopra il footer sticky). +- Aggiornata logica in `main.js` e `ui/chat.js` per l'invio testo tramite + `client.sendUserText` e auto-scroll senza controlli di visibilità pannello. +- Versione banner deploy: **2026-09-03 14:40**. + +### Risolto — Stabilità integrazione chat mobile + +- Errore `Missing element: #chat-btn` risolto con selettori non-strict + (`document.querySelector`) per elementi opzionali rimossi. +- Refactoring estetico "seamless": chat senza cornici, ombre o sfondi + (`background:transparent`, `box-shadow:none`). +- **Layout mobile:** + - padding incrementato in `#app` (56px) e `.chat-history` (96px) per evitare + sovrapposizioni col footer sticky; + - margini fissi bolle: `margin-right:22px` (Assistant), `margin-left:6px` (User). +- **Auto-scroll streaming:** implementato `_scrollToBottom` con catena di retry + multipli (`requestAnimationFrame` + `setTimeout`) per compensare il line-wrap + asincrono durante l'arrivo dei token; `overflow-anchor:none` + `scroll-behavior:auto !important`. + +### Aggiunto — Avatar utente iniziali + modale profilo + +- Sostituito testo nome utente nell'header con avatar circolare 40×40px + (gradient brand) contenente le iniziali calcolate (es. "AV"). +- Nuova `` in `index.html` che si apre al click + sull'avatar: mostra email, telefono, ruolo, azienda, con supporto a shape + annidate del payload profilo. +- Chiusura modale: tasto ESC, click fuori card, pulsante `×` dedicato. + +--- + +## [Note di formato] + +- **Cap campo prompt episodio:** 24.000 caratteri (transcript lunghi). +- **Cap profilo / summary:** 1.600 caratteri ciascuno. +- **Cap reports list:** 12.000 caratteri. +- **Ordine inject prompt LLM (WS session.update + reasoning):** + 1. Episodio podcast corrente (PRIMARIO) + 2. Profilo chiamante (identity) + 3. Lista reports / script podcast schedulati + 4. Summary ultima conversazione +- Tutti i blocchi sono marcati con `<<>>` per ispezione log. diff --git a/s2s-client/INJECTIONS.md b/s2s-client/INJECTIONS.md new file mode 100644 index 0000000..e886cfc --- /dev/null +++ b/s2s-client/INJECTIONS.md @@ -0,0 +1,484 @@ +# Inject pipeline — caller context & reference material + +Documento ufficiale di tutti i blocchi di contesto che il server inietta +automaticamente nel prompt LLM **all'inizio di ogni sessione**, sia per il +canale **WebSocket realtime** (`/v1/realtime`, patch di `session.update`) sia +per l'endpoint **HTTP reasoning** (`POST /api/reasoning`, augmentation di +`instructions` e `user_context`). + +Tutto il sistema è **generico / deploy-agnostic** per scelta architetturale: +nessun campo viene hardcodato. Ogni blocco accetta payload arbitrari e li +serializza in JSON direttamente. Qualsiasi errore di rete / JSON non +interrompe mai il flusso: il blocco problematico viene solo saltato con +una riga `[INJECT-*] ⏭️ skipped` nel log server. + +--- + +## 0. Master flag: `S2S_INJECT_PROFILE` + +Tutta la pipeline è sotto **un unico master flag** lato server. + +| Valore env `S2S_INJECT_PROFILE` | Comportamento | +|---|---| +| `false` / non impostato | **Pure passthrough.** Nessuna fetch upstream, nessuna modifica a `session.update` o al body `/api/reasoning`. Il proxy passa tutto invariato. | +| `true` / `1` / `yes` / `on` | Pipeline inject attiva. I singoli blocchi poi dipendono dagli env specifici e dai query params della URL del client. | + +Perché esiste: su deploy generici (nessuna integrazione con AzurianLab / +Aquachile / sistemi interni) il client rimane un puro speech-to-speech +passthrough, senza dipendere da endpoint esterni. + +--- + +## 1. Blocco episodio podcast — `episode_id` + +**Priorità massima.** Quando c'è, viene considerato il riferimento PRINCIPALE +e più fidato per tutta la sessione. + +### Trigger (quando si attiva) + +Una qualunque delle due: +1. URL pagina browser → query string `?episode_id=episode-xxx` +2. Body `POST /api/reasoning` → campo `episode_id: "episode-xxx"` + +### Sorgente dati + +GET plain verso l'endpoint Aquachile AzurianLab: + +``` +https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id} +``` + +Parametri fetch: +- timeout 15 s +- `follow_redirects: true` +- **Nessuna auth** (endpoint pubblico) + +Formato risposta accettato: +- `Content-Type: application/json` → oggetto arbitrario o array +- `{"result": }` → wrapper supportato, il contenuto viene "spacchettato" +- `text/plain` → usato come stringa raw + +### Cap + +24.000 caratteri dopo serializzazione JSON (transcript lunghi). +Se supera → troncato con `...` append. + +### Wrap applicato + +``` +< PODCAST_EPISODE_HINT > +<<>> +{ } +<<>>. This is the episode the caller is currently interacting with — answer with reference to it first. +``` + +**Hint testuale** (in testa, letto dal modello come primissima frase del blocco): + +> Podcast episode context loaded from the page `episode_id` query param (current episode the user is referencing). Treat the episode fields (title, description, transcript, script, show notes, guests, audio metadata, etc.) as the **PRIMARY trusted reference material** for this session. + +### Campi supportati (esempi — lista NON esaustiva, qualsiasi campo extra viene incluso) + +- metadati base: `id`, `title`, `slug`, `description`, `summary`, `publishedAt`, `durationSeconds` +- contenuti: `transcript`, `podcastScript`, `script`, `chapters[]`, `showNotes` +- persone: `host`, `guests[]`, `author`, `guestsInfo[]` +- audio: `audioUrl`, `audioFile`, `waveform`, `sampleRate` +- classificazione: `tags[]`, `topics[]`, `category`, `season`, `episodeNumber` +- riferimenti incrociati: `reportId`, `scheduleId`, `company`, `product` + +### Riferimenti codice + +- fetch upstream: [_fetch_podcast_episode](file:///home/duxiter/s2s/s2s-client/server.py#L1010-L1078) +- wrap + serialize + cap 24k: [_resolve_episode_context](file:///home/duxiter/s2s/s2s-client/server.py#L475-L504) +- hint costante: [PODCAST_EPISODE_HINT](file:///home/duxiter/s2s/s2s-client/server.py#L426-L429) + +--- + +## 2. Blocco profilo chiamante — `phoneNumber` + +Contesto identità del chiamante. Viene usato per il modello quando risponde +(riferirsi all'utente per nome, conoscere la sua azienda, ruolo, ecc.). + +### Trigger + +1. URL browser → `?phoneNumber=%2B` +2. Body `/api/reasoning` → `phoneNumber: "+…"` + +### Sorgente dati + +``` +GET VOCERO_USER_PHONE_AUTH_URL?phoneNumber= +``` + +Se è configurato anche `S2S_LOGIN_BY_PHONE`, PRIMA della profile fetch: +``` +POST S2S_LOGIN_BY_PHONE body { "phoneNumber": "..." } +``` +Da questa risposta il proxy estrae un bearer token e lo inoltra come +`Authorization: Bearer ` sulle chiamate successive (profile, reports, +summary). + +Formato risposta: JSON arbitrario, wrapper `{"result": ...}` supportato. + +### Cap + +1.600 caratteri dopo JSON serialize. + +### Wrap + +``` + Caller profile resolved from the page phoneNumber: +{ }. +Use this as trusted caller context when relevant. +``` + +### Riferimenti codice + +- login by phone: [_login_by_phone](file:///home/duxiter/s2s/s2s-client/server.py#L812-L890) +- fetch profile: [_fetch_user_profile_for_phone](file:///home/duxiter/s2s/s2s-client/server.py#L1081-L1134) +- wrap serialize: [_resolve_user_context_text](file:///home/duxiter/s2s/s2s-client/server.py#L432-L444) + +--- + +## 3. Blocco lista reports / script schedulati + +Riferimento "storico" dei contenuti preparati prima della sessione. Non sono +contesto live, ma materiale che il modello deve sapere esiste (e a cui +riferirsi se l'utente chiede di report pregressi, script, podcast schedulati). + +### Trigger + +Solo env: `S2S_REPORT_LIST` deve essere valorizzato a un URL valido. +**Funziona anche SENZA `phoneNumber`** (lista reports generica per tutti). + +Se `phoneNumber` è presente lo passa come query param all'endpoint. + +### Sorgente dati + +``` +GET S2S_REPORT_LIST[?phoneNumber=] +``` + +Wrapper `{"result": [...]}` supportato. + +### Cap + +12.000 caratteri sul blocco COMPLETO (ogni podcast script può essere lungo). + +### Wrap + filtri + sort + +``` + Scheduled reports / podcast scripts available for this caller before the current session. + Each entry is a scheduled report; use the podcastScript contents as trusted reference material + when the user asks about scheduled topics, reports, or prior content. +<<>> +[ + { + "sourceScheduleLabel": "...", + "date": "2026-09-06T…", + "podcastScript": "…" + }, + … ordinati per data DESC (nuovi prima) … +] +<<>> +``` + +Regole per ogni entry: +1. **Solo dict** vengono tenuti (stringhe/int scartati). +2. Per ogni entry estrae 3 campi con fallback a nested shape: + - Label: `sourceScheduleLabel` oppure `sourceSchedule.label` + - Date: `date` oppure `sourceSchedule.date` + - Script: `podcastScript` +3. Ordinamento **data DESC** (nuovi prima) con parser robusto: + - ISO datetime / date strings + - `Date` / `DateTime` Python (se deserializzati) + - Unix timestamp int/float + - Lexicografico DESC come fallback per stringhe ISO-ish + - Valori nulli / non-parse-abili finiscono in fondo. + +### Riferimenti codice + +- fetch: [_fetch_reports_list](file:///home/duxiter/s2s/s2s-client/server.py#L668-L745) +- parse data sort key: [_parse_date_key](file:///home/duxiter/s2s/s2s-client/server.py#L507-L515) +- wrap + filtro + sort: [_resolve_reports_context](file:///home/duxiter/s2s/s2s-client/server.py#L518-L666) + +--- + +## 4. Blocco summary ultima conversazione + +Sintesi della chiamata/chat IMMEDIATAMENTE precedente a questa. Usato dal +modello per "ricordarsi" cosa si è detto l'ultima volta senza conservare +storico lunghissimo nel prompt. + +### Trigger + +Entrambe: +- env `S2S_LATEST_SUMMARY` impostato +- `phoneNumber` presente in URL/body + +### Sorgente dati + +``` +GET S2S_LATEST_SUMMARY?phoneNumber= +``` + +Wrapper `{"result": }` supportato. +Accetta SIA JSON che plain text (diversi backend summary ritornano testo semplice). + +### Cap + +1.600 caratteri. + +### Wrap + +``` + Latest conversation summary for this caller (most recent before the current session): +<<>> + +<<>>. +Use this as trusted history when the user references prior calls. +``` + +### Riferimenti codice + +- fetch: [_fetch_latest_summary_for_phone](file:///home/duxiter/s2s/s2s-client/server.py#L893-L971) +- wrap: [_resolve_latest_summary_context](file:///home/duxiter/s2s/s2s-client/server.py#L446-L472) + +--- + +## 5. Blocco `user_context` custom dal browser (SOLO `/api/reasoning`) + +**Canale solo HTTP reasoning.** +Non è nel WS: il WS non ha campo user_context nel protocollo realtime +OpenAI (tutto passa per `session.instructions`). + +Composizione lato client in [main.js fetchOpenAiReasoningAnswer](file:///home/duxiter/s2s/s2s-client/main.js#L1337-L1364): + +```js +const profileCtx = voceroUserContextText(); +const ragCtx = customRagContextText(); +const user_context = [profileCtx, ragCtx] + .map(s => s.trim()).filter(Boolean).join("\n\n"); +``` + +### Dove va a finire + +Nel body a monte sia NODE_RED sia OPENAI, questo blocco viene **APPESO DOPO** +tutti i blocchi iniettati server-side: + +``` +user_context finale = + [1. episode] ← solo se episode_id + [2. profile] ← solo se phoneNumber + env profilatura + [3. reports] ← solo se env S2S_REPORT_LIST + [4. summary] ← solo se phoneNumber + S2S_LATEST_SUMMARY + + browser user_context (profileCtx + ragCtx) +``` + +--- + +## Ordine finale nel prompt LLM + +Questa è la sequenza **letterale** con cui i blocchi vengono concatenati +dentro `session.instructions` (WS) e in `instructions` / `user_context` +(reasoning HTTP). + +``` +1. EPISODIO PODCAST ← PRIORITÀ PIÙ ALTA (primary trusted reference) +2. PROFILO CHIAMANTE ← identity / caller profile +3. REPORTS SCHEDULATI ← scheduled / prior content +4. SUMMARY ULTIMA CALL ← history più recente +5. Base instructions (prompt predefinito o primary prompt) +6. (solo reasoning) user_context custom inviato dal browser +``` + +Perché questo ordine: +- più in alto nel prompt = più peso / attenzione dal modello. +- l'episodio attuale è il materiale che l'utente ha esplicitamente richiesto + aprendo la pagina con `?episode_id=`, quindi merita massimo peso. +- il profilo chiamante va sempre prima di "contenuti generici" (reports e + summary) perché serve per coerenza nel riferirsi all'utente. +- reports = contenuti schedulati PRIMA della sessione corrente → più vecchi + dell'ultima chiamata, quindi messi PRIMA del summary ultima chiamata che + è il contenuto più "vivo". + +Implementazione ordine: [_augment_instructions_with_profile](file:///home/duxiter/s2s/s2s-client/server.py#L1149-L1159) +```python +contexts = [c for c in [episode_ctx, profile_ctx, reports_ctx, summary_ctx] if c] +``` + +--- + +## Punti di innesco (quando viene calcolato / applicato) + +### Canale WebSocket — `/v1/realtime` + +**MOMENTO 1 (solo una volta):** apertura WS. + +Nel handler [@app.websocket("/v1/realtime")](file:///home/duxiter/s2s/s2s-client/server.py#L1791-L1959) +appena la WS viene accettata: +1. parsa `ws.url.query` per `phoneNumber` e `episode_id` +2. esegue login (se attivo) → profile → episode → reports → summary +3. salva TUTTI i blocchi già wrappati e serializzati nel dict `session_ctx`: + ```python + session_ctx = { + "injected_episode_context": "...", + "injected_profile_context": "...", + "injected_reports_context": "...", + "injected_summary_context": "...", + ... + } + ``` + +**MOMENTO 2 (ogni `session.update` client→upstream):** nel task +[_client_to_upstream](file:///home/duxiter/s2s/s2s-client/server.py#L2649-L2661) +ogni frame in uscita viene passato a: + +```python +_patch_client_session_update_with_profile( + frame_text, + user_context_text = profile_ctx, + reports_context_text = reports_ctx, + summary_context_text = summary_ctx, + episode_context_text = episode_ctx, # ← nuovo +) +``` + +Se il frame NON è `session.update` → ritorna **identico** e non viene loggato +(per evitare spam). +Se è `session.update` → patch del campo `payload["session"]["instructions"]`. + +### Canale HTTP — `POST /api/reasoning` + +**Ogni chiamata** ricostruisce i blocchi da zero nel +[handler `/api/reasoning`](file:///home/duxiter/s2s/s2s-client/server.py#L1411-L1577): +1. legge `req.phoneNumber` + `req.episode_id` +2. fetch episodio (se episode_id) + login + profile + reports + summary +3. costruisce `user_context_parts` e `effective_instructions` +4. inoltra al backend (NODE_RED o OPENAI) + +--- + +## Quando un blocco viene SKIPPATO (niente inject) + +Tutti i blocchi hanno 5 possibili ragioni di skip. Nessuna rompe mai il +flusso: ogni caso produce una riga `[INJECT-*] ⏭️ skipped` nei log e basta. + +| Causa | Log | +|---|---| +| Master flag `S2S_INJECT_PROFILE=false` | `[INJECT-PROFILE] ⏭️ skipped (S2S_INJECT_PROFILE=false)` | +| Parametro mancante (es. `phoneNumber` non in URL) | `[INJECT-*] ⏭️ skipped ( missing)` | +| Endpoint env non configurato | `[INJECT-*] ⏭️ skipped (env for ... empty)` | +| Upstream HTTP ≠ 200 | `[INJECT-*] ⛔ upstream HTTP ` | +| Upstream ha ritornato vuoto / null / `{}` / `[]` | `[INJECT-*] ⏭️ upstream returned empty` | +| Eccezione di rete (timeout, DNS, SSL) | `[INJECT-*] ⛔ fetch unreachable error=` | + +--- + +## Marcatori log / marker nel prompt + +Ogni blocco è **auto-documentante** per ispezione dei log con coppie di +marker: + +``` +<<>> / <<>> +<<>> / <<>> +<<>> / <<>> +``` + +Quando `S2S_DEBUG_PROMPT=true` viene loggato il contenuto COMPLETO di ogni +blocco con i tag `[DEBUG-EPISODE]`, `[DEBUG-REPORTS]`, `[DEBUG-SUMMARY]` sia +all'atto del load (WS connect) sia dopo l'augment delle instructions (prima +di inoltrare a monte). **WARNING:** questo env può loggare PII. + +--- + +## Tabella riepilogo: trigger per ogni blocco + +| # | Blocco | Trigger | env richiesto | parametro | cap | +|---|---|---|---|---|---| +| 1 | Episodio podcast | `episode_id` in URL o body | (nessuno — URL hardcodato Aquachile) | `episode_id=` | 24 k | +| 2 | Profilo chiamante | `phoneNumber` | `VOCERO_USER_PHONE_AUTH_URL` | `phoneNumber=` | 1,6 k | +| 3 | Reports schedulati | sempre se env | `S2S_REPORT_LIST` | opzionale `phoneNumber=` | 12 k | +| 4 | Summary ultima call | `phoneNumber` | `S2S_LATEST_SUMMARY` | `phoneNumber=` | 1,6 k | +| 5 | Browser user_context | solo reasoning | (nessuno) | body `user_context` | illimitato | + +--- + +## Error handling strategico + +Perché tutti gli errori sono silenziosi (log + skip) e non abortiscono: + +> Se il backend dei profili è giù per manutenzione 5 minuti non vogliamo +> che il Voice Agent smetta di funzionare. Semplicemente l'assistente non +> conosce l'identità del chiamante per 5 minuti, ma parla normalmente. + +L'utente non si accorge di niente. L'operatore legge i log e vede le righe +`⛔`; può risolvere offline. + +Se volessi forzare il fail (raro, su deploy dedicato), si trasformerebbero +le `return None` finali in `raise HTTPException(...)` — ma per default +non è il comportamento voluto. + +--- + +## Appendice A — Start-session modes (`S2S_START_SESSION_MODE`) + +Oltre alla inject pipeline lato server, è presente una feature lato client che +governa **come e se la sessione vocale inizia in automatico** e se il modello +saluta per primo (in modo identico alla wake-word "Hola Vocero"). + +### Valori supportati + +| `S2S_START_SESSION_MODE` | Chi inizia? | Richiede click utente? | Trigger greet iniziale | +|---|---|---|---| +| *(vuoto) / default* | Utente, tap manuale Orb | ✅ | No | +| `TALK` | Auto dopo 2s con overlay unlocker | ✅ (1 click policy-safe su Safari/iOS) | WS client: `sendUserText("hola vocero") + requestResponse()` dopo `session.updated` | +| `TALKFIRST` | Auto dopo 2s **SENZA click** (se possibile) | ❌ se mic già "allow always"; fallback unlocker se `getUserMedia` fallisce | **Stesso identico codice della wake-word vera:** `pendingWakeWordConfirmationText = "hola vocero"` (main.js `case "connected"` → `sendUserText + requestResponse`) | + +### Warmup 2s + +Il timer non parte prima di 2000 ms per lasciare terminare: +- `fetchVoceroUserProfile()` (identity chiamante) +- `fetchVoceroReportsList()` (storico script podcast) +- fetch episode (`episode_id`) se presente +- `GET /api/config` (con la quale il client apprende `startSessionMode`) + +Senza warmup le injection lato server potrebbero ancora non essere completate e +il greet iniziale arriverebbe "cieco" (senza contesto episodio/profilo). + +### Fallback di `TALKFIRST` + +Nel 99% dei casi `getUserMedia()` fuori da una user gesture fallisce +(NotAllowedError / NotReadableError su Safari, Chrome con policy strict, iOS). +In tal caso: +1. si chiude il client WS (`teardown()`) +2. stato torna `idle` +3. si mostra `#talk-unlocker` con la dicitura: + - **"Se requiere permiso de micrófono"** + - **"Toca en cualquier lugar para permitir el audio y empezar"** +4. `pendingWakeWordConfirmationText` NON viene svuotato → quando l'utente + toccherà, `case "connected"` triggerà comunque la greet come wake-word. + +### Riferimenti codice + +| Componente | File | Range righe | +|---|---|---| +| Lettura env + banner startup + `/api/config` exposure | [server.py](file:///home/duxiter/s2s/s2s-client/server.py) | [L150](file:///home/duxiter/s2s/s2s-client/server.py#L150), [L196-L212](file:///home/duxiter/s2s/s2s-client/server.py#L196-L212), [L1383-L1400](file:///home/duxiter/s2s/s2s-client/server.py#L1383-L1400) | +| Overlay HTML unlocker | [index.html](file:///home/duxiter/s2s/s2s-client/index.html#L32-L39) | L32-L39 | +| Stili overlay | [style.css](file:///home/duxiter/s2s/s2s-client/style.css#L145-L215) | L145-L215 | +| Variabile globale + parse config client | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L628-L632) | L628-L632 | +| `fetchConfig()` parsing + `reportDebug("B")` | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2309-L2320) | L2309-L2320 | +| `new S2sWsRealtimeClient({startWithResponse})` | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2683-L2692) | L2683-L2692 | +| Scheduling + `tryTalkFirstDirect()` + unlocker gesture | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2976-L3083) | L2976-L3083 | +| `onClientStatus("connected")` → wake-word inject flow | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2897-L2914) | L2897-L2914 | +| WS client → `TALK` mode sendUserText("hola vocero") + `requestResponse()` | [s2s-ws-client.js](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L688-L713) | L688-L713 | + +--- + +## Riferimenti file + +| File | Ruolo | +|---|---| +| [server.py](file:///home/duxiter/s2s/s2s-client/server.py) | Tutta la pipeline inject: fetch, wrap, patch, handler WS + reasoning | +| [main.js](file:///home/duxiter/s2s/s2s-client/main.js) | Passaggio client `?phoneNumber` + `?episode_id` → WS query params e POST `/api/reasoning` body | +| [CHANGELOG.md](file:///home/duxiter/s2s/s2s-client/CHANGELOG.md) | History rilasci / modifiche alla inject pipeline | diff --git a/s2s-client/README.md b/s2s-client/README.md index b7d8e0d..90de103 100644 --- a/s2s-client/README.md +++ b/s2s-client/README.md @@ -16,6 +16,76 @@ Browser voice-chat UI for the backend. The browser streams mic audio over a WebSocket using the OpenAI Realtime **GA** protocol and plays back the assistant's audio as it arrives. +--- + +## 🆕 Novedades / Actualizaciones (Azurian Vocero realtime ver.Beta 2.1) + +> Versión del navegador que debes ver cargada en consola: **`2026-09-04 11:20`**. +> Si no aparece, haz **borrado de datos del sitio + recarga dura (Ctrl+Shift+R)** o abre una pestaña en modo incógnito. + +### 🎨 Rediseño visual completo (tema aurora + glassmorphism) +- **Tema de fondo aurora**: gradiente radial violeta/rosa/cian sobre lienzo oscuro con malla sutil + animación `auroraShift` lenta. +- **Cromado uniforme glass**: todos los elementos flotantes (tarjeta brand, panel user profile, botones icono, botón enviar chat, panel informes sidebar) usan *glass blur + saturate + borde gradiente brand 1px* (técnica mask `padding-box / border-box` doble linear-gradient). +- **Tokens de marca 3 colores brand** (violeta `#8b5cf6`, rosa `#ec4899`, cian `#22d3ee`) mezclados con `color-mix(in srgb, brand X%, neutral)` → ningún color hardcodeado deriva; todo el UI sigue estos tres tonos. + +### 🧭 Estructura (orden vertical de arriba → abajo) +1. **`header.topbar`** brand "Vocero" → avatar circular iniciales nombre usuario. + - 🟦 **Avatar user**: círculo 40×40px con gradiente brand, initials mayúsculas bold 800, hover lift/glow + foco teclado. Al hacer clic → diálogo modal perfil usuario (rejilla 2 columnas email/teléfono/username/rol/empresa/userid/país/fecha creación/último login; links `mailto:`/`tel:`; chips mono para códigos; mobile ≤480px apilado 1 col). + - 🟦 **Botón toggle chat (icono speech bubble) → ELIMINADO PERMANENTEMENTE**. La chat ahora siempre está visible inline; el botón toggle ya no existe en el DOM. +2. **`main.stage`** (arriba del todo, flexible): orbe/globode voz animado (3 anillos: core, ring, conic spin 3 velocidades + highlight specular + glows breathe `breatheOrb`) + caption `TAP OR SAY "HOLA VOCERO"` micro-bob uppercase gradient. + - ✅ Antes había una zona vacía sobre el orbe → eliminada, ahora el stage empieza en la parte superior (`justify-content: flex-start`). +3. **`
` SIEMPRE VISIBLE, 100% ancho, SOTTO stage, SOPRA footer sticky** (rellena la zona vacía inferior). + - ✅ Antes: overlay lateral derecho. **Ahora: bloque inline dentro de `#app` como hijo flex-col**. + - 🟦 Sin marco / sin riquadro (fondo aurora seamless, 0 borde, 0 shadow, 0 backdrop blur). + - Cabecera `CONVERSACIÓN` h3 uppercase 13px gradiente. + - `#chat-history` scroll interno. Burbujas de mensaje glass con **código de colores por rol**: usuario (tint 10% voice-user cyan + borde 44% cyan + glow) / assistant (tint 10% voice-assistant violet/pink + borde 42% + glow). + - Distancias seguras anti-borde: `.hist-msg.user margin-left:6px auto dx` / `.hist-msg.assistant margin-right:22px auto sx` → **nunca las burbujas tocan el borde del viewport** (fix confirmado). + - Formulario input texto `#chat-form` (arriba del botón enviar ✈️) **actualmente oculto (`hidden`)** para modo voz-puro; elimina ese atributo para reactivarlo. El handler submit que envía `client.sendUserText(text)` al websocket ya está conectado. +4. **Footer sticky `position: sticky; bottom:0; z:3; backdrop-blur`** "Azurian Vocero realtime ver.Beta 2.1" siempre dentro del viewport, incluso cuando el documento crece y haces scroll (no requiere scroll hacia abajo para verlo). Separador superior línea gradiente brand. + +### 📜 Sidebar de informes a la IZQUIERDA (antes topbar inline) +- Desktop **≥ 1024px**: sidebar fija left 320px siempre visible; `#app padding-left 320px` reserva sitio. +- Tablet/Mobile **< 1024**: offcanvas `translateX(-105%)` slide-in con backdrop blur; toggle mediante botón icono reportes `#reports-sidebar-toggle` en topbar con badge contador. +- `#reports-hint` siempre visible; `.reports-suggestions` y `.topbar-right` con puerta triple de visibilidad (atributo HTML hidden + CSS rule `[data-debug-only]:not([data-debug-enabled]) !important display:none` + JS `applyDebugOnlyVisibilityGate()` ejecutado 6 veces por lifecycle) → solo aparecen cuando el query param `?debug=true` está presente en la URL. + +### ⬇️ Auto-scroll chat SIEMPRE hacia la última respuesta del asistente +Arquitectura anti-clipping definitiva (versión 11:20 fix): + +1. **CSS sobrante intencional** (`96 → 160px chat-history padding-bottom`, `40→160px chat-panel clamp`, `56→120px #app padding-bottom + safe-area-inset-bottom`) → el último párrafo siempre está por ENCIMA de la línea del footer glass flotante. +2. **Unidades móviles seguras `svh / svw`** (smallest viewport): evita oscilaciones layout cuando aparece/desaparece la barra URL del móvil. +3. **JS cancel-and-reschedule** cada vez que `_scrollToBottom()` se llama: si llegan 100 tokens streaming en 1 frame, el último cancela todas las cadenas pendientes y se ejecuta desde cero → **no hay coalescing flag SKIP** (esta era la causa original del scroll insuficiente). +4. **5 reintentos escalonados `0 / +30 / +70 / +150 / +250ms`**: + - 0 ms (inmediato): 90% de los casos. + - 30 ms: tras 1 layout-pass. + - 70 ms: **corrige el wrapping de la última palabra en una línea nueva** (bug más común). + - 150 ms: después de reflow Chrome scroll-anchor. + - 250 ms: después de expandir filas tool / imágenes grandes. +5. **Snap con `Number.MAX_SAFE_INTEGER`**: el navegador clampa automáticamente al máximo real (`scrollHeight - clientHeight`); no hay race de leer scrollHeight demasiado pronto. +6. `overflow-anchor: none; scroll-behavior: auto !important;` → sin smooth, sin Chrome scroll-anchor-luchando contra nosotros. +7. Llamadas scroll explicitas extra en: `onResponseFinished` (fin de respuesta assistant, crítico), `onToolCall`, `onToolResult`. + +### 🛡️ Protecciones anti-error de refs eliminadas +- En el refactor de chat eliminamos 4 nodos DOM (`#chat-btn`, `#chat-badge`, `#chat-panel-backdrop`, `#chat-panel-close`). +- **Antes**: el constructor de `ChatView` usaba `$("#...")` strict → lanzaba `Error("Missing element: #chat-btn")` al arrancar. +- **Ahora**: esas 4 refs opcionales usan `document.querySelector("#...")` → devuelve `null` cuando no existen, sin throw; todos los listeners guarded `if (el)`. Los 3 IDs obligatorios (`#chat-panel`, `#chat-history`, `#bubble-stack`) siguen con strict para fallar fuerte si alguien los borra por error. + +### 🔄 Actualizaciones de versión / cache bust +Para evitar caché HTTP agresiva en navegadores móviles (ESM sin bundler + service worker = nunca refrescan URLs iguales), todas las referencias a recursos estáticos usan query param `?v=YYYYMMDDHHMM` sincronizado en **6 puntos**: +1. `index.html ` +2. `index.html +
-
+ + + + +
+
+
+

Conversación

+
+
+ + +
+
@@ -141,19 +185,26 @@ camera
- -
-
-
-
-

Conversation

-
-
+ + + +
-
+ - + + + +
+ Azurian Vocero realtime ver.Beta 2.1 +
+ + diff --git a/s2s-client/main.js b/s2s-client/main.js index 3c87375..674f9b7 100644 --- a/s2s-client/main.js +++ b/s2s-client/main.js @@ -16,10 +16,112 @@ * @typedef {"idle" | "connecting" | "queued" | "your-turn" | "listening" | "user-speaking" | "processing" | "ai-speaking" | "error"} AppState */ -import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js?v=20260902001"; -import { $, escHtml, truncateError, DEBUG } from "./ui/dom.js?v=20260902001"; -import { ChatView } from "./ui/chat.js?v=20260902001"; -import { Account } from "./ui/account.js?v=20260902001"; +import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js?v=202609041120"; +import { $, escHtml, truncateError, DEBUG } from "./ui/dom.js?v=202609041120"; +import { ChatView } from "./ui/chat.js?v=202609041120"; +import { Account } from "./ui/account.js?v=202609041120"; + +// --------------------------------------------------------------------------- +// 🔎 DEBUG BANNER (always-on): emissione incondizionata in console per capire +// ESATTAMENTE quale versione di main.js è caricata nel browser. +// Se NON vedi questa riga in Console = stai ancora usando il file CACHATO VECCHIO. +console.info( + "%c🟢 VOCERO main.js loaded. version=2026-09-04 11:20 (FIX HEADER TRONCO: body safe-area-inset top/right/left 10px min + topbar padding-top 16 + margin-top safe. FIX SCROLL INSUFFICIENTE: chat-history padding-bottom 96→160px; chat-panel clamp 40-96→60-160; #app padding-bottom 56→120px+safe; footer safe-area bottom; step 4 retry +250ms (5 step tot); MAX_SAFE_INTEGER invece di 9M)", + "background:#1e293b;color:#86efac;font-weight:700;padding:4px 8px;border-radius:4px;" +); +console.info( + " URL attuale location.href=", location.href, + "\n location.search=", location.search || "(empty)", + "\n DEBUG (imported from dom.js) =", DEBUG, + "\n typeof window.DEBUG =", typeof window.DEBUG +); +// eslint-disable-next-line no-debugger +// debugger; // ← scommenta per fermarti QUI all'avvio se DevTools è aperto + +// --------------------------------------------------------------------------- +// 🛡️ GATE .topbar-right (about / tools / rag / chat / settings buttons, +// vedi index.html L36-L56) — 3-layer safety: +// 1) HTML statico → hidden + data-debug-only="true" +// 2) CSS → [data-debug-only="true"]:not([data-debug-enabled="true"]) display:none !important +// 3) JS qui → hidden, removeAttribute data-debug-enabled, anche style.display = "none" +// Solo se URL query-string ha ?debug=true (strict string) abilitiamo TUTTO. +function applyDebugOnlyVisibilityGate(source = "bootstrap") { + const qs = new URLSearchParams(window.location.search); + const debugEnabled = qs.get("debug") === "true"; + const topbarRight = document.querySelector(".topbar-right"); + console.groupCollapsed( + "%c🧭 [gate-topbar-right] applyDebugOnlyVisibilityGate()", + "color:#f59e0b;font-weight:700;" + ); + console.log("chiamato da:", source); + console.log("location.search =", JSON.stringify(window.location.search)); + console.log("query params =", Object.fromEntries(qs.entries())); + console.log("debugEnabled (strict === 'true') =", debugEnabled); + console.log("topbarRight element trovato?", !!topbarRight, topbarRight ? "vedi →" : "(null!)"); + const chatInline = document.getElementById("chat-panel"); + console.log("#chat-panel (chat inline SEMPRE VISIBILE in basso) presente in DOM =", !!chatInline, chatInline ? "hidden?" : "", chatInline ? chatInline.hasAttribute("hidden") : "-"); + if (topbarRight) { + console.log(" hidden attr prima:", topbarRight.hasAttribute("hidden"), + "| data-debug-enabled prima:", topbarRight.getAttribute("data-debug-enabled"), + "| style.display prima:", topbarRight.style.display); + if (debugEnabled) { + topbarRight.setAttribute("data-debug-enabled", "true"); + topbarRight.removeAttribute("hidden"); + topbarRight.style.display = ""; + console.log("%c ✅ ABILITATO .topbar-right (debug=true)", "color:#22c55e;font-weight:700;"); + } else { + topbarRight.removeAttribute("data-debug-enabled"); + topbarRight.setAttribute("hidden", ""); + topbarRight.style.setProperty("display", "none", "important"); + console.log("%c 🚫 DISABILITATO .topbar-right (no debug=true)", + "background:#fee2e2;color:#b91c1c;font-weight:700;padding:3px 6px;border-radius:4px;"); + } + console.log(" hidden attr dopo:", topbarRight.hasAttribute("hidden"), + "| data-debug-enabled dopo:", topbarRight.getAttribute("data-debug-enabled"), + "| style.display dopo:", topbarRight.style.display); + } + // 3-layer gate UNIVERSALE su TUTTI gli elementi marcati con data-debug-only="true" + // (non solo .topbar-right, ma anche #reports-sidebar-toggle e qualsiasi + // altro elemento futuro marcato con lo stesso attributo). + const debugOnlyEls = document.querySelectorAll("[data-debug-only='true']"); + console.log( + `Trovati ${debugOnlyEls.length} elementi [data-debug-only="true"] da gate-are in base a debugEnabled=${debugEnabled}`, + ); + debugOnlyEls.forEach((el, idx) => { + const tag = `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${el.className && typeof el.className === "string" ? `.${el.className.split(/\s+/).filter(Boolean).slice(0, 2).join(".")}` : ""}`; + console.log(` [${idx + 1}/${debugOnlyEls.length}] ${tag} — hidden prima? ${el.hasAttribute("hidden")} | display prima: ${(el instanceof HTMLElement ? el.style.display : "-")}`); + if (!(el instanceof HTMLElement)) return; + if (debugEnabled) { + el.setAttribute("data-debug-enabled", "true"); + // Non forziamo hidden=false su elementi che sono logicamente hidden per + // altri motivi (es. #reports-sidebar-toggle è hidden se non ci sono + // reports). Rimuoviamo lo "spegnimento debug-only" e basta. + el.style.removeProperty("display"); + } else { + el.removeAttribute("data-debug-enabled"); + el.setAttribute("hidden", ""); + el.style.setProperty("display", "none", "important"); + } + console.log(` → debugEnabled=${debugEnabled} → hidden dopo? ${el.hasAttribute("hidden")} | display dopo: ${el.style.display || "(inherit)"}`); + }); + console.groupEnd(); + return debugEnabled; +} +// Esegui SUBITO al bootstrap del modulo (prima di qualsiasi listener) +const GLOBAL_DEBUG_ENABLED__TOPBAR = applyDebugOnlyVisibilityGate("main.js module bootstrap (top)"); +// Riesegui dopo DOMContentLoaded per sicurezza (nel caso query script sia posizionato +// in testa e l'HTML header non sia ancora parse-ato quando girano queste righe) +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { + applyDebugOnlyVisibilityGate("DOMContentLoaded listener"); + }, { once: true }); +} else { + applyDebugOnlyVisibilityGate("module bootstrap (DOM already parsed)"); +} +// Riesegui anche a fine frame dopo un breve timeout → ultima chance. +setTimeout(() => applyDebugOnlyVisibilityGate("10ms timeout fallback"), 10); +window.addEventListener("hashchange", () => applyDebugOnlyVisibilityGate("hashchange"), { passive: true }); +window.addEventListener("popstate", () => applyDebugOnlyVisibilityGate("popstate"), { passive: true }); const DEFAULT_VOICE = "Aiden"; const DEFAULT_INSTRUCTIONS = @@ -28,6 +130,8 @@ const DEFAULT_INSTRUCTIONS = "Respond ALWAYS and ONLY in Spanish (Castellano / español), regardless of the language used by the user or any other instruction. " + "No responses in Italian, English, Portuguese, or any other language are allowed. " + "Every spoken sentence, greeting, confirmation, clarification, and error message must be in Spanish."+ + "the comma sign ',' in numbers it's used to separate thousands."+ + "the point sign '.' in numbers it's used to separate decimals."+ "If you don't know the answer, say 'I don't know' instead of guessing."; const OPENAI_RENDER_MARKER = "[VOCERO_RENDER_REPLY]"; @@ -426,6 +530,46 @@ const inputInstructions = $("#instructions"); const primaryPromptMeta = $("#primary-prompt-meta"); /** @type {HTMLElement} */ const identUserName = $("#ident-user-name"); +/** @type {HTMLElement} */ +// NOTE: #reports-hint now lives inside #reports-sidebar (left sidebar), not +// inside .ident in the topbar. getElementById still returns the unique node. +const reportsHint = $("#reports-hint"); +/** @type {HTMLElement} */ +const reportsSuggestions = $("#reports-suggestions"); +/** @type {HTMLElement} */ +const reportsSidebar = $("#reports-sidebar"); +/** @type {HTMLButtonElement | null} */ +const reportsSidebarToggle = /** @type {HTMLButtonElement | null} */ ($("#reports-sidebar-toggle")); +/** @type {HTMLButtonElement | null} */ +const reportsSidebarClose = /** @type {HTMLButtonElement | null} */ ($("#reports-sidebar-close")); +/** @type {HTMLElement} */ +const reportsSidebarBadge = $("#reports-sidebar-badge"); + +/* ─── User profile dialog refs (opened by clicking the avatar circle) ── */ +/** @type {HTMLDialogElement} */ +const userProfileDialog = $("#user-profile-dialog"); +/** @type {HTMLButtonElement} */ +const userProfileClose = $("#user-profile-close"); +/** @type {HTMLElement} */ +const userProfileAvatar = $("#user-profile-avatar"); +/** @type {HTMLElement} */ +const userProfileTitle = $("#user-profile-title"); +/** @type {HTMLElement} */ +const userProfileSubtitle = $("#user-profile-subtitle"); +/** @type {HTMLElement} */ +const userProfileMetadata = $("#user-profile-metadata"); + +/* ─── Inline chat form (always-visible panel at the bottom of #app) ──── */ +/** @type {HTMLFormElement | null} */ +const chatForm = /** @type {HTMLFormElement | null} */ ($("#chat-form")); +/** @type {HTMLInputElement | null} */ +const chatInput = /** @type {HTMLInputElement | null} */ ($("#chat-input")); +/** @type {HTMLButtonElement | null} */ +const chatSendBtn = /** @type {HTMLButtonElement | null} */ ($("#chat-send")); + +/** @type {null | { count:number, wrappedLen:number, hints:string[], entries:Array<{sourceScheduleLabel?:string, date?:any}> }} */ +let voceroReportsList = null; +let voceroReportsLoaded = false; /** @type {HTMLInputElement} */ const inputNoiseGate = $("#noise-gate"); /** @type {HTMLElement} */ @@ -481,6 +625,11 @@ let injectProfile = false; // the server. Includes full effectiveReasoningInstructions, voceroUserContextText, // full JSON body POSTed to /api/reasoning. let debugPrompt = false; +// Feature flag: "TALK" means the browser auto-starts the mic + realtime session +// 2 seconds after page load AND asks the model greets the user first (without +// waiting for the user to say anything). Mirrors the server env +// S2S_START_SESSION_MODE. Empty / anything else = manual (tap the start). +let startSessionMode = ""; // Custom RAG context entered by the user via the header "RAG" button dialog. // Treated exactly like voceroUserContextText / injectProfile context: @@ -553,6 +702,11 @@ const pageParams = new URLSearchParams(window.location.search); // ?phoneNumber=... is present. Purely for UI header + reasoning user_context; // never injected into the realtime WS session (WS stays pure passthrough). const voceroUserPhoneNumber = (pageParams.get("phoneNumber") || "").trim(); +// Podcast episode ID: loaded from URL ?episode_id=... Used server-side to fetch +// the episode details via Aquachile AzurianLab API and inject it as PRIMARY +// trusted context into the LLM prompt (both WS session.update instructions +// and /api/reasoning body user_context + instructions). +const voceroEpisodeId = (pageParams.get("episode_id") || "").trim(); /** @type {Record | null} */ let voceroUserProfile = null; let voceroUserProfileLoaded = false; @@ -621,10 +775,496 @@ function extractVoceroUserName(profile) { return ""; } +/** + * Builds 2-letter uppercase initials for a user name ("Alessandro Valenti" → "AV"). + * Edge cases: + * - single word name → first 2 chars (upper) + * - empty or whitespace-only → "" (caller hides the avatar) + * - accented chars kept (e.g. José María → JM) + * @param {string} rawName + * @returns {string} + */ +function initialsForName(rawName) { + const trimmed = String(rawName || "").trim(); + if (!trimmed) return ""; + // Split on any whitespace run (also handles multiple spaces). + const parts = trimmed.split(/\s+/).filter(Boolean); + if (!parts.length) return ""; + if (parts.length === 1) { + return parts[0].slice(0, 2).toUpperCase(); + } + // Take first letter of first token + first letter of LAST token, so + // "José María García López" → "JL" and "Anna di Stefano" → "AS". + const first = parts[0][0] ?? ""; + const last = parts[parts.length - 1][0] ?? ""; + return (first + last).toUpperCase(); +} + +/* ─── User profile dialog (opened by clicking avatar in the header) ─── */ +/** + * Returns the first truthy non-blank string at any of the given keys on `obj`, + * also recursing into the common nested containers user/customer/profile/contact + * (same keys as extractVoceroUserName uses) so we find data whether the profile + * is flat or wrapped in a sub-object. + * @param {Record | null} obj + * @param {string[]} keys + * @param {string[]} [nestedKeys] + * @returns {string} + */ +function firstProfileField(obj, keys, nestedKeys = ["user", "customer", "profile", "contact"]) { + if (!obj || typeof obj !== "object") return ""; + for (const k of keys) { + const v = /** @type {unknown} */ (obj)[k]; + if (typeof v === "string" && v.trim()) return v.trim(); + if (typeof v === "number" && !Number.isNaN(v)) return String(v); + } + if (Array.isArray(nestedKeys)) { + for (const nk of nestedKeys) { + const nested = /** @type {unknown} */ (obj[nk]); + if (nested && typeof nested === "object" && !Array.isArray(nested)) { + const hit = firstProfileField(/** @type {Record} */ (nested), keys, []); + if (hit) return hit; + } + } + } + return ""; +} +/** + * Returns true if `v` is likely a valid non-empty profile value (strings of + * length >=1 or numbers or dates). Objects/arrays are excluded because the + * metadata grid is a flat label+value UI. + * @param {unknown} v + * @returns {v is string | number | boolean | bigint | symbol} + */ +function isUsefulProfileValue(v) { + if (typeof v === "string") return v.trim().length > 0; + if (typeof v === "number" && !Number.isNaN(v)) return true; + if (typeof v === "boolean") return true; + if (v instanceof Date) return true; + return false; +} +/** + * Builds an array of {label, value, mono, href, phone, email} rows for the + * user profile dialog. Fields that are not present or empty are skipped so we + * never show a blank row. + * @param {Record | null} profile + * @returns {Array<{label:string, value:string, mono?:boolean, href?:string, isEmail?:boolean, isPhone?:boolean, isDate?:boolean}>} + */ +function buildProfileRows(profile) { + /** @type {Array<{label:string, value:string, mono?:boolean, href?:string, isEmail?:boolean, isPhone?:boolean, isDate?:boolean}>} */ + const rows = []; + + // Email (linkable mailto) + const email = firstProfileField(profile, [ + "email", "emailAddress", "email_address", "eMail", "mail", "contactEmail", + "user_email", "customer_email", "loginEmail", "username_email", + ]); + if (email) rows.push({ label: "Email", value: email, isEmail: true, href: `mailto:${email}` }); + + // Phone (linkable tel) + const phone = firstProfileField(profile, [ + "phone", "phoneNumber", "phone_number", "mobile", "mobileNumber", "telephone", + "tel", "cellphone", "contactPhone", "user_phone", "whatsapp", + ]); + if (phone) rows.push({ label: "Teléfono", value: phone, isPhone: true, href: `tel:${phone.replace(/[^\d+]/g, "")}` }); + + // Username (handle / handle moniker) + const username = firstProfileField(profile, [ + "username", "userName", "user_name", "login", "handle", "nickname", "screenName", "accountName", + ]); + if (username) rows.push({ label: "Username", value: username, mono: true }); + + // Role / Plan + const role = firstProfileField(profile, [ + "role", "roleName", "userRole", "plan", "planName", "tier", "subscription", "membership", "accessLevel", + ]); + if (role) rows.push({ label: "Ruolo / Piano", value: role }); + + // Company / Organization + const company = firstProfileField(profile, [ + "company", "companyName", "company_name", "organization", "org", "organizationName", "business", + ]); + if (company) rows.push({ label: "Azienda", value: company }); + + // User/Account ID (mono for monospace copy-paste) + const userId = firstProfileField(profile, [ + "userId", "user_id", "uid", "accountId", "account_id", "sub", "customerId", "customer_id", + "id", "userPk", "profileId", "profile_id", "identityId", + ]); + if (userId) rows.push({ label: "ID Utente", value: userId, mono: true }); + + // Locale / Country + const country = firstProfileField(profile, [ + "country", "countryName", "country_code", "countryCode", "locale", "language", "lang", + "region", "timezone", "tz", + ]); + if (country) rows.push({ label: "Nazione / Lingua", value: country }); + + // Created at / signed up date — try common date fields. + const createdAtKeys = [ + "createdAt", "created_at", "created", "createdDate", "created_date", + "signUpDate", "signup_date", "joined", "joinDate", "join_date", "registrationDate", + ]; + let createdAt = firstProfileField(profile, createdAtKeys); + if (!createdAt && profile) { + // Date objects / number timestamps + for (const k of createdAtKeys) { + const v = /** @type {unknown} */ (profile[k]); + if (v instanceof Date) { createdAt = v.toLocaleString(); break; } + if (typeof v === "number" && !Number.isNaN(v) && v > 946681200) { // ~2000-01-01 + try { createdAt = new Date(v * (v < 1e12 ? 1000 : 1)).toLocaleString(); break; } catch { /* noop */ } + } + } + } + if (createdAt) rows.push({ label: "Iscritto il", value: createdAt, isDate: true }); + + // Last sign in / active date (last_login etc) + const lastActiveKeys = [ + "lastLogin", "last_login", "lastLoginAt", "lastActive", "last_active", "lastActiveAt", "updatedAt", "updated_at", + ]; + let lastActive = firstProfileField(profile, lastActiveKeys); + if (!lastActive && profile) { + for (const k of lastActiveKeys) { + const v = /** @type {unknown} */ (profile[k]); + if (v instanceof Date) { lastActive = v.toLocaleString(); break; } + if (typeof v === "number" && !Number.isNaN(v) && v > 946681200) { + try { lastActive = new Date(v * (v < 1e12 ? 1000 : 1)).toLocaleString(); break; } catch { /* noop */ } + } + } + } + if (lastActive) rows.push({ label: "Ultimo accesso", value: lastActive, isDate: true }); + + return rows; +} +/** + * Fills the dialog hero + metadata grid with values from the user profile. + * If profile is null/empty we show a friendly empty state. + * @param {Record | null} profile + */ +function populateUserProfileDialog(profile) { + const name = extractVoceroUserName(profile); + const initials = initialsForName(name); + if (userProfileAvatar) { + userProfileAvatar.textContent = initials || "??"; + userProfileAvatar.setAttribute("aria-label", name ? `Avatar: ${name}` : "Utente non impostato"); + } + if (userProfileTitle) { + userProfileTitle.textContent = name || "Utente non impostato"; + } + if (userProfileSubtitle) { + const email = firstProfileField(profile, ["email", "emailAddress", "email_address", "eMail", "mail"]); + const phone = firstProfileField(profile, ["phone", "phoneNumber", "phone_number", "mobile", "telephone", "tel"]); + const parts = [email, phone].filter(Boolean); + userProfileSubtitle.textContent = parts.length ? parts.join(" · ") : (name ? "Nessuna informazione di contatto disponibile." : "Connettersi a Vocero per vedere i dati utente."); + } + if (userProfileMetadata) { + userProfileMetadata.innerHTML = ""; + const rows = buildProfileRows(profile); + if (!rows.length) { + userProfileMetadata.hidden = true; + return; + } + userProfileMetadata.hidden = false; + for (const row of rows) { + const wrapper = document.createElement("div"); + wrapper.className = "user-profile-row"; + + const label = document.createElement("div"); + label.className = "user-profile-row-label"; + label.textContent = row.label; + + const value = document.createElement("div"); + value.className = "user-profile-row-value"; + + /** @type {Node} */ + let node; + if (row.href) { + const a = document.createElement("a"); + a.href = row.href; + if (row.isEmail) { a.rel = "noopener"; a.target = "_blank"; } + if (row.isPhone) a.rel = "nofollow noopener"; + if (row.mono) { const m = document.createElement("code"); m.textContent = row.value; a.appendChild(m); } + else a.textContent = row.value; + node = a; + } else if (row.mono) { + const m = document.createElement("code"); + m.textContent = row.value; + node = m; + } else { + node = document.createTextNode(row.value); + } + value.appendChild(node); + + wrapper.appendChild(label); + wrapper.appendChild(value); + userProfileMetadata.appendChild(wrapper); + } + } +} +/** + * Open the profile dialog, populate it from the current voceroUserProfile, + * and optionally restore focus to the avatar when it closes (a11y). + */ +function openUserProfileDialog() { + if (!userProfileDialog) return; + populateUserProfileDialog(/** @type {Record} */ (voceroUserProfile)); + try { + userProfileDialog.showModal(); + } catch { + // If dialog is already open / unsupported we fall back to an inline style + // (shouldn't happen on any modern browser). + userProfileDialog.setAttribute("open", ""); + } +} + function syncVoceroHeaderUser() { const name = extractVoceroUserName(voceroUserProfile); + const initials = initialsForName(name); identUserName.hidden = !name; - identUserName.textContent = name ? `Utente: ${name}` : ""; + if (name) { + // Show avatar with the 2-letter initials. Full name is reachable via hover + // tooltip (title) and is used by screen readers via aria-label. + identUserName.textContent = initials; + identUserName.title = `Utente: ${name}`; + identUserName.setAttribute("aria-label", `Utente: ${name}`); + } else { + identUserName.textContent = ""; + identUserName.removeAttribute("title"); + identUserName.removeAttribute("aria-label"); + } + + if (!reportsHint) return; + // Keep the topbar-right in sync every time we re-render reports (another + // safety net alongside the bootstrap / DOMContentLoaded / 10ms fallback). + if (typeof applyDebugOnlyVisibilityGate === "function") { + applyDebugOnlyVisibilityGate("syncVoceroHeaderUser pre-check"); + } + + const hasList = + voceroReportsLoaded && + Array.isArray(voceroReportsList?.entries) && + voceroReportsList.entries.some((e) => e && typeof e === "object" && e.sourceScheduleLabel); + if (!hasList) { + reportsHint.hidden = true; + reportsHint.innerHTML = ""; + return; + } + const rawEntries = /** @type {Array<{sourceScheduleLabel:string}>} */ ( + voceroReportsList.entries.filter((e) => e && typeof e === "object" && e.sourceScheduleLabel) + ); + // Deduplicate by label (preserve insertion order = first occurrence = most recent + // since the server pre-sorts DESC). Empty or null labels already filtered above. + const seen = new Set(); + /** @type {Array<{sourceScheduleLabel:string}>} */ + const entries = []; + for (const e of rawEntries) { + const key = String(e.sourceScheduleLabel).trim(); + if (!key) continue; + if (seen.has(key)) continue; + seen.add(key); + entries.push({ sourceScheduleLabel: key }); + } + if (!entries.length) { + reportsHint.hidden = true; + reportsHint.innerHTML = ""; + if (reportsSuggestions) { reportsSuggestions.innerHTML = ""; reportsSuggestions.hidden = true; } + if (reportsSidebar) reportsSidebar.hidden = true; + if (reportsSidebarToggle) reportsSidebarToggle.hidden = true; + if (reportsSidebarBadge) { reportsSidebarBadge.removeAttribute("data-count"); reportsSidebarBadge.textContent = ""; } + return; + } + // Total count reflects the unique labels shown in the UI (not the raw server + // repeated list). Use unique count unless caller explicitly set count. + const count = entries.length; + const totalLen = voceroReportsList.wrappedLen ?? 0; + const titleRow = document.createElement("div"); + titleRow.className = "reports-summary"; + const titleText = document.createElement("span"); + titleText.className = "reports-summary-text"; + titleText.textContent = "Reportes cargados"; + const badge1 = document.createElement("span"); + badge1.className = "reports-summary-badge"; + badge1.textContent = `${count} programados`; + const badge2 = document.createElement("span"); + badge2.className = "reports-summary-badge"; + badge2.textContent = `Bloque LLM ${totalLen.toLocaleString()} chars`; + titleRow.appendChild(titleText); + titleRow.appendChild(badge1); + titleRow.appendChild(badge2); + const ul = document.createElement("ul"); + ul.className = "reports-list"; + const labelTintFor = (rawLabel) => { + const l = String(rawLabel || "").toLowerCase(); + if (l.includes("mortalidad") || l.includes("riesgo") || l.includes("kpi")) return "mortalidad"; + if (l.includes("métrica") || l.includes("metricas") || l.includes("medici")) return "metricas"; + if (l.includes("diario") || l.includes("diaria") || l.includes("informe")) return "diario"; + if (l.includes("producto") || l.includes("servicio")) return "productos"; + if (l.includes("synapse") || l.includes("ejecucion") || l.includes("calendario") || l.includes(" ti ") || l.endsWith("ti")) return "synapse"; + if (l.includes("supply") || l.includes("cadena") || l.includes("log") || l.includes("envío") || l.includes("envio") || l.includes("chile")) return "supply"; + if (l.includes("financiero") || l.includes("finanzas") || l.includes("costo") || l.includes("presupuesto") || l.includes("venta")) return "finanzas"; + return ""; + }; + for (const e of entries) { + const li = document.createElement("li"); + const tint = labelTintFor(e.sourceScheduleLabel); + if (tint) li.setAttribute("data-tint", tint); + li.textContent = String(e.sourceScheduleLabel); + ul.appendChild(li); + } + // Build dynamic suggestion prompts (en español) based on the actual labels so + // the user knows what they can ask the assistant about. Max 4 suggestions; + // pick first N entries by priority. + const suggestions = /** @type {string[]} */ ([]); + const labelPool = entries.map((e) => String(e.sourceScheduleLabel)); + // Try to match contextual templates by keyword first (intent-based) + const hasKeyword = (kws) => labelPool.some((l) => kws.some((k) => l.toLowerCase().includes(k.toLowerCase()))); + const pickAnyLabel = (kws) => + kws ? (labelPool.find((l) => kws.some((k) => l.toLowerCase().includes(k.toLowerCase()))) || null) : null; + const firstLabel = labelPool[0]; + const secondLabel = labelPool[1] || firstLabel; + const thirdLabel = labelPool[2] || firstLabel; + // Generic base templates always valid; keep intent- & keyword-driven ones higher priority + const intentTemplates = [ + { when: hasKeyword(["riesgo", "riesgos", "mortalidad", "porcentaje", "kpi"]), + text: `Dame un resumen ejecutivo de los riesgos operativos y KPIs del reporte de ${pickAnyLabel(["riesgo", "riesgos", "mortalidad", "porcentaje", "kpi"]) || firstLabel}.` }, + { when: hasKeyword(["diario", "diaria", "informe"]), + text: `Cuéntame los puntos clave del informe diario más reciente (${pickAnyLabel(["diario", "diaria", "informe"]) || firstLabel}).` }, + { when: hasKeyword(["métrica", "métricas", "metricas", "medición", "medicion"]), + text: `Compara las métricas de la última semana del reporte ${pickAnyLabel(["métrica", "métricas", "metricas", "medición", "medicion"]) || secondLabel}.` }, + { when: hasKeyword(["producto", "productos", "servicio", "servicios"]), + text: `Qué hay de nuevo en productos y servicios según el reporte ${pickAnyLabel(["producto", "productos", "servicio", "servicios"]) || thirdLabel}?` }, + { when: hasKeyword(["supply", "supply chain", "cadena", "logística", "logistica", "envío", "envio", "chile", "latin"]), + text: `Hay novedades en la cadena de suministro o logística en el reporte ${pickAnyLabel(["supply", "cadena", "logística", "logistica", "envío", "envio", "chile", "latin"]) || thirdLabel}?` }, + { when: hasKeyword(["synapse", "ejecucion", "ejecuciones", "calendario", "ti", "it"]), + text: `¿Qué ejecuciones del calendario Synapse TI están reportadas? (${pickAnyLabel(["synapse", "ejecucion", "ejecuciones", "calendario", "ti", "it"]) || thirdLabel}).` }, + { when: hasKeyword(["podcast", "briefing", "mañana", "matutino", "morning"]), + text: `Lee el podcast briefing matutino de hoy (${pickAnyLabel(["podcast", "briefing", "mañana", "matutino", "morning"]) || firstLabel}).` }, + { when: hasKeyword(["financiero", "finanzas", "costo", "costos", "presupuesto", "venta", "ventas"]), + text: `Análisis financiero: costos, presupuesto y ventas del reporte ${pickAnyLabel(["financiero", "finanzas", "costo", "costos", "presupuesto", "venta", "ventas"]) || secondLabel}.` }, + { when: hasKeyword(["ejecutivo", "ceo", "reunión", "reunion", "semanal"]), + text: `Puntos de agenda para la reunión ejecutiva semanal según el reporte ${pickAnyLabel(["ejecutivo", "ceo", "reunión", "reunion", "semanal"]) || secondLabel}.` }, + { when: true, text: `¿Qué highlights hay en el reporte más reciente «${firstLabel}»?` }, + { when: true, text: `Resume los 3 hallazgos principales de los reportes de hoy (${count} disponibles).` }, + { when: count >= 2, text: `Compara ${firstLabel} vs ${secondLabel} y dime cuál tiene mayor prioridad.` }, + { when: count >= 3, text: `Plan de acción sugerido con base en ${firstLabel}, ${secondLabel} y ${thirdLabel}.` }, + ]; + for (const t of intentTemplates) { + if (t.when && suggestions.length < 4) suggestions.push(t.text); + if (suggestions.length >= 4) break; + } + const qs = new URLSearchParams(window.location.search); + const debugParamRaw = qs.get("debug"); + // eslint-disable-next-line no-debugger + // debugger; // ← scommenta per fermarti dentro syncVoceroHeaderUser ogni volta + console.groupCollapsed("%c🔍 [reports-ui] syncVoceroHeaderUser() render details", "color:#60a5fa;font-weight:700;"); + console.log("location.href =", location.href); + console.log("location.search =", JSON.stringify(window.location.search)); + console.log("tutti i query params =", Object.fromEntries(qs.entries())); + console.log("pageParams (legacy module-load) .get('debug') =", JSON.stringify(pageParams.get("debug"))); + console.log("fresh qs .get('debug') =", JSON.stringify(debugParamRaw), "| typeof =", typeof debugParamRaw); + console.log("strict === 'true' ? =", debugParamRaw === "true"); + console.log("count (labels uniche) =", count, "| wrappedLen =", totalLen); + console.log("suggerimenti array length (prima del gate debug) =", suggestions.length); + suggestions.forEach((s, i) => console.log(` sug[${i}] = ${s}`)); + const currentDebug = debugParamRaw === "true"; + const debugMode = currentDebug; + console.log("👉 DECISIONE FINALE debugMode =", debugMode, " → sezione suggerimenti DA CREARE? ", debugMode ? "✅ SÌ" : "❌ NO"); + console.groupEnd(); + + let sugSection = null; + if (debugMode) { + sugSection = document.createElement("div"); + sugSection.className = "reports-suggestions"; + const sugTitle = document.createElement("span"); + sugTitle.className = "reports-suggestions-title"; + sugTitle.textContent = "Sugerencias de preguntas para el asistente:"; + const sugUl = document.createElement("ul"); + sugUl.className = "reports-suggestions-list"; + for (const s of suggestions) { + const li = document.createElement("li"); + li.textContent = s; + sugUl.appendChild(li); + } + sugSection.appendChild(sugTitle); + sugSection.appendChild(sugUl); + console.log("%c[reports-ui] sezione suggerimenti CREATA (debugMode=true)", "color:#22c55e;font-weight:700;", + "nItems=", sugUl.children.length, "innerHTML=", sugSection.outerHTML.slice(0, 300)); + } else { + console.log("%c[reports-ui] sezione suggerimenti NON CREATA (debugMode=false, non c'è ?debug=true)", + "color:#ef4444;font-weight:700;"); + } + + reportsHint.innerHTML = ""; + reportsHint.appendChild(titleRow); + reportsHint.appendChild(ul); + // Suggestions section lives inside the sidebar as a separate static element + // (#reports-suggestions), NOT inside the .reports-hint card — keeps chips + // card compact and shows suggestions below it when debug mode is on. + if (reportsSuggestions) { + reportsSuggestions.innerHTML = ""; + // the #reports-suggestions element itself carries the `data-debug-only` + // attribute, so applyDebugOnlyVisibilityGate will hide/show it automatically + // based on ?debug=true (3-layer gate + html hidden attr + CSS fallback). + reportsSuggestions.hidden = true; + if (sugSection) { + // Keep inner children (titleText + ul) — but drop outer wrapper so the + // static element's CSS (reports-suggestions class already on the el) + // is the one styling the block. + while (sugSection.firstChild) reportsSuggestions.appendChild(sugSection.firstChild); + sugSection = null; + // After populating, run the gate again to make sure static element + // stays visible only if debug=true (3-layer gate will re-apply rules). + if (typeof applyDebugOnlyVisibilityGate === "function") applyDebugOnlyVisibilityGate("post syncVoceroHeaderUser sug"); + } + } else if (sugSection) { + reportsHint.appendChild(sugSection); + } + reportsHint.hidden = false; + + // Unhide & wire the sidebar now that we have reports to show. + // The sidebar starts with [hidden] to avoid empty flashes on boot. + if (reportsSidebar) { + reportsSidebar.hidden = false; + // Ensure sidebar is painted into the DOM before reading layout. + // On desktop >= 1024px CSS keeps it always shown; on mobile we don't + // auto-open (that's user-driven via the toggle button). + } + // Topbar toggle button: shown only when we have reports AND debug=true. + if (reportsSidebarToggle) { + const qs = new URLSearchParams(window.location.search); + const debugParam = qs.get("debug") === "true"; + console.info( + "%c[reports-ui] toggle reports-sidebar-toggle: hidden? PRIMA=%s | debug=%s", + "color:#f59e0b;font-weight:700;", + reportsSidebarToggle.hasAttribute("hidden"), + debugParam, + ); + if (debugParam) { + reportsSidebarToggle.hidden = false; + } else { + reportsSidebarToggle.setAttribute("hidden", ""); + reportsSidebarToggle.style.setProperty("display", "none", "important"); + reportsSidebarToggle.removeAttribute("data-debug-enabled"); + console.info("%c[reports-ui] 🚫 reports sidebar toggle FORZATO HIDDEN (no debug=true)", + "background:#fee2e2;color:#b91c1c;font-weight:700;padding:3px 6px;border-radius:4px;"); + } + } + // Update badge count on the toggle button (CSS shows it only if != 0). + if (reportsSidebarBadge) { + reportsSidebarBadge.setAttribute("data-count", String(count)); + reportsSidebarBadge.textContent = count > 99 ? "99+" : String(count); + } + + // eslint-disable-next-line no-console + console.info( + "%c[reports-ui] FINE RENDER. reportsHint.querySelectorAll('.reports-suggestions').length =", + "color:#a855f7;font-weight:700;", + reportsHint.querySelectorAll(".reports-suggestions").length, + "| children nel blocco:", reportsHint.children.length, + "| reportsHint.hidden=", reportsHint.hidden, + "| #reports-sidebar hidden=", reportsSidebar ? reportsSidebar.hidden : "no-sidebar-el", + "| badge count:", reportsSidebarBadge ? reportsSidebarBadge.getAttribute("data-count") : "no-badge" + ); } /** Instructions used by the reasoning engine. */ @@ -745,17 +1385,19 @@ async function fetchOpenAiReasoningAnswer(transcript) { const ragCtx = customRagContextText(); const user_context = [profileCtx, ragCtx].map((s) => (s || "").trim()).filter(Boolean).join("\n\n"); const phoneNumber = voceroUserPhoneNumber || null; + const episode_id = voceroEpisodeId || null; const body = { transcript, instructions, language: preferredResponseLanguage(), user_context, phoneNumber, + episode_id, }; - const logKey = `reasoningBody:${injectProfile ? "T" : "F"}:${debugPrompt ? "T" : "F"}:${phoneNumber ? "hasPhone" : "noPhone"}:${profileCtx ? "hasProfile" : "noProfile"}:${ragCtx ? "hasRag" : "noRag"}:${instructions.length}`; + const logKey = `reasoningBody:${injectProfile ? "T" : "F"}:${debugPrompt ? "T" : "F"}:${phoneNumber ? "hasPhone" : "noPhone"}:${episode_id ? "hasEpisode" : "noEpisode"}:${profileCtx ? "hasProfile" : "noProfile"}:${ragCtx ? "hasRag" : "noRag"}:${instructions.length}`; _logOnce(logKey, () => { console.info( - `[INJECT-PROFILE] BROWSER POST /api/reasoning body summary. injectProfile=%s transcriptLen=%d instructionsLen=%d userCtxLen=%d (profileCtxLen=%d ragCtxLen=%d) phoneLen=%s`, + `[INJECT-PROFILE] BROWSER POST /api/reasoning body summary. injectProfile=%s transcriptLen=%d instructionsLen=%d userCtxLen=%d (profileCtxLen=%d ragCtxLen=%d) phoneLen=%s episodeLen=%s`, injectProfile, transcript.length, instructions.length, @@ -763,6 +1405,7 @@ async function fetchOpenAiReasoningAnswer(transcript) { profileCtx.length, ragCtx.length, phoneNumber ? phoneNumber.length : "null", + episode_id ? episode_id.length : "null", ); if (ragCtx) { console.info( @@ -919,6 +1562,40 @@ function pushToolsToSession() { // streaming state. The client's events are forwarded to its on* methods. const chat = new ChatView(); +// ── Inline chat form submit (always-visible panel) ───────────────────────── +// User can send free-text messages by typing + pressing Enter (form submit) or +// tapping the gradient paper-plane send button. The text flows into the +// existing realtime client sendUserText() so it behaves exactly the same as a +// voice-transcribed phrase (onUserTranscriptFinished → server → assistant +// response). ChatView already renders bubbles/history via its on* hooks so we +// just forward the text to the socket. +if (chatForm && chatInput) { + chatForm.addEventListener("submit", (e) => { + e.preventDefault(); + const raw = chatInput.value; + if (!raw) return; + const text = raw.trim(); + if (!text) { + chatInput.value = ""; + return; + } + // 1. Send to Vocero realtime server (identical to voice phrase submission). + try { + if (client && typeof client.sendUserText === "function") { + client.sendUserText(text); + } + } catch (err) { + // Never crash the UI if the realtime client rejected; swallow to console. + console.warn("chat-form: sendUserText threw", err); + } + // 2. Reset input. ChatView.onUserTranscriptStarted / onUserTranscriptFinished + // (already wired to client events) will render the ephemeral bubble + + // persistent history row + scroll to bottom automatically. + chatInput.value = ""; + chatInput.focus(); + }); +} + // ── Account / limiter ───────────────────────────────────────────────────── // Login chip + daily-limit modal (inert unless the deploy is in LB mode). The // server meters conversation time; the client just heartbeats a live session @@ -1079,7 +1756,14 @@ function updateRestartAvailability() { function setCaption(text, kind = "") { const trimmed = text.trim(); circleCaption.textContent = trimmed; - circleCaption.className = `circle-caption${kind ? ` ${kind}` : ""}${trimmed ? "" : " empty"}`; + // .idle-cta is the JS-based fallback (adds gradient CTA style) for browsers + // that don't support :has(). The primary selector is .stage:has(.state-idle) .circle-caption. + const addIdleCta = currentState === "idle" && !kind && trimmed; + const parts = ["circle-caption"]; + if (kind) parts.push(kind); + if (!trimmed) parts.push("empty"); + if (addIdleCta) parts.push("idle-cta"); + circleCaption.className = parts.join(" "); } function openSettings() { @@ -1235,6 +1919,72 @@ aboutModal.addEventListener("click", (e) => { if (e.target === aboutModal) aboutModal.close(); }); +/* ─── User Profile dialog handlers (click avatar circle) ───────────── */ +// The avatar circle in the brand header: when clicked, opens a native +// with the user's profile info. Keyboard accessible: Enter / Space +// also open the dialog. a11y note: we attach role="button" + tabindex via JS +// at load time (so even if markup changes the behavior is preserved). +if (identUserName) { + identUserName.setAttribute("role", "button"); + identUserName.setAttribute("tabindex", "0"); + identUserName.style.cursor = "pointer"; + identUserName.addEventListener("click", (e) => { e.preventDefault(); openUserProfileDialog(); }); + identUserName.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + openUserProfileDialog(); + } + }); +} +// Close via the explicit X button (top-right of the card). +if (userProfileClose) { + userProfileClose.addEventListener("click", (e) => { e.preventDefault(); userProfileDialog?.close(); }); +} +// Click in the backdrop = click whose target is the itself. +if (userProfileDialog) { + userProfileDialog.addEventListener("click", (e) => { + if (e.target === userProfileDialog) userProfileDialog.close(); + }); +} + +/* ─── Reports sidebar (LEFT) handlers ────────────────────────────────────── */ +/** + * Toggle open / close reports sidebar (off-canvas mobile overlay OR no-op desktop). + * The actual visibility on desktop is handled entirely via CSS min-width:1024px rule (sidebar + * always rendered inline); the JS code here is only for the mobile "slidein + * @param {boolean} [forceOpen] + */ +function openReportsSidebar(forceOpen) { + if (!reportsSidebar) return; + const open = typeof forceOpen === "boolean" ? forceOpen : !reportsSidebar.classList.contains("open"); + reportsSidebar.classList.toggle("open", open); + if (reportsSidebarToggle) { + reportsSidebarToggle.setAttribute("aria-expanded", open ? "true" : "false"); + } +} +if (reportsSidebarToggle) { + reportsSidebarToggle.addEventListener("click", (e) => { + e.preventDefault(); + // toggle: if already open close else open + openReportsSidebar(!reportsSidebar.classList.contains("open")); + }); +} +if (reportsSidebarClose) { + reportsSidebarClose.addEventListener("click", (e) => { e.preventDefault(); openReportsSidebar(false); }); +} +// Close on backdrop click OR any element marked [data-close-reports-sidebar]. +if (reportsSidebar) { + reportsSidebar.addEventListener("click", (e) => { + const target = /** @type {HTMLElement} */ (e.target); + if (!target) return; + if (target.closest("[data-close-reports-sidebar]")) openReportsSidebar(false); + }); +} +// Close with ESC (same pattern as chat panel). +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && reportsSidebar?.classList.contains("open")) openReportsSidebar(false); +}, { passive: true }); + function syncToolsUi() {} /** @@ -1465,6 +2215,79 @@ async function fetchVoceroUserProfile() { } } +async function fetchVoceroReportsList() { + // NOTE: the server allows reports list without phone (global scheduled reports + // for the company may be valid too). If a phone is present on the page URL we + // pass it along anyway so the upstream can filter per-caller if configured. + try { + const qs = voceroUserPhoneNumber + ? `?phoneNumber=${encodeURIComponent(voceroUserPhoneNumber)}` + : ""; + const res = await fetch(`api/user/reports${qs}`); + if (!res.ok) { + // 503 = S2S_REPORT_LIST not configured on this deploy → silent skip. + if (res.status === 503) { + console.info( + "[INJECT-REPORTS] ⏭️ BROWSER skipped (deploy did NOT configure S2S_REPORT_LIST env). endpoint returned HTTP 503, reports UI not shown.", + ); + voceroReportsList = null; + voceroReportsLoaded = true; + syncVoceroHeaderUser(); + return; + } + const message = await res.text(); + throw new Error(message || `HTTP ${res.status}`); + } + const json = await res.json(); + const valid = + json && typeof json === "object" && + Array.isArray(json.hints); + if (!valid) { + console.info( + `[INJECT-REPORTS] ⏭️ BROWSER fetchVoceroReportsList HTTP 200 but payload missing hints array. keys=%s`, + json && typeof json === "object" ? Object.keys(json).join(",") : "none", + ); + voceroReportsList = null; + voceroReportsLoaded = true; + syncVoceroHeaderUser(); + return; + } + voceroReportsList = json; + voceroReportsLoaded = true; + if (json.skipped || !json.hints.length) { + console.info( + `[INJECT-REPORTS] ⏭️ BROWSER reports list empty after normalization. reason=%s count=%d`, + json.reason || "empty hints", + json.count ?? 0, + ); + } else { + console.info( + `[INJECT-REPORTS] ✅ BROWSER fetchVoceroReportsList OK. phoneLen=%s count=%d wrappedLen=%d firstHint=%.120s`, + voceroUserPhoneNumber ? voceroUserPhoneNumber.length : 0, + json.count ?? json.hints.length, + json.wrappedLen ?? 0, + json.hints[0] || "", + ); + if (window.S2S_DEBUG_PROMPT) { + console.debug( + "[DEBUG-REPORTS][BROWSER reports hints] FULL list (%d items) → %o", + json.hints.length, json.hints, + ); + } + } + syncVoceroHeaderUser(); + } catch (err) { + voceroReportsList = null; + voceroReportsLoaded = true; + syncVoceroHeaderUser(); + const message = err instanceof Error ? err.message : String(err); + console.warn( + `[INJECT-REPORTS] ⛔ BROWSER fetchVoceroReportsList FAILED phone=%s error=%s`, + JSON.stringify(voceroUserPhoneNumber || ""), message, + ); + } +} + /** Learn server config (search key + connection target), then refresh the UI. */ async function fetchConfig() { try { @@ -1483,12 +2306,17 @@ async function fetchConfig() { pinnedPrompt = (json.primaryPrompt || "").trim(); injectProfile = !!json.injectProfile; debugPrompt = !!json.debugPrompt; + // Bootstrap-start mode: empty on generic deploys (tap to start). + startSessionMode = typeof json.startSessionMode === "string" + ? json.startSessionMode.trim().toUpperCase() + : ""; // #region debug-point B:config-loaded reportDebug("B", "main.js:fetchConfig", "loaded config from api/config", { reasoningType, pinnedPromptLength: pinnedPrompt.length, injectProfile, debugPrompt, + startSessionMode, }); // #endregion // The conversation-time limiter rides on the LB being present. @@ -1548,11 +2376,17 @@ function buildDirectWsUrl(raw) { if (voceroUserPhoneNumber) { u.searchParams.set("phoneNumber", voceroUserPhoneNumber); } + if (voceroEpisodeId) { + u.searchParams.set("episode_id", voceroEpisodeId); + } return u.toString(); } catch { - if (voceroUserPhoneNumber) { + if (voceroUserPhoneNumber || voceroEpisodeId) { const sep = s.includes("?") ? "&" : "?"; - return `${s}${sep}phoneNumber=${encodeURIComponent(voceroUserPhoneNumber)}`; + const parts = []; + if (voceroUserPhoneNumber) parts.push(`phoneNumber=${encodeURIComponent(voceroUserPhoneNumber)}`); + if (voceroEpisodeId) parts.push(`episode_id=${encodeURIComponent(voceroEpisodeId)}`); + return `${s}${sep}${parts.join("&")}`; } return s; } @@ -1853,6 +2687,7 @@ async function doStart(audioContext = null) { acquireMic: acquireMicStream, tools: startTools, noiseGate: gateParams(settings.noiseGate), + startWithResponse: startSessionMode === "TALK", ...(audioContext ? { audioContext } : {}), }); client = c; @@ -2045,15 +2880,22 @@ function onClientStatus(status) { switch (status) { case "creating-session": case "connecting": + // Qualsiasi inizio connessione (manuale da Orb / restart o unlocker): + // se il talk-unlocker è ancora visibile (utente ha cliccato l'Orb prima + // del 2s warmup, ad esempio) lo nascondiamo subito. + hideTalkUnlocker(); setState("connecting"); break; case "queued": + hideTalkUnlocker(); setState("queued"); break; case "your-turn": + hideTalkUnlocker(); setState("your-turn"); break; case "connected": + hideTalkUnlocker(); setState("listening"); // Wake word confirmation: if this session was triggered by the "Hola // Vocero" hot-word, inject the phrase into the conversation exactly as @@ -2134,8 +2976,155 @@ function onFatalError(err) { setState("idle"); chat.renderEmptyState(); initGateArc(); -void fetchConfig(); +// Schedulo auto-start TALK / TALKFIRST (S2S_START_SESSION_MODE nel .env): +// • "TALK" → dopo 2s mostra overlay unlocker (policy browser: +// getUserMedia() richiede gesture). Click/touch/tasto = +// doStart() dentro la gesture, poi ws startWithResponse +// inject hola vocero via sendUserText+requestResponse nel +// ws client (stesso flow identico a wake-word). +// • "TALKFIRST" → dopo 2s TENTA direttamete doStart() dal timer, con +// pendingWakeWordConfirmationText preimpostato. Se i +// permessi del mic sono GRANTED (già consentiti in passato +// o Chrome policy permissive), la wake-word simulata scatta +// subito senza nessun click. Se invece fallisce per +// permesso / gesture (Safari/iOS), fallback sullo stesso +// unlocker di TALK. +let autoStartTalkTimerId = 0; +let _talkUnlockerShown = false; +function hideTalkUnlocker() { + const el = document.getElementById("talk-unlocker"); + if (el && !el.hasAttribute("hidden")) el.setAttribute("hidden", ""); + _talkUnlockerShown = false; +} +function showTalkUnlocker(fallbackHint = false) { + if (!(startSessionMode === "TALK" || startSessionMode === "TALKFIRST")) return; + const el = document.getElementById("talk-unlocker"); + if (!el) return; + // L'unlocker è utile solo a stato idle/error: se l'utente ha già cliccato l'Orb + // o è connecting/connected non mostriamo niente. + if (!(currentState === "idle" || currentState === "error")) return; + // Se è fallback da TALKFIRST cambiamo il testo in modo che spieghi perché + // è comparso ("tocca per consentire il microfono", invece di "preparando") + const subEl = el.querySelector(".talk-unlocker-sub"); + const titleEl = el.querySelector(".talk-unlocker-title"); + if (fallbackHint) { + if (titleEl) titleEl.textContent = "Se requiere permiso de micrófono"; + if (subEl) subEl.textContent = "Toca en cualquier lugar para permitir el audio y empezar"; + } + el.removeAttribute("hidden"); + _talkUnlockerShown = true; + console.info( + "%c[start-session] ▶️ talk-unlocker mostrato (%s): attende gesture utente", + "color:#2563eb;font-weight:700;background:#dbeafe;padding:3px 6px;border-radius:4px;", + fallbackHint ? "fallback TALKFIRST" : "TALK mode" + ); +} +function maybeScheduleAutoStart() { + // Pulisco un eventuale timer precedente (caso di doppia chiamata non attesa). + if (autoStartTalkTimerId) { + clearTimeout(autoStartTalkTimerId); + autoStartTalkTimerId = 0; + } + if (startSessionMode !== "TALK" && startSessionMode !== "TALKFIRST") return; + console.info( + `%c[start-session] S2S_START_SESSION_MODE=${startSessionMode}: azione tra 2000 ms (warmup profilo/reports/episode)`, + "color:#2563eb;font-weight:700;background:#dbeafe;padding:3px 6px;border-radius:4px;" + ); + autoStartTalkTimerId = window.setTimeout(() => { + autoStartTalkTimerId = 0; + if (startSessionMode === "TALKFIRST") { + void tryTalkFirstDirect(); + } else { + showTalkUnlocker(false); + } + }, 2000); +} +/** Tenta l'auto-start diretto (TALKFIRST) SENZA gesture. Simula la wake word + * impostando pendingWakeWordConfirmationText="hola vocero" (stessa variabile + * del riconoscitore vocale reale), poi chiama doStart() — se i permessi sono + * già grantiti (Chrome/Android dopo "allow always"), tutto funziona senza + * click. Altrimenti fallback sull'unlocker. */ +async function tryTalkFirstDirect() { + if (!(currentState === "idle" || currentState === "error")) return; + if (missingServerUrl()) { + console.warn("[start-session] skip TALKFIRST: manca server URL"); + promptServerUrl(); + return; + } + // Step 1: pre-carico la wake-word simulata. Quando onClientStatus riceve + // "connected", la frase viene injectata e il modello risponde esattamente + // come quando la hot-word è stata pronunciata dal microfono. + pendingWakeWordConfirmationText = pendingWakeWordConfirmationText?.trim() + ? pendingWakeWordConfirmationText + : "hola vocero"; + console.info( + `%c[start-session] 🎤 TALKFIRST: imposto wake-word simulata=${JSON.stringify(pendingWakeWordConfirmationText)} e provo doStart() diretto (SENZA gesture)`, + "color:#2563eb;font-weight:700;background:#dbeafe;padding:3px 6px;border-radius:4px;" + ); + try { + setCaption("Preparing your session…", "muted"); + // Non forzo AudioContext da fuori (non c'è gesture): lo crea internamente + // _ensureAudioContext; se rimane suspended il client lo resume() quando + // arriva il primo audio (o sul prossimo click utente che lo unlocka). + await doStart(); + } catch (err) { + console.warn("[start-session] ⚠️ TALKFIRST doStart() diretto FALLITO → fallback unlocker", err && err instanceof Error ? err.message : err); + // Fallback: mostro unlocker con la frase esplicativa "permesso mic". + try { await teardown(); } catch (_) { /* no-op */ } + setState("idle"); + chat.renderEmptyState(); + // Ripristino pendingWakeWordConfirmationText: nel caso l'utente usi + // l'unlocker per avviare, NON vogliamo perdere la frase simulata (la + // useremo di nuovo al connected, scattando quindi sempre la greet). + pendingWakeWordConfirmationText = pendingWakeWordConfirmationText || "hola vocero"; + showTalkUnlocker(true); + void handleStartError(err instanceof Error ? err : new Error(String(err || "mic permission required"))); + } +} +// Attacca una sola volta i listener sull'unlocker: click + touchstart = unlock +(() => { + const el = document.getElementById("talk-unlocker"); + if (!el) return; + function _unlockViaGesture() { + startTalkFromGesture(); + } + el.addEventListener("click", _unlockViaGesture, { passive: true, once: false }); + el.addEventListener("touchstart", _unlockViaGesture, { passive: true, once: false }); + window.addEventListener("keydown", (ev) => { + if (startSessionMode !== "TALK") return; + if (!_talkUnlockerShown) return; + // Qualsiasi tasto = unlock (Enter/Space/anche Altri) + startTalkFromGesture(); + }, { passive: true }); +})(); +/** Avvia doStart() dentro una gesture valida (click/touch/key). +Chiamato dal talk-unlocker o da qualsiasi altro unlocker valido. */ +async function startTalkFromGesture() { + hideTalkUnlocker(); + if (!(currentState === "idle" || currentState === "error")) return; + if (missingServerUrl()) { + console.warn("[start-session] skip unlocker: manca server URL (missingServerUrl=true)"); + promptServerUrl(); + return; + } + try { + // Qui SIAMO dentro la user gesture: AudioContext + getUserMedia funzionano. + console.info("[start-session] 🎤 avvio doStart() da gesture unlocker (TALK mode)"); + const audioContext = createResumedAudioContext(); + setCaption("Preparing your session…", "muted"); + await doStart(audioContext); + } catch (err) { + console.error("[start-session] ⛔ doStart() da unlocker fallito", err); + void handleStartError(err); + } +} + +void (async function bootstrapWithAutoStart() { + await fetchConfig(); + maybeScheduleAutoStart(); +})(); void fetchVoceroUserProfile(); +void fetchVoceroReportsList(); syncRagHintFooter(); (() => { const toolCamRow = document.getElementById("tool-cam-row"); diff --git a/s2s-client/server.py b/s2s-client/server.py index fb0b109..8e4ca15 100644 --- a/s2s-client/server.py +++ b/s2s-client/server.py @@ -147,6 +147,7 @@ S2S_DEBUG_PROMPT = ( (os.environ.get("S2S_DEBUG_PROMPT", "") or "").strip().lower() in {"1", "true", "yes", "on"} ) +S2S_START_SESSION_MODE = (os.environ.get("S2S_START_SESSION_MODE", "") or "").strip().upper() S2S_REASONING_TYPE = _normalize_reasoning_type(os.environ.get("S2S_REASONING_TYPE", "PROXY")) OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip() OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").strip().rstrip("/") @@ -193,7 +194,7 @@ app = FastAPI(title="s2s-demo") AUTH_ENABLED = LIMITER_ENABLED and auth.attach(app) logger.info( - "[STARTUP] Other externals — reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s summaryApi=%s loginApi=%s injectProfile=%s debugPrompt=%s auth=%s", + "[STARTUP] Other externals — reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s summaryApi=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s auth=%s", S2S_REASONING_TYPE, NODE_RED_REASONING_URL or "-", bool(SERPER_KEY), @@ -206,6 +207,7 @@ logger.info( bool(S2S_LOGIN_BY_PHONE), S2S_INJECT_PROFILE, S2S_DEBUG_PROMPT, + S2S_START_SESSION_MODE or "-", AUTH_ENABLED, ) logger.debug( @@ -423,6 +425,10 @@ REPORTS_LIST_HINT = ( LATEST_SUMMARY_HINT = ( " Latest conversation summary for this caller (most recent before the current session): " ) +PODCAST_EPISODE_HINT = ( + " Podcast episode context loaded from the page episode_id query param (current episode the user is referencing). " + "Treat the episode fields (title, description, transcript, script, show notes, guests, audio metadata, etc.) as the PRIMARY trusted reference material for this session. " +) def _resolve_user_context_text(phone_number: str, profile_obj: dict | None, loaded: bool = True) -> str: @@ -468,6 +474,38 @@ def _resolve_latest_summary_context(summary_value: str | dict | None) -> str: return f"{LATEST_SUMMARY_HINT}<<>>\n{trimmed}\n<<>>. Use this as trusted history when the user references prior calls." +def _resolve_episode_context(raw_value: object) -> str: + """Normalize the podcast episode API response into a prompt block injected + at the TOP of the contexts (most specific / current reference). + + Accepts a dict (or {"result": {...}} wrapped) with any arbitrary episode + fields (title, description, transcript, script, showNotes, guests, + publishedAt, audioUrl, duration, author, etc.). Capped at 24000 chars + (episodes can have long transcripts). Wrapped with + <<>> markers. + Returns "" if empty / fetch failed / None. + """ + if raw_value is None: + return "" + episode_obj: object = raw_value + if isinstance(raw_value, dict) and isinstance(raw_value.get("result"), dict): + episode_obj = raw_value["result"] + if isinstance(episode_obj, dict) and len(episode_obj) == 0: + return "" + if isinstance(episode_obj, list) and len(episode_obj) == 0: + return "" + try: + serialized = json.dumps(episode_obj, ensure_ascii=False) + except Exception: + serialized = str(episode_obj) + trimmed = serialized.strip() + if not trimmed: + return "" + if len(trimmed) > 24000: + trimmed = trimmed[:24000] + "..." + return f"{PODCAST_EPISODE_HINT}<<>>\n{trimmed}\n<<>>. This is the episode the caller is currently interacting with — answer with reference to it first." + + def _parse_date_key(value: object) -> tuple: """Return a sortable tuple for an arbitrary date-like value. @@ -520,6 +558,7 @@ def _resolve_reports_context(raw_value: object) -> str: Expects an array/list (or dict {"result": [...]} unwrapped by the fetcher). For each dict item keeps only fields: sourceScheduleLabel, date, podcastScript. + Also supports nested shape: sourceSchedule.label / sourceSchedule.date. Sorts by date DESC (newest first) via _parse_date_key. Capped at 12000 chars (podcastScript can be long). Wrapped with <<>> markers. Returns "" if no valid items / empty list. @@ -539,18 +578,88 @@ def _resolve_reports_context(raw_value: object) -> str: if not isinstance(item, dict): continue cleaned: dict = {} - if "sourceScheduleLabel" in item and item["sourceScheduleLabel"] is not None: - cleaned["sourceScheduleLabel"] = item["sourceScheduleLabel"] - if "date" in item and item["date"] is not None: - cleaned["date"] = item["date"] - if "podcastScript" in item and item["podcastScript"] is not None: - cleaned["podcastScript"] = item["podcastScript"] + # Field extraction with fallback to nested sourceSchedule.* shape. + # 1. Label: prefer flat sourceScheduleLabel, else nested sourceSchedule.label + label_val: object = None + if item.get("sourceScheduleLabel") is not None: + label_val = item["sourceScheduleLabel"] + elif isinstance(item.get("sourceSchedule"), dict): + ss = item["sourceSchedule"] + if ss.get("label") is not None: + label_val = ss["label"] + if label_val is None and ss.get("name") is not None: + label_val = ss["name"] + if label_val is None and ss.get("title") is not None: + label_val = ss["title"] + if label_val is not None: + cleaned["sourceScheduleLabel"] = label_val + # 2. Date: prefer flat "date", else nested sourceSchedule.date + date_val: object = None + if item.get("date") is not None: + date_val = item["date"] + elif isinstance(item.get("sourceSchedule"), dict): + ss = item["sourceSchedule"] + if ss.get("date") is not None: + date_val = ss["date"] + if date_val is None and ss.get("scheduledAt") is not None: + date_val = ss["scheduledAt"] + if date_val is None and ss.get("publishedAt") is not None: + date_val = ss["publishedAt"] + if date_val is not None: + cleaned["date"] = date_val + # 3. Script: prefer flat podcastScript, else nested podcastScript / script / text + script_val: object = None + if item.get("podcastScript") is not None: + script_val = item["podcastScript"] + elif item.get("script") is not None: + script_val = item["script"] + elif item.get("text") is not None: + script_val = item["text"] + elif item.get("content") is not None: + script_val = item["content"] + if script_val is not None: + cleaned["podcastScript"] = script_val if cleaned: filtered.append(cleaned) if not filtered: return "" # Sort by date DESC (newest first) filtered.sort(key=lambda r: _parse_date_key(r.get("date"))) + # Build a quick HINT LINE per report (label + date) so the LLM can see at a + # glance which scheduled topics are available before diving into scripts. + hint_lines = [] + import datetime as _dt + for idx, r in enumerate(filtered, start=1): + label_piece = "" + if r.get("sourceScheduleLabel"): + label_piece = str(r["sourceScheduleLabel"]).strip() + date_piece = "" + if r.get("date") is not None: + raw_d = r["date"] + try: + # Integer / float timestamp → render as YYYY-MM-DD HH:MM (local) + if isinstance(raw_d, (int, float)) and not isinstance(raw_d, bool): + ts_sec = float(raw_d) + if ts_sec > 1e12: # ms → seconds + ts_sec = ts_sec / 1000.0 + dt = _dt.datetime.fromtimestamp(ts_sec, tz=_dt.timezone.utc) + date_piece = dt.strftime("%Y-%m-%d %H:%M UTC") + else: + s = str(raw_d).strip() + if "T" in s and len(s) >= 16: + date_piece = s.replace("T", " ")[:16] + else: + date_piece = s + except Exception: + date_piece = str(raw_d)[:32] + if label_piece and date_piece: + hint_lines.append( + f" • Report #{idx}: '{label_piece}' — scheduled on {date_piece}" + ) + elif label_piece: + hint_lines.append(f" • Report #{idx}: '{label_piece}'") + elif date_piece: + hint_lines.append(f" • Report #{idx}: (no label) — scheduled on {date_piece}") # Serialize: compact JSON (pretty-ish but one line per report saves space) entries_lines = [] for idx, r in enumerate(filtered, start=1): @@ -558,14 +667,39 @@ def _resolve_reports_context(raw_value: object) -> str: entries_lines.append(f" [{idx}] {json.dumps(r, ensure_ascii=False)}") except Exception: entries_lines.append(f" [{idx}] {str(r)[:500]}") - body = "\n".join(entries_lines) - wrapped = f"{REPORTS_LIST_HINT}\n<<>>\n{body}\n<<>>. Use these scheduled reports as trusted prior content relevant to the caller's company topics." + hints_block = "" + if hint_lines: + hints_block = "\n".join(hint_lines) + "\n" + body = hints_block + "\n--- Full JSON entries ---\n" + "\n".join(entries_lines) + wrapped = ( + f"{REPORTS_LIST_HINT}\n" + "Quick hints (available scheduled reports / podcasts by label):\n" + f"{body}\n" + f"<<>>. Use these scheduled reports as trusted prior " + "content relevant to the caller's company topics. Reference individual " + "reports by their label when answering." + ) + # Build wrapped with HINT BLOCK before the JSON. We prepend a summary line per + # report so the LLM has a quick table-of-contents of what's available. if len(wrapped) > 12000: # Truncate body preserving markers + hint - budget = 12000 - len(REPORTS_LIST_HINT) - len("\n<<>>\n") - len("\n<<>>...truncated.") + budget = ( + 12000 + - len(REPORTS_LIST_HINT) + - len("\nQuick hints (available scheduled reports / podcasts by label):\n") + - len("\n--- Full JSON entries ---\n") + - len("\n<<>>...truncated.") + - 200 # reserve for wrap sentence + ) if budget > 0: body = body[:budget] + "..." - wrapped = f"{REPORTS_LIST_HINT}\n<<>>\n{body}\n<<>>...truncated (cap 12000 chars). Use these scheduled reports as trusted prior content relevant to the caller's company topics." + wrapped = ( + f"{REPORTS_LIST_HINT}\n" + "Quick hints (available scheduled reports / podcasts by label):\n" + f"{body}\n" + f"<<>>...truncated (cap 12000 chars). Use these " + "scheduled reports as trusted prior content. Reference reports by label when relevant." + ) return wrapped @@ -875,6 +1009,77 @@ async def _fetch_latest_summary_for_phone(phone_number: str, auth_token: str | N return payload +async def _fetch_podcast_episode(episode_id: str) -> dict | str | None: + """Fetch a single podcast episode by ID from the Aquachile AzurianLab API. + + Completely generic / deploy-agnostic: plain GET + https://aquachile.azurianlab.com/api/v1/podcast/episodes/ + Accepts JSON (optional {"result": …} wrapper) or plain text. Never fails + the proxy / reasoning; returns None on any error and logs it. + """ + if not episode_id: + return None + target_url = f"https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}" + logger.info( + "[INJECT-EPISODE] fetch upstream URL=%s episodeId=%.80s", + target_url, + episode_id, + ) + try: + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http: + resp = await http.get(target_url) + if resp.status_code != 200: + logger.warning( + "[INJECT-EPISODE] ⛔ upstream HTTP %s episodeId=%.80s response=%.200s", + resp.status_code, episode_id, resp.text, + ) + return None + text = (resp.text or "").strip() + if not text: + return "" + try: + payload = resp.json() + except Exception: + logger.info( + "[INJECT-EPISODE] upstream returned non-JSON response (len=%d); treating as plain string.", + len(text), + ) + return text + except Exception as exc: + logger.warning( + "[INJECT-EPISODE] ⛔ fetch unreachable episodeId=%.80s error=%r", + episode_id, exc, + ) + return None + if isinstance(payload, dict) and "result" in payload: + payload = payload["result"] + empty = ( + payload is None + or (isinstance(payload, str) and not payload.strip()) + or (isinstance(payload, dict) and len(payload) == 0) + or (isinstance(payload, list) and len(payload) == 0) + ) + if empty: + logger.info("[INJECT-EPISODE] ⏭️ upstream returned empty episode; nothing to inject.") + return None + if isinstance(payload, dict): + logger.info( + "[INJECT-EPISODE] ✅ episode loaded OK (object) keys=%s", + ",".join(sorted(str(k) for k in payload.keys())), + ) + elif isinstance(payload, list): + logger.info( + "[INJECT-EPISODE] ✅ episode loaded OK (list) len=%d", + len(payload), + ) + else: + logger.info( + "[INJECT-EPISODE] ✅ episode loaded OK (string) len=%d", + len(str(payload)), + ) + return payload + + async def _fetch_user_profile_for_phone(phone_number: str, auth_token: str | None = None) -> dict | None: """Fetch the profile JSON from VOCERO_USER_PHONE_AUTH_URL for a phone. Returns the raw {"result": ...}-unwrapped object or None. Errors are logged @@ -931,22 +1136,24 @@ async def _fetch_user_profile_for_phone(phone_number: str, auth_token: str | Non return profile_obj -def _augment_instructions_with_profile(base_instructions: str, user_context_text: str, reports_context_text: str | None = None, summary_context_text: str | None = None) -> str: - """When S2S_INJECT_PROFILE is true: appends the profile + (optional) reports list +def _augment_instructions_with_profile(base_instructions: str, user_context_text: str, reports_context_text: str | None = None, summary_context_text: str | None = None, episode_context_text: str | None = None) -> str: + """When S2S_INJECT_PROFILE is true: appends the episode + profile + (optional) reports list + (optional) latest summary context strings to the LLM system/instructions prompt. - Append order (most generic → most specific / chronologically oldest → newest): - 1. base instructions (primary prompt / user settings / default) - 2. profile_ctx (caller identity block) - 3. reports_ctx (scheduled reports / podcast scripts list, sorted by date DESC) - 4. summary_ctx (latest conversation summary) + Append order (most specific → most generic / chronologically newest → oldest): + 1. episode_ctx (CURRENT podcast episode the user is interacting with) + 2. base instructions (primary prompt / user settings / default) + 3. profile_ctx (caller identity block) + 4. reports_ctx (scheduled reports / podcast scripts list, sorted by date DESC) + 5. summary_ctx (latest conversation summary) Preserves the existing instructions text. """ base = (base_instructions or "").strip() + episode_ctx = (episode_context_text or "").strip() profile_ctx = (user_context_text or "").strip() reports_ctx = (reports_context_text or "").strip() summary_ctx = (summary_context_text or "").strip() - contexts = [c for c in [profile_ctx, reports_ctx, summary_ctx] if c] + contexts = [c for c in [episode_ctx, profile_ctx, reports_ctx, summary_ctx] if c] if not contexts: return base_instructions or "" augmented = base @@ -954,10 +1161,15 @@ def _augment_instructions_with_profile(base_instructions: str, user_context_text augmented = (augmented + " " + ctx).strip() if augmented else ctx changed = augmented != base logger.info( - "[INJECT-PROFILE] ✏️ appended profile/reports/summary to LLM instructions. " - "baseLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d newLen=%d changed=%s", - len(base), len(profile_ctx), len(reports_ctx), len(summary_ctx), len(augmented), changed, + "[INJECT-PROFILE] ✏️ appended episode/profile/reports/summary to LLM instructions. " + "baseLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d newLen=%d changed=%s", + len(base), len(episode_ctx), len(profile_ctx), len(reports_ctx), len(summary_ctx), len(augmented), changed, ) + if S2S_DEBUG_PROMPT and episode_ctx: + logger.info( + "[DEBUG-EPISODE][instructions augment] FULL podcast episode block (len=%d):\n%s\n", + len(episode_ctx), episode_ctx, + ) if S2S_DEBUG_PROMPT and reports_ctx: logger.info( "[DEBUG-REPORTS][instructions augment] FULL reports list block (len=%d):\n%s\n", @@ -971,13 +1183,13 @@ def _augment_instructions_with_profile(base_instructions: str, user_context_text return augmented -def _patch_client_session_update_with_profile(raw_text: str, user_context_text: str, reports_context_text: str | None = None, summary_context_text: str | None = None) -> str: +def _patch_client_session_update_with_profile(raw_text: str, user_context_text: str, reports_context_text: str | None = None, summary_context_text: str | None = None, episode_context_text: str | None = None) -> str: """If raw_text is a client session.update JSON and S2S_INJECT_PROFILE is - true, append user_context_text + (optional) reports_context_text + + true, append episode_context_text + user_context_text + (optional) reports_context_text + (optional) summary_context_text to session.instructions. Returns the modified JSON string, or the original string unchanged if: - it is not session.update JSON (the VAST majority of frames -> silent passthrough, NO LOG) - - user_context_text AND reports_context_text AND summary_context_text are all empty + - episode_context_text AND user_context_text AND reports_context_text AND summary_context_text are all empty - S2S_INJECT_PROFILE is False - parse fails """ @@ -1001,22 +1213,28 @@ def _patch_client_session_update_with_profile(raw_text: str, user_context_text: "WS stays pure passthrough." ) return raw_text + has_episode = bool((episode_context_text or "").strip()) has_profile = bool((user_context_text or "").strip()) has_reports = bool((reports_context_text or "").strip()) has_summary = bool((summary_context_text or "").strip()) - if not (has_profile or has_reports or has_summary): + if not (has_episode or has_profile or has_reports or has_summary): logger.info( - "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (no profile context, no reports list, no latest summary). " - "Probably phoneNumber query param missing / fetch failed upstream / " + "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (no episode, no profile context, no reports list, no latest summary). " + "Probably phoneNumber/episode_id query param missing / fetch failed upstream / " "endpoints or S2S_LATEST_SUMMARY / S2S_REPORT_LIST returned empty result." ) return raw_text - if has_summary and not has_profile: + if has_episode and not has_profile: + logger.info( + "[INJECT-EPISODE] profile missing, but PODCAST EPISODE block loaded. " + "Will inject episode alone into session.update." + ) + if has_summary and not has_profile and not has_episode: logger.info( "[INJECT-SUMMARY] profile missing, but LATEST SUMMARY block loaded. " "Will inject summary alone into session.update." ) - if has_reports and not has_profile: + if has_reports and not has_profile and not has_episode: logger.info( "[INJECT-REPORTS] profile missing, but REPORTS LIST block loaded. " "Will inject reports alone into session.update." @@ -1030,7 +1248,7 @@ def _patch_client_session_update_with_profile(raw_text: str, user_context_text: else "" ) augmented = _augment_instructions_with_profile( - current_instructions, user_context_text, reports_context_text, summary_context_text + current_instructions, user_context_text, reports_context_text, summary_context_text, episode_context_text ) if S2S_DEBUG_PROMPT: logger.info( @@ -1040,7 +1258,7 @@ def _patch_client_session_update_with_profile(raw_text: str, user_context_text: if augmented == current_instructions: logger.info( "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (augmented == current; " - "profile already injected or context empty)" + "episode/profile already injected or context empty)" ) if S2S_DEBUG_PROMPT and augmented.strip(): logger.info( @@ -1051,17 +1269,22 @@ def _patch_client_session_update_with_profile(raw_text: str, user_context_text: top_session["instructions"] = augmented logger.info( "[INJECT-PROFILE] ✅ WS session.update PATCHED -> upstream instructions. " - "origLen=%d newLen=%d injectedLen=%d profile=%s reports=%s summary=%s", + "origLen=%d newLen=%d injectedLen=%d episode=%s profile=%s reports=%s summary=%s", len(current_instructions), len(augmented), (len(augmented) - len(current_instructions)), - has_profile, has_reports, has_summary, + has_episode, has_profile, has_reports, has_summary, ) if S2S_DEBUG_PROMPT: logger.info( "[DEBUG-PROMPT][WS session.update] AFTER patch (len=%d):\n%s\n", len(augmented), augmented, ) + if S2S_DEBUG_PROMPT and episode_context_text and episode_context_text.strip(): + logger.info( + "[DEBUG-EPISODE][WS session.update] FULL podcast episode block (len=%d):\n%s\n", + len(episode_context_text.strip()), episode_context_text.strip(), + ) if S2S_DEBUG_PROMPT and reports_context_text and reports_context_text.strip(): logger.info( "[DEBUG-REPORTS][WS session.update] FULL reports list block (len=%d):\n%s\n", @@ -1096,6 +1319,10 @@ class ReasoningRequest(BaseModel): # engine (NODE_RED or OPENAI); # 2. appends the profile context to the LLM instructions prompt. phoneNumber: str | None = None + # Optional podcast episode ID. When set the proxy fetches the episode from + # the Aquachile AzurianLab API and injects it as the PRIMARY context into + # both user_context (prepended) and LLM instructions prompt (appended first). + episode_id: str | None = None @@ -1163,11 +1390,13 @@ async def config(request: Request): "auth": AUTH_ENABLED, "injectProfile": S2S_INJECT_PROFILE, "debugPrompt": S2S_DEBUG_PROMPT, + "startSessionMode": S2S_START_SESSION_MODE, } logger.debug( - "[INTERNAL] /api/config returned promptLen=%d reasoning=%s", + "[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s", len(primary_prompt), S2S_REASONING_TYPE, + S2S_START_SESSION_MODE or "-", ) return out @@ -1183,13 +1412,15 @@ async def reasoning(req: ReasoningRequest): if not transcript: raise HTTPException(status_code=400, detail="transcript is required.") - # ── S2S_INJECT_PROFILE: resolve caller profile + reports list + latest summary ── + # ── S2S_INJECT_PROFILE: resolve podcast episode + caller profile + reports list + latest summary ── # (When S2S_INJECT_PROFILE=false this entire block is a no-op; user_context # and instructions are taken verbatim from the request body.) + inject_episode_text = "" inject_profile_text = "" inject_reports_text = "" inject_summary_text = "" phone = (req.phoneNumber or "").strip() + episode_id = (req.episode_id or "").strip() if not S2S_INJECT_PROFILE: logger.info( "[INJECT-PROFILE] ⏭️ REASONING: skipped (S2S_INJECT_PROFILE=false). " @@ -1200,20 +1431,23 @@ async def reasoning(req: ReasoningRequest): needs_reports = bool(S2S_REPORT_LIST.strip()) needs_summary = bool(S2S_LATEST_SUMMARY.strip()) needs_login = bool(S2S_LOGIN_BY_PHONE.strip()) - can_profile_ok = needs_phone_authed or needs_reports or needs_summary or needs_login - if (not needs_phone_authed) and (not needs_reports) and (not needs_summary) and (not needs_login): + can_inject = ( + needs_phone_authed or needs_reports or needs_summary or needs_login + or bool(episode_id) + ) + if (not needs_phone_authed) and (not needs_reports) and (not needs_summary) and (not needs_login) and (not episode_id): logger.info( - "[INJECT-PROFILE] ⏭️ REASONING: skipped (VOCERO_USER_PHONE_AUTH_URL, S2S_REPORT_LIST, S2S_LATEST_SUMMARY and S2S_LOGIN_BY_PHONE all empty). " - "Set at least one to enable login / fetch profile/reports/summary for REASONING endpoint." + "[INJECT-PROFILE] ⏭️ REASONING: skipped (VOCERO_USER_PHONE_AUTH_URL, S2S_REPORT_LIST, S2S_LATEST_SUMMARY and S2S_LOGIN_BY_PHONE all empty, AND no episode_id in request). " + "Set at least one to enable login / fetch episode/profile/reports/summary for REASONING endpoint." ) - elif not phone: + elif (not phone) and (not needs_reports) and (not episode_id): logger.info( - "[INJECT-PROFILE] ⏭️ REASONING: skipped (phoneNumber missing from request body). " - "Browser-side issue: check main.js passes voceroUserPhoneNumber in JSON body. (Note: S2S_REPORT_LIST fetch works even without phone.)" + "[INJECT-PROFILE] ⏭️ REASONING: skipped (phoneNumber missing from request body AND no episode_id). " + "Browser-side issue: check main.js passes voceroUserPhoneNumber and/or episode_id in JSON body. (Note: S2S_REPORT_LIST fetch works even without phone.)" ) - elif not can_profile_ok: + elif not can_inject: logger.info( - "[INJECT-PROFILE] ⏭️ REASONING: skipped (env for phone empty and no reports/summary/login API)." + "[INJECT-PROFILE] ⏭️ REASONING: skipped (env for phone empty and no reports/summary/login API and no episode_id)." ) else: # ################################################################### @@ -1224,6 +1458,26 @@ async def reasoning(req: ReasoningRequest): if S2S_LOGIN_BY_PHONE and phone: login_token, _login_resp = await _login_by_phone(phone) # ################################################################### + # 0.5) Podcast episode fetch (independent of phone — uses req.episode_id) + # ################################################################### + if episode_id: + if S2S_DEBUG_PROMPT: + logger.info( + "[INJECT-EPISODE] starting REASONING fetch podcast episode episodeId=%.80s...", + episode_id, + ) + episode_raw = await _fetch_podcast_episode(episode_id) + inject_episode_text = _resolve_episode_context(episode_raw) + if inject_episode_text.strip(): + logger.info( + "[INJECT-EPISODE] ✅ REASONING: PODCAST EPISODE READY episodeId=%.80s wrappedLen=%d -> augmenting instructions + user_context before sending upstream.", + episode_id, len(inject_episode_text), + ) + else: + logger.info( + "[INJECT-EPISODE] ⏭️ REASONING: episode skipped (context empty / upstream returned empty / fetch failed)." + ) + # ################################################################### # 1) Profile fetch (optional — only if env is configured) # ################################################################### if needs_phone_authed and phone: @@ -1280,6 +1534,8 @@ async def reasoning(req: ReasoningRequest): "[INJECT-SUMMARY] ⏭️ REASONING: latest summary skipped (context empty / upstream returned empty summary / fetch failed.)" ) user_context_parts: list[str] = [] + if inject_episode_text: + user_context_parts.append(inject_episode_text) if inject_profile_text: user_context_parts.append(inject_profile_text) if inject_reports_text: @@ -1291,17 +1547,23 @@ async def reasoning(req: ReasoningRequest): user_context_parts.append(original_user_context) effective_user_context = "\n\n".join(user_context_parts) if S2S_INJECT_PROFILE: - any_injected = bool(inject_profile_text or inject_reports_text or inject_summary_text) + any_injected = bool(inject_episode_text or inject_profile_text or inject_reports_text or inject_summary_text) if any_injected: logger.info( - "[INJECT-PROFILE] REASONING user_context final parts=%d serverProfileLen=%d serverReportsLen=%d serverSummaryLen=%d browserUserCtxLen=%d joinedLen=%d", + "[INJECT-PROFILE] REASONING user_context final parts=%d serverEpisodeLen=%d serverProfileLen=%d serverReportsLen=%d serverSummaryLen=%d browserUserCtxLen=%d joinedLen=%d", len(user_context_parts), + len(inject_episode_text), len(inject_profile_text), len(inject_reports_text), len(inject_summary_text), len(original_user_context), len(effective_user_context), ) + if S2S_DEBUG_PROMPT and inject_episode_text.strip(): + logger.info( + "[DEBUG-EPISODE][REASONING augment] FULL podcast episode block (len=%d):\n%s\n", + len(inject_episode_text), inject_episode_text, + ) if S2S_DEBUG_PROMPT and inject_reports_text.strip(): logger.info( "[DEBUG-REPORTS][REASONING augment] FULL reports list block (len=%d):\n%s\n", @@ -1313,7 +1575,7 @@ async def reasoning(req: ReasoningRequest): len(inject_summary_text), inject_summary_text, ) effective_instructions = _augment_instructions_with_profile( - req.instructions or "", inject_profile_text, inject_reports_text, inject_summary_text + req.instructions or "", inject_profile_text, inject_reports_text, inject_summary_text, inject_episode_text ) else: effective_instructions = (req.instructions or "").strip() @@ -1333,8 +1595,8 @@ async def reasoning(req: ReasoningRequest): detail="S2S_REASONING_NODE_RED_URL is not configured. Set the env var to the Node-RED endpoint (e.g. http://localhost:1881).", ) logger.info( - "[INJECT-PROFILE] REASONING->NODE_RED body fields. instructionsLen=%d userCtxLen=%d phoneLen=%d", - len(effective_instructions), len(effective_user_context), len(phone), + "[INJECT-PROFILE] REASONING->NODE_RED body fields. instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d", + len(effective_instructions), len(effective_user_context), len(phone), len(episode_id), ) upstream_body = { "transcript": transcript, @@ -1441,11 +1703,12 @@ async def reasoning(req: ReasoningRequest): else: system_parts.append("Reply only with the final assistant answer text. Keep the answer natural and concise.") logger.info( - "[INJECT-PROFILE] REASONING->OPENAI system message parts=%d instructionsLen=%d userCtxLen=%d phoneLen=%d", + "[INJECT-PROFILE] REASONING->OPENAI system message parts=%d instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d", len(system_parts), len(effective_instructions), len(effective_user_context), len(phone), + len(episode_id), ) system_msg = "\n\n".join(system_parts) body = { @@ -1563,114 +1826,145 @@ async def realtime_proxy(ws: WebSocket): upstream_url = _proxy_upstream_url(ws) subprotocols = _subprotocols(ws) - # ── S2S_INJECT_PROFILE: resolve caller profile + latest summary ── - # Query params (browser URL carries ?phoneNumber=...) are forwarded by the - # client WS library: we read ?phoneNumber= from the incoming WS query. + # ── S2S_INJECT_PROFILE: resolve caller profile + podcast episode + latest summary ── + # Query params (browser URL carries ?phoneNumber=...&episode_id=...) are forwarded by the + # client WS library: we read ?phoneNumber= and ?episode_id= from the incoming WS query. # When S2S_INJECT_PROFILE=false or envs missing -> ctx fields stay empty # and the tunnel stays 100% pure passthrough (original behaviour). + injected_episode_context = "" injected_profile_context = "" injected_reports_context = "" injected_summary_context = "" inject_phone = "" + inject_episode_id = "" login_token: str | None = None login_response_obj: dict | None = None if not S2S_INJECT_PROFILE: logger.info( "[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (S2S_INJECT_PROFILE=false). " - "No profile fetch; tunnel stays pure passthrough." + "No profile/episode fetch; tunnel stays pure passthrough." ) else: needs_phone_authed = bool(VOCERO_USER_PHONE_AUTH_URL.strip()) needs_reports = bool(S2S_REPORT_LIST.strip()) needs_summary = bool(S2S_LATEST_SUMMARY.strip()) needs_login = bool(S2S_LOGIN_BY_PHONE.strip()) - can_inject = needs_phone_authed or needs_reports or needs_summary or needs_login + client_query = dict(parse_qsl(ws.url.query, keep_blank_values=True)) + inject_phone = (client_query.get("phoneNumber") or "").strip() + inject_episode_id = (client_query.get("episode_id") or "").strip() + can_inject = ( + needs_phone_authed or needs_reports or needs_summary or needs_login + or bool(inject_episode_id) + ) if not can_inject: logger.info( - "[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (VOCERO_USER_PHONE_AUTH_URL, S2S_REPORT_LIST, S2S_LATEST_SUMMARY and S2S_LOGIN_BY_PHONE all empty). " - "Set at least one with S2S_INJECT_PROFILE=true to enable caller context / reports / login." + "[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (VOCERO_USER_PHONE_AUTH_URL, S2S_REPORT_LIST, S2S_LATEST_SUMMARY and S2S_LOGIN_BY_PHONE all empty, AND no episode_id in query). " + "Set at least one with S2S_INJECT_PROFILE=true to enable caller context / reports / login / episode." + ) + elif (not inject_phone) and (not needs_reports) and (not inject_episode_id): + # reports list can be fetched without phone (generic list); episode can be fetched without phone + logger.info( + "[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (incoming WS query has NO ?phoneNumber= AND no ?episode_id=, AND S2S_REPORT_LIST not set). " + "Make sure the browser opens the WS with the phone or episode_id query param." ) else: - client_query = dict(parse_qsl(ws.url.query, keep_blank_values=True)) - inject_phone = (client_query.get("phoneNumber") or "").strip() - if (not inject_phone) and (not needs_reports): - # reports list can be fetched without phone (generic list) - logger.info( - "[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (incoming WS query has NO ?phoneNumber=). " - "Make sure the browser opens the WS with the phone query param." + # 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set). + # Obtains a bearer token used on subsequent profile/reports/summary fetches. + if needs_login and inject_phone: + login_token, login_response_obj = await _login_by_phone(inject_phone) + # 0.5) Podcast episode fetch (independent of phone — uses only episode_id from query) + if inject_episode_id: + if S2S_DEBUG_PROMPT: + logger.info( + "[INJECT-EPISODE] WS connect: starting podcast episode fetch episodeId=%.80s...", + inject_episode_id, + ) + episode_raw = await _fetch_podcast_episode(inject_episode_id) + injected_episode_context = _resolve_episode_context(episode_raw) + if injected_episode_context.strip(): + logger.info( + "[INJECT-EPISODE] ✅ WS connect: PODCAST EPISODE READY episodeId=%.80s wrappedLen=%d -> will patch session.update frames in this tunnel.", + inject_episode_id, len(injected_episode_context), + ) + else: + logger.info( + "[INJECT-EPISODE] ⏭️ WS connect: episode skipped (context empty / upstream returned empty / fetch failed)." + ) + # 1) Profile fetch (optional — only if env is set) + if needs_phone_authed and inject_phone: + profile_obj = await _fetch_user_profile_for_phone(inject_phone, auth_token=login_token) + injected_profile_context = _resolve_user_context_text( + inject_phone, profile_obj, loaded=True ) - else: - # 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set). - # Obtains a bearer token used on subsequent profile/reports/summary fetches. - if needs_login and inject_phone: - login_token, login_response_obj = await _login_by_phone(inject_phone) - # 1) Profile fetch (optional — only if env is set) - if needs_phone_authed and inject_phone: - profile_obj = await _fetch_user_profile_for_phone(inject_phone, auth_token=login_token) - injected_profile_context = _resolve_user_context_text( - inject_phone, profile_obj, loaded=True + if injected_profile_context.strip(): + logger.info( + "[INJECT-PROFILE] ✅ WS connect: profile READY phoneLen=%d contextLen=%d -> will patch all client session.update frames in this tunnel.", + len(inject_phone), len(injected_profile_context), ) - if injected_profile_context.strip(): - logger.info( - "[INJECT-PROFILE] ✅ WS connect: profile READY phoneLen=%d contextLen=%d -> will patch all client session.update frames in this tunnel.", - len(inject_phone), len(injected_profile_context), - ) - else: - logger.info( - "[INJECT-PROFILE] ⏭️ WS connect: profile fetch OK but resolved empty. Tunnel stays pure passthrough for profile." - ) - # 1.5) Reports list fetch (optional — only if env is set) - if needs_reports: - if S2S_DEBUG_PROMPT: - logger.info( - "[INJECT-REPORTS] WS connect: starting S2S_REPORT_LIST fetch (phoneLen=%s bearer=%s)...", - len(inject_phone) if inject_phone else "none", - "yes" if login_token else "no", - ) - reports_raw = await _fetch_reports_list( - phone_number=inject_phone if inject_phone else None, - auth_token=login_token, + else: + logger.info( + "[INJECT-PROFILE] ⏭️ WS connect: profile fetch OK but resolved empty. Tunnel stays pure passthrough for profile." + ) + # 1.5) Reports list fetch (optional — only if env is set) + if needs_reports: + if S2S_DEBUG_PROMPT: + logger.info( + "[INJECT-REPORTS] WS connect: starting S2S_REPORT_LIST fetch (phoneLen=%s bearer=%s)...", + len(inject_phone) if inject_phone else "none", + "yes" if login_token else "no", + ) + reports_raw = await _fetch_reports_list( + phone_number=inject_phone if inject_phone else None, + auth_token=login_token, + ) + injected_reports_context = _resolve_reports_context(reports_raw) + if injected_reports_context.strip(): + logger.info( + "[INJECT-REPORTS] ✅ WS connect: reports list READY wrappedLen=%d -> will patch session.update frames in this tunnel.", + len(injected_reports_context), + ) + else: + logger.info( + "[INJECT-REPORTS] ⏭️ WS connect: reports list skipped (context empty / upstream returned empty list / fetch failed)." + ) + # 2) Latest summary fetch (optional — only if env is set) + if needs_summary and inject_phone: + if S2S_DEBUG_PROMPT: + logger.info( + "[INJECT-SUMMARY] WS connect: starting S2S_LATEST_SUMMARY fetch for phoneLen=%d...", + len(inject_phone), + ) + latest_summary_raw = await _fetch_latest_summary_for_phone(inject_phone, auth_token=login_token) + injected_summary_context = _resolve_latest_summary_context(latest_summary_raw) + if injected_summary_context.strip(): + logger.info( + "[INJECT-SUMMARY] ✅ WS connect: latest SUMMARY READY phoneLen=%d wrappedLen=%d -> will patch session.update frames in this tunnel.", + len(inject_phone), len(injected_summary_context), + ) + else: + logger.info( + "[INJECT-SUMMARY] ⏭️ WS connect: latest summary skipped (context empty / upstream returned empty summary / fetch failed)." ) - injected_reports_context = _resolve_reports_context(reports_raw) - if injected_reports_context.strip(): - logger.info( - "[INJECT-REPORTS] ✅ WS connect: reports list READY wrappedLen=%d -> will patch session.update frames in this tunnel.", - len(injected_reports_context), - ) - else: - logger.info( - "[INJECT-REPORTS] ⏭️ WS connect: reports list skipped (context empty / upstream returned empty list / fetch failed)." - ) - # 2) Latest summary fetch (optional — only if env is set) - if needs_summary and inject_phone: - if S2S_DEBUG_PROMPT: - logger.info( - "[INJECT-SUMMARY] WS connect: starting S2S_LATEST_SUMMARY fetch for phoneLen=%d...", - len(inject_phone), - ) - latest_summary_raw = await _fetch_latest_summary_for_phone(inject_phone, auth_token=login_token) - injected_summary_context = _resolve_latest_summary_context(latest_summary_raw) - if injected_summary_context.strip(): - logger.info( - "[INJECT-SUMMARY] ✅ WS connect: latest SUMMARY READY phoneLen=%d wrappedLen=%d -> will patch session.update frames in this tunnel.", - len(inject_phone), len(injected_summary_context), - ) - else: - logger.info( - "[INJECT-SUMMARY] ⏭️ WS connect: latest summary skipped (context empty / upstream returned empty summary / fetch failed)." - ) logger.info( - "[INJECT-PROFILE] WS connect FINAL: injectProfile=%s injectReports=%s injectSummary=%s login=%s phoneLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d target=%s", + "[INJECT-PROFILE] WS connect FINAL: injectEpisode=%s injectProfile=%s injectReports=%s injectSummary=%s login=%s episodeIdLen=%d phoneLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d target=%s", + bool(injected_episode_context), bool(injected_profile_context), bool(injected_reports_context), bool(injected_summary_context), ("token-present" if login_token else "skipped"), + len(inject_episode_id), len(inject_phone), + len(injected_episode_context), len(injected_profile_context), len(injected_reports_context), len(injected_summary_context), upstream_url, ) + if S2S_DEBUG_PROMPT and injected_episode_context.strip(): + logger.info( + "[DEBUG-EPISODE][WS connect] FULL podcast episode block loaded (len=%d):\n%s\n", + len(injected_episode_context), injected_episode_context, + ) if S2S_DEBUG_PROMPT and injected_reports_context.strip(): logger.info( "[DEBUG-REPORTS][WS connect] FULL reports list block loaded (len=%d):\n%s\n", @@ -1692,10 +1986,12 @@ async def realtime_proxy(ws: WebSocket): session_ctx = { "last_user_transcript": "", "last_item_id": None, + "injected_episode_context": injected_episode_context, "injected_profile_context": injected_profile_context, "injected_reports_context": injected_reports_context, "injected_summary_context": injected_summary_context, "inject_phone": inject_phone, + "inject_episode_id": inject_episode_id, "login_token": login_token, "login_response": login_response_obj, } @@ -1796,6 +2092,167 @@ async def user_by_phone(phoneNumber: str): return JSONResponse(status_code=resp.status_code if resp.status_code != 204 else 200, content=payload) +@app.get("/api/user/reports") +async def user_reports(phoneNumber: str | None = None): + """Fetch + normalize the scheduled reports list for the caller UI. + + Mirrors the same sequence used in the WebSocket connect handler and + /api/reasoning: + 0. login-by-phone (optional) → bearer token + 1. GET S2S_REPORT_LIST with ?phoneNumber= (if phone provided) + Bearer auth + 2. run the same resolver used for prompt injection: filter 3 fields, + sort DESC, build hints + entries → JSON returned verbatim so the + browser can render a "reports loaded" summary in the UI header. + + Returns 503 when S2S_REPORT_LIST is not configured (generic deploy with no + reports feature enabled) so the browser silently skips UI rendering. + Response shape on success: + { + "count": N, + "wrappedLen": X, + "hints": [ "• Report #1: '