Files
vocero-client/s2s-client/ARCHITECTURE.md
T

189 lines
14 KiB
Markdown

# Demo S2S — schema di funzionamento
> Documento orientativo del modulo `demo/` (UI + proxy S2S + reasoning). Non sostituisce
> il codice: serve a trovare il proprio posto quando si apre il progetto.
---
## 1. Cosa c'è in `demo/`
Il modulo fa **tre cose contemporaneamente**:
| Ruolo | Cosa fa | Entry point |
|---|---|---|
| **HTTP server statico + API** | Serve l'UI, offre endpoint REST, gira un WebSocket proxy | [server.py](file:///home/duxiter/s2s/demo/server.py) |
| **Voice UI (browser)** | Orba, mic, transcriptioni, audio playback, pannelli | [index.html](file:///home/duxiter/s2s/demo/index.html) + [main.js](file:///home/duxiter/s2s/demo/main.js) |
| **Reasoning hook** | Inoltra transcript utente a un motore di ragionamento esterno (NODE_RED o OpenAI) | [server.py](file:///home/duxiter/s2s/demo/server.py#L658-L834) `/api/reasoning` |
La configurazione (URL backend S2S, tipo di reasoning, chiavi API) viene presa dal file `.env` nella root del progetto.
---
## 2. Architettura in un diagramma
```
┌──────────────────────────────────────────────────┐
│ BROWSER (demo UI) │
│ [index.html] → [main.js] (ESM type=module) │
│ │ ▲ │
│ │ audio + WS events │ transcript chunks │
│ ▼ │ │
│ ┌──────────────────────────────────────────┐ │
│ │ s2s-ws-client.js → WebSocket /v1/realtime │ │
│ │ + codec.js + orb-visualizer.js │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────┬────────────────────────┘
│ WebSocket /v1/realtime
▼
┌───────────────────────────────────────────────┐
│ server.py (FastAPI) │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ realtime_proxy ← TUNNEL → upstream │ │
│ │ (WS relay puro, copia byte-per-byte)│ │
│ │ _client_to_upstream ─┐ │ │
│ │ _upstream_to_client ─┤ session_ctx │ │
│ │ session_ctx{user,id} │ shared dict │ │
│ └───────────────────────┴──────────────┘ │
│ │
│ endpoint REST: │
│ GET / → index.html │
│ GET /api/config → env flags │
│ POST /api/reasoning → NODE_RED / │
│ OPENAI │
│ POST /api/auth/* → Aquachile │
│ │
└──────┬──────────────────────────────┬─────────┘
│ POST /api/reasoning │ fire-and-forget
▼ ▼
┌──────────────────────┐ ┌───────────────────────────────┐
│ Node-RED (port 1881)│ │ Node-RED (port 1881) │
│ POST /api/reasoning │ │ POST /api/log/system-response │
│ log user transcript │ │ log assistant TTS + pairing │
└──────────────────────┘ └───────────────────────────────┘
^
│ forward 4 eventi realtime:
│ audio_transcript.done
│ content_part.done
│ output_item.done role=assistant
│ conversation.item.created role=assistant
```
> ### 📝 Nota importante sul flusso AUDIO con l'attuale `.env`
>
> Con `.env` attuale che ha `S2S_PROXY_TARGET=ws://ai-server:12345/v1/realtime`:
>
> ✅ **Sì, TUTTO l'AUDIO utente (PCM16 16kHz mono, eventi `input_audio_buffer.append`) arriva a `ws://ai-server:12345/v1/realtime`** — ma **non direttamente**: passa SEMPRE per il **tunnel 1:1 WebSocket di `server.py`** (`_client_to_upstream`), che copia byte-per-byte senza modifiche.
>
> ✅ Allo stesso modo l'AUDIO risposta (TTS, `response.output_audio.delta`) ritorna da `ai-server:12345` → tunnel `_upstream_to_client` → browser → playback nativo.
>
> ❌ **Cosa invece NON va a `ai-server:12345`** (canale HTTP separato parallelo, non tocca l'audio):
> - Il **transcript STT** viene mandato via HTTP a `http://localhost:1881/api/reasoning` (Node-RED, hook LOG_ONLY oggi)
> - Il **testo risposta TTS** viene mandato via HTTP a `http://localhost:1881/api/log/system-response` (Node-RED, forward fire-and-forget)
> - Chiamate **auth / profilo Aquachile** verso `aquachile.azurianlab.com`
>
> Questi 3 flussi REST sono **aggiuntivi** e **non alterano minimamente il tunnel audio**: browser ↔ server.py ↔ `ai-server:12345` rimane il percorso esclusivo per voce e risposta.
---
## 3. Cose importanti di [server.py](file:///home/duxiter/s2s/demo/server.py)
### File → ruoli
- **`/`** → StaticFast `StaticFiles` su `demo/` (index.html + assets)
- **`@app.middleware http` no_cache_static_assets** → `Cache-Control: no-store` per `.js/.html/.css/.mjs/.wasm` per sconfiggere cache browser durante sviluppo
- **`@app.get /api/config`** → manda al browser variabili env (`S2S_REASONING_TYPE`, `S2S_PROXY_TARGET`, auth, features). Il client decida da sé cosa mostrare in UI.
- **`@app.post /api/reasoning` ([L658-L834](file:///home/duxiter/s2s/demo/server.py#L658-L834))** → cuore del reasoning. Due modalità, scelta da env `S2S_REASONING_TYPE`:
- **Modalità A — NODE_RED**: inoltra `POST {NODE_RED_URL}/api/reasoning` timeout 30s. Accetta `log_only=true` + `answer=""` (Fase 1).
- **Modalità B — OPENAI**: chiamata diretta `POST $OPENAI_BASE_URL/chat/completions` (Fasi successive).
- **`@app.websocket /v1/realtime` ([L874-L980](file:///home/duxiter/s2s/demo/server.py#L874-L980))** → **tunnel S2S nativo**. Non traduce messaggi: copia + registra + forwarda. 2 task paralleli:
- `_client_to_upstream(ws, upstream, client_ip, session_ctx)`: browser → AI server
- `_upstream_to_client(ws, upstream, client_ip, session_ctx)`: AI server → browser
- `session_ctx = {last_user_transcript, last_item_id}` **dizionario mutabile condiviso** per accoppiare input↔output
- **`async def _forward_agent_text_to_nodered` ([L837-L873](file:///home/duxiter/s2s/demo/server.py#L837-L873))** → helper fire-and-forget. Tutti errori → `logger.debug` (non spezza il tunnel WS).
- **`_upstream_to_client` hook eventi ([L1305-L1500](file:///home/duxiter/s2s/demo/server.py#L1305-L1500))**: per ogni evento output agente avvia `asyncio.create_task(_forward_agent_text_to_nodered(...))` → non blocca lo streaming audio.
- **Sidecar Python locali**: [auth.py](file:///home/duxiter/s2s/demo/auth.py) (Aquachile profile+user+phone), [limiter.py](file:///home/duxiter/s2s/demo/limiter.py) (rate limiter).
### Ordine di chiamata in un turno vocale
1. Browser clicca orb → `ws /v1/realtime` → `realtime_proxy()` accetta
2. Browser manda `session.update` → `input_audio_buffer.append` in loop
3. Upstream genera `conversation.item.input_audio_transcription.completed` → server cattura → **`session_ctx["last_user_transcript"] = transcript`**
4. `_upstream_to_client` vede transcript STT completo → server invoca in parallelo **`POST /api/reasoning`** con quel transcript
5. Upstream manda `response.output_audio.delta` audio chunks → tunnelati diretti al browser → playback nativo
6. Upstream manda `response.output_audio_transcript.done` → server cattura → `create_task(_forward_agent_text_to_nodered({tts, source_event, user_transcript, …}))` → Node-RED SYSTEM_RESPONSE log
7. Audio finisce → turno chiuso
---
## 4. Cose importanti del lato client ([main.js](file:///home/duxiter/s2s/demo/main.js))
### Moduli ESM importati (tutti con `?v=VERSIONE` per cache-bust)
```
main.js
├─ dom.js → helper DOM (create, qs, bubble, ecc.)
├─ chat.js → pannello messaggi + appendAssistantText
├─ account.js → pannello auth + profilo utente
└─ ws/s2s-ws-client.js
├─ codec.js → codec pcm + base64
└─ orb-visualizer.js → animazione orba su eventi volume/state
```
### Funzioni chiave
| Funzione | Ruolo |
|---|---|
| `fetchOpenAiReasoningAnswer(transcript)` | `POST /api/reasoning`, ritorna `{answer, log_only, raw}` |
| `maybeRunOpenAiReasoning(transcript, itemId)` | guardia "ho già processato questo turn?" (`processedReasoningTurns`). Se `result.log_only === true` → **early return, zero side-effect**. Altrimenti branch standard (Fasi successive). |
| `cancelActiveResponse()` | Fasi successive: ferma risposta corrente prima di inject custom |
| `appendAssistantText(answer)` / `sendUserText(renderMarker)+requestResponse` | Fasi successive: sostituisce testo risposta backend nativo |
| `speechSynthesis.speak(utterance)` | Fasi successive: TTS *browser* per answer custom, invece del backend nativo |
### Regole per non rompere Fase 1
- Se `S2S_REASONING_TYPE=NODE_RED` **e** Node-RED torna `log_only=true` → il client non tocca nulla. Zero `cancelActiveResponse`, zero `speechSynthesis.speak`, zero `conversation.item.create` manuali. La voce è quella del backend S2S nativo.
- **Cache busting triplo**: `index.html` meta tag → `Cache-Control no-store` middleware → query-string `?v=20260825003` su ogni import.
---
## 5. Node-RED (Fase 1 — LOG_ONLY)
Risiede in [`s2s-reasoning-engine/flows.json`](file:///home/duxiter/s2s/s2s-reasoning-engine/flows.json). Espone 2 endpoint completamente indipendenti:
| Endpoint | Payload | Output Node-RED |
|---|---|---|
| `POST /api/reasoning` | `{transcript, language, phoneNumber, …}` | `[S2S NODE_RED LOG_ONLY] ts=… lang=… phone=… transcript="..."` |
| `POST /api/log/system-response` | `{tts, source_event, user_transcript, role, item_id, …}` | `[S2S NODE_RED SYSTEM_RESPONSE] ts=… phone=… user="..." tts="..."` |
Entrambi ritornano 200 `{log_only:true, intent:"..."}`.
---
## 6. Worklets (non toccare)
- [worklets/mic-capture.js](file:///home/duxiter/s2s/demo/worklets/mic-capture.js) — sample-rate input 16kHz PCM mono, calcolo RMS per VAD visuale.
- [worklets/audio-playback.js](file:///home/duxiter/s2s/demo/worklets/audio-playback.js) — riproduzione audio 24kHz server side.
> Nota: i worklets vengono caricati via `AudioWorklet.addModule()`, che usa script-context **non-ESM**. Non aggiungere `export function` a questi file: rompono il caricamento.
---
## 7. Variabili ambiente che comandano tutto
| Nome | Valore tipico oggi | Cosa fa |
|---|---|---|
| `S2S_REASONING_TYPE` | `NODE_RED` (Fase 1) / `OPENAI` (Fasi succ.) / `PROXY` disabilita | Scelta motore ragionamento in `/api/reasoning` |
| `S2S_REASONING_NODE_RED_URL` | `http://localhost:1881` | Endpoint base Node-RED reasoning engine |
| `S2S_PROXY_TARGET` | `ws://ai-server:12345/v1/realtime` | URL upstream tunnel WebSocket |
| `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_REASONING_MODEL` | da .env | Usati in Modalità B OPENAI |
| `S2S_AGENT_PROFILE` / `S2S_USER_PHONE_AUTH_URL` / `S2S_TOOL_EXECUTION_MCP_URL` | `aquachile.azurianlab.com/api/v1/…` | URL provider esterni Aquachile |
| `LOG_LEVEL` | `INFO` / `DEBUG` | Livello uvicorn |
---
## 8. Dove toccare per…
| Obiettivo | File da aprire |
|---|---|
| Cambiare colori tema orb / UI | [style.css](file:///home/duxiter/s2s/demo/style.css) `:root` + [DESIGN.md](file:///home/duxiter/s2s/demo/DESIGN.md) |
| Aggiungere un pannello in UI | [index.html](file:///home/duxiter/s2s/demo/index.html) markup + [main.js](file:///home/duxiter/s2s/demo/main.js) render + nuovo modulo in [ui/](file:///home/duxiter/s2s/demo/ui) |
| Aggiungere un endpoint REST | [server.py](file:///home/duxiter/s2s/demo/server.py), dopo le route esistenti |
| Nuova logica Node-RED Fase 2 | [flows.json](file:///home/duxiter/s2s/s2s-reasoning-engine/flows.json) → Node-RED editor → export |
| Passare da LOG_ONLY a produzione di answer | Flip in flows.json `log_only=false` + riempire `answer`, il branch `result.log_only` del client si spegne automaticamente |
| Debugging SyntaxError browser `Unexpected token 'export'` | 1) Controlla `syntax AST` server.py (se crasha → 500 → browser riceve HTML invece di main.js) 2) Hard reload DevTools ✓ Disable cache 3) `node --check main.js` e import |