Files
vocero-client/reasoning.md
T
2026-08-26 17:42:00 -04:00

609 lines
37 KiB
Markdown

# Reasoning & Context Injection — Architettura Completa
Questo documento descrive **come funziona il "reasoning"** (il layer di intelligenza che decide cosa rispondere) nell'app Vocero/S2S e **in quali punti puoi iniettare contesto** (identità dell'agente, istruzioni, strumenti MCP, profilo chiamante, dati extra, ecc.).
Esistono **due modalità di reasoning** distinte:
| Modalità | Variabile env | Chi fa il "pensiero" | Flusso |
|---|---|---|---|
| **PROXY** *(default)* | `S2S_REASONING_TYPE=PROXY` | Il server realtime upstream (`ai-server`, Vocero, o qualunque backend dietro `S2S_PROXY_TARGET`) | Browser → WebSocket `/v1/realtime` → upstream (prompt + tools inviati in `session.update`) |
| **OPENAI** *(reasoning esterno)* | `S2S_REASONING_TYPE=OPENAI` | Una chiamata HTTP separata a OpenAI Chat Completions (es. `gpt-4.1-mini`) | Browser → `POST /api/reasoning` → OpenAI → risposta reiniettata nel realtime come messaggio utente forzato |
In **entrambe** le modalità le informazioni di contesto vengono assemblate lato client in un'unica stringa "istruzioni" (`effectiveReasoningInstructions`) e passate poi in modo diverso.
---
## 1. Panoramica del flusso completo (uno sguardo dall'alto)
```
┌─────────────────────────────────────────────────────────────────────────┐
│ /home/duxiter/s2s/.env │
│ S2S_PRIMARY_PROMPT S2S_AGENT_PROFILE S2S_REASONING_TYPE │
│ S2S_TOOL_EXECUTION_MCP_URL S2S_USER_PHONE_AUTH_URL │
│ OPENAI_API_KEY / BASE_URL / REASONING_MODEL / ORG / PROJECT │
│ S2S_PROXY_TARGET S2S_SEARCH_MCP etc. │
└───────────────────────────┬─────────────────────────────────────────────┘
│ caricato da start_ui.sh
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ demo/server.py (backend Python FastAPI) │
│ │
│ fetchConfig() GET /api/config ←─────── GET /api/user/phone │
│ │ │ (se URL param phone) │
│ ├─ primaryPrompt (priorità 1: Aquachile /agents/profile │
│ │ priorità 2: S2S_PRIMARY_PROMPT env) │
│ ├─ agentProfile (URL di dove abbiamo preso il prompt) │
│ ├─ agentTools[] (catalogo MCP Aquachile, enabled=True) │
│ ├─ reasoningType = "PROXY" oppure "OPENAI" │
│ └─ searchMcp, s2sUrl, lb, auth, search, ecc. │
│ │
│ POST /api/reasoning (solo se reasoningType=OPENAI) │
│ body → httpx POST {OPENAI_BASE_URL}/chat/completions │
│ │
│ POST /api/mcp/tool │
│ body → httpx POST {S2S_TOOL_EXECUTION_MCP_URL} + chiavi API │
│ │
│ WS /v1/realtime (proxy a S2S_PROXY_TARGET) │
│ client ↔ proxy ↔ upstream: logga [INPUT UTENTE]/[OUTPUT AGENTE] │
└───────────────────────────┬─────────────────────────────────────────────┘
│ fetch su caricamento pagina
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ demo/main.js (frontend browser) │
│ │
│ pinnedPrompt = primaryPrompt (da /api/config) — read-only se valoriz.│
│ agentToolDefs[] = agentTools (da /api/config) │
│ reasoningType = "PROXY" | "OPENAI" │
│ voceroUserPhoneNumber = new URL(location).searchParams.phoneNumber │
│ │ │
│ └─► async fetchVoceroUserProfile() │
│ GET api/user/phone?phoneNumber=… → voceroUserProfile{} │
│ │
│ Quando l'utente clicca l'orb → doStart(): │
│ startInstructions = effectiveInstructions() │
│ startTools = activeToolDefs() │
│ │ │
│ └─► new S2sWsRealtimeClient({instructions, tools, …}) │
│ │ │
│ ├─► session.update al realtime upstream │
│ │ (se reasoningType=PROXY → qui pensa) │
│ │ │
│ └─► event "transcript" (user, partial=false) │
│ │ │
│ └─► maybeRunOpenAiReasoning() │
│ (solo reasoningType=OPENAI) │
│ → POST /api/reasoning → answer │
│ → sendUserText(renderPrompt) │
│ → requestResponse() │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 2. Assemblaggio del prompt: `effectiveReasoningInstructions()`
La funzione **centrale** che costruisce il blocco di testo che verrà dato al modello è in [main.js:516-533](file:///home/duxiter/s2s/demo/main.js#L516-L533):
```js
function effectiveReasoningInstructions() {
const base = pinnedPrompt || settings.instructions;
let instructions = base;
if (activeToolDefs().length) instructions += TOOL_USE_HINT;
if (searchMcpEnabled && agentToolDefs.length) {
instructions += MCP_SEARCH_HINT;
instructions += ` Available MCP tools: ${buildMcpToolCatalogSummary(agentToolDefs)}`;
}
const suggested = topSuggestedAgentTools(3);
if (searchMcpEnabled && suggested.length) {
instructions += ` Current user request: "${lastUserQuery}". Client-side ranking hint only: …`;
}
const userContext = voceroUserContextText();
if (userContext) instructions += userContext;
return instructions;
}
```
Ordine di concatenazione (dalla priorità più alta alla più bassa):
| # | Fonte | Dove viene da | Note |
|---|---|---|---|
| 1 | **`pinnedPrompt`** | `S2S_AGENT_PROFILE` (`instructions` campo Aquachile) → fallback `S2S_PRIMARY_PROMPT` env → fallback `settings.instructions` (localStorage) → fallback `DEFAULT_INSTRUCTIONS` | **Identità e personalità principale dell'agente**. Quando `pinnedPrompt` è presente, il campo `instructions` di Settings è disabilitato in UI (read-only). |
| 2 | **`TOOL_USE_HINT`** | Costante in linea [main.js:32-36](file:///home/duxiter/s2s/demo/main.js#L32-L36) | Aggiunto se c'è **almeno 1 tool attivo**: "*When the user's request calls for one of your tools, do not describe your capabilities or say you can do it and wait for another turn. Instead, say a brief acknowledgement… and call the tool right away in the same response.*" |
| 3 | **`MCP_SEARCH_HINT`** + catalogo completo tools | Condizionato a `S2S_SEARCH_MCP=true` in env | Istruisce il modello a leggere l'intero catalogo MCP e scegliere lo strumento migliore. Poi **appende come testo TUTTI i tool MCP** (`buildMcpToolCatalogSummary`) con nome, descrizione e parametri obbligatori. |
| 4 | **Suggerimento tool client-side (top 3)** | `topSuggestedAgentTools(3)` basato su `lastUserQuery` | Messaggio "Client-side ranking hint only: 1. get_day_report: … 2. … 3. …". Usato **solo come hint secondario**, il modello deve sempre controllare il catalogo completo prima. |
| 5 | **`voceroUserContextText()`** | Profile restituito da `/api/user/phone?phoneNumber=…` | Stringa: `" Caller profile resolved from the page phoneNumber: {JSON profilo}. Use this as trusted caller context when relevant."` (troncato a 1600 char) |
Poi la funzione **`effectiveInstructions()`** ([main.js:537-539](file:///home/duxiter/s2s/demo/main.js#L537-L539)) prende il risultato e:
- Se `reasoningType=OPENAI` → **appende `OPENAI_RENDER_HINT`**: istruisce il realtime a **rispondere solo ai messaggi che iniziano con il marker `[VOCERO_RENDER_REPLY]`**, in modalità "leggi e basta" (niente tool, niente ragionamento). Questo è il segreto che permette di scrivere la frase pensata da OpenAI e farla ripetere fedelmente in TTS.
- Se `reasoningType=PROXY` → ritorna le istruzioni tal quali.
### Quando viene ricalcolato e reinviato al modello
- **Alla connessione** in `doStart()`: `startInstructions = effectiveInstructions()` → `new S2sWsRealtimeClient({instructions: startInstructions, …})`.
- **Durante la chiamata**, in risposta a:
- ogni **trascrizione utente** parziale o finale (linee [1122-1128](file:///home/duxiter/s2s/demo/main.js#L1122-L1128)): `pushLiveToolPolicy()` + `pushLiveInstructions(effectiveInstructions())` → `client.updateSession({ instructions })`
- quando arriva il **profilo chiamante da phone auth** ([2388-2390](file:///home/duxiter/s2s/demo/main.js#L2388-L2390)): stesso `pushLiveInstructions`
- quando l'utente **modifica le impostazioni** nel form e salva.
---
## 3. I due motori di reasoning, nel dettaglio
### 3.1 Modalità **PROXY** (default — consigliata con Aquachile)
```
S2S_REASONING_TYPE=PROXY (o qualsiasi valore diverso da "OPENAI")
S2S_PROXY_TARGET=ws://ai-server:12345/v1/realtime # o qualunque endpoint WS realtime
```
In questa modalità, **l'upstream realtime è l'unico a "pensare"**. Tutta l'intelligenza risiede nel backend puntato da `S2S_PROXY_TARGET` (es. Vocero, un wrapper realtime custom, un server che parla OpenAI Realtime API).
Flusso **testuale** di quello che succede a ogni avvio:
1. `doStart()` calcola `startInstructions = effectiveInstructions()` (tutto il blocco §2).
2. Costruisce `startTools = activeToolDefs()` = tool browser (web_search, camera_snapshot) **+** `agentToolDefs` (catalogo MCP Aquachile).
3. Passa `instructions` e `tools` al costruttore di `S2sWsRealtimeClient`.
4. Quando il WS si connette e arriva `session.created`, il client manda `session.update` con:
```json
{
"type": "realtime",
"instructions": "<tutte le istruzioni assemblate>",
"tools": [ { "type":"function", "name":"…", "description":"…", "parameters":{…} }, … ],
"tool_choice": "auto",
"audio": { "output": { "voice": "Aiden" } }
}
```
5. **TUTTO IL RESTO è SULL'UPSTREAM**: L'upstream decide quando fare tool call, quando parlare, quando ascoltare. Il browser è solo uno "schermo+microfono+telecomando".
6. Quando l'upstream fa `conversation.item.input_audio_transcription.completed` il log server stampa `[INPUT UTENTE] …`.
7. Quando l'upstream fa `response.*_transcript.done` / `response.content_part.done` ecc. il log server stampa `[OUTPUT AGENTE] …`.
**Vantaggi:**
- Un solo round trip per tutto (latenza più bassa per voce→voce).
- L'upstream può usare qualsiasi modello/rete interna (non per forza OpenAI).
- Le MCP tool call sono orchestrate dallo stesso server che fa STT/TTS → meno desincronizzazioni.
**Svantaggi:**
- Tutto il "pensiero" è black-box sull'upstream; non puoi vedere i token del thinking né usare un modello diverso senza cambiare upstream.
---
### 3.2 Modalità **OPENAI** (reasoning HTTP separato)
```
S2S_REASONING_TYPE=OPENAI
OPENAI_API_KEY=sk-… # obbligatorio
OPENAI_BASE_URL=https://api.openai.com/v1 # default
OPENAI_REASONING_MODEL=gpt-4.1-mini # default
OPENAI_ORGANIZATION=… # opzionale
OPENAI_PROJECT=… # opzionale
```
In questa modalità il **ragionamento vero e proprio** non viene fatto dall'upstream realtime. Invece:
1. L'upstream fa solo **STT** (converte voce in testo) e **TTS** (converte testo in voce). Agisce come un "dumb" audio I/O.
2. **Non appena arriva una trascrizione utente COMPLETA** (evento `transcript`, `role=="user"`, `partial==false`), `maybeRunOpenAiReasoning(transcript, itemId)` viene triggerato.
3. Questo chiama `fetchOpenAiReasoningAnswer(transcript)` ([main.js:563-586](file:///home/duxiter/s2s/demo/main.js#L563-L586)) che fa:
```
POST /api/reasoning
body = {
transcript: "quanto è il report giornaliero?",
instructions: effectiveReasoningInstructions(), // stesse di PROXY
language: "es-CL" o vuoto,
user_context: voceroUserContextText(), // duplicato per sicurezza
}
```
4. Il server (endpoint in [server.py:635-710](file:///home/duxiter/s2s/demo/server.py#L635-L710)) a sua volta fa:
```
POST {OPENAI_BASE_URL}/chat/completions
body = {
model: OPENAI_REASONING_MODEL,
messages: [
{ role:"system",
content: "{instructions}\n\n{user_context}\nReply only with final answer. Language=…" },
{ role:"user", content: transcript }
]
}
```
5. Riceve `answer` (stringa semplice, pensata per essere "parlata"), logga `[Vocero REASONING] transcript=… answer=…` e la restituisce al client.
6. Il client costruisce `renderPrompt = "[VOCERO_RENDER_REPLY]\n" + answer` e lo **manda come se fosse un testo scritto dall'utente** al realtime:
```
client.sendUserText(renderPrompt);
client.requestResponse();
```
7. L'upstream realtime, a causa di `OPENAI_RENDER_HINT` nel prompt, **vede il marker `[VOCERO_RENDER_REPLY]`** e quindi:
- NON usa tool, NON fa ragionamento proprio, NON modifica la frase.
- Si limita a leggere la parte dopo il marker in TTS.
8. Risultato: l'utente sente "la voce dell'agente" (TTS dell'upstream) ma il testo **è stato scritto da OpenAI** in `/chat/completions`.
Log server corrispondenti:
```
[EXT][OPENAI] reasoning POST https://api.openai.com/v1/chat/completions model=gpt-4.1-mini systemLen=… transcriptLen=…
[EXT][OPENAI] reasoning RESP status=200 len=…
[EXT][OPENAI] reasoning USAGE prompt=1234 completion=89 total=1323
INFO [s2s] [Vocero REASONING] transcript=… answer=…
```
**Vantaggi:**
- Scegli tu **qualsiasi modello compatibile Chat Completions** (anche Azure, Together, self-hosted se l'endpoint parla lo stesso protocollo).
- Hai **usage e log del reasoning separati** da quelli dell'audio.
- Puoi aggiungere facilmente post-processing, cache o fallback.
**Svantaggi:**
- Due round trip invece di uno → un po' più di latenza totale (~200-800 ms).
- Più costoso in token: `instructions` + contesto utente vengono mandati **due volte** (una a OpenAI, una al realtime).
- Le tool call che il modello OpenAI vorrebbe fare non possono essere usate direttamente — in OPENAI mode `chat/completions` NON ha accesso a `tools` (al momento l'endpoint `POST /api/reasoning` non inoltra il catalogo MCP a OpenAI). **Quindi in OPENAI mode, le MCP Aquachile vengono ancora passate al realtime (che in questa modalità non "pensa" ma solo reinderizza il testo), quindi non sono usabili.**
> ⚠️ Importante: se usi Aquachile con il suo ecosistema MCP, **la modalità PROXY è quella corretta da usare**. OPENAI mode è pensata per scenari "solo testo + voce" senza tool complessi.
---
## 4. TUTTI i modi per iniettare contesto all'agente, dal più semplice al più avanzato
I punti di injection sono ordinati per **priorità di applicazione** (chi "vince" in caso di conflitto) e per **facilità di modifica**.
---
### 🅿️ Punto 1 — `.env` (variabili di ambiente, configurazione statica)
File: [.env](file:///home/duxiter/s2s/.env)
Caricato in memoria da [start_ui.sh](file:///home/duxiter/s2s/start_ui.sh#L8-L12) (`source "$ROOT_DIR/.env"`) prima di avviare uvicorn.
| Variabile | Tipo | Cosa fa all'agente |
|---|---|---|
| **`S2S_PRIMARY_PROMPT`** | testo lungo | **Prompt backup quando Aquachile è irraggiungibile o `S2S_AGENT_PROFILE` non è impostato.** Sarà il valore di `primaryPrompt` in `/api/config` se `_extract_agent_prompt()` torna stringa vuota. Supporta `\n` → convertiti in newline. |
| **`S2S_AGENT_PROFILE`** | URL | Endpoint `GET` (es. Aquachile: `https://aquachile.azurianlab.com/api/v1/agents/profile`). Deve ritornare JSON `{ agent: { instructions: "...", tools: [ {name, description, parameters, enabled}, ... ] } }`. **Ha priorità su `S2S_PRIMARY_PROMPT`** — vedi `primary_prompt = agent_prompt or S2S_PRIMARY_PROMPT` in [server.py:517](file:///home/duxiter/s2s/demo/server.py#L517). |
| **`S2S_TOOL_EXECUTION_MCP_URL`** | URL | Endpoint `POST` per **eseguire** le tool call MCP (stesso backend Aquachile). Riceve `{server, name, arguments}` e viene chiamata ogni volta che il modello invoca uno strumento. |
| `S2S_TOOL_EXECUTION_MCP_API_KEY` / `S2S_TOOL_EXECUTION_MCP_API_SECRET` | stringhe segrete | Intestazioni `X-API-KEY` e `X-API-SECRET` aggiunte a ogni chiamata MCP. |
| **`S2S_USER_PHONE_AUTH_URL`** | URL | Endpoint `GET` (con `?phoneNumber=…`) che restituisce il profilo del chiamante. Se l'URL della UI contiene `?phoneNumber=+56…`, questo endpoint viene chiamato e il JSON risultante viene appeso al prompt di sistema (vedi Punto 4). |
| **`S2S_SEARCH_MCP=true\|false`** | bool | Quando `true`, appende `MCP_SEARCH_HINT` + il catalogo COMPLETO dei tool MCP dentro le `instructions`. Fondamentale se vuoi che il modello sappia che esistono 17 strumenti e possa scegliere tra quelli. |
| **`S2S_REASONING_TYPE`** | `PROXY` \| `OPENAI` | Motore di reasoning (§3). Normalizzato con `_normalize_reasoning_type()` (case-insensitive). |
| **`OPENAI_API_KEY` / `BASE_URL` / `REASONING_MODEL`** | stringhe | Solo per mode OPENAI. `BASE_URL` serve anche per compatibilità Azure/Together (basta che parli /chat/completions). |
| **`S2S_PROXY_TARGET`** | URL WS | Upstream realtime che fa STT/TTS + (in mode PROXY) il reasoning vero e proprio. Di solito `ws://ai-server:12345/v1/realtime`. |
| `S2S_PRELOAD_DATA_ENDPOINT`, `SERPER_API_KEY`, `LOAD_BALANCER_URL`, `SPEECH_TO_SPEECH_URL` | varie | Non iniettano testo nel prompt direttamente, ma abilitano altre funzionalità (PowerBI preload, web search Serper, queue & limiter, direct URL fissa). |
---
### 🅿️ Punto 2 — **Aquachile `/api/v1/agents/profile`** (contesto Aziendale + Tools)
URL impostato da `S2S_AGENT_PROFILE`. Fetchato lato server in [_agent_profile_data()](file:///home/duxiter/s2s/demo/server.py#L357-L397) con:
- cache 60 secondi globale (`_agent_profile_cached`), lock asincrona
- timeout 10 s
- chiamata GET senza header speciali (aggiungibili se serve a mano nel codice)
**Formato JSON atteso** (si adatta in modo tollerante):
```jsonc
{
"agent": {
// ↓ usato come prompt principale (priority #1)
"instructions": "Eres el asistente de voz oficial de Aquachile. Te llamas Aquachile Assistant…",
// ↓ fallback se instructions non esiste
"description": "…",
"tools": [
{
"name": "get_day_report",
"description": "Obtiene el reporte de producción diario de una planta…",
"parameters": {
"type": "object",
"properties": {
"center": {"type": "string", "description": "Nombre de la planta"}
},
"required": []
},
"enabled": true // ↓ se missing o true → incluso; se false → ignorato
},
// … altri 16 strumenti
]
}
}
```
Parser corrispondente:
- [_extract_agent_prompt()](file:///home/duxiter/s2s/demo/server.py#L405-L424) — cerca in ordine: `agent.instructions` → `agent.description` → `instructions` radice → `description` radice.
- [_extract_agent_tools()](file:///home/duxiter/s2s/demo/server.py#L427-L437) — cerca `agent.tools[]`, filtra quelli con `enabled` != `false`.
Tutto il resto del payload viene ignorato. Se vuoi estrarre altri campi (es. `agent.name`, `agent.greeting`, `agent.language`) puoi aggiungerne l'estensione in `_extract_agent_prompt` per concatenarli, oppure creare un nuovo campo nel dizionario di ritorno e poi usarlo in `/api/config`.
**Quando modificare questo endpoint piuttosto che altre parti:**
- Quando il contenuto cambia spesso (ogni giorno: nuovi report, nuove tipologie di domanda).
- Quando vuoi che **tutte le installazioni** dell'UI aggiornino all'istante senza rebuild/restart del server.
- Quando vuoi rilasciare un **nuovo tool MCP**: basta aggiungerlo all'array `tools[]` e comparirà ovunque entro 60 s (prossima invalidazione della cache).
---
### 🅿️ Punto 3 — **URL parameters** della pagina (contesto per-chiamata)
Analizzati all'avvio di `main.js` ([riga 410](file:///home/duxiter/s2s/demo/main.js#L410)):
```js
const pageParams = new URLSearchParams(window.location.search);
const voceroUserPhoneNumber = (pageParams.get("phoneNumber") || "").trim();
```
Attualmente è gestito un solo param:
| Parametro URL | Tipo | Effetto |
|---|---|---|
| `?phoneNumber=+56912345678` | stringa E.164 | Triggera `fetchVoceroUserProfile()` → `GET /api/user/phone?phoneNumber=…` → endpoint Aquachile `S2S_USER_PHONE_AUTH_URL` → profilo JSON appenduto al prompt (vedi Punto 4). |
È molto semplice estendere la lista: aggiungi una riga tipo:
```js
const plantId = (pageParams.get("plant") || "").trim();
```
e poi inietta il valore in modo statico (in `voceroUserContextText()` o in un pezzo di testo a parte in `effectiveReasoningInstructions`) o fai una fetch al tuo endpoint.
---
### 🅿️ Punto 4 — **Profilo chiamante `/api/user/phone`** (contesto per-caller)
End-to-end:
```
URL: https://<app>/?phoneNumber=+56912345678
│
▼ fetchVoceroUserProfile() [main.js:2370-2398]
GET api/user/phone?phoneNumber=%2B56912345678
│
▼ user_by_phone() [server.py:539-573]
GET {S2S_USER_PHONE_AUTH_URL}?phoneNumber=%2B56912345678
│
▼
Risposta Aquachile (es.)
{
"id": 987,
"name": "Don Manuel",
"role": "Jefe de Planta",
"center": "Puerto Montt",
"email": "manuel@aquachile.cl",
"authorizations": ["report:daily", "report:weekly"]
}
│
▼ { "result": <payload> } al browser
│
▼ voceroUserProfile = <payload> ; voceroUserProfileLoaded = true
│
▼ voceroUserContextText() [main.js:454-458]
" Caller profile resolved from the page phoneNumber: {name:\"Don Manuel\",…}. Use this as trusted caller context when relevant."
│
└─► concatenato ALLA FINE di effectiveReasoningInstructions
```
Una volta che il profilo è arrivato, viene chiamato `pushLiveInstructions(effectiveInstructions())` → l'istruzione sul realtime viene **aggiornata in volo** anche se la conversazione è già iniziata. Quindi anche se il profilo impiega 2 secondi ad arrivare, le battute successive alla ricezione beneficeranno del contesto.
**Quando usarlo:** passare dati per-caller (ruolo, permessi, centro, nome, lingua preferita, reporting line, storico chiamate recenti ecc.). Tieni conto che il JSON viene serializzato in chiaro nel prompt (max 1600 caratteri), quindi non passare PII sensibili in chiaro.
---
### 🅿️ Punto 5 — **Catalogo MCP Aquachile** (contesto come "abilità" dell'agente)
Fonte: `agent.tools[]` dal profilo Aquachile (Punto 2), poi in `agentToolDefs` lato client.
Entrano nel prompt in due modi:
1. **Come `tools` (dichiarazione formale OpenAI Realtime function-call)** : `S2sWsRealtimeClient({tools: activeToolDefs()})` → arrivano all'upstream come `session.tools[]` con schema JSON Schema. Questo permette all'upstream di **decidere di chiamarli** tramite `response.create_response + function_call_arguments.done`.
2. **Come testo nel prompt vero e proprio** : `Available MCP tools: get_day_report - Obtiene el reporte… Required parameters: []; get_metrics_summary - …. ` (solo se `S2S_SEARCH_MCP=true`).
Quando poi il modello effettivamente **chiama** un tool MCP, il client:
- emette l'evento `toolcall`
- il server chiama `POST /api/mcp/tool` ([server.py](file:///home/duxiter/s2s/demo/server.py#L576…)):
```
POST {S2S_TOOL_EXECUTION_MCP_URL}
headers: X-API-KEY, X-API-SECRET, Content-Type: application/json
body: {"server": "mcp", "name": "get_day_report", "arguments": "{\"center\":\"Puerto Montt\"}"}
```
- Il risultato torna al client e viene rinviato all'upstream come `conversation.item` di tipo `function_call_output`. Il modello legge l'output e prosegue il turn.
---
### 🅿️ Punto 6 — **Settings utente (localStorage)** (solo se prompt non pinned)
Se `pinnedPrompt` è stringa vuota (né da Aquachile, né da env), allora in `effectiveReasoningInstructions()` la base è `settings.instructions` che viene da:
```js
// main.js loadSettings: riga 201
instructions: localStorage.getItem("s2s.ws.instructions") || DEFAULT_INSTRUCTIONS
```
Nel form Settings (pannellino in alto a destra) l'utente può editare il testo "System instructions" e salvarlo → va in localStorage.
⚠️ **Attenzione**: se è presente un prompt "pinned" (da Aquachile o env), il campo nel Settings diventa **read-only** (non è modificabile). Puoi sempre verificare lo stato aprendo il pannello.
---
### 🅿️ Punto 7 — **Aggiungere contesto custom via codice (patch diretta)**
Se vuoi iniettare dati specifici (es. l'ID di una campagna, il turno di lavoro corrente, la stazione meteorologica più vicina, ecc.) senza creare un nuovo endpoint esterno, hai **3 punti comodi** dove patchare con poche righe:
#### 7a. Aggiungere un pezzo di testo statico alle `instructions`
Modifica direttamente `effectiveReasoningInstructions()` in [main.js:516-533](file:///home/duxiter/s2s/demo/main.js#L516-L533), aggiungendo una sezione. Esempio:
```js
function effectiveReasoningInstructions() {
const base = pinnedPrompt || settings.instructions;
let instructions = base;
if (activeToolDefs().length) instructions += TOOL_USE_HINT;
if (searchMcpEnabled && agentToolDefs.length) { … }
const suggested = topSuggestedAgentTools(3);
if (searchMcpEnabled && suggested.length) { … }
const userContext = voceroUserContextText();
if (userContext) instructions += userContext;
// ↓ aggiungi contesto custom QUI
instructions += `\n\nTurno actual: ${TURNO_CORRIENTE}. Campaña: ${CAMPAIGN_ID}. Planta: ${PLANTA_ID}.`;
return instructions;
}
```
Funziona in **entrambe** le modalità (PROXY e OPENAI).
#### 7b. Inviare un messaggio pre-filla a inizio conversazione
Dopo che il client è connesso e pronto (evento `ready` o `status=="connected"`), chiama:
```js
client.sendUserText(
"Contexto adicional de esta llamada (no responder a esto directamente): " +
"El usuario está llamando desde la planta Puerto Montt. Su último reporte " +
"fue el día 2026-08-17 y mostró un 95% de cumplimiento. El turno actual " +
"es el turno noche. Usa esta información cuando respondas."
);
// (opzionale) poi client.requestResponse() se vuoi che l'agente saluti subito
```
Il messaggio entra nella history del modello (coda `conversation.items[]`) ed è visibile per il resto della conversazione.
#### 7c. Patchare `/api/config` lato server per ritornare un campo custom e usarlo dal client
Aggiungi in [server.py ~riga 518-531](file:///home/duxiter/s2s/demo/server.py#L518-L531):
```python
out = {
...,
"myCustomContext": {"turno": current_shift(), "planta": os.environ.get("PLANTA_ID", "")},
}
```
Poi lato client in `fetchConfig()` (circa riga 2408):
```js
myCustomContext = json.myCustomContext || null;
```
e usa il valore come in 7a. Utile quando il contesto lo conosci solo lato server (es. viene da DB aziendale o da variabili env del deploy).
#### 7d. Aggiungere un parametro HTTP `user_context` custom alla chiamata `/api/reasoning` (solo OPENAI mode)
L'endpoint già accetta il campo `user_context` nel body della request — è già popolato da `voceroUserContextText()`, ma puoi aggiungere ulteriore testo concatenandolo nel client o nel server.
Oppure estendi `ReasoningRequest` in [server.py:346-350](file:///home/duxiter/s2s/demo/server.py#L346-L350) con un nuovo campo BaseModel (es. `turno: str = ""`, `planta: str = ""`) e poi usalo in `system_parts.append(...)` dentro `def reasoning()`.
---
## 5. Verifiche e Debug — come controllare "cosa vede veramente l'agente"
### 5a. Log del server (cosa arriva all'esterno)
Su `docker logs -f s2s_ui` (o sull'output di `start_ui.sh`) puoi monitorare TUTTE le chiamate esterne. Filtri utili:
```bash
# Solo contesto caricato all'avvio (profilo Aquachile)
grep "\[EXT\]\[AQUACHILE\]" server.log
# Solo ragionamento OpenAI (se mode OPENAI)
grep "\[EXT\]\[OPENAI\]" server.log
# Dialogo parlato (TESTO della chat)
grep -E "INPUT UTENTE|OUTPUT AGENTE" server.log
# Tutte le chiamate MCP
grep "\[EXT\]\[AQUACHILE\].*mcp.*tools/call" server.log
# /api/config (quello che arriva al browser come initial setup)
grep "/api/config returned" server.log
# Autenticazione chiamante
grep "Vocero USER AUTH" server.log
```
### 5b. Debug lato client (cosa viene assemblato)
Apri la console del browser (F12) e, dopo che la pagina ha caricato e ti sei connesso, puoi eseguire:
```js
// Controlla il tipo di reasoning attivo
> reasoningType
"PROXY" // oppure "OPENAI"
// Stringa effettiva di instruzioni (copiala/incolala per ispezione manuale)
> copy(effectiveInstructions())
// Testo che è stato passato all'ultimo session.update
> liveInstructionsSnapshot.length
32456
// Elenco dei tool attivi che l'agente "vede"
> activeToolDefs().map(t => t.name)
(19) ['web_search', 'camera_snapshot', 'get_day_report', 'get_weekly_report', 'get_monthly_report', 'get_report_detail', … ]
// Profilo chiamante caricato
> voceroUserProfileLoaded, voceroUserProfile
true, { id: 987, name: "Don Manuel", … }
// Top 3 suggeriti per l'ultima domanda
> topSuggestedAgentTools(3).map(x => `${x.tool.name}:${x.score}`)
["get_day_report:38", "get_metrics_summary:22", "get_report_detail:15"]
```
### 5c. Cosa viene mandato a OpenAI in modalità OPENAI
Basta guardare il log `[EXT][OPENAI] reasoning POST … systemLen=… transcriptLen=…` e saprai già il numero di token di sistema. Se vuoi vedere l'esatto system message, puoi aggiungere temporaneamente:
```python
# dentro def reasoning() dopo la costruzione di body
logger.debug("Reasoning system message PREVIEW:\n%s",
body["messages"][0]["content"][:3000])
```
---
## 6. Priorità effettiva di tutte le sorgenti (chi vince se c'è conflitto)
Tabella riassuntiva che risponde a: *"Se due punti dicono cose diverse, quale vince?"*
| Cosa | Vincitore (priorità max → min) |
|---|---|
| **Identità / sistema prompt** | 1. `agent.instructions` da Aquachile `S2S_AGENT_PROFILE` → 2. `S2S_PRIMARY_PROMPT` env → 3. `settings.instructions` (localStorage utente) → 4. `DEFAULT_INSTRUCTIONS` hardcoded |
| **Catalogo MCP** | Aquachile `agent.tools[]` (unico fornitore). Gli unici tool aggiuntivi sono `web_search` e `camera_snapshot` (browser-native). |
| **Hint su come usare i tool** | Sempre `TOOL_USE_HINT` fisso se `tools>0`; se `searchMcpEnabled` si aggiunge `MCP_SEARCH_HINT` + catalogo testuale completo. |
| **Hint tool ranking (top 3)** | Calcolato lato client per ogni battuta; prompt dice "secondary signal, full catalog wins" |
| **Profilo chiamante** | `S2S_USER_PHONE_AUTH_URL` + URL param `phoneNumber` (nessun altro percorso attuale). |
| **Modalità di reasoning** | 1. `S2S_REASONING_TYPE` env normalizzato (`OPENAI` o default `PROXY`) |
| **URL endpoint upstream** | 1. `SPEECH_TO_SPEECH_URL` pinned direct → 2. `LOAD_BALANCER_URL` LB + queue → 3. URL inserito manualmente in Settings (solo allowDirect) |
---
## 7. Troubleshooting: "L'agente non usa il contesto che mi aspetto"
Checklist da fare in ordine:
1. **`/api/config` cosa restituisce?**
```
curl -s http://localhost:8000/api/config | python -m json.tool
```
Verifica: `primaryPrompt` è valorizzato e lungo? `agentTools` quanti elementi ha? `reasoningType` è quello che ti aspetti?
2. **Aquachile profile risponde?** Guarda `[EXT][AQUACHILE] agent_profile` nel log. Status 200? Cache HIT o MISS? `PARSED promptLen=… toolCount=…` ha i numeri attesi?
3. **Il browser ha `phoneNumber` nella URL?** Apri DevTools → Network e cerca `user/phone`. C'è una richiesta? Torna 200? Che JSON ritorna? Il `result` viene inserito in `voceroUserProfile` (check console).
4. **Hai cambiato qualcosa nel prompt in Aquachile ma non l'aggiornamento non si vede?** Il profilo ha cache 60 secondi. Aspetta o riavvia il server (la cache è in memoria).
5. **In modalità PROXY i tool MCP non vengono chiamati:**
- Verifica che il **tool catalogo testuale** sia nel prompt (controlla `copy(effectiveInstructions())` e cerca la stringa `Available MCP tools`).
- Verifica che `S2S_SEARCH_MCP=true`.
- Guarda nella console del browser `[Vocero MCP MATCH]` e `[Vocero MCP RANK]` — se nessuno score è sopra la soglia (default 20), l'auto-invocazione lato client non scatta; il modello potrebbe comunque chiamarlo via function-call standard.
6. **In modalità OPENAI non sente le MCP:** Corretto — attualmente `/api/reasoning` NON manda tools a OpenAI. Vedi §3.2. Se vuoi chiamare tool in OPENAI mode devi spezzare la catena: OpenAI deve dire quale tool chiamare, tu lo chiami via `/api/mcp/tool`, e poi reinietti il risultato in un nuovo reasoning turn. O, più semplicemente, usa la modalità PROXY quando servono i tool Aquachile.
7. **Output lungo o ripetitivo:** Inserisci in coda al prompt un blocco tipo: `Limita la risposta a 2 frasi e parla in modo naturale. Non ripetere la domanda dell'utente.` — funziona in entrambe le modalità.
---
## 8. Riferimenti rapidi (file e linee)
| Concetto | File e righe |
|---|---|
| `.env` caricato | [start_ui.sh](file:///home/duxiter/s2s/start_ui.sh#L8-L12) |
| Variabili env parsate lato server | [server.py](file:///home/duxiter/s2s/demo/server.py#L71-L120) |
| `ReasoningRequest` body schema | [server.py:346-350](file:///home/duxiter/s2s/demo/server.py#L346-L350) |
| Fetch profilo Aquachile + cache 60 s | [_agent_profile_data](file:///home/duxiter/s2s/demo/server.py#L357-L397) |
| Parse istruzioni + tools dal JSON Aquachile | [_extract_agent_prompt](file:///home/duxiter/s2s/demo/server.py#L405-L424), [_extract_agent_tools](file:///home/duxiter/s2s/demo/server.py#L427-L437) |
| `/api/config` (bootstrap verso client) | [server.py:509-536](file:///home/duxiter/s2s/demo/server.py#L509-L536) |
| `/api/user/phone` (profilo chiamante) | [server.py:539-573](file:///home/duxiter/s2s/demo/server.py#L539-L573) |
| Esecuzione tool MCP (inoltro ad Aquachile) | [server.py /api/mcp/tool](file:///home/duxiter/s2s/demo/server.py#L576-L633) |
| `/api/reasoning` (OpenAI chat completions proxy) | [server.py:635-710](file:///home/duxiter/s2s/demo/server.py#L635-L710) |
| Proxy WS realtime, INPUT/OUTPUT text log | [_client_to_upstream](file:///home/duxiter/s2s/demo/server.py#L1112-L1171), [_upstream_to_client](file:///home/duxiter/s2s/demo/server.py#L1174-L1255) |
| `fetchConfig()` lato client | [main.js:2401-2439](file:///home/duxiter/s2s/demo/main.js#L2401-L2439) |
| `fetchVoceroUserProfile()` phone auth | [main.js:2370-2398](file:///home/duxiter/s2s/demo/main.js#L2370-L2398) |
| `effectiveReasoningInstructions()` assemblaggio prompt | [main.js:516-533](file:///home/duxiter/s2s/demo/main.js#L516-L533) |
| `effectiveInstructions()` (prompt per realtime, + OPENAI marker hint) | [main.js:537-539](file:///home/duxiter/s2s/demo/main.js#L537-L539) |
| Tool hint + MCP search hint costanti | [main.js:32-42](file:///home/duxiter/s2s/demo/main.js#L32-L42) |
| `voceroUserContextText()` caller profile injection | [main.js:454-458](file:///home/duxiter/s2s/demo/main.js#L454-L458) |
| `fetchOpenAiReasoningAnswer()` POST /api/reasoning client | [main.js:563-586](file:///home/duxiter/s2s/demo/main.js#L563-L586) |
| `maybeRunOpenAiReasoning()` trigger da transcript + inject `[VOCERO_RENDER_REPLY]` | [main.js:592-625](file:///home/duxiter/s2s/demo/main.js#L592-L625) |
| `pushLiveInstructions()` / `pushLiveToolPolicy()` update sessione live | [main.js:628-649](file:///home/duxiter/s2s/demo/main.js#L628-L649) |
| `doStart()` avvio sessione + invio istruzioni/tools iniziali | [main.js:2739-2788](file:///home/duxiter/s2s/demo/main.js#L2739-L2788) |
| Gestore transcript utente → trigger reasoning + ranking MCP | [main.js:2814-2821](file:///home/duxiter/s2s/demo/main.js#L2814-L2821) |