feat(workflows): add RAG publishing and startup reconciliation

This commit is contained in:
2026-09-03 06:03:06 -04:00
parent ad95d530ad
commit d58be9613f
27 changed files with 1516 additions and 2487 deletions
+584
View File
@@ -0,0 +1,584 @@
# HOWITWORKS — demo S2S
> Compagno di [ARCHITECTURE.md](file:///home/duxiter/s2s/demo/ARCHITECTURE.md): spiega il
> ciclo di vita concreto dei dati in 3 moduli su cui oggi ci siamo concentrati:
> 1. Il campo `instructions` da client a `ai-server:12345`
> 2. `MCP_SEARCH_TRANSLATION_PHRASES` + ranking tool MCP lato client
> 3. **FASE 2 — Dichiarazione tools nativi a `ai-server:12345` via Realtime `session.update.tools[]`**
---
## 0. FASE 2 — Integrazione tools nativi S2S upstream (ai-server:12345)
### Obiettivo
Prima della Fase 2, i 100+ tool MCP Aquachile erano solo **testo dentro `instructions`** (9 KB di prompt). L'upstream non sapeva eseguirli e produceva frasi tipo:
> *"Perfecto, buscaré… [list_recent_reports]"*
Con la Fase 2 dichiariamo i tools tramite il **protocollo nativo OpenAI Realtime GA**:
```
session.update { tools: [...], tool_choice: "auto" }
```
→ Se `ai-server` lo supporta, produce eventi veri `response.function_call_arguments.done` con nome tool + argomenti JSON. Il listener già esistente nel client (s2s-ws-client.js) si attiva automaticamente e tutto il resto della pipeline — `runTool()` → `POST /api/mcp/tool` Aquachile → `client.sendToolOutput()` — funziona senza altre modifiche.
### 0.1 Dove è stato implementato lato proxy (s2s_ui container)
| Modulo | File / Righe | Cosa fa |
|---|---|---|
| **Cache + normalizzazione** | [server.py:451-544](file:///home/duxiter/s2s/demo/server.py#L451-L544) | `_normalize_tools_for_realtime(raw_tools)`: converte qualsiasi formato tool Aquachile → formato OpenAI Realtime standard. Non alza eccezioni; tool invalidi saltati con warning. |
| **Inject WS upstream** | [server.py:1034-1075](file:///home/duxiter/s2s/demo/server.py#L1034-L1075) | Subito dopo `TUNNEL ESTABLISHED`, **PRIMA** che inizi lo streaming audio: fetch cache `_agent_profile_data()` → `session.update` con `instructions` (profilo agente Aquachile) + `tools[]` + `tool_choice=auto`. Errori suppressi: non rompiamo mai il flusso audio. |
| **Log di successo KEY** | [server.py:312-334](file:///home/duxiter/s2s/demo/server.py#L312-L334) | Quando arriva `response.function_call_arguments.done` dall'upstream → 2 log `[MCP INJECT] ✓ UPSTREAM ha emesso…` (segnale CHE LA STRATEGIA B FUNZIONA) |
| **Proxy MCP già esistente** | [server.py:600-656](file:///home/duxiter/s2s/demo/server.py#L600-L656) | `POST /api/mcp/tool` → Aquachile JSON-RPC 2.0 `tools/call`. Nessuna modifica necessaria. |
### 0.2 Sequenza temporale WS Realtime (Fase 2)
```
1. Browser → ws://s2s_server/v1/realtime (connessione)
2. server.py → websockets.connect(ai-server:12345)
3. [MCP INJECT] server.py → upstream:
{ "type": "session.update",
"session": { // ⚠️ FORMATO CORRETTO — TUTTO dentro session:{}
"instructions": "…system prompt profilo Aquachile…",
"tools": [ {type: "function", name:"list_recent_reports", …}, …79 più… ],
"tool_choice": "auto"
}
}
← LOG "[MCP INJECT] session.update upstream inviato (tools=79, fmt=nested_session OK)"
4. browser → ai-server: (turno audio utente) "el reporte de ayer por favor"
5. (se upstream ha tools[] in RuntimeConfig.session.tools — ORA SUPPORTATO Nativamente)
ai-server → server.py → browser:
{ "type": "response.function_call_arguments.done",
"call_id": "call_abc",
"name": "list_recent_reports",
"arguments": "{\"date_from\":\"2026-08-24\",\"limit\":5}" }
← LOG "[MCP INJECT] ✓ UPSTREAM ha emesso response.function_call_arguments.done!"
6. s2s-ws-client.js:803 → dispatch "toolcall"
7. main.js:3023 → runTool("list_recent_reports", args, "call_abc")
branch agentToolDefs → execRemoteMcpTool
8. execRemoteMcpTool → POST /api/mcp/tool → Aquachile JSON-RPC tools/call
LOG: "[Vocero MCP CALL]" → "[Vocero MCP HTTP]" → "[Vocero MCP OUT]"
9. client.sendToolOutput("call_abc", "Risultati: 5 report, ultimo=Report_2026-08-24.xlsx")
→ WS payload:
{ "type": "conversation.item.create",
"item": { "type": "function_call_output", "call_id": "call_abc", "output": "..." } }
10. ai-server continua il turno con TTS nativo:
"Aquí tienes los 5 reportes de ayer, el más reciente es Report_2026-08-24.xlsx..."
(nessun [tool_name] nel testo del TTS!)
```
### 0.3. Formato atteso da `ai-server:12345` — guida per modifiche sul backend S2S
**Se dopo il deploy non vedi il log `[MCP INJECT] ✓ UPSTREAM ha emesso…`**, vuol dire che `ai-server` oggi scarta il campo `tools[]` dal `session.update`. Ecco **esattamente i campi che devono essere supportati** (copia-incollabile nel codice di `ai-server`):
#### 0.3.1 `session.update` in ingresso (cosa manda oggi `server.py` — FORMATO NESTED CORRETTO)
⚠️ **REGOLA NON NEGOZIABILE**: TUTTI i campi di configurazione (instructions, tools, tool_choice, voice, model) devono stare **DENTRO l'oggetto `session`**. Non mandarli MAI alla radice del payload. Il validatore Pydantic strict di OpenAI Realtime GA rifiuta qualsiasi campo sconosciuto alla root.
```jsonc
{
"type": "session.update",
// ⚠️ TUTTO dentro "session":{} — il validator Pydantic strict è extra=forbid sulla root.
"session": {
// 1) Istruzioni di sistema (profilo agente Aquachile)
"instructions": "María, asistente virtual Aquachile, habla español de Chile…",
// 2) CATALOGO TOOLS — 79 tool Aquachile normalizzati
"tools": [
{
"type": "function",
"name": "list_recent_reports",
"description": "Restituisce l'elenco dei report più recenti, opzionalmente filtrati per data.",
"parameters": {
"type": "object",
"properties": {
"date_from": {"type": "string", "format": "date", "description": "Data inizio YYYY-MM-DD"},
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10}
},
"required": []
}
}
// … altri 78 tool
],
// 3) Strategia selezione tool
"tool_choice": "auto"
}
}
```
**Perché prima non funzionava?** (ROOT CAUSE)
Il proxy stava inviando `instructions`, `tools`, `tool_choice` **ALLA RADICE** del payload, fuori da `session:{}`. Il modello Pydantic `SessionUpdateEvent` (OpenAI Realtime GA) ha `extra=forbid` e ha rifiutato con `Unknown or invalid event: session.update`. **Non era un rifiuto del campo `tools` in quanto tale** — era un rifiuto di qualsiasi campo non riconosciuto messo nel posto sbagliato. La prova: `runtime_config.session.instructions`, `.session.tools`, `.session.tool_choice` in `language_model.py:526-532` leggono TUTTI i campi da dentro `.session.*`.
**Audit sorgente backend `speech_to_speech` conferma: GIA' 100% WIRED PER NATIVI TOOLS**
| Componente | File / Righe | Conferma |
|---|---|---|
| LLM pick tools da sessione | `LLM/language_model.py:526-532` | `tools = runtime_config.session.tools` |
| Chat buffer gestisce FC/FCO | `LLM/chat.py:141-223 / 342-366 / 394-427` | `RealtimeConversationItemFunctionCall` + `FunctionCallOutput` |
| Response handler emette WS done event | `api/openai_realtime/handlers/response.py:336-345` | `ResponseFunctionCallArgumentsDoneEvent` con `call_id, name, arguments(STRINGA), response_id, item_id, output_index` |
| Conversation handler accetta ritorno output | `api/openai_realtime/handlers/conversation.py:30-96` | `handle_conversation_item_create` con `deferred_items` anti-race |
| Fallback parser retrocompatibile | `api/openai_realtime/service.py:267-325` | Se arrivano campi flat alla radice → promossi a nested `session` wrapper |
**Regole convalida obbligatorie lato `ai-server` per evitare rigetti silenziosi**:
| Regola | Valore |
|---|---|
| Nome tool | lunghezza ≤ 64, match regex `^[a-z0-9_]+$` |
| Numero max tools | Consigliato ≤ 80. Oggi il proxy manda 100+ con warning. |
| `parameters.type` | Sempre `"object"` (anche se vuoto) |
| `parameters.properties` | Sempre `{}` presente, anche se 0 proprietà |
| `tool_choice` | Valori accettati: `"none"`, `"auto"`, oppure `{type:"function",name:"…"}` |
| Chiavi ignote | NON fare strict reject. Logga warning e scarta solo la chiave. |
#### 0.3.2 Evento di ritorno richiesto da `ai-server` (quando il modello decide di chiamare un tool)
Quando il modello determina che serve un tool → emetti **3 eventi in ordine ESATTO**:
```
a) { "type": "response.function_call_arguments.started", "call_id": "call_abc123", "name": "list_recent_reports", "response_id":"resp_X", "item_id":"item_Y" }
(opzionale — se non hai streaming arguments puoi anche saltarlo)
b) (opzionale) N × { "type": "response.function_call_arguments.delta", "call_id": "call_abc123", "delta": "{\"dat" }
(solo se streammi il JSON argomenti per pezzi)
c) ✅ EVENTO RICHIESTO OBBLIGATORIO (senza questo il client non esegue il tool):
{
"type": "response.function_call_arguments.done",
"call_id": "call_abc123", // STRINGA OBBLIGATORIA — serve per la sendToolOutput
"name": "list_recent_reports", // STRINGA OBBLIGATORIA — nome del tool (come sopra)
"arguments": "{\"date_from\":\"2026-08-24\",\"limit\":5}",
"response_id": "resp_xyz",
"item_id": "item_zzz",
"output_index": 0
}
```
**⚠️ Importante**: `arguments` DEVE essere una **STRINGA JSON serializzata** (non un oggetto). Il client fa `JSON.parse(event.arguments)`. Se mandi un oggetto JS → parser fallisce e il tool non viene eseguito.
#### 0.3.3 Atteso come input successivo (il ritorno del tool dal client)
Dopo ~500ms-45s, il client manda indietro al S2S upstream:
```jsonc
{
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": "call_abc123",
"output": "5 report trovati. Ultimo: Report_2026-08-24.xlsx - Aquachile ventas"
}
}
→ Poi { "type": "response.create" }
```
→ L'S2S dovrebbe usare `output` come contesto nel prossimo turno di generazione TTS (quindi parla del risultato, senza tag `[tool]`).
### 0.4 Checklist di deploy Fase 2 (2026-08-25 — SORGENTE ai-server TROVATA in locale)
**Prerequisito**: Sorgente backend `ai-server:12345` = `/home/duxiter/s2s/speech_to_speech` (Python puro).
✅ **TEST CURL 2026-08-25 CONFERMATO**: `https://aquachile.azurianlab.com/api/v1/mcp/` è **ATTUALMENTE PUBBLICO** — accetta `tools/list` e `tools/call` SENZA API KEY. Con credenziali dummy `dummy-test-key/secret` restituisce HTTP 200 OUTPUT IDENTICO alla chiamata senza auth. Quindi NON serve valorizzare:
```
S2S_TOOL_EXECUTION_MCP_API_KEY=
S2S_TOOL_EXECUTION_MCP_API_SECRET=
```
⚠️ Nota: Il server accetta entrambi gli auth headers, ma attualmente non li valida. Restano configurati come best practice per eventuali versioni future dell'endpoint che attiveranno auth.
#### Ordine deploy 3 step
| # | Azione | Perché |
|---|---|---|
| 0 | ~~Valorizza `.env` MCP credentials~~ | ✅ **SUPERATO** — endpoint MCP Aquachile pubblico |
| 1 | **Restart container `s2s_ui`** | Deploy fix formato inject `fmt=nested_session` in [server.py:1054-1102](file:///home/duxiter/s2s/demo/server.py#L1054-L1102) |
| 2 | **Riavvia backend `ai-server:12345`** con il codice aggiornato di `/home/duxiter/s2s/speech_to_speech` (in particolare `api/openai_realtime/service.py`) | Deploy fallback parser retrocompatibile flat→nested in [service.py:267-325](file:///home/duxiter/s2s/speech_to_speech/api/openai_realtime/service.py#L267-L325) |
| 3 | **Hard Reload browser** (Ctrl+Shift+R o Disable cache + refresh) | Pulisce cache vecchio client JS |
#### Checklist di verifica DOPO deploy 3+1 step
```bash
# Terminal A
docker logs -f s2s_ui 2>&1 | grep -E 'MCP|Vocero MCP|session.update|function_call_arguments'
```
| Step | Trigger | Log atteso nel container | Esito |
|---|---|---|---|
| 1 | Apri pagina demo (connessione WS) | `[MCP INJECT] session.update upstream → tools=79 hasAgentPrompt=True tool_choice=auto` | ✅ Inject partito |
| 2 | Subito dopo step 1 | `[MCP INJECT] session.update upstream inviato (tools=79, fmt=nested_session OK)`<br>🔴 **NON DEVE** più comparire `[MCP INJECT] suppressed upstream error` — se compare ancora, il deploy di `speech_to_speech service.py` non è riuscito | ✅ Nessun rigetto |
| 3 | Console browser | 0 occorrenze `Unknown or invalid event: session.update` | ✅ Filtro OK |
| 4 | Frase trigger tool: *"¿dónde está el report de ayer?"* | **KEY LOG 🔑**<br>`[MCP INJECT] ✓ UPSTREAM ha emesso response.function_call_arguments.done! tool=list_recent_reports args_keys=[...]` | ✅ **Strategia B FUNZIONA — il modello produce tool calls native** |
| 5 | 0.5-1s dopo Step 4 | `[Vocero MCP CALL] name=list_recent_reports target=https://aquachile...`<br>`[Vocero MCP HTTP] name=list_recent_reports status=200`<br>`[Vocero MCP OUT] name=list_recent_reports preview=...resultKeys=...` | ✅ Tool eseguito realmente via REST Aquachile |
| 6 | 1-2s dopo Step 5 | TTS vocale naturale — es: *"Aquí tienes los 5 reportes de ayer, el más reciente es Report_2026-08-24.xlsx..."* | ✅ Nessun `(list_recent_reports)` / `[tool]` nel testo TTS |
| 7 | Frase non-tool: *"Hola, ¿cómo estás?"* | Risposta standard SENZA tool calls, POST /api/reasoning 200, Node-RED LOG_ONLY OK | ✅ Nessuna regressione Fase 1 |
---
### 0.5 Stato attuale (2026-08-25 11:00) — BLOCCO RISOLTO, pipeline 100% implementata
#### 🎯 Root Causa Reale (99.99% confidence, sorprendente)
**Ipotesi iniziale, SUPERATA**: *"ai-server rifiuta strict il campo `tools` perché non è implementato"*
**Realtà, AUDIT SORGENTE CONFERMATO**: TUTTO il sistema — **backend `speech_to_speech` + proxy `demo/server.py` + client `main.js` / `s2s-ws-client.js`** — è GIA' COMPLETAMENTE IMPLEMENTATO end-to-end per il tool calling nativo OpenAI Realtime GA. **L'unico blocco era un errore di formato payload nel proxy**:
```python
# ❌ SBAGLIATO (come era prima) — campi alla RADICE
_inject_payload = {
"type": "session.update",
"instructions": "...",
"tools": [...],
"tool_choice": "auto"
} # ← Pydantic SessionUpdateEvent extra=forbid → Unknown or invalid event
# ✅ CORRETTO (deployato in server.py:1054-1102) — TUTTO dentro session:{}
_inject_payload = {
"type": "session.update",
"session": {
"instructions": "...",
"tools": [...],
"tool_choice": "auto"
}
} # ← Perfetto, corrisponde a runtime_config.session.tools in LLM
```
**Prova inconfutabile**: [language_model.py:526-532](file:///home/duxiter/s2s/speech_to_speech/LLM/language_model.py#L526-L532) legge istruzioni, tools e tool_choice **TUTTI dentro `.session.*`**:
```python
instructions = response.instructions if ... else runtime_config.session.instructions
tools = response.tools if ... else runtime_config.session.tools
tool_choice = response.tool_choice if ... else runtime_config.session.tool_choice
```
#### 📋 Mappa riassuntiva: 7 componenti TUTTI già funzionanti
| Componente | File | Stato | Cosa fa |
|---|---|---|---|
| 🔧 **Normalizzatore tool** | `demo/server.py:464-544` | ✅ | 79 tool Aquachile → formato Realtime GA standard, 0 eccezioni |
| 🚀 **Inject WS upstream** | `demo/server.py:1054-1102` | ✅ **NUOVO FIX 2026-08-25** | Invia nested `session:{instructions,tools,tool_choice}` subito dopo tunnel |
| 🛡️ **Fallback parser retrocompatibile** | `speech_to_speech/service.py:267-325` | ✅ **NUOVO FIX 2026-08-25** | Se arrivano campi flat root → promossi a nested `session` |
| 🧠 **LLM + Chat buffer** | `speech_to_speech/LLM/*` | ✅ GIA' ESISTENTE | Tools da sessione, FC/FCO nel chat, pending dict anti-race |
| 📡 **WS response handler (tool call OUT)** | `speech_to_speech/handlers/response.py:336-345` | ✅ GIA' ESISTENTE | Emette `ResponseFunctionCallArgumentsDoneEvent` con arguments=STRINGA JSON, call_id, name, response_id, item_id, output_index=0 |
| 💾 **WS conversation handler (tool output IN)** | `speech_to_speech/handlers/conversation.py:30-96` | ✅ GIA' ESISTENTE | `conversation.item.create function_call_output` con `deferred_items` anti-race |
| 🌐 **Client WS → MCP REST → ritorno WS** | `demo/ws/s2s-ws-client.js:803-1077` + `demo/main.js:2180-2288 + 3023-3043` | ✅ GIA' ESISTENTE | (1) `response.function_call_arguments.done` → dispatch `toolcall` event<br>(2) `runTool()` branch `agentToolDefs` → `execRemoteMcpTool()` → `POST /api/mcp/tool`<br>(3) `client.sendToolOutput(callId, output)` → `conversation.item.create function_call_output`<br>(4) `client.requestResponse()` → prossimo turno TTS |
| 📣 **Proxy MCP REST Aquachile** | `demo/server.py:704-760` | ✅ GIA' ESISTENTE | `POST /api/mcp/tool` JSON-RPC 2.0 tools/call + log `[Vocero MCP CALL/HTTP/OUT]` |
| 🛡️ **Filtro errori WS V2** | `demo/server.py:1713-1792` | ✅ GIA' ESISTENTE | Sopprime `unknown_or_invalid_event` se mai dovesse ricomparire, passthrough altri errori |
#### 🚀 Prossimo passo: ESEGUIRE I 3 STEP DI §0.4
1. ~~Valorizza `.env` MCP API Key/Secret~~ ✅ **SUPERATO** (endpoint pubblico)
2. Restart `s2s_ui`
3. Restart `ai-server` con il codice aggiornato da `/home/duxiter/s2s/speech_to_speech`
4. Hard reload browser (Ctrl+Shift+R)
5. Testa: *"¿dónde está el report de ayer?"* → aspetta il key log `[MCP INJECT] ✓ UPSTREAM ha emesso response.function_call_arguments.done!`
**Se Step 5 (function_call_arguments.done) non compare dopo 3 frasi trigger**: apri una shell con `docker logs -f ai-server` per vedere se la sessione sta leggendo `.session.tools` dal runtime config.
---
#### ⚙️ MCP MASTER SWITCH (nuovo feature 2026-08-25)
**Controllato da una sola riga in `.env`**:
```
#S2S_TOOL_EXECUTION_MCP_URL=https://aquachile.azurianlab.com/api/v1/mcp # ⬅️ se commentata/vuota → MCP DISABLED
S2S_TOOL_EXECUTION_MCP_URL=https://aquachile.azurianlab.com/api/v1/mcp # ⬅️ se decommentata/valorizzata → MCP ENABLED
```
Implementato come `MCP_EXEC_ENABLED = bool(S2S_TOOL_EXECUTION_MCP_URL)` in [server.py:106-114](file:///home/duxiter/s2s/demo/server.py#L106-L114). Quando **DISABLED** (stato attuale `.env`):
| Punto di controllo | Comportamento con MCP DISABLED | Riferimento codice |
|---|---|---|
| **Startup log** | `[MCP MASTER] ⛔ DISABLED — S2S_TOOL_EXECUTION_MCP_URL is empty/commented in .env…` | [server.py:172-182](file:///home/duxiter/s2s/demo/server.py#L172-L182) |
| **WS `/v1/realtime` inject tools** | `session.update` contiene **solo `instructions`** (profilo agente); `tools[]` e `tool_choice` **non vengono mai mandati**. Log marker `[MCP INJECT] ⛔ SKIPPED tool injection …`. ⚠️ Il prompt profilo continua a funzionare. | [server.py:1081-1123](file:///home/duxiter/s2s/demo/server.py#L1081-L1123) |
| **`/api/config` bootstrap client** | Invia al browser `agentTools=[]` (invece dei 79) + `searchMcp=false`. Effetti lato client: ranker MCP early exit, prompt non contiene più "Available MCP tools:" e ranking hints, UI pannello MCP mostra "No MCP tools available.", nessun pulsante "Esegui tool". | [server.py:660-694](file:///home/duxiter/s2s/demo/server.py#L660-L694) |
| **`POST /api/mcp/tool` endpoint** | Se un client tenta comunque la chiamata (es. con codice vecchio): log `[MCP DISABLED] ⛔ /api/mcp/tool called but…` + HTTP 503 dettagliato (non crash, non exception trace). | [server.py:721-733](file:///home/duxiter/s2s/demo/server.py#L721-L733) |
Per **riabilitare la Fase 2 MCP in futuro**, 4 step:
1. ✏️ `.env` → togli `#` davanti a `S2S_TOOL_EXECUTION_MCP_URL=https://aquachile.azurianlab.com/api/v1/mcp`
2. 🔄 Restart `s2s_ui` (deploya MCP_EXEC_ENABLED=True + inject formato nested corretto)
3. 🔄 Restart `ai-server` con codice aggiornato `/home/duxiter/s2s/speech_to_speech` (fallback parser flat→nested)
4. 🌐 Hard reload browser Ctrl+Shift+R
---
## 1. Flow di `instructions` — da impostazioni utente a S2S upstream
**Definizione**: `instructions` è il **system prompt** del protocollo OpenAI Realtime GA.
Viene mandato al backend in 2 punti distinti:
| Canale | Dove | Destinatario |
|---|---|---|
| ✅ **TUNNEL WS AUDIO** → `session.update` all'inizio di ogni sessione | Browser → tunnel proxy → upstream S2S nativo (`ai-server:12345`) | Usato **dal modello S2S stesso** per la risposta vocale nativa |
| ✅ **REST PARALLELO** → body `POST /api/reasoning` | Browser → `server.py` → Node-RED o OpenAI | Usato **dal reasoning hook** esterno (oggi Fase 1 LOG_ONLY) |
Entrambi i canali partono dalla stessa funzione generatrice `effectiveReasoningInstructions()` nel client.
### Step-by-step
```
┌────────────────────────────────────────────────────────────────────┐
│ 1. BASE — DEFAULT_INSTRUCTIONS (main.js:26) │
│ "You are a friendly voice assistant. Keep replies short..." │
│ OR textarea Settings #instructions salvato in localStorage │
│ chiave "s2s.ws.instructions" (main.js:138, 201, 223) │
└───────────────────────────────────────┬────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────┐
│ 2. effectiveReasoningInstructions() (main.js:516-533) │
│ Appende in coda, in ordine: │
│ │
│ + (se ≥1 tool attivo) TOOL_USE_HINT (main.js:32-35) │
│ → "do not describe your capabilities... call tool immediately" │
│ │
│ + (se searchMcpEnabled + agentToolDefs) MCP_SEARCH_HINT + │
│ "Available MCP tools: …catalogo completo…" │
│ │
│ + (se searchMcpEnabled + tool suggeriti) riga ranking client-side │
│ "Current user request: \"${lastUserQuery}\". Ranking hint: 1. X │
│ 2. Y 3. Z — use as secondary signal only" │
│ │
│ + (se utente autenticato phone) voceroUserContextText() │
│ → es: "User profile: name=Juan Pérez phone=+569… account=…" │
└───────────────────────────────────────┬────────────────────────────┘
│
┌──────────────────┴──────────────────┐
▼ ▼
┌─────────────────────────────┐ ┌──────────────────────────────┐
│ 3a. effectiveInstructions() │ │ 3b. POST /api/reasoning │
│ (main.js:537-539) │ │ body req.instructions │
│ │ │ (main.js:569) │
│ • mode=OPENAI → accoda │ │ = effectiveReasoningInst…() │
│ OPENAI_RENDER_HINT │ │ tale e quale (NO append) │
│ • mode=NODE_RED / PROXY → │ │ │
│ ritorna il valore │ │ → server.py lo gira a │
│ senza modifiche │ │ Node-RED o OpenAI │
└──────────────┬──────────────┘ └──────────────┬───────────────┘
│ │
▼ ▼
┌─────────────────────────────┐ ┌──────────────────────────────┐
│ 4a. startInstructions = │ │ 4b. /api/reasoning → │
│ effectiveInstructions() │ │ Node-RED log: │
│ (main.js:2829) │ │ [S2S NODE_RED LOG_ONLY] │
└──────────────┬──────────────┘ │ instructions len=… │
│ └──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ 5. new S2sWsRealtimeClient({ │
│ instructions: startInstructions │
│ voice, tools, noiseGate, audioContext │
│ }) main.js:2839-2847 │
└──────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ 6. this.options.instructions → _sendSessionUpdate() │
│ s2s-ws-client.js:995 + 1016 │
│ { type: "session.update", │
│ session: { type: "realtime", │
│ instructions: this.options.…, │
│ audio:{output:{voice}}, │
│ tools, tool_choice:"auto" } } │
└──────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ 7. server.py _client_to_upstream — TUNNEL 1:1 │
│ → forward esatto byte-per-byte all'upstream │
│ → server.py NON tocca MAI instructions qui │
│ → Logga solo summary.instructionLength = len() │
│ (server.py:197-203) — NON MODIFICA │
└──────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ 8. ai-server:12345 riceve session.update → │
│ istruzioni usate come system prompt da VLM/LMM │
│ per TUTTA la durata della sessione finché │
│ non arriva un altro session.update (patch) │
└─────────────────────────────────────────────────────┘
```
### Patch di `instructions` durante la sessione (viva)
Se vuoi aggiornare istruzioni a metà conversazione **senza chiudere la WS**:
- Client → chiama `client.updateSession({ instructions })` (main.js:1021-1035)
- Oppure, in futuro: **proxy server-side inietta patch** via `upstream.send({type:"session.update", session:{...}})` in qualsiasi momento del tunnel
### Tabella riassuntiva — chi oggi modifica `instructions`?
| Livello | Modifica? | Note |
|---|---|---|
| 🔵 Browser `main.js` | **Sì** — generatore principale | DEFAULT_INSTRUCTIONS + hints + user context |
| 🟣 Browser `s2s-ws-client.js` | **Solo forwarding** | Incapsula in `session.update` |
| 🟢 Proxy `server.py` tunnel WS | **NO** — copia byte-per-byte | Logga solo lunghezza, non tocca |
| 🟠 Proxy `server.py` reasoning REST | **Sì (Modalità B)** | Usa `req.instructions` come system part nel prompt OpenAI (Fase 2) |
| 🔴 Upstream `ai-server:12345` | **Sì** — applica come system prompt | Entra nel ciclo STT→VLM→TTS |
---
## 2. Ranking tool MCP lato client + `MCP_SEARCH_TRANSLATION_PHRASES`
**Obiettivo**: quando arriva la query utente (es. `"Quiero el reporte de hoy"` in spagnolo),
ordinare velocemente i tool MCP del catalogo Aquachile per **rilevanza**, così
le `instructions` possono includere un hint di ranking ("prova prima questi 3") e
il modello sceglie lo strumento giusto con meno tentativi.
### 2.1 Perché serve la traduzione
Il catalogo dei tool `agentToolDefs` arriva dall'endpoint Aquachile MCP:
- `tool.name` e `tool.description` sono scritti in **INGLESE** per convenzione di
OpenAI Functions / MCP protocol.
- Ma l'utente parla **spagnolo del Cile o italiano** con l'agente.
Senza traduzione il matching `frase ES/IT ↔ tool EN` → **score 0** (zero corrispondenze).
I tool non vengono mai suggeriti.
### 2.2 I due dizionari di traduzione (main.js)
| Costante | Tipo | Riga | Cosa fa |
|---|---|---|---|
| `MCP_SEARCH_TRANSLATION_PHRASES` | `[RegExp, string][]` frasi complete | [L51-L70](file:///home/duxiter/s2s/demo/main.js#L51-L70) | Sostituisce **frasi multi-parola** prima del tokenizer. Es. `reporte de hoy → today report`. **Priorità alta**: viene applicato PER PRIMO perché le frasi intere non si possono ricostruire da token singoli. |
| `MCP_SEARCH_TRANSLATION_TOKENS` | `Record<it/es, en>` token singolo | [L72](file:///home/duxiter/s2s/demo/main.js#L72) | Sostituisce il **resto** parola per parola dopo la phase-phrase. Es. `ayuda → help`, `cliente → customer`. |
### 2.3 Catalogo completo traduzioni oggi attivo
#### Phrase-level (MCP_SEARCH_TRANSLATION_PHRASES)
| Lingua | Input | Output EN | Riga |
|---|---|---|---|
| 🇪🇸 | `informaciones del usuario` / `informacion del usuario` | `user information` | L52-L53 |
| 🇮🇹 | `informazioni utente` / `informazione utente` | `user information` | L54-L55 |
| 🇪🇸 | `report del dia` / `reporte del dia` | `day report` | L56-L57 |
| 🇮🇹 | `report di oggi` | `today report` | L58 |
| 🇪🇸 | `reporte de hoy` | `today report` | L59 |
| 🇮🇹 | `report giornaliero` | `daily report` | L60 |
| 🇪🇸 | `reporte diario` | `daily report` | L61 |
| 🇪🇸 | `mi numero es` | `my number is` | L62 |
| 🇮🇹 | `il mio numero e` | `my number is` | L63 |
| 🇮🇹 | `numero di telefono` | `phone number` | L64 |
| 🇪🇸 | `numero telefonico` | `phone number` | L65 |
| 🇪🇸 | `numero de telefono` | `phone number` | L66 |
| 🇪🇸 | `numero de whatsapp` | `whatsapp number` | L67 |
| 🇪🇸 | `informacion api` | `api information` | L68 |
| 🇮🇹 | `informazioni api` | `api information` | L69 |
> 💡 Aggiungere nuove frasi: **è sufficiente aggiungere righe qui**, senza toccare il codice del ranking. La pipeline le applicherà automaticamente.
### 2.4 Pipeline di ranking chiamata per ogni turno
```
Utente dice: "Quiero el reporte diario y informaciones del usuario"
│
▼
rankAgentTools(query) main.js:1262
│
▼
buildMcpSearchTexts(query) main.js:1330
│
├─── [0] normalizeMcpSearchText(query) ← versione ORIGINALE normalizzata
│ = "quiero el reporte diario y informaciones del usuario"
│
└─── [1] translateMcpSearchTextToEnglish(query) main.js:1314
│
├─ step 1: normalize
├─ step 2: loop replace per tutte le entry di MCP_SEARCH_TRANSLATION_PHRASES
│ → "quiero el daily report y user information"
├─ step 3: split token, map per MCP_SEARCH_TRANSLATION_TOKENS singoli
│ → "want the daily report and user information"
└─ step 4: normalize finale
│
▼
searchTexts = [ originale normalizzato, versione tradotta in EN ]
(duplicati eliminati via new Set)
│
▼
tokens = split searchTexts + lunghezza ≥ 3 chars + dedup
= [ quiero, reporte, diario, informaciones, usuario,
want, daily, report, and, user, information ]
│
▼
Check rule hard-coded:
dailyReportIntent = match ("day"|"daily"|"today") + "report" → TRUE
(se match: boost score dei tool che hanno report in name/description)
│
▼
Per ogni tool in agentToolDefs[]:
score = somma (frequenza token in tool.name + tool.description)
+ 2× bonus dailyReportIntent se tool matcha "report"
+ 3× bonus se esatta match name vs query originale
Sort(score desc, name asc)
│
▼
Torna al chiamante: [ {tool:"get_daily_report", score:42},
{tool:"get_user_info", score:31},
... ]
│
▼
Usato in 2 punti:
A) effectiveReasoningInstructions() main.js:525-529 → hint "Current user request…
ranking hint: 1. get_daily_report… 2. get_user_info…" dentro le instructions
B) UI pannello (se visibile) — lista tool suggeriti accanto alla trascrizione
```
### 2.5 Quando è chiamato `rankAgentTools` — ogni turno?
| Trigger | Frequenza | Perché |
|---|---|---|
| `maybeRunOpenAiReasoning(transcript)` subito dopo transcript STT | 1× per turno utente | Include ranking hint dentro `instructions` che andrà a Node-RED / OpenAI |
| Live edit textarea domanda (autocomplete) | Mentre utente scrive | Suggerisce tool nella UI |
### 2.6 Disattivazione automatica
Ranking ritorna `[]` vuoto e salta tutta la traduzione se:
- `searchMcpEnabled == false` (MCP Aquachile non configurato)
- `agentToolDefs.length == 0` (catalogo tool non ricevuto da `/agents/profile`)
- Query normalizzata è vuota o troppo corta
- Nessun token dopo split ≥ 3 caratteri
---
## 2bis. DOVE VEDERE I LOG MCP — due postazioni
Il ranking MCP produce log in **2 posti diversi** per design:
| Postazione | Tipo di log | Filtro DevTools / grep | Quando appare |
|---|---|---|---|
| 🖥️ **Browser — DevTools Console** | Client-side ranking dettagliato: **tutta la pipeline translate → build → rank per-tool matching** | `MCP RANK` (case-sensitive) | **Sempre** per ogni turno — anche in caso di EARLY EXIT (motivo esplicito: `searchMcpEnabled=false`, `agentToolDefs=0`, ecc.) |
| 🐳 **Server — container `s2s_ui` (uvicorn Python)** | Server-side **snapshot del payload `instructions`** quando arriva `POST /api/reasoning` — quanti tool MCP ci sono nel catalogo hint, TOP3 suggeriti dal ranking lato client | `grep '[MCP RANK]'` o filter terminale `MCP RANK` | **1 riga per ogni POST /api/reasoning** (lo hai già nel flusso tra `[INPUT UTENTE]` e `[EXT][NODE_RED] reasoning POST`) |
### Nel tuo log di oggi (FASE 1 LOG_ONLY, senza MCP tools abilitati)
Aspettati questo flusso dopo il deploy di adesso:
```
7|s2s_ui | INFO [s2s] [INPUT UTENTE] client=… stt=Necesito el report.
7|s2s_ui | INFO [s2s] [MCP RANK] reasoning payload transcript="Necesito el report" instructionsLen=97 hasMcpCatalogHint=False hasRankingHint=False mcpCatalogTools=0 top3Suggested=none
7|s2s_ui | DEBUG [s2s] [EXT][NODE_RED] reasoning POST http://localhost:1881/api/reasoning …
```
E se apri **DevTools Console** (Verbose/Debug on, Disable cache ✓ Ctrl+Shift+R) vedi:
```
[MCP RANK] updateMcpToolSuggestions ENTRY partial=false itemId=- query="necesito el report" searchMcpEnabled=false
[MCP RANK] ════════════════════════════════════════
[MCP RANK] rankAgentTools START query="necesito el report" searchMcpEnabled=false agentToolDefsCount=0
[MCP RANK] buildSearchTexts raw="Necesito el report" normalized="necesito el report" translated="necesito el report" count=1 final=["necesito el report"]
[MCP RANK] translate input="necesito el report" phrasesApplied=0 tokensApplied=0 output="necesito el report"
[MCP RANK] → EARLY EXIT reason="searchMcpEnabled=false — da /api/config: MCP Aquachile feature OFF oppure S2S_TOOL_EXECUTION_MCP_URL non configurato; agentToolDefs=0 — catalogo tool MCP vuoto: profilo agente non ha tools in agentProfile via Aquachile /api/agents/profile" searchMcpEnabled=false normalized=true agentToolDefs=0
[MCP RANK] ════════════════════════════════════════ (empty result)
[MCP RANK] updateMcpToolSuggestions RESULTS partial=false matchedTools=0 ranked=none
```
### Quando il ranking MCP inizia a produrre score > 0
Affinché un turno abbia log MCP con tool score>0, devono essere veri **TUTTI e 3**:
1. `.env` ha `S2S_TOOL_EXECUTION_MCP_URL` configurato (URL Aquachile MCP) **e** `searchMcpEnabled=true` ritorna da `/api/config`
2. `/agents/profile` Aquachile torna **almeno 1 tool** in `agentTools[]` (campo `json.agentTools`)
3. La query utente ha token ≥ 3 chars che matchano `tool.name` o `tool.description` o `tool.parameters`
Se 1 solo dei 3 è falso → **early exit spiegato nel log `[MCP RANK] → EARLY EXIT reason="…"`**.
---
## 3. Come estendere (quick cheatsheet)
| Se vuoi… | Dove modificare |
|---|---|
| Aggiungere 1 frase tradotta ES/IT → EN per tool matching | `MCP_SEARCH_TRANSLATION_PHRASES` [main.js](file:///home/duxiter/s2s/demo/main.js#L51-L70) — nuova riga |
| Aggiungere 1 parola singola tradotta | `MCP_SEARCH_TRANSLATION_TOKENS` [main.js:72](file:///home/duxiter/s2s/demo/main.js#L72) |
| Boost score di un tipo di tool (es. report) | `rankAgentTools` [main.js:1268-1270](file:///home/duxiter/s2s/demo/main.js#L1268-L1270) + mappa subito dopo |
| Aggiungere nuovo hint a `instructions` all'avvio sessione | `effectiveReasoningInstructions()` [main.js:516-533](file:///home/duxiter/s2s/demo/main.js#L516-L533) — nuova concatenazione |
| Iniettare `instructions` server-side (segret@ dati Aquachile) senza toccare client | **Proxy tunnel** `realtime_proxy()` in [server.py](file:///home/duxiter/s2s/demo/server.py#L891-L908) → `await upstream.send({type:"session.update", session:{instructions}})` **prima** dei 2 `create_task` |