From d58be9613ff62811b2c76a1827cc5844b997e910 Mon Sep 17 00:00:00 2001 From: valenti Date: Thu, 3 Sep 2026 06:03:06 -0400 Subject: [PATCH] feat(workflows): add RAG publishing and startup reconciliation --- {demo => s2s-client}/.dockerignore | 0 {demo => s2s-client}/.gitignore | 0 {demo => s2s-client}/ARCHITECTURE.md | 0 {demo => s2s-client}/CONTEXT.md | 0 {demo => s2s-client}/DESIGN.md | 0 {demo => s2s-client}/Dockerfile | 0 {demo => s2s-client}/HOWITWORKS.md | 0 {demo => s2s-client}/README.md | 0 {demo => s2s-client}/auth.py | 0 .../0001-docker-space-with-search-proxy.md | 0 {demo => s2s-client}/index.html | 46 +- {demo => s2s-client}/index_video.html | 2 +- {demo => s2s-client}/limiter.py | 0 {demo => s2s-client}/main.js | 1953 +++------------- {demo => s2s-client}/project/index.html | 0 {demo => s2s-client}/public/docs.html | 0 {demo => s2s-client}/requirements.txt | 0 {demo => s2s-client}/server.py | 1990 +++++++++-------- {demo => s2s-client}/style.css | 0 {demo => s2s-client}/ui/account.js | 0 {demo => s2s-client}/ui/chat.js | 0 {demo => s2s-client}/ui/dom.js | 0 .../worklets/audio-playback.js | 0 {demo => s2s-client}/worklets/mic-capture.js | 0 {demo => s2s-client}/ws/codec.js | 0 {demo => s2s-client}/ws/orb-visualizer.js | 0 {demo => s2s-client}/ws/s2s-ws-client.js | 12 +- 27 files changed, 1516 insertions(+), 2487 deletions(-) rename {demo => s2s-client}/.dockerignore (100%) rename {demo => s2s-client}/.gitignore (100%) rename {demo => s2s-client}/ARCHITECTURE.md (100%) rename {demo => s2s-client}/CONTEXT.md (100%) rename {demo => s2s-client}/DESIGN.md (100%) rename {demo => s2s-client}/Dockerfile (100%) rename {demo => s2s-client}/HOWITWORKS.md (100%) rename {demo => s2s-client}/README.md (100%) rename {demo => s2s-client}/auth.py (100%) rename {demo => s2s-client}/docs/adr/0001-docker-space-with-search-proxy.md (100%) rename {demo => s2s-client}/index.html (92%) rename {demo => s2s-client}/index_video.html (99%) rename {demo => s2s-client}/limiter.py (100%) rename {demo => s2s-client}/main.js (55%) rename {demo => s2s-client}/project/index.html (100%) rename {demo => s2s-client}/public/docs.html (100%) rename {demo => s2s-client}/requirements.txt (100%) rename {demo => s2s-client}/server.py (56%) rename {demo => s2s-client}/style.css (100%) rename {demo => s2s-client}/ui/account.js (100%) rename {demo => s2s-client}/ui/chat.js (100%) rename {demo => s2s-client}/ui/dom.js (100%) rename {demo => s2s-client}/worklets/audio-playback.js (100%) rename {demo => s2s-client}/worklets/mic-capture.js (100%) rename {demo => s2s-client}/ws/codec.js (100%) rename {demo => s2s-client}/ws/orb-visualizer.js (100%) rename {demo => s2s-client}/ws/s2s-ws-client.js (99%) diff --git a/demo/.dockerignore b/s2s-client/.dockerignore similarity index 100% rename from demo/.dockerignore rename to s2s-client/.dockerignore diff --git a/demo/.gitignore b/s2s-client/.gitignore similarity index 100% rename from demo/.gitignore rename to s2s-client/.gitignore diff --git a/demo/ARCHITECTURE.md b/s2s-client/ARCHITECTURE.md similarity index 100% rename from demo/ARCHITECTURE.md rename to s2s-client/ARCHITECTURE.md diff --git a/demo/CONTEXT.md b/s2s-client/CONTEXT.md similarity index 100% rename from demo/CONTEXT.md rename to s2s-client/CONTEXT.md diff --git a/demo/DESIGN.md b/s2s-client/DESIGN.md similarity index 100% rename from demo/DESIGN.md rename to s2s-client/DESIGN.md diff --git a/demo/Dockerfile b/s2s-client/Dockerfile similarity index 100% rename from demo/Dockerfile rename to s2s-client/Dockerfile diff --git a/demo/HOWITWORKS.md b/s2s-client/HOWITWORKS.md similarity index 100% rename from demo/HOWITWORKS.md rename to s2s-client/HOWITWORKS.md diff --git a/demo/README.md b/s2s-client/README.md similarity index 100% rename from demo/README.md rename to s2s-client/README.md diff --git a/demo/auth.py b/s2s-client/auth.py similarity index 100% rename from demo/auth.py rename to s2s-client/auth.py diff --git a/demo/docs/adr/0001-docker-space-with-search-proxy.md b/s2s-client/docs/adr/0001-docker-space-with-search-proxy.md similarity index 100% rename from demo/docs/adr/0001-docker-space-with-search-proxy.md rename to s2s-client/docs/adr/0001-docker-space-with-search-proxy.md diff --git a/demo/index.html b/s2s-client/index.html similarity index 92% rename from demo/index.html rename to s2s-client/index.html index 04a7e39..ff957b5 100644 --- a/demo/index.html +++ b/s2s-client/index.html @@ -13,7 +13,7 @@ href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> - + @@ -355,6 +395,6 @@ - + diff --git a/demo/index_video.html b/s2s-client/index_video.html similarity index 99% rename from demo/index_video.html rename to s2s-client/index_video.html index 6f085e6..c41bd1f 100644 --- a/demo/index_video.html +++ b/s2s-client/index_video.html @@ -262,7 +262,7 @@ diff --git a/demo/limiter.py b/s2s-client/limiter.py similarity index 100% rename from demo/limiter.py rename to s2s-client/limiter.py diff --git a/demo/main.js b/s2s-client/main.js similarity index 55% rename from demo/main.js rename to s2s-client/main.js index 9030c7b..3c87375 100644 --- a/demo/main.js +++ b/s2s-client/main.js @@ -16,30 +16,20 @@ * @typedef {"idle" | "connecting" | "queued" | "your-turn" | "listening" | "user-speaking" | "processing" | "ai-speaking" | "error"} AppState */ -import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js?v=20260826001"; -import { $, escHtml, truncateError, DEBUG } from "./ui/dom.js?v=20260826001"; -import { ChatView } from "./ui/chat.js?v=20260826001"; -import { Account } from "./ui/account.js?v=20260826001"; +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"; const DEFAULT_VOICE = "Aiden"; const DEFAULT_INSTRUCTIONS = - "You are a friendly voice assistant. " + - "Keep replies short, warm, and spoken. Avoid long monologues."; + "You are Vocero, a friendly voice assistant. " + + "Keep replies short, warm, and spoken. Avoid long monologues. " + + "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."+ + "If you don't know the answer, say 'I don't know' instead of guessing."; -// Appended to the user's instructions whenever at least one tool is enabled. -// Stops the model from announcing capabilities ("Yes, I can search") and then -// idling for the next turn — it should act immediately in the same response. -const TOOL_USE_HINT = - " 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 like "Let me search for that..." and call the tool ' + - "right away in the same response."; - -const MCP_SEARCH_HINT = - " When the user asks for information, review the full MCP tool catalog, choose " + - "the single best tool for the request, and call it immediately when it can solve " + - "the task. If required parameters are missing, ask a short follow-up question. " + - "If no MCP tool fits, answer normally or use the other available tools."; const OPENAI_RENDER_MARKER = "[VOCERO_RENDER_REPLY]"; const OPENAI_RENDER_HINT = ` Do not answer ordinary user speech directly. Stay silent unless the latest user message starts with ` + @@ -47,114 +37,6 @@ const OPENAI_RENDER_HINT = `removing, or changing facts. Do not call tools while rendering that message.`; const VOCERO_USER_CONTEXT_HINT = " Caller profile resolved from the page phoneNumber: "; -/** @type {[RegExp, string][]} */ -const MCP_SEARCH_TRANSLATION_PHRASES = [ - [/informaciones del usuario/g, "user information"], - [/informacion del usuario/g, "user information"], - [/informazioni utente/g, "user information"], - [/informazione utente/g, "user information"], - [/report del dia/g, "day report"], - [/reporte del dia/g, "day report"], - [/report di oggi/g, "today report"], - [/reporte de hoy/g, "today report"], - [/report giornaliero/g, "daily report"], - [/reporte diario/g, "daily report"], - [/reporte de ayer/g, "yesterday report"], - [/reporte del día anterior/g, "previous day report"], - [/report di ieri/g, "yesterday report"], - [/report del giorno prima/g, "previous day report"], - [/último reporte/g, "latest report"], - [/ultimo reporte/g, "latest report"], - [/ultimo report/g, "latest report"], - [/report più recente/g, "most recent report"], - [/reporte más reciente/g, "most recent report"], - [/reporte mas reciente/g, "most recent report"], - [/más reciente/g, "most recent"], - [/mas reciente/g, "most recent"], - [/più recente/g, "most recent"], - [/listado de reportes/g, "list reports report list"], - [/lista di report/g, "list reports report list"], - [/reportes recientes/g, "recent reports list"], - [/report recenti/g, "recent reports list"], - [/mi numero es/g, "my number is"], - [/il mio numero e/g, "my number is"], - [/numero di telefono/g, "phone number"], - [/numero telefonico/g, "phone number"], - [/numero de telefono/g, "phone number"], - [/numero de whatsapp/g, "whatsapp number"], - [/informacion api/g, "api information"], - [/informazioni api/g, "api information"], -]; -/** @type {Record} */ -const MCP_SEARCH_TRANSLATION_TOKENS = { - ayuda: "help", - api: "api", - ayer: "yesterday", - ieri: "yesterday", - anterior: "previous", - precedente: "previous", - dami: "give", - dame: "give", - dammi: "give", - buscar: "search", - busca: "search", - busqueda: "search", - call: "call", - catalogo: "catalogue", - catalogue: "catalogue", - cerca: "search", - chiama: "call", - cliente: "customer", - clientes: "customers", - customer: "customer", - detalle: "detail", - detalles: "details", - dettagli: "details", - dia: "day", - diario: "daily", - dio: "day", - documento: "document", - documentos: "documents", - elenco: "list", - email: "email", - estado: "status", - estadoo: "status", - historial: "history", - hoy: "today", - informacion: "information", - informazioni: "information", - informe: "report", - informes: "reports", - lista: "list", - listado: "list", - liste: "lists", - mostrar: "show", - mostra: "show", - numero: "number", - numeros: "numbers", - obtener: "get", - ottieni: "get", - perfil: "profile", - profilo: "profile", - reciente: "recent", - recientes: "recent", - recente: "recent", - recenti: "recent", - reporte: "report", - reportes: "reports", - reporto: "report", - stato: "status", - telefono: "phone", - telefonoo: "phone", - usuario: "user", - utenti: "users", - utente: "user", - ultimo: "latest", - ultimi: "latest", - último: "latest", - últimos: "latest", - whatsapp: "whatsapp", -}; const STORAGE_KEYS = { directUrl: "s2s.ws.directUrl", @@ -386,9 +268,6 @@ const TOOL_DEFS = { }, }; -/** @type {import("./ws/s2s-ws-client.js").ToolDef[]} */ -let agentToolDefs = []; - function loadSettings() { return { directUrl: localStorage.getItem(STORAGE_KEYS.directUrl) || "", @@ -501,6 +380,21 @@ const toolsBtn = $("#tools-btn"); const toolsModal = $("#tools-modal"); /** @type {HTMLButtonElement} */ const toolsClose = $("#tools-close"); + +/** @type {HTMLButtonElement} */ +const ragBtn = $("#rag-btn"); +/** @type {HTMLDialogElement} */ +const ragModal = $("#rag-modal"); +/** @type {HTMLButtonElement} */ +const ragClose = $("#rag-close"); +/** @type {HTMLTextAreaElement} */ +const ragTextarea = $("#rag-textarea"); +/** @type {HTMLButtonElement} */ +const ragSubmit = $("#rag-submit"); +/** @type {HTMLButtonElement} */ +const ragClear = $("#rag-clear"); +/** @type {HTMLElement} */ +const ragHint = $("#rag-hint"); /** @type {HTMLInputElement} */ const toolWebSwitch = $("#tool-web"); /** @type {HTMLElement} */ @@ -515,10 +409,6 @@ const toolCamHint = $("#tool-cam-hint"); const camPip = $("#cam-pip"); /** @type {HTMLVideoElement} */ const camVideo = $("#cam-video"); -/** @type {HTMLElement} */ -const agentToolsHint = $("#agent-tools-hint"); -/** @type {HTMLElement} */ -const agentToolsList = $("#agent-tools-list"); /** @type {HTMLInputElement} */ const searchKeyInput = $("#search-key"); @@ -579,7 +469,75 @@ let allowDirect = true; let pinnedUrl = ""; // Deploy-pinned primary prompt (S2S_PRIMARY_PROMPT). Non-empty -> locked prompt. let pinnedPrompt = ""; -let pinnedPromptSource = ""; +// Feature flag learned from /api/config (mirrors server env S2S_INJECT_PROFILE). +// When true: caller profile resolved via ?phoneNumber= is BOTH: +// 1. baked into instructions (LLM system prompt) AND +// 2. sent as a separate `user_context` field in /api/reasoning body. +// When false (default, generic deploy): caller profile is ONLY sent as the +// user_context field (WS stays pure passthrough — instructions untouched). +let injectProfile = false; +// Feature flag: when true the browser logs FULL prompt texts (not just lengths) +// coming back from /api/config.debugPrompt = true when S2S_DEBUG_PROMPT=true on +// the server. Includes full effectiveReasoningInstructions, voceroUserContextText, +// full JSON body POSTed to /api/reasoning. +let debugPrompt = false; + +// Custom RAG context entered by the user via the header "RAG" button dialog. +// Treated exactly like voceroUserContextText / injectProfile context: +// 1. appended directly to LLM instructions (effectiveReasoningInstructions) +// 2. included in the user_context block sent to /api/reasoning body +// 3. if session WS is live (client != null), also pushed live to the open +// realtime session via session.update so the model sees it immediately. +let customRagText = ""; +let customRagLoaded = false; +const VOCERO_RAG_HINT = " Custom RAG context provided by the operator (treat as trusted context for this session): "; +function customRagContextText() { + if (!customRagLoaded || !customRagText) return ""; + const trimmed = customRagText.trim(); + if (!trimmed) return ""; + const capped = trimmed.length > 12000 ? `${trimmed.slice(0, 12000)}…` : trimmed; + return `${VOCERO_RAG_HINT}<<>>\n${capped}\n<<>>`; +} +function pushCustomRagToLiveSessionIfAny() { + const ctx = customRagContextText(); + if (!ctx) return; + if (typeof client === "undefined" || !client || typeof client.updateSession !== "function") return; + const currentInstructions = (pinnedPrompt || settings.instructions || "").trim(); + const profileCtx = injectProfile ? (voceroUserContextText() || "") : ""; + const next = [currentInstructions, profileCtx, ctx] + .map((s) => (s || "").trim()) + .filter(Boolean) + .join(" "); + console.info( + `[CUSTOM-RAG] pushed LIVE to open session via client.updateSession() contextLen=%d finalInstructionsLen=%d`, + ctx.length, next.length, + ); + if (debugPrompt) { + console.info(`[DEBUG-PROMPT][CUSTOM-RAG LIVE session.update instructions] len=%d:\n%s\n`, next.length, next); + } + try { + client.updateSession({ instructions: next }); + } catch (err) { + console.warn(`[CUSTOM-RAG] updateSession threw (ignored; will re-apply on next start): %s`, err instanceof Error ? err.message : String(err)); + } +} +function syncRagHintFooter() { + if (!ragHint) return; + if (!customRagLoaded || !customRagText.trim()) { + ragHint.textContent = "Nessun RAG context caricato. Premi \"Invia nella sessione\" per applicare le modifiche."; + return; + } + const lines = customRagText.split(/\r?\n/).length; + ragHint.textContent = `✅ RAG context caricato: ${customRagText.length} chars / ${lines} linee. Viene aggiunto a istruzioni LLM e user_context.`; +} +// Dedupe cache for [INJECT-PROFILE] browser logs (avoid 20 logs per click). +let _lastInjectProfileLogKey = ""; +/** @param {string} key @param {() => void} logFn */ +function _logOnce(key, logFn) { + if (key === _lastInjectProfileLogKey) return; + _lastInjectProfileLogKey = key; + logFn(); +} // ── Tool state ────────────────────────────────────────────────────────────── let toolsEnabled = loadTools(); @@ -587,30 +545,17 @@ let toolsEnabled = loadTools(); let serverSearchKey = false; // A user-supplied key (fallback when the deploy has none). localStorage only. let userSearchKey = localStorage.getItem(STORAGE_KEYS.searchKey) || ""; -let searchMcpEnabled = false; let lastUserQuery = ""; -/** @type {{ tool: import("./ws/s2s-ws-client.js").ToolDef, score: number }[]} */ -let rankedAgentTools = []; -let liveInstructionsSnapshot = ""; -let liveToolPolicySnapshot = ""; /** @type {Record} */ let mcpToolCallState = Object.create(null); const pageParams = new URLSearchParams(window.location.search); +// Caller identity info: loaded from /api/user/phone when the URL param +// ?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(); /** @type {Record | null} */ let voceroUserProfile = null; let voceroUserProfileLoaded = false; -const AUTO_MCP_MIN_SCORE = 20; -const AUTO_MCP_MIN_SCORE_WITH_LEAD = 12; -const AUTO_MCP_MIN_LEAD = 4; -const AUTO_MCP_RELAXED_REPORT_SCORE = 15; -const AUTO_MCP_FAST_DELAY_MS = 150; -const AUTO_MCP_FALLBACK_DELAY_MS = 1200; -let autoMcpInFlight = false; -let lastAutoMcpTurnKey = ""; -let pendingAutoMcpTimer = 0; -let lastDeclaredAssistantToolKey = ""; -const processedReasoningTurns = new Set(); /** * @param {string} hypothesisId @@ -634,7 +579,7 @@ function searchAvailable() { function activeToolDefs() { const defs = []; if (toolsEnabled.web_search && searchAvailable()) defs.push(TOOL_DEFS.web_search); - return defs.concat(agentToolDefs); + return defs; } /** @returns {string} */ @@ -682,41 +627,87 @@ function syncVoceroHeaderUser() { identUserName.textContent = name ? `Utente: ${name}` : ""; } -/** - * @param {import("./ws/s2s-ws-client.js").ToolDef[]} tools - * @returns {string} - */ -function buildMcpToolCatalogSummary(tools) { - return tools - .map((tool) => { - const required = requiredToolParams(tool.parameters); - const requiredText = required.length ? ` required: ${required.join(", ")}` : " required: none"; - const desc = (tool.description || "No description provided.") - .replace(/\s+/g, " ") - .trim(); - return `${tool.name} - ${desc}.${requiredText}.`; - }) - .join(" "); -} - /** Instructions used by the reasoning engine. */ 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)}`; + // When S2S_INJECT_PROFILE=true (deploy flag) the caller profile is also + // baked directly into the instructions prompt text, in addition to being + // sent separately in the user_context body field. + // When S2S_INJECT_PROFILE=false (generic deploy / default / RAG backend) + // user_context lives only in the separate body field; instructions stay + // clean and the WS proxy remains pure passthrough. + const ctx = voceroUserContextText(); + let profileAppended = false; + if (injectProfile) { + if (ctx) { + instructions = instructions ? `${instructions} ${ctx}` : ctx; + profileAppended = true; + } } - const suggested = topSuggestedAgentTools(3); - if (searchMcpEnabled && suggested.length) { - const lines = suggested - .map((entry, index) => `${index + 1}. ${entry.tool.name}: ${entry.tool.description || "No description provided."}`) - .join(" "); - instructions += ` Current user request: "${lastUserQuery}". Client-side ranking hint only: ${lines}. Use this hint only as a secondary signal after reviewing the full MCP catalog.`; + // Custom operator RAG context (pasted via header RAG dialog) is ALWAYS + // appended to instructions when non-empty (operator intent is explicit). + const ragCtx = customRagContextText(); + let ragAppended = false; + if (ragCtx) { + instructions = instructions ? `${instructions} ${ragCtx}` : ragCtx; + ragAppended = true; + } + const key = `effInst:${injectProfile ? "T" : "F"}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}:${(base || "").length}`; + _logOnce(key, () => { + if (ragAppended) { + console.info( + `[CUSTOM-RAG] ✅ effectiveReasoningInstructions APPENDED custom RAG context inside LLM prompt. ragCtxLen=%d baseLen=%d profileAppended=%s -> finalLen=%d`, + ragCtx.length, (base || "").length, profileAppended, instructions.length, + ); + } + if (injectProfile) { + if (profileAppended) { + console.info( + `[INJECT-PROFILE] ✅ BROWSER effectiveReasoningInstructions APPENDED caller profile inside LLM prompt. baseLen=%d ctxLen=%d -> finalLen=%d`, + (base || "").length, ctx.length, instructions.length, + ); + } else { + console.info( + `[INJECT-PROFILE] ⏭️ BROWSER S2S_INJECT_PROFILE=true but NO profile context to inject (fetchVoceroUserProfile missing / failed / no ?phoneNumber=?). baseLen=%d`, + (base || "").length, + ); + } + } else { + console.info( + `[INJECT-PROFILE] ⏭️ BROWSER S2S_INJECT_PROFILE=false -> instructions stay GENERIC, profile NOT baked inside LLM prompt, WS pure passthrough. Profile is ONLY in body user_context (if any). baseLen=%d`, + (base || "").length, + ); + } + }); + if (debugPrompt) { + const key2 = `debugEffInst:${instructions.length}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}`; + _logOnce(key2, () => { + console.info( + `[DEBUG-PROMPT][BROWSER effectiveReasoningInstructions] FULL TEXT len=%d profile=%s rag=%s:\n%s\n`, + instructions.length, + ctx ? `${ctx.length}c` : "none", + ragCtx ? `${ragCtx.length}c` : "none", + instructions, + ); + if (ctx) { + console.info( + `[DEBUG-PROMPT][BROWSER voceroUserContextText] FULL TEXT len=%d:\n%s\n`, + ctx.length, ctx, + ); + } else { + console.info(`[DEBUG-PROMPT][BROWSER voceroUserContextText] EMPTY len=0`); + } + if (ragCtx) { + console.info( + `[DEBUG-PROMPT][BROWSER customRagContextText] FULL TEXT len=%d:\n%s\n`, + ragCtx.length, ragCtx, + ); + } else { + console.info(`[DEBUG-PROMPT][BROWSER customRagContextText] EMPTY len=0`); + } + }); } - const userContext = voceroUserContextText(); - if (userContext) instructions += userContext; return instructions; } @@ -727,37 +718,69 @@ function effectiveInstructions() { } /** - * @param {string} answer * @returns {string} */ +function preferredResponseLanguage() { + // Hard-pin output language to Spanish regardless of the browser locale, + // matching DEFAULT_INSTRUCTIONS ("respond ALWAYS and ONLY in Spanish"). + // The TTS voice picker and /api/reasoning body will both use this. + return "es-ES"; +} + +/** + * @param {string} answer @returns {string} + */ function buildRenderOnlyPrompt(answer) { const clean = String(answer || "").trim(); return clean ? `${OPENAI_RENDER_MARKER}\n${clean}` : ""; } -/** - * @returns {string} - */ -function preferredResponseLanguage() { - const lang = (navigator.language || document.documentElement.lang || "").trim(); - return lang || ""; -} - /** * @param {string} transcript * @returns {Promise<{ answer: string, log_only: boolean, raw: any }>} */ async function fetchOpenAiReasoningAnswer(transcript) { + const instructions = effectiveReasoningInstructions(); + const profileCtx = voceroUserContextText(); + const ragCtx = customRagContextText(); + const user_context = [profileCtx, ragCtx].map((s) => (s || "").trim()).filter(Boolean).join("\n\n"); + const phoneNumber = voceroUserPhoneNumber || null; + const body = { + transcript, + instructions, + language: preferredResponseLanguage(), + user_context, + phoneNumber, + }; + const logKey = `reasoningBody:${injectProfile ? "T" : "F"}:${debugPrompt ? "T" : "F"}:${phoneNumber ? "hasPhone" : "noPhone"}:${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`, + injectProfile, + transcript.length, + instructions.length, + user_context.length, + profileCtx.length, + ragCtx.length, + phoneNumber ? phoneNumber.length : "null", + ); + if (ragCtx) { + console.info( + `[CUSTOM-RAG] included in POST /api/reasoning user_context block ragLen=%d`, + ragCtx.length, + ); + } + }); + if (debugPrompt) { + console.info( + `[DEBUG-PROMPT][BROWSER POST /api/reasoning] FULL body JSON:\n%s\n`, + JSON.stringify(body, null, 2), + ); + } const res = await fetch("api/reasoning", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - transcript, - instructions: effectiveReasoningInstructions(), - language: preferredResponseLanguage(), - user_context: voceroUserContextText(), - phoneNumber: voceroUserPhoneNumber || null, - }), + body: JSON.stringify(body), }); if (!res.ok) { let detail = String(res.status); @@ -782,11 +805,8 @@ async function fetchOpenAiReasoningAnswer(transcript) { async function maybeRunOpenAiReasoning(transcript, itemId) { if (!["OPENAI", "NODE_RED"].includes(reasoningType)) return; const text = transcript.trim(); - const turnKey = itemId || text; - if (!text || !turnKey || processedReasoningTurns.has(turnKey)) return; - processedReasoningTurns.add(turnKey); + if (!text) return; if (!client || !LIVE_STATES.has(currentState)) return; - clearPendingAutoMcpFallback(); reportDebug("R", "main.js:maybeRunOpenAiReasoning", "starting external reasoning turn", { itemId, transcriptLength: text.length, @@ -869,106 +889,29 @@ async function maybeRunOpenAiReasoning(transcript, itemId) { } } -/** @param {string} instructions */ -function pushLiveInstructions(instructions) { - if (!client || !LIVE_STATES.has(currentState)) return; - if (instructions === liveInstructionsSnapshot) return; - liveInstructionsSnapshot = instructions; - // #region debug-point C:push-live-instructions - reportDebug("C", "main.js:pushLiveInstructions", "pushing computed instructions to live session", { - instructionLength: instructions.length, - lastUserQuery, - suggestedTools: topSuggestedAgentTools(3).map((entry) => entry.tool.name), - }); - // #endregion - client.updateSession({ instructions }); -} - -function pushLiveToolPolicy() { - if (!client || !LIVE_STATES.has(currentState)) return; - const tools = activeToolDefs(); - const toolChoice = tools.length ? "auto" : "none"; - const snapshot = JSON.stringify({ toolChoice, toolNames: tools.map((tool) => tool.name) }); - if (snapshot === liveToolPolicySnapshot) return; - liveToolPolicySnapshot = snapshot; - // #region debug-point B:push-live-tool-policy - reportDebug("B", "main.js:pushLiveToolPolicy", "pushing live tool policy", { - toolChoice, - toolNames: tools.map((tool) => tool.name), - lastUserQuery, - searchMcpEnabled, - }); - // #endregion - client.setTools(tools, toolChoice); -} - -function clearPendingAutoMcpFallback() { - if (!pendingAutoMcpTimer) return; - window.clearTimeout(pendingAutoMcpTimer); - pendingAutoMcpTimer = 0; -} - -/** - * @param {string} text - * @returns {import("./ws/s2s-ws-client.js").ToolDef | undefined} - */ -function extractDeclaredAssistantTool(text) { - const match = text.match(/<\s*calls?\s+tool\s*:\s*([a-z0-9_.-]+)\s*>/i); - if (!match?.[1]) return undefined; - const toolName = match[1].trim(); - return findAgentToolDef(toolName); -} - -/** - * @param {string} text - * @param {string} responseId - */ -function maybeRunDeclaredAssistantTool(text, responseId = "") { - if (!client || !LIVE_STATES.has(currentState) || autoMcpInFlight) return; - const tool = extractDeclaredAssistantTool(text); - if (!tool) return; - const dedupeBase = responseId || normalizeMcpSearchText(text); - const dedupeKey = `${dedupeBase}::${tool.name}`; - if (dedupeKey === lastDeclaredAssistantToolKey) return; - lastDeclaredAssistantToolKey = dedupeKey; - clearPendingAutoMcpFallback(); - autoMcpInFlight = true; - console.debug( - `[Vocero MCP DECLARED] trigger responseId=${JSON.stringify(responseId)} tool=${tool.name} ` + - `query=${JSON.stringify(lastUserQuery)}` - ); - client.suppressAssistantAudio?.(); - chat.onToolCall(tool.name); - void callAgentToolFromPanel(tool.name, true, lastUserQuery).finally(() => { - autoMcpInFlight = false; - }); -} - /** Push the active tool set to a live session so toggles apply mid-call. */ function pushToolsToSession() { if (!client || !LIVE_STATES.has(currentState)) return; const tools = activeToolDefs(); + const toolChoice = tools.length ? "auto" : "none"; // #region debug-point B:push-tools-session reportDebug("B", "main.js:pushToolsToSession", "pushing tools to live session", { state: currentState, toolNames: tools.map((tool) => tool.name), toolCount: tools.length, - searchMcpEnabled, }); // #endregion - liveToolPolicySnapshot = ""; - client.setTools(tools, tools.length ? "auto" : "none"); + client.setTools(tools, toolChoice); // The hidden tool-use hint depends on whether any tool is active, so refresh // instructions alongside the tool set. const instructions = effectiveInstructions(); // #region debug-point C:push-instructions-session reportDebug("C", "main.js:pushToolsToSession", "updating live session instructions", { instructionLength: instructions.length, - hasSuggestedTools: topSuggestedAgentTools(3).length > 0, lastUserQuery, }); // #endregion - pushLiveInstructions(instructions); + if (typeof client.updateSession === "function") client.updateSession({ instructions }); } // ── Chat view ─────────────────────────────────────────────────────────────── @@ -1039,7 +982,7 @@ function _kickIdleTimer(reason = "activity") { // teardown() calls setState("idle") which restores the normal idle // caption. Leave a visible breadcrumb on the subcaption briefly so the // user can tell what happened instead of wondering why the orb reset. - circleSubcaption.textContent = "30 sec sil."; + circleSubcaption.textContent = "Sessione chiusa dopo 30 secondi di silenzio. Tocca l'orb o ripeti «Hola Vocero» per iniziare di nuovo."; circleSubcaption.hidden = false; window.setTimeout(() => { circleSubcaption.hidden = true; }, 8000); })(); @@ -1146,9 +1089,7 @@ function openSettings() { inputInstructions.readOnly = !!pinnedPrompt; primaryPromptMeta.hidden = !pinnedPrompt; primaryPromptMeta.textContent = pinnedPrompt - ? pinnedPromptSource - ? `Primary prompt from ${pinnedPromptSource}` - : "Primary prompt pinned by this deployment." + ? "Primary prompt pinned by this deployment." : ""; syncGateUi(); updateRestartAvailability(); @@ -1294,961 +1235,80 @@ aboutModal.addEventListener("click", (e) => { if (e.target === aboutModal) aboutModal.close(); }); -// ── Tools panel ─────────────────────────────────────────────────────────── - -/** Reflect the current tool state into the panel controls. */ -function syncToolsUi() { - const openToolNames = new Set( - Array.from(agentToolsList.querySelectorAll("details[open][data-mcp-tool-item]")) - .map((item) => item.getAttribute("data-mcp-tool-item") || "") - .filter(Boolean), - ); - const avail = searchAvailable(); - toolWebSwitch.checked = toolsEnabled.web_search && avail; - toolWebSwitch.disabled = !avail; - toolWebRow.classList.toggle("disabled", !avail); - toolCamSwitch.closest("div")?.setAttribute("hidden", ""); - toolCamHint.setAttribute("hidden", ""); - camPip.setAttribute("hidden", ""); - - if (serverSearchKey) { - // Key lives server-side: show it as configured, never expose it. - searchKeyInput.value = ""; - searchKeyInput.placeholder = "•••••••• · provided by the server"; - searchKeyInput.disabled = true; - toolWebHint.textContent = "Ready. The search key is held server-side and never sent to your browser."; - } else { - searchKeyInput.disabled = false; - searchKeyInput.value = userSearchKey; - searchKeyInput.placeholder = "Paste a Serper key to enable web search"; - toolWebHint.textContent = userSearchKey - ? "Using your key — stored in this browser only." - : "No server key configured. Add your own Serper key to enable web search."; - } - - const count = agentToolDefs.length; - if (!count) { - agentToolsHint.textContent = "No MCP tools loaded from the remote agent profile."; - } else if (searchMcpEnabled && lastUserQuery && rankedAgentTools.length) { - agentToolsHint.textContent = `${count} MCP tools loaded. Top matches for "${truncateError(lastUserQuery)}".`; - } else if (searchMcpEnabled && lastUserQuery) { - agentToolsHint.textContent = `${count} MCP tools loaded. No description match found for "${truncateError(lastUserQuery)}".`; - } else { - agentToolsHint.textContent = `${count} MCP tool${count === 1 ? "" : "s"} loaded from the remote agent profile.`; - } - agentToolsList.innerHTML = renderAgentToolsHtml(); - for (const toolName of openToolNames) { - const item = agentToolsList.querySelector(`details[data-mcp-tool-item="${CSS.escape(toolName)}"]`); - if (item instanceof HTMLDetailsElement) item.open = true; - } -} - -/** @returns {string} */ -function renderAgentToolsHtml() { - if (!agentToolDefs.length) return '
No MCP tools available.
'; - const suggested = topSuggestedAgentTools(3); - const suggestedNames = new Set(suggested.map((entry) => entry.tool.name)); - let html = ""; - if (searchMcpEnabled && lastUserQuery) { - html += suggested.length - ? ` -
-
Suggested For Current Request
-
${escHtml(lastUserQuery)}
-
- ` - : ` -
- No MCP tool description matches the current request. -
- `; - } - const orderedTools = suggested - .map((entry) => entry.tool) - .concat(agentToolDefs.filter((tool) => !suggestedNames.has(tool.name))); - html += orderedTools.map((tool) => { - const paramRows = describeToolParams(tool.parameters); - const toolState = getMcpToolCallState(tool.name); - const suggestedIndex = suggested.findIndex((entry) => entry.tool.name === tool.name); - const badge = suggestedIndex >= 0 - ? `Suggested #${suggestedIndex + 1}` - : 'MCP'; - return ` -
- -
- ${escHtml(tool.name)} - ${badge} -
- ${escHtml(tool.description || "No description provided.")} -
-
-
Parameters
- ${paramRows || '
No parameters.
'} -
Manual Call
- -
- - -
- ${renderMcpToolCallResultHtml(tool.name)} -
-
- `; - }).join(""); - return html; -} - -/** @param {number} limit */ -function topSuggestedAgentTools(limit) { - return rankedAgentTools.slice(0, limit); -} +function syncToolsUi() {} /** - * @param {string} query - * @param {{ partial?: boolean, itemId?: string }} [options] + * @param {string} _query + * @param {any} [_opts] */ -function updateMcpToolSuggestions(query, options = {}) { - lastUserQuery = query.trim(); - console.debug( - `[MCP RANK] updateMcpToolSuggestions ENTRY partial=${!!options.partial} itemId=${options.itemId || "-"} ` + - `query=${JSON.stringify(lastUserQuery)} searchMcpEnabled=${searchMcpEnabled}`, - ); - rankedAgentTools = rankAgentTools(lastUserQuery); - // #region debug-point A:ranked-mcp-suggestions - reportDebug("A", "main.js:updateMcpToolSuggestions", "ranked MCP tools for user query", { - partial: !!options.partial, - query: lastUserQuery, - ranked: rankedAgentTools.map((entry) => ({ name: entry.tool.name, score: entry.score })), - searchMcpEnabled, - }); - // #endregion - const rankedNames = rankedAgentTools.map((entry) => `${entry.tool.name}:${entry.score}`).join(", "); - console.debug( - `[MCP RANK] updateMcpToolSuggestions RESULTS partial=${!!options.partial} ` + - `matchedTools=${rankedAgentTools.length} ranked=${rankedNames || "none"}`, - ); - if (!options.partial && rankedAgentTools.length) { - const top3 = rankedAgentTools - .slice(0, 3) - .map((entry, index) => `${index + 1}:${entry.tool.name}:${entry.score}`) - .join(" | "); - console.debug(`[MCP RANK] ✦ FINAL top3 query=${JSON.stringify(lastUserQuery)} top3=${top3 || "none"}`); - } - syncToolsUi(); - if (client && LIVE_STATES.has(currentState)) { - const instructions = effectiveInstructions(); - if (options.partial && rankedAgentTools.length) { - pushLiveToolPolicy(); - pushLiveInstructions(instructions); - } else if (!options.partial) { - pushLiveToolPolicy(); - pushLiveInstructions(instructions); - maybeAutoRunSuggestedMcpTool(lastUserQuery, options.itemId || ""); - } - } -} +function updateMcpToolSuggestions(_query, _opts) {} -/** - * @param {string} query - * @param {string} itemId - */ -function maybeAutoRunSuggestedMcpTool(query, itemId) { - if (!client || !LIVE_STATES.has(currentState) || !searchMcpEnabled || autoMcpInFlight) return; - const normalized = normalizeMcpSearchText(query); - const top = rankedAgentTools[0]; - if (!normalized || !top) return; - const second = rankedAgentTools[1]; - const topNameText = normalizeMcpSearchText(top.tool.name); - const topDescText = normalizeMcpSearchText(top.tool.description || ""); - const topRequiredParams = requiredToolParams(top.tool.parameters); - const relaxedDailyReportMatch = top.score >= AUTO_MCP_RELAXED_REPORT_SCORE && - !topRequiredParams.length && - ( - ((topNameText.includes("day") || topNameText.includes("daily") || topNameText.includes("today")) && - topNameText.includes("report")) || - ((topDescText.includes("day") || topDescText.includes("daily") || topDescText.includes("today")) && - topDescText.includes("report")) - ); - const strongByScore = top.score >= AUTO_MCP_MIN_SCORE; - const strongByLead = top.score >= AUTO_MCP_MIN_SCORE_WITH_LEAD && - (!second || (top.score - second.score) >= AUTO_MCP_MIN_LEAD); - const strongMatch = top.score >= AUTO_MCP_MIN_SCORE || - (top.score >= AUTO_MCP_MIN_SCORE_WITH_LEAD && (!second || (top.score - second.score) >= AUTO_MCP_MIN_LEAD)) || - relaxedDailyReportMatch; - if (!strongMatch) { - console.debug(`[Vocero MCP AUTO] skip weak match query=${JSON.stringify(query)} top=${top.tool.name}:${top.score}`); - return; - } - const turnKey = `${itemId || normalized}::${top.tool.name}`; - if (turnKey === lastAutoMcpTurnKey) return; - lastAutoMcpTurnKey = turnKey; - clearPendingAutoMcpFallback(); - const fastSafeMatch = !topRequiredParams.length && (top.score >= 40 || relaxedDailyReportMatch || strongByLead); - const autoDelayMs = fastSafeMatch ? AUTO_MCP_FAST_DELAY_MS : AUTO_MCP_FALLBACK_DELAY_MS; - reportDebug("A", "main.js:maybeAutoRunSuggestedMcpTool", "scheduled MCP fallback for matched user query", { - itemId, - query, - toolName: top.tool.name, - score: top.score, - nextScore: second?.score ?? null, - autoDelayMs, - }); - console.debug( - `[Vocero MCP AUTO] scheduled query=${JSON.stringify(query)} itemId=${JSON.stringify(itemId)} ` + - `tool=${top.tool.name} score=${top.score} reason=${strongByScore ? "strict" : strongByLead ? "lead" : "relaxed-report"} ` + - `delayMs=${autoDelayMs}` - ); - pendingAutoMcpTimer = window.setTimeout(() => { - pendingAutoMcpTimer = 0; - if (!client || !LIVE_STATES.has(currentState) || autoMcpInFlight) return; - autoMcpInFlight = true; - console.debug( - `[Vocero MCP AUTO] trigger query=${JSON.stringify(query)} itemId=${JSON.stringify(itemId)} ` + - `tool=${top.tool.name} score=${top.score}` - ); - client.suppressAssistantAudio?.(); - chat.onToolCall(top.tool.name); - void callAgentToolFromPanel(top.tool.name, true, query).finally(() => { - autoMcpInFlight = false; - }); - }, autoDelayMs); -} - -/** - * @param {string} query - * @returns {{ tool: import("./ws/s2s-ws-client.js").ToolDef, score: number }[]} - */ -function rankAgentTools(query) { - console.debug(`[MCP RANK] ════════════════════════════════════════`); - console.debug(`[MCP RANK] rankAgentTools START query=${JSON.stringify(query)} searchMcpEnabled=${searchMcpEnabled} agentToolDefsCount=${agentToolDefs.length}`); - const searchTexts = buildMcpSearchTexts(query); - const normalized = searchTexts[0] || ""; - if (!searchMcpEnabled || !normalized || !agentToolDefs.length) { - const reasonParts = []; - if (!searchMcpEnabled) reasonParts.push("searchMcpEnabled=false — da /api/config: MCP Aquachile feature OFF oppure S2S_TOOL_EXECUTION_MCP_URL non configurato"); - if (!normalized) reasonParts.push("query-normalized-empty"); - if (!agentToolDefs.length) reasonParts.push("agentToolDefs=0 — catalogo tool MCP vuoto: profilo agente non ha tools in agentProfile via Aquachile /api/agents/profile"); - console.debug( - `[MCP RANK] → EARLY EXIT reason="${reasonParts.join("; ")}" ` + - `searchMcpEnabled=${searchMcpEnabled} normalized=${!!normalized} agentToolDefs=${agentToolDefs.length}`, - ); - console.debug(`[MCP RANK] ════════════════════════════════════════ (empty result)`); - return []; - } - const tokens = [...new Set(searchTexts.flatMap((text) => text.split(" ").filter((token) => token.length >= 3)))]; - if (!tokens.length) { - console.debug( - `[MCP RANK] → EARLY EXIT reason="nessun token >= 3 chars dopo split" searchTexts=${JSON.stringify(searchTexts)}`, - ); - console.debug(`[MCP RANK] ════════════════════════════════════════ (empty result)`); - return []; - } - const dailyReportIntent = searchTexts.some((text) => - /\b(day|daily|today)\b/.test(text) && /\breport\b/.test(text), - ); - console.debug( - `[MCP RANK] searchTexts=${JSON.stringify(searchTexts)} ` + - `tokensCount=${tokens.length} tokens=${JSON.stringify(tokens)} ` + - `dailyReportIntent=${dailyReportIntent}`, - ); - const scored = agentToolDefs - .map((tool) => { - const nameText = normalizeMcpSearchText(tool.name); - const descText = normalizeMcpSearchText(tool.description || ""); - const paramText = normalizeMcpSearchText(extractToolParamSearchText(tool.parameters)); - const requiredParams = requiredToolParams(tool.parameters); - const breakdown = { - nameHitFull: 0, - descHitFull: 0, - paramHitFull: 0, - nameHitTokens: 0, - descHitTokens: 0, - paramHitTokens: 0, - allFoundBonus: 0, - noRequiredParamsBonus: 0, - dailyReportMatchBoost: 0, - dailyReportPenalty: 0, - }; - let score = 0; - for (const searchText of searchTexts) { - if (!searchText) continue; - if (descText.includes(searchText)) { - const add = searchText === normalized ? 20 : 16; - score += add; - breakdown.descHitFull += add; - } - if (nameText.includes(searchText)) { - const add = searchText === normalized ? 24 : 18; - score += add; - breakdown.nameHitFull += add; - } - if (paramText.includes(searchText)) { - const add = searchText === normalized ? 6 : 4; - score += add; - breakdown.paramHitFull += add; - } - } - const tokenMatchDetails = []; - for (const token of tokens) { - const onName = nameText.includes(token); - const onDesc = descText.includes(token); - const onParam = paramText.includes(token); - if (!onName && !onDesc && !onParam) continue; - const entry = { token }; - if (onName) { score += 8; breakdown.nameHitTokens += 8; entry.name = true; } - if (onDesc) { score += 5; breakdown.descHitTokens += 5; entry.desc = true; } - if (onParam) { score += 2; breakdown.paramHitTokens += 2; entry.param = true; } - tokenMatchDetails.push(entry); - } - const allFound = searchTexts.some((searchText) => - searchText.split(" ").filter((token) => token.length >= 3).every((token) => - nameText.includes(token) || descText.includes(token) || paramText.includes(token), - ), - ); - if (allFound) { score += 10; breakdown.allFoundBonus = 10; } - if (!requiredParams.length) { score += 3; breakdown.noRequiredParamsBonus = 3; } - if (dailyReportIntent) { - if ((nameText.includes("day report") || nameText.includes("daily report") || nameText.includes("today")) && - (descText.includes("day report") || descText.includes("daily report") || descText.includes("today"))) { - score += 24; - breakdown.dailyReportMatchBoost = 24; - } else if (requiredParams.length) { - score -= 10; - breakdown.dailyReportPenalty = -10; - } - } - console.debug( - `[MCP RANK] ▸ tool=${JSON.stringify(tool.name)} score=${score} ` + - `requiredParams=${requiredParams.length} allFound=${allFound}\n` + - `[MCP RANK] ↳ fullMatches: name=${breakdown.nameHitFull} desc=${breakdown.descHitFull} params=${breakdown.paramHitFull}\n` + - `[MCP RANK] ↳ tokenMatches(${tokenMatchDetails.length}): ${tokenMatchDetails.length ? JSON.stringify(tokenMatchDetails) : "-"}\n` + - `[MCP RANK] ↳ bonuses: allFound=${breakdown.allFoundBonus} noRequiredParams=${breakdown.noRequiredParamsBonus} ` + - `dailyBoost=${breakdown.dailyReportMatchBoost} dailyPenalty=${breakdown.dailyReportPenalty}\n` + - `[MCP RANK] ↳ nameTokens=${JSON.stringify(nameText.slice(0, 100))}\n` + - `[MCP RANK] ↳ descTokens=${JSON.stringify(descText.slice(0, 200))}\n` + - `[MCP RANK] ↳ paramTokens=${JSON.stringify(paramText.slice(0, 150))}`, - ); - return { tool, score }; - }); - const filtered = scored.filter((entry) => entry.score > 0); - const sorted = filtered.sort((a, b) => b.score - a.score || a.tool.name.localeCompare(b.tool.name)); - console.debug( - `[MCP RANK] DONE scoredCount=${scored.length} matchedCount=${filtered.length} ` + - `top5=${JSON.stringify(sorted.slice(0, 5).map((e) => ({ t: e.tool.name, s: e.score })))}`, - ); - console.debug(`[MCP RANK] ════════════════════════════════════════`); - return sorted; -} - -/** - * @param {string} text - * @returns {string} - */ -function translateMcpSearchTextToEnglish(text) { - const before = normalizeMcpSearchText(text); - let translated = before; - const appliedPhrases = []; - for (const [pattern, replacement] of MCP_SEARCH_TRANSLATION_PHRASES) { - const previous = translated; - translated = translated.replace(pattern, replacement); - if (translated !== previous) { - appliedPhrases.push({ pattern: String(pattern), to: replacement }); - } - } - const beforeTokens = translated; - const appliedTokens = []; - translated = translated - .split(" ") - .map((token) => { - const mapped = MCP_SEARCH_TRANSLATION_TOKENS[token]; - if (mapped && mapped !== token) { - appliedTokens.push({ from: token, to: mapped }); - return mapped; - } - return token; - }) - .join(" "); - translated = normalizeMcpSearchText(translated); - console.debug( - `[MCP RANK] translate input=${JSON.stringify(before)} phrasesApplied=${appliedPhrases.length} ` + - `tokensApplied=${appliedTokens.length} output=${JSON.stringify(translated)}` + - (appliedPhrases.length ? ` phrases=${JSON.stringify(appliedPhrases)}` : "") + - (appliedTokens.length ? ` tokens=${JSON.stringify(appliedTokens)}` : ""), - ); - return translated; -} - -/** - * @param {string} text - * @returns {string[]} - */ -function buildMcpSearchTexts(text) { - const normalized = normalizeMcpSearchText(text); - const translated = translateMcpSearchTextToEnglish(text); - const result = [...new Set([normalized, translated].filter(Boolean))]; - console.debug( - `[MCP RANK] buildSearchTexts raw=${JSON.stringify(text)} ` + - `normalized=${JSON.stringify(normalized)} translated=${JSON.stringify(translated)} ` + - `count=${result.length} final=${JSON.stringify(result)}`, - ); - return result; -} - -/** - * @param {string} text - * @returns {string} - */ -function normalizeMcpSearchText(text) { - return text - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^a-z0-9]+/g, " ") - .trim(); -} - -/** - * @param {any} schema - * @returns {string} - */ -function extractToolParamSearchText(schema) { - const props = schema && typeof schema === "object" && schema.properties && typeof schema.properties === "object" - ? schema.properties - : null; - if (!props) return ""; - /** @type {string[]} */ - const chunks = []; - for (const [name, raw] of Object.entries(props)) { - chunks.push(name); - if (raw && typeof raw === "object" && typeof raw.description === "string") { - chunks.push(raw.description); - } - } - return chunks.join(" "); -} - -/** - * @param {any} schema - * @returns {string} - */ -function describeToolParams(schema) { - const props = schema && typeof schema === "object" && schema.properties && typeof schema.properties === "object" - ? schema.properties - : null; - if (!props) return ""; - /** @type {string[]} */ - const rows = []; - const required = Array.isArray(schema.required) ? new Set(schema.required) : new Set(); - for (const [name, raw] of Object.entries(props)) { - const spec = raw && typeof raw === "object" ? raw : {}; - const type = paramTypeLabel(spec); - const def = "default" in spec ? ` · default: ${formatParamValue(spec.default)}` : ""; - const req = required.has(name) ? " required" : ""; - rows.push( - `
` + - `${escHtml(name)}` + - `${escHtml(type + req + def)}` + - `
`, - ); - } - return rows.join(""); -} - -/** - * @param {any} spec - * @returns {string} - */ -function paramTypeLabel(spec) { - if (Array.isArray(spec.anyOf)) { - /** @type {string[]} */ - const labels = []; - for (const item of spec.anyOf) { - labels.push((item && typeof item === "object" && typeof item.type === "string") ? item.type : "unknown"); - } - return labels.join(" | "); - } - if (typeof spec.type === "string") return spec.type; - return "unknown"; -} - -/** - * @param {unknown} value - * @returns {string} - */ -function formatParamValue(value) { - if (value === null) return "null"; - if (typeof value === "string") return value; - return JSON.stringify(value); -} - -/** - * @param {any} schema - * @returns {Record} - */ -function schemaProperties(schema) { - return schema && typeof schema === "object" && schema.properties && typeof schema.properties === "object" - ? schema.properties - : {}; -} - -/** - * @param {any} schema - * @returns {string[]} - */ -function requiredToolParams(schema) { - if (!Array.isArray(schema?.required)) return []; - /** @type {string[]} */ - const required = []; - for (const name of schema.required) { - if (typeof name === "string") required.push(name); - } - return required; -} - -/** - * @param {any} spec - * @param {string} type - * @returns {boolean} - */ -function schemaSupportsType(spec, type) { - if (!spec || typeof spec !== "object") return false; - if (spec.type === type) return true; - if (Array.isArray(spec.anyOf)) { - for (const item of spec.anyOf) { - if (item && typeof item === "object" && item.type === type) return true; - } - } - return false; -} - -/** - * @param {any} spec - * @returns {boolean} - */ -function isStringArraySchema(spec) { - if (!spec || typeof spec !== "object") return false; - if (spec.type === "array") { - return !spec.items || spec.items.type === "string"; - } - if (Array.isArray(spec.anyOf)) { - for (const item of spec.anyOf) { - if (item && typeof item === "object" && item.type === "array" && (!item.items || item.items.type === "string")) { - return true; - } - } - } - return false; -} - -/** - * @param {string} name - * @returns {string[]} - */ -function paramAliases(name) { - const aliases = new Set([name, name.replace(/_/g, " "), name.replace(/_/g, "-")]); - if (/phone|mobile|whatsapp/i.test(name)) { - aliases.add("phone"); - aliases.add("phone number"); - aliases.add("mobile"); - aliases.add("mobile number"); - aliases.add("whatsapp"); - aliases.add("whatsapp number"); - aliases.add("number"); - } - return [...aliases].filter(Boolean); -} - -const DIGIT_WORDS = { - zero: "0", - oh: "0", - one: "1", - two: "2", - three: "3", - four: "4", - five: "5", - six: "6", - seven: "7", - eight: "8", - nine: "9", -}; - -/** - * @param {string} name - * @returns {boolean} - */ -function isPhoneLikeParam(name) { - return /phone|mobile|whatsapp/i.test(name); -} - -/** - * @param {string} text - * @returns {string} - */ -function normalizePhoneText(text) { - const trimmed = text.trim(); - if (!trimmed) return ""; - const hasLeadingPlus = /^\s*\+/.test(trimmed); - const compact = trimmed - .toLowerCase() - .replace(/[^a-z0-9+\s-]/g, " ") - .split(/\s+/) - .filter(Boolean); - /** @type {string[]} */ - const digits = []; - for (const token of compact) { - if (token === "+" && !digits.length && hasLeadingPlus) continue; - if (/^\d+$/.test(token)) { - digits.push(token); - continue; - } - if (Object.prototype.hasOwnProperty.call(DIGIT_WORDS, token)) { - digits.push(DIGIT_WORDS[/** @type {keyof typeof DIGIT_WORDS} */ (token)]); - } - } - if (!digits.length) return ""; - return `${hasLeadingPlus ? "+" : ""}${digits.join("")}`; -} - -/** - * @param {string} query - * @returns {string} - */ -function extractPhoneValue(query) { - const patterns = [ - /\b(?:my\s+)?(?:phone|mobile|whatsapp|number)\s*(?:number)?\s*(?:is|=|:)?\s*([^.!,;\n]+)/i, - /\bcall me at\s*([^.!,;\n]+)/i, - /\breach me at\s*([^.!,;\n]+)/i, - ]; - for (const pattern of patterns) { - const match = query.match(pattern); - const normalized = normalizePhoneText(match?.[1] || ""); - if (normalized) return normalized; - } - return normalizePhoneText(query); -} - -/** - * @param {string} query - * @param {string} name - * @returns {string} - */ -function inferStringParamValue(query, name) { - if (isPhoneLikeParam(name)) return extractPhoneValue(query); - return ""; -} - -/** - * @param {string} query - * @param {string[]} aliases - * @returns {string} - */ -function matchParamValueText(query, aliases) { - for (const alias of aliases) { - const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const patterns = [ - new RegExp(`\\b${escaped}\\b\\s*(?:=|:|is|to)?\\s*"([^"]+)"`, "i"), - new RegExp(`\\b${escaped}\\b\\s*(?:=|:|is|to)?\\s*'([^']+)'`, "i"), - new RegExp(`\\b${escaped}\\b\\s*(?:=|:|is|to)?\\s*([^,;\\n]+)`, "i"), - ]; - for (const pattern of patterns) { - const match = query.match(pattern); - if (match?.[1]) return match[1].trim(); - } - } - return ""; -} - -/** - * @param {string} query - * @param {any} schema - * @returns {Record} - */ -function inferToolArgsFromQuery(query, schema) { - const props = schemaProperties(schema); - /** @type {Record} */ - const inferred = {}; - for (const [name, raw] of Object.entries(props)) { - const spec = raw && typeof raw === "object" ? raw : {}; - const valueText = matchParamValueText(query, paramAliases(name)); - const fallbackText = !valueText && schemaSupportsType(spec, "string") - ? inferStringParamValue(query, name) - : ""; - const resolvedText = valueText || fallbackText; - if (!resolvedText) continue; - if (schemaSupportsType(spec, "number")) { - const num = Number(resolvedText); - if (!Number.isNaN(num)) inferred[name] = num; - continue; - } - if (schemaSupportsType(spec, "boolean")) { - const normalized = resolvedText.toLowerCase(); - if (["true", "yes", "1", "on"].includes(normalized)) inferred[name] = true; - else if (["false", "no", "0", "off"].includes(normalized)) inferred[name] = false; - continue; - } - if (isStringArraySchema(spec)) { - inferred[name] = resolvedText - .split(",") - .map((item) => item.trim()) - .filter(Boolean); - continue; - } - if (schemaSupportsType(spec, "string")) { - inferred[name] = resolvedText; - } - } - return inferred; -} - -/** - * @param {Record} args - * @param {any} schema - * @returns {Record} - */ -function applyToolArgDefaults(args, schema) { - const props = schemaProperties(schema); - /** @type {Record} */ - const merged = { ...args }; - for (const [name, raw] of Object.entries(props)) { - const spec = raw && typeof raw === "object" ? raw : {}; - if (!(name in merged) && Object.prototype.hasOwnProperty.call(spec, "default")) { - merged[name] = spec.default; - } - } - return merged; -} - -/** - * @param {Record} args - * @param {any} schema - * @returns {string[]} - */ -function missingRequiredToolArgs(args, schema) { - return requiredToolParams(schema).filter((name) => { - const value = args[name]; - return value === undefined || value === null || value === ""; - }); -} - -/** - * @param {string} toolName - * @returns {{ argsText: string, status: "idle" | "running" | "success" | "error", resultText: string, lastAction: "call" | "send" }} - */ -function getMcpToolCallState(toolName) { - if (!mcpToolCallState[toolName]) { - mcpToolCallState[toolName] = { - argsText: "{}", - status: "idle", - resultText: "", - lastAction: "call", - }; - } - return mcpToolCallState[toolName]; -} - -/** - * @param {string} toolName - * @returns {import("./ws/s2s-ws-client.js").ToolDef | undefined} - */ -function findAgentToolDef(toolName) { - return agentToolDefs.find((tool) => tool.name === toolName); -} - -/** - * @param {string} toolName - * @returns {string} - */ -function renderMcpToolCallResultHtml(toolName) { - const state = getMcpToolCallState(toolName); - if (state.status === "idle" && !state.resultText) return ""; - const cls = state.status === "error" - ? "agent-tool-call-result error" - : state.status === "success" - ? "agent-tool-call-result success" - : "agent-tool-call-result running"; - const label = state.status === "running" - ? (state.lastAction === "send" ? "Calling tool and sending to conversation..." : "Calling tool...") - : state.status === "error" - ? "Call failed" - : (state.lastAction === "send" ? "Sent to conversation" : "Call result"); - return ` -
-
${escHtml(label)}
-
${escHtml(state.resultText || "")}
-
- `; -} - -/** - * @param {string} toolName - * @param {boolean} [sendToConversation] - * @param {string} [sourceQuery] - * @returns {Promise} - */ -async function callAgentToolFromPanel(toolName, sendToConversation = false, sourceQuery = "") { - const tool = findAgentToolDef(toolName); - if (!tool) return; - const state = getMcpToolCallState(toolName); - state.lastAction = sendToConversation ? "send" : "call"; - let args = /** @type {Record} */ ({}); - try { - args = state.argsText.trim() ? JSON.parse(state.argsText) : {}; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - state.status = "error"; - state.resultText = `Invalid JSON arguments: ${msg}`; - syncToolsUi(); - console.warn(`[Vocero MCP PANEL] invalid args name=${toolName} error=${msg}`); - return; - } - if (!args || typeof args !== "object" || Array.isArray(args)) { - state.status = "error"; - state.resultText = "Arguments must be a JSON object."; - syncToolsUi(); - console.warn(`[Vocero MCP PANEL] invalid args shape name=${toolName}`); - return; - } - const inferredArgs = sourceQuery.trim() ? inferToolArgsFromQuery(sourceQuery, tool.parameters) : {}; - args = applyToolArgDefaults({ ...inferredArgs, ...args }, tool.parameters); - const missingArgs = missingRequiredToolArgs(args, tool.parameters); - if (missingArgs.length) { - state.status = "error"; - state.resultText = `Missing required arguments: ${missingArgs.join(", ")}`; - syncToolsUi(); - console.warn( - `[Vocero MCP PANEL] missing required args name=${toolName} missing=${missingArgs.join(",")} ` + - `sourceQuery=${JSON.stringify(sourceQuery)}` - ); - return; - } - state.status = "running"; - state.resultText = ""; - syncToolsUi(); - reportDebug("E", "main.js:callAgentToolFromPanel", "manual MCP tool call from tools panel", { - name: toolName, - args, - sendToConversation, - sourceQuery, - }); - console.debug( - `[Vocero MCP PANEL] call name=${toolName} sendToConversation=${sendToConversation} ` + - `sourceQuery=${JSON.stringify(sourceQuery)} inferred=${JSON.stringify(inferredArgs)} args=${JSON.stringify(args)}` - ); - try { - const execStartedAt = performance.now(); - const { rawOutput, speakableOutput } = await execRemoteMcpTool(toolName, args); - const output = rawOutput; - reportDebug("A", "main.js:callAgentToolFromPanel", "MCP tool execution finished", { - name: toolName, - sendToConversation, - outputLength: output.length, - speakableOutputLength: speakableOutput.length, - durationMs: Math.round(performance.now() - execStartedAt), - }); - if (sendToConversation) { - if (!client || !LIVE_STATES.has(currentState)) { - throw new Error("No live conversation is active."); - } - const promptBuildStartedAt = performance.now(); - const modelOutput = speakableOutput || summarizeMcpOutputForConversation(output); - const prompt = ( - sourceQuery.trim() - ? `User request: ${sourceQuery.trim()}\n\n` - : "" - ) + `Tool ${toolName} returned the following internal facts:\n\n${modelOutput}\n\n` + - "Answer the user naturally in their language. Do not read JSON keys, field names, or raw URLs aloud unless the user explicitly asks for them."; - reportDebug("A", "main.js:callAgentToolFromPanel", "built live prompt from MCP output", { - name: toolName, - outputLength: output.length, - modelOutputLength: modelOutput.length, - promptLength: prompt.length, - buildMs: Math.round(performance.now() - promptBuildStartedAt), - }); - const chatRenderStartedAt = performance.now(); - chat.onToolResult(toolName, JSON.stringify(args), output); - reportDebug("C", "main.js:callAgentToolFromPanel", "rendered MCP tool result in chat", { - name: toolName, - outputLength: output.length, - renderMs: Math.round(performance.now() - chatRenderStartedAt), - }); - chat.onTranscript({ - role: "user", - text: prompt, - partial: false, - itemId: `manual-mcp-${toolName}-${Date.now()}`, - }); - reportDebug("B", "main.js:callAgentToolFromPanel", "sending MCP prompt back to live conversation", { - name: toolName, - promptLength: prompt.length, - outputLength: output.length, - }); - client.sendUserText(prompt); - client.requestResponse(); - console.debug(`[Vocero MCP PANEL] sent to conversation name=${toolName} length=${prompt.length}`); - } - state.status = "success"; - state.resultText = output; - console.debug(`[Vocero MCP PANEL] result name=${toolName} output=${output.slice(0, 500)}`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - state.status = "error"; - state.resultText = msg; - console.warn(`[Vocero MCP PANEL] error name=${toolName} error=${msg}`); - } - syncToolsUi(); -} - -toolsBtn.addEventListener("click", () => { syncToolsUi(); toolsModal.showModal(); }); +toolsBtn.addEventListener("click", () => { toolsModal.showModal(); }); toolsClose.addEventListener("click", () => toolsModal.close()); toolsModal.addEventListener("click", (e) => { if (e.target === toolsModal) toolsModal.close(); }); -agentToolsList.addEventListener("input", (event) => { - const target = /** @type {HTMLElement | null} */ (event.target instanceof HTMLElement ? event.target : null); - if (!(target instanceof HTMLTextAreaElement)) return; - const toolName = target.dataset.mcpToolArgs; - if (!toolName) return; - const state = getMcpToolCallState(toolName); - state.argsText = target.value; + +ragBtn.addEventListener("click", () => { + // Pre-fill the textarea with current value (if any) so user can edit. + if (customRagLoaded && ragTextarea && !ragTextarea.value.trim()) { + ragTextarea.value = customRagText; + } else if (ragTextarea && !ragTextarea.value.trim() && customRagText.trim()) { + ragTextarea.value = customRagText; + } + syncRagHintFooter(); + ragModal.showModal(); }); -agentToolsList.addEventListener("click", (event) => { - const target = /** @type {HTMLElement | null} */ (event.target instanceof HTMLElement ? event.target : null); - const button = target?.closest("[data-mcp-call-btn]"); - if (button instanceof HTMLButtonElement) { - const toolName = button.dataset.mcpCallBtn; - if (!toolName) return; - void callAgentToolFromPanel(toolName, false); +ragClose.addEventListener("click", () => ragModal.close()); +ragModal.addEventListener("click", (e) => { + if (e.target === ragModal) ragModal.close(); +}); +ragClear.addEventListener("click", () => { + if (ragTextarea) ragTextarea.value = ""; + syncRagHintFooter(); + if (ragTextarea) ragTextarea.focus(); +}); +ragSubmit.addEventListener("click", () => { + const next = (ragTextarea ? ragTextarea.value : "").trim(); + if (!next) { + customRagText = ""; + customRagLoaded = true; + syncRagHintFooter(); + console.info(`[CUSTOM-RAG] submit: textarea empty -> clearing any prior custom RAG context.`); + ragModal.close(); return; } - const sendButton = target?.closest("[data-mcp-send-btn]"); - if (!(sendButton instanceof HTMLButtonElement)) return; - const toolName = sendButton.dataset.mcpSendBtn; - if (!toolName) return; - void callAgentToolFromPanel(toolName, true); + customRagText = next; + customRagLoaded = true; + syncRagHintFooter(); + const ctx = customRagContextText(); + const lines = next.split(/\r?\n/).length; + console.info( + `[CUSTOM-RAG] submit OK: rawLen=%d lines=%d wrappedCtxLen=%d -> will appear in instructions + user_context body.`, + next.length, lines, ctx.length, + ); + if (debugPrompt) { + console.info( + `[DEBUG-PROMPT][CUSTOM-RAG submitted raw text] len=%d lines=%d:\n%s\n`, + next.length, lines, next, + ); + if (ctx) { + console.info( + `[DEBUG-PROMPT][CUSTOM-RAG wrapped context (with CUSTOM_RAG_BEGIN/END markers)] len=%d:\n%s\n`, + ctx.length, ctx, + ); + } + } + // If a realtime session is already live (e.g. user already clicked the orb) + // we push the new context LIVE into the session so it takes effect + // immediately without having to restart. + pushCustomRagToLiveSessionIfAny(); + ragModal.close(); }); toolWebSwitch.addEventListener("change", () => { if (toolWebSwitch.checked && !searchAvailable()) { - toolWebSwitch.checked = false; // guard: can't enable without a key + toolWebSwitch.checked = false; return; } toolsEnabled.web_search = toolWebSwitch.checked; @@ -2265,7 +1325,6 @@ searchKeyInput.addEventListener("input", () => { const avail = searchAvailable(); toolWebSwitch.disabled = !avail; toolWebRow.classList.toggle("disabled", !avail); - // Losing the key disables a previously-enabled tool. if (!avail && toolsEnabled.web_search) { toolsEnabled.web_search = false; toolWebSwitch.checked = false; @@ -2277,18 +1336,6 @@ searchKeyInput.addEventListener("input", () => { : "No server key configured. Add your own Serper key to enable web search."; }); -// ── Tool executor ───────────────────────────────────────────────────────── -// Runs the function the model called, returns the result, and asks for a -// response so the model speaks it. Errors come back as the tool output too, so -// the model can recover gracefully instead of the turn stalling. - -/** - * Run the function the model called, return its result to the backend, and ask - * for a follow-up response. We also hand the result back to the caller so it - * can be shown in the conversation once the tool has actually run. - * @param {string} name @param {string} argsJson @param {string} callId - * @returns {Promise<{ output: string, image?: string }>} - */ async function runTool(name, argsJson, callId) { if (!client) return { output: "" }; let args = /** @type {Record} */ ({}); @@ -2299,344 +1346,38 @@ async function runTool(name, argsJson, callId) { name, callId, args, - knownMcpTool: agentToolDefs.some((tool) => tool.name === name), }); // #endregion - - if (DEBUG) console.debug(`[tool] run name=${name} callId=${JSON.stringify(callId)} args=${argsJson}`); - if (!callId) console.warn("[tool] empty call_id — the backend didn't tag the call, can't return a function_call_output"); - - /** @type {{ output: string, image?: string }} */ - let result = { output: "" }; try { + let output = ""; + let image; if (name === "web_search") { - const query = typeof args.query === "string" ? args.query : ""; - result.output = await execWebSearch(query); - // Return the result and let the bare response.create (below) trigger the - // spoken answer. - client.sendToolOutput(callId, result.output); - } else if (agentToolDefs.some((tool) => tool.name === name)) { - // #region debug-point E:mcp-proxy-before-fetch - reportDebug("E", "main.js:runTool", "calling MCP proxy", { - name, - callId, - args, - }); - // #endregion - const mcpResult = await execRemoteMcpTool(name, args); - result.output = mcpResult.rawOutput; - console.debug(`[Vocero MCP OUT] name=${name} callId=${JSON.stringify(callId)} output=${result.output}`); - // #region debug-point E:mcp-proxy-after-fetch - reportDebug("E", "main.js:runTool", "MCP proxy returned result", { - name, - callId, - outputPreview: result.output.slice(0, 300), - speakableOutputLength: mcpResult.speakableOutput.length, - }); - // #endregion - client.sendToolOutput(callId, mcpResult.speakableOutput || summarizeMcpOutputForConversation(result.output)); + const q = String(args.query || "").trim(); + output = await execWebSearch(q); } else { - result.output = `Unknown tool: ${name}`; - client.sendToolOutput(callId, result.output); + output = `Unknown tool: ${name}.`; } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - result.output = `Tool failed: ${msg}`; - // #region debug-point E:mcp-proxy-error - reportDebug("E", "main.js:runTool", "tool execution failed", { + // #region debug-point E:run-tool-success + reportDebug("E", "main.js:runTool", "tool execution completed", { name, - callId, - error: msg, + outputLen: output.length, + hasImage: !!image, }); // #endregion - client.sendToolOutput(callId, result.output); - } - if (DEBUG) console.debug(`[tool] requesting model response after ${name}`); - client.requestResponse(result.image ? { image: result.image } : undefined); - return result; -} - -/** - * Execute an MCP-backed tool through the app's same-origin proxy. - * @param {string} name - * @param {Record} args - * @returns {Promise<{ rawOutput: string, speakableOutput: string }>} - */ -async function execRemoteMcpTool(name, args) { - const startedAt = performance.now(); - if (DEBUG) { - console.debug(`[Vocero MCP HTTP] request name=${name} url=/api/mcp/tool args=${JSON.stringify(args || {})}`); - } - const res = await fetch("api/mcp/tool", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, arguments: args || {} }), - }); - if (DEBUG) { - console.debug(`[Vocero MCP HTTP] response name=${name} status=${res.status} ok=${res.ok}`); - } - if (!res.ok) { - let detail = String(res.status); - try { const j = await res.json(); if (j.detail) detail = j.detail; } catch {} - console.warn(`[Vocero MCP HTTP] error name=${name} detail=${detail}`); - throw new Error(`mcp error (${detail})`); - } - const json = await res.json(); - reportDebug("A", "main.js:execRemoteMcpTool", "received MCP HTTP response body", { - name, - durationMs: Math.round(performance.now() - startedAt), - resultJsonLength: JSON.stringify(json?.result ?? null).length, - }); - if (DEBUG) { - const preview = JSON.stringify(json?.result ?? null).slice(0, 300); - console.debug(`[Vocero MCP HTTP] payload name=${name} preview=${preview}`); - } - const rawOutput = formatMcpToolResult(json.result); - const speakableOutput = formatSpeakableMcpToolResult(json.result, rawOutput); - return { rawOutput, speakableOutput }; -} - -/** - * Turn an MCP tool result into plain text the model can read back. - * @param {any} result - * @returns {string} - */ -function formatMcpToolResult(result) { - if (!result) return "Tool returned no result."; - if (result.structuredContent) { - return JSON.stringify(result.structuredContent, null, 2); - } - /** @type {{ type?: string, text?: string }[]} */ - const parts = Array.isArray(result.content) ? result.content : []; - /** @type {string[]} */ - const textParts = []; - for (const item of parts) { - if (item && item.type === "text" && typeof item.text === "string") { - const text = item.text.trim(); - if (text) textParts.push(text); - } - } - if (textParts.length) return textParts.join("\n\n"); - return JSON.stringify(result, null, 2); -} - -/** - * Turn an MCP tool result into compact speech-ready facts for the live model/TTS path. - * @param {any} result - * @param {string} [rawOutput] - * @returns {string} - */ -function formatSpeakableMcpToolResult(result, rawOutput = "") { - if (!result) return "Tool returned no result."; - if (result.structuredContent != null) { - const summary = buildSpeakableMcpFacts(result.structuredContent); - if (summary) return summary.length > 1200 ? `${summary.slice(0, 1200).trim()}\n...` : summary; - } - /** @type {{ type?: string, text?: string }[]} */ - const parts = Array.isArray(result.content) ? result.content : []; - /** @type {string[]} */ - const textParts = []; - for (const item of parts) { - if (item && item.type === "text" && typeof item.text === "string") { - const text = item.text.trim(); - if (text) textParts.push(text); - } - } - if (textParts.length) { - return summarizeMcpOutputForConversation(textParts.join("\n\n")); - } - return summarizeMcpOutputForConversation(rawOutput || JSON.stringify(result, null, 2)); -} - -const SPEAKABLE_PRIORITY_KEYS = new Set([ - "name", - "title", - "summary", - "description", - "message", - "status", - "role", - "date", - "reportId", - "report_id", - "center", - "priority", - "audience", - "briefing", - "value", - "label", - "text", -]); - -const SKIP_SPEAKABLE_KEYS = new Set([ - "id", - "userId", - "user_id", - "openapiUrl", - "docsUrl", - "mcpPath", - "url", - "urls", - "image", - "images", - "raw", - "json", -]); - -/** - * @param {string} text - * @param {number} maxLen - * @returns {string} - */ -function trimSpeakableText(text, maxLen) { - const clean = String(text || "").replace(/\s+/g, " ").trim(); - if (!clean) return ""; - return clean.length > maxLen ? `${clean.slice(0, maxLen).trim()}...` : clean; -} - -/** - * @param {string} key - * @returns {string} - */ -function humanizeFactKey(key) { - return key - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/[_-]+/g, " ") - .replace(/\s+/g, " ") - .trim() - .replace(/^./, (c) => c.toUpperCase()); -} - -/** - * @param {any} value - * @param {number} [maxLen] - * @returns {string} - */ -function pickSpeakableSnippet(value, maxLen = 180) { - if (value == null) return ""; - if (typeof value === "string") return trimSpeakableText(value, maxLen); - if (typeof value === "number" || typeof value === "boolean") return String(value); - if (Array.isArray(value)) { - const parts = value - .slice(0, 3) - .map((item) => pickSpeakableSnippet(item, 80)) - .filter(Boolean); - return trimSpeakableText(parts.join("; "), maxLen); - } - if (typeof value === "object") { - const obj = /** @type {Record} */ (value); - for (const key of SPEAKABLE_PRIORITY_KEYS) { - if (key in obj) { - const text = pickSpeakableSnippet(obj[key], maxLen); - if (text) return text; - } - } - for (const [key, item] of Object.entries(obj)) { - if (SKIP_SPEAKABLE_KEYS.has(key)) continue; - const text = pickSpeakableSnippet(item, maxLen); - if (text) return text; - } - } - return ""; -} - -/** - * @param {Record} obj - * @returns {string[]} - */ -function collectSpeakableObjectFacts(obj) { - /** @type {string[]} */ - const facts = []; - for (const [key, value] of Object.entries(obj)) { - if (SKIP_SPEAKABLE_KEYS.has(key) || value == null) continue; - if (typeof value === "string") { - const text = trimSpeakableText(value, 220); - if (text) facts.push(`${humanizeFactKey(key)}: ${text}`); - continue; - } - if (typeof value === "number" || typeof value === "boolean") { - facts.push(`${humanizeFactKey(key)}: ${String(value)}`); - continue; - } - if (Array.isArray(value)) { - if (!value.length) continue; - const first = pickSpeakableSnippet(value[0], 160); - if (first) { - facts.push(`${humanizeFactKey(key)}: ${first}`); - if (value.length > 1) facts.push(`${humanizeFactKey(key)} count: ${value.length}`); - } - continue; - } - if (typeof value === "object") { - const nested = pickSpeakableSnippet(value, 180); - if (nested) facts.push(`${humanizeFactKey(key)}: ${nested}`); - } - } - return facts; -} - -/** - * @param {any} parsed - * @returns {string} - */ -function buildSpeakableMcpFacts(parsed) { - if (parsed == null) return "No useful facts returned by the tool."; - if (typeof parsed === "string") return trimSpeakableText(parsed, 900); - if (typeof parsed === "number" || typeof parsed === "boolean") return String(parsed); - if (Array.isArray(parsed)) { - const items = parsed - .slice(0, 4) - .map((item, index) => { - const text = pickSpeakableSnippet(item, 180); - return text ? `- Item ${index + 1}: ${text}` : ""; - }) - .filter(Boolean); - if (parsed.length > 4) items.push(`- Additional items not shown: ${parsed.length - 4}`); - return items.join("\n") || "The tool returned a list with no speakable details."; - } - const obj = /** @type {Record} */ (parsed); - const facts = collectSpeakableObjectFacts(obj).slice(0, 8); - if (facts.length) return facts.map((line) => `- ${line}`).join("\n"); - const fallback = pickSpeakableSnippet(obj, 900); - return fallback || "The tool returned structured data with no speakable summary."; -} - -/** - * @param {string} output - * @returns {string} - */ -function summarizeMcpOutputForConversation(output) { - const text = (output || "").trim(); - if (!text) return "Tool returned no result."; - try { - const parsed = JSON.parse(text); - const summary = buildSpeakableMcpFacts(parsed); - return summary.length > 1200 ? `${summary.slice(0, 1200).trim()}\n...` : summary; - } catch { - return trimSpeakableText(text, 1200); + if (client) client.sendFunctionCallOutput(callId, output); + return { output, image }; + } catch (err) { + const detail = truncateError(err); + // #region debug-point E:run-tool-error + reportDebug("E", "main.js:runTool", "tool execution failed", { + name, + error: detail, + }); + // #endregion + if (client) client.sendFunctionCallOutput(callId, `Tool error: ${detail}`); + return { output: `Tool error: ${detail}` }; } } - -/** - * Normalize agent profile tools into OpenAI-compatible function tool defs. - * @param {any[]} tools - * @returns {import("./ws/s2s-ws-client.js").ToolDef[]} - */ -function normalizeAgentTools(tools) { - if (!Array.isArray(tools)) return []; - return tools - .filter((tool) => tool && tool.enabled !== false && typeof tool.name === "string") - .map((tool) => ({ - type: "function", - name: tool.name, - description: typeof tool.description === "string" ? tool.description : "", - parameters: tool.parameters && typeof tool.parameters === "object" - ? tool.parameters - : { type: "object", properties: {}, required: [] }, - })); -} - -/** @param {string} query @returns {Promise} */ async function execWebSearch(query) { if (!query) return "No query provided."; /** @type {Record} */ @@ -2668,32 +1409,59 @@ async function execWebSearch(query) { } async function fetchVoceroUserProfile() { - if (!voceroUserPhoneNumber) return; + if (!voceroUserPhoneNumber) { + console.info( + "[INJECT-PROFILE] ⏭️ BROWSER fetchVoceroUserProfile SKIPPED: URL has NO ?phoneNumber=. " + + "Profile context will be empty (info-user fetch + injectProfile not possible without caller phone)." + ); + return; + } try { const res = await fetch(`api/user/phone?phoneNumber=${encodeURIComponent(voceroUserPhoneNumber)}`); if (!res.ok) { + // 503 = endpoint not configured on this deploy (generic deploy); skip + // silently. Any other non-2xx is an error worth reporting. + if (res.status === 503) { + console.info( + "[INJECT-PROFILE] ⏭️ BROWSER skipped (deploy did NOT configure VOCERO_USER_PHONE_AUTH_URL env). endpoint returned HTTP 503, proxy /api/user/phone unavailable. " + + "Inject-profile will have empty profile context server-side if WS/reasoning also try." + ); + voceroUserProfile = null; + voceroUserProfileLoaded = false; + syncVoceroHeaderUser(); + return; + } const message = await res.text(); throw new Error(message || `HTTP ${res.status}`); } const json = await res.json(); voceroUserProfile = json && typeof json === "object" && json.result && typeof json.result === "object" ? json.result - : null; + : (json && typeof json === "object" && !Array.isArray(json) ? json : null); voceroUserProfileLoaded = true; - console.debug( - `[Vocero USER AUTH] phoneNumber=${JSON.stringify(voceroUserPhoneNumber)} ` + - `loaded=${!!voceroUserProfile} keys=${voceroUserProfile ? Object.keys(voceroUserProfile).join(",") : "none"}` - ); - syncVoceroHeaderUser(); - if (client && LIVE_STATES.has(currentState)) { - pushLiveInstructions(effectiveInstructions()); + if (voceroUserProfile) { + console.info( + `[INJECT-PROFILE] ✅ BROWSER fetchVoceroUserProfile OK. phoneLen=%d profileKeys=%s headerUser=%s`, + voceroUserPhoneNumber.length, + Object.keys(voceroUserProfile).join(","), + extractVoceroUserName(voceroUserProfile) || "(none)", + ); + } else { + console.info( + `[INJECT-PROFILE] ⏭️ BROWSER fetchVoceroUserProfile HTTP 200 but empty/non-object JSON. profileIsNull=%s keys=none`, + voceroUserProfile === null, + ); } + syncVoceroHeaderUser(); } catch (err) { voceroUserProfile = null; voceroUserProfileLoaded = false; syncVoceroHeaderUser(); const message = err instanceof Error ? err.message : String(err); - console.warn(`[Vocero USER AUTH] phoneNumber=${JSON.stringify(voceroUserPhoneNumber)} error=${message}`); + console.warn( + `[INJECT-PROFILE] ⛔ BROWSER fetchVoceroUserProfile FAILED phone=%s error=%s`, + JSON.stringify(voceroUserPhoneNumber), message, + ); } } @@ -2713,17 +1481,14 @@ async function fetchConfig() { // Deploy-pinned direct URL (overrides the LB server-side already). pinnedUrl = (json.s2sUrl || "").trim(); pinnedPrompt = (json.primaryPrompt || "").trim(); - pinnedPromptSource = (json.agentProfile || "").trim(); - agentToolDefs = normalizeAgentTools(json.agentTools || []); - searchMcpEnabled = !!json.searchMcp; - rankedAgentTools = rankAgentTools(lastUserQuery); + injectProfile = !!json.injectProfile; + debugPrompt = !!json.debugPrompt; // #region debug-point B:config-loaded reportDebug("B", "main.js:fetchConfig", "loaded config from api/config", { - searchMcpEnabled, reasoningType, - agentToolNames: agentToolDefs.map((tool) => tool.name), - agentToolCount: agentToolDefs.length, pinnedPromptLength: pinnedPrompt.length, + injectProfile, + debugPrompt, }); // #endregion // The conversation-time limiter rides on the LB being present. @@ -2780,8 +1545,15 @@ function buildDirectWsUrl(raw) { try { const u = new URL(s); if (u.pathname === "" || u.pathname === "/") u.pathname = "/v1/realtime"; + if (voceroUserPhoneNumber) { + u.searchParams.set("phoneNumber", voceroUserPhoneNumber); + } return u.toString(); } catch { + if (voceroUserPhoneNumber) { + const sep = s.includes("?") ? "&" : "?"; + return `${s}${sep}phoneNumber=${encodeURIComponent(voceroUserPhoneNumber)}`; + } return s; } } @@ -3072,7 +1844,6 @@ async function doStart(audioContext = null) { toolNames: startTools.map((tool) => tool.name), toolCount: startTools.length, instructionLength: startInstructions.length, - searchMcpEnabled, }); // #endregion const c = new S2sWsRealtimeClient({ @@ -3085,7 +1856,6 @@ async function doStart(audioContext = null) { ...(audioContext ? { audioContext } : {}), }); client = c; - liveInstructionsSnapshot = startInstructions; c.addEventListener("queue", (e) => { const { position, queueId } = /** @type {CustomEvent<{ position: number; queueId: string }>} */ (e).detail; @@ -3116,34 +1886,27 @@ async function doStart(audioContext = null) { _kickIdleTimer(`transcript-${d.role}${d.partial ? ":partial" : ""}`); chat.onTranscript(d); if (d.role === "user") { - updateMcpToolSuggestions(d.text || "", { partial: d.partial, itemId: d.itemId }); + updateMcpToolSuggestions(d.text || ""); if (!d.partial) void maybeRunOpenAiReasoning(d.text || "", d.itemId || ""); } - if (d.role === "assistant" && !d.partial) maybeRunDeclaredAssistantTool(d.text || "", d.responseId || ""); }); c.addEventListener("response-finished", (e) => { const detail = /** @type {CustomEvent<{ responseId: string; status: string; audible?: boolean; transcript?: string }>} */ (e).detail; _kickIdleTimer(`response-finished:${detail.status || "done"}`); chat.onResponseFinished(detail); - maybeRunDeclaredAssistantTool(detail.transcript || "", detail.responseId || ""); }); c.addEventListener("toolcall", (e) => { const { name, arguments: args, callId } = /** @type {CustomEvent<{ name: string; arguments: string; callId: string }>} */ (e).detail; _kickIdleTimer(`toolcall:${name}`); - clearPendingAutoMcpFallback(); // #region debug-point D:toolcall-event reportDebug("D", "main.js:client.toolcall", "toolcall event received by main app", { name, callId, args, - knownMcpTool: agentToolDefs.some((tool) => tool.name === name), }); // #endregion - if (agentToolDefs.some((tool) => tool.name === name)) { - console.debug(`[Vocero MCP CALL] name=${name} callId=${JSON.stringify(callId)} args=${args}`); - } chat.onToolCall(name); // Execute the tool, then push it to the conversation once the result is in, // so the toggle shows both the call input and its output together. @@ -3327,15 +2090,12 @@ function onClientStatus(status) { } async function teardown() { - clearPendingAutoMcpFallback(); stopHeartbeat(); stopJoinCountdown(); endTrackedSession(); endQueueTicket(); chat.reset({ dismiss: true }); updateMcpToolSuggestions(""); - liveInstructionsSnapshot = ""; - liveToolPolicySnapshot = ""; // If teardown fires *before* the first "connected" status (e.g. failed dial, // user left queue, limit exceeded) the pending wake-word confirmation would // be re-injected on the next session. Clear it so we never double-fire. @@ -3376,6 +2136,7 @@ chat.renderEmptyState(); initGateArc(); void fetchConfig(); void fetchVoceroUserProfile(); +syncRagHintFooter(); (() => { const toolCamRow = document.getElementById("tool-cam-row"); if (toolCamRow) toolCamRow.setAttribute("hidden", ""); diff --git a/demo/project/index.html b/s2s-client/project/index.html similarity index 100% rename from demo/project/index.html rename to s2s-client/project/index.html diff --git a/demo/public/docs.html b/s2s-client/public/docs.html similarity index 100% rename from demo/public/docs.html rename to s2s-client/public/docs.html diff --git a/demo/requirements.txt b/s2s-client/requirements.txt similarity index 100% rename from demo/requirements.txt rename to s2s-client/requirements.txt diff --git a/demo/server.py b/s2s-client/server.py similarity index 56% rename from demo/server.py rename to s2s-client/server.py index e19b6de..fb0b109 100644 --- a/demo/server.py +++ b/s2s-client/server.py @@ -103,31 +103,50 @@ SERPER_KEY = os.environ.get("SERPER_API_KEY", "").strip() S2S_PRIMARY_PROMPT = os.environ.get("S2S_PRIMARY_PROMPT", "").strip() if S2S_PRIMARY_PROMPT: S2S_PRIMARY_PROMPT = S2S_PRIMARY_PROMPT.replace("\\n", "\n") -S2S_AGENT_PROFILE = os.environ.get("S2S_AGENT_PROFILE", "").strip() -S2S_TOOL_EXECUTION_MCP_URL = os.environ.get("S2S_TOOL_EXECUTION_MCP_URL", "").strip() -S2S_TOOL_EXECUTION_MCP_API_KEY = os.environ.get("S2S_TOOL_EXECUTION_MCP_API_KEY", "").strip() -S2S_TOOL_EXECUTION_MCP_API_SECRET = os.environ.get("S2S_TOOL_EXECUTION_MCP_API_SECRET", "").strip() -# ⚙️ MASTER SWITCH MCP EXECUTION: Disabilita TUTTA la funzionalità MCP quando -# S2S_TOOL_EXECUTION_MCP_URL non è presente / è commentata / è vuota in .env. -# - Non viene caricato il catalogo 79 tool dal profilo agente Aquachile -# - Non viene injettato tools[] / tool_choice nel session.update WS -# - POST /api/mcp/tool risponde 503 con log [MCP DISABLED] -MCP_EXEC_ENABLED = bool(S2S_TOOL_EXECUTION_MCP_URL) -VOCERO_USER_PHONE_AUTH_URL = os.environ.get( - "VOCERO_USER_PHONE_AUTH_URL", - os.environ.get("S2S_USER_PHONE_AUTH_URL", ""), -).strip() -S2S_INSIGHTS_URL = os.environ.get("S2S_INSIGHTS_URL", "").strip() -# Credenziali opzionali per endpoint insights (stesso pattern di MCP execution; -# endpoint Aquachile attualmente è pubblico ma lasciamo predisposto per auth futura). -S2S_INSIGHTS_API_KEY = os.environ.get("S2S_INSIGHTS_API_KEY", "").strip() or os.environ.get("S2S_TOOL_EXECUTION_MCP_API_KEY", "").strip() -S2S_INSIGHTS_API_SECRET = os.environ.get("S2S_INSIGHTS_API_SECRET", "").strip() or os.environ.get("S2S_TOOL_EXECUTION_MCP_API_SECRET", "").strip() -# 🧠 Master switch per caricamento insights diario all'inizio della sessione. -# Se S2S_INSIGHTS_URL è valorizzato → GET oggi e append blocco a instructions agente. -INSIGHTS_ENABLED = bool(S2S_INSIGHTS_URL) -# Numero massimo di insights serializzati nel prompt (evita esplosione contesto). -MAX_INSIGHTS_IN_CONTEXT = 20 -S2S_SEARCH_MCP = os.environ.get("S2S_SEARCH_MCP", "").strip().lower() == "true" +# External user-by-phone info endpoint. When set, GET /api/user/phone proxies +# to this URL (passing ?phoneNumber=) and forwards the JSON payload to the +# browser so the UI can render caller identity / caller context. +VOCERO_USER_PHONE_AUTH_URL = os.environ.get("VOCERO_USER_PHONE_AUTH_URL", "").strip().rstrip("/") +# Feature flag: when "true" (case-insensitive) => injects the resolved caller +# profile into the realtime session (patch session.update client->upstream, +# append profile to the LLM system instructions prompt instructions, and prepend it to the +# reasoning user_context for the /api/reasoning HTTP body. +# Default = disabled (generic deploy; pure passthrough; all characterization lives +# in the backend RAG per original architectural decision.) +S2S_INJECT_PROFILE = ( + (os.environ.get("S2S_INJECT_PROFILE", "") or "").strip().lower() + in {"1", "true", "yes", "on"} +) +# Endpoint URL for fetching the latest per-caller conversation summary, used when +# both S2S_INJECT_PROFILE=true AND S2S_LATEST_SUMMARY is set. Completely +# generic: the proxy performs a plain GET S2S_LATEST_SUMMARY?phoneNumber= +# and expects JSON back (optionally wrapped as {"result": }). Empty +# string means the feature is off (no network call, nothing injected). +S2S_LATEST_SUMMARY = (os.environ.get("S2S_LATEST_SUMMARY", "") or "").strip() +# Optional: login-by-phone endpoint. When set (and S2S_INJECT_PROFILE=true and +# phoneNumber is available), before fetching user profile / latest summary the proxy +# first performs a POST S2S_LOGIN_BY_PHONE with {"phoneNumber": "..."} JSON body, +# extracts an auth token from the response (if any), and forwards it as +# Authorization: Bearer for subsequent profile / summary / reports HTTP calls. +S2S_LOGIN_BY_PHONE = (os.environ.get("S2S_LOGIN_BY_PHONE", "") or "").strip().rstrip("/") +# Optional: list of scheduled reports / podcasts endpoint. When set (and S2S_INJECT_PROFILE=true) +# the proxy performs a plain GET S2S_REPORT_LIST, unwraps the JSON array (or {"result": [...]}), +# filters per-item fields (sourceScheduleLabel, date, podcastScript), sorts by date DESC, +# and injects the compiled block into the LLM instructions / user_context between +# the caller profile block and the latest conversation summary block. +S2S_REPORT_LIST = (os.environ.get("S2S_REPORT_LIST", "") or "").strip().rstrip("/") +# Feature flag: when "true" the proxy logs the FULL PROMPT TEXT (not just +# lengths) going to the upstream LLM/realtime backends. Includes: +# - WS session.update session.instructions (before/after patch) +# - /api/reasoning NODE_RED instructions + user_context full text +# - /api/reasonING OPENAI full system message + user transcript +# Default = off; only length summaries are logged (current behaviour). +# WARNING: setting this on will log potentially PII / sensitive caller +# profile data / user transcripts. Use only in development / debugging. +S2S_DEBUG_PROMPT = ( + (os.environ.get("S2S_DEBUG_PROMPT", "") or "").strip().lower() + in {"1", "true", "yes", "on"} +) 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("/") @@ -173,45 +192,20 @@ app = FastAPI(title="s2s-demo") # Sign-in only matters when we're metering (prod Space), so gate it on that. AUTH_ENABLED = LIMITER_ENABLED and auth.attach(app) -logger.debug( - "[STARTUP] Aquachile endpoints configured — profile=%s mcp=%s userAuth=%s powerbiCatalog=%s", - bool(S2S_AGENT_PROFILE), - MCP_EXEC_ENABLED, - bool(VOCERO_USER_PHONE_AUTH_URL), - bool(os.environ.get("S2S_PRELOAD_DATA_ENDPOINT", "").strip()), -) -if MCP_EXEC_ENABLED: - logger.info( - "[MCP MASTER] ENABLED — S2S_TOOL_EXECUTION_MCP_URL=%s; tool catalog will be injected via WS session.update tools[] and /api/mcp/tool proxy is active.", - S2S_TOOL_EXECUTION_MCP_URL, - ) -else: - logger.warning( - "[MCP MASTER] ⛔ DISABLED — S2S_TOOL_EXECUTION_MCP_URL is empty/commented in .env. " - "Tool execution is OFF: no tools[] injection in WS session.update, /api/mcp/tool returns 503. " - "To enable, uncomment or set S2S_TOOL_EXECUTION_MCP_URL and restart the container.", - ) -if INSIGHTS_ENABLED: - logger.info( - "[INSIGHTS MASTER] ENABLED — S2S_INSIGHTS_URL=%s. At every new WS realtime session we will GET today's insights (start_date=end_date=YYYY-MM-DD today) and append a summary block to the agent instructions so the assistant has today's operational context. MAX_INSIGHTS_IN_CONTEXT=%d apiKey=%s", - S2S_INSIGHTS_URL, - MAX_INSIGHTS_IN_CONTEXT, - "set" if S2S_INSIGHTS_API_KEY else "not-set (public endpoint OK)", - ) -else: - logger.debug( - "[INSIGHTS MASTER] DISABLED — S2S_INSIGHTS_URL is empty/commented in .env. " - "Agent instructions will NOT include today's insights context." - ) -logger.debug( - "[STARTUP] Other externals — searchMcp=%s reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s auth=%s", - S2S_SEARCH_MCP, +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", S2S_REASONING_TYPE, NODE_RED_REASONING_URL or "-", bool(SERPER_KEY), bool(LOAD_BALANCER_URL), bool(S2S_PROXY_TARGET), bool(SPEECH_TO_SPEECH_URL), + bool(VOCERO_USER_PHONE_AUTH_URL), + bool(S2S_REPORT_LIST), + bool(S2S_LATEST_SUMMARY), + bool(S2S_LOGIN_BY_PHONE), + S2S_INJECT_PROFILE, + S2S_DEBUG_PROMPT, AUTH_ENABLED, ) logger.debug( @@ -352,12 +346,12 @@ def _realtime_event_summary(raw: str) -> dict | None: elif event_type == "response.function_call_arguments.done": summary["arguments"] = payload.get("arguments") # Questo evento è il KEY CHECK della Strategia B: se lo vediamo = - # ai-server ha accettato tools[] nativi e chiama MCP Aquachile via protocollo. + # ai-server ha accettato tools[] nativi (native toolcall detected). _tool_name = payload.get("name") if isinstance(payload.get("name"), str) else "" _call_id = payload.get("call_id") if isinstance(payload.get("call_id"), str) else "" _args_json = payload.get("arguments") if isinstance(payload.get("arguments"), str) else "{}" logger.info( - "[MCP INJECT] ✓ UPSTREAM ha emesso response.function_call_arguments.done! " + "[TOOLCALL] ✓ UPSTREAM ha emesso response.function_call_arguments.done! " "tool=%s callId=%s argsLen=%d argsPreview=%s", _tool_name or "(empty)", _call_id or "-", @@ -367,7 +361,7 @@ def _realtime_event_summary(raw: str) -> dict | None: try: _args_parsed = json.loads(_args_json or "{}") logger.info( - "[MCP INJECT] ✓ Tool call parsed argsKeys=%s", + "[TOOLCALL] ✓ Tool call parsed argsKeys=%s", sorted(_args_parsed.keys()) if isinstance(_args_parsed, dict) else "N/A", ) except Exception: @@ -419,9 +413,676 @@ class SearchRequest(BaseModel): key: str | None = None -class McpToolCallRequest(BaseModel): - name: str - arguments: dict = {} +VOCERO_USER_CONTEXT_HINT = ( + " Caller profile resolved from the page phoneNumber: " +) +REPORTS_LIST_HINT = ( + " 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." +) +LATEST_SUMMARY_HINT = ( + " Latest conversation summary for this caller (most recent before the current session): " +) + + +def _resolve_user_context_text(phone_number: str, profile_obj: dict | None, loaded: bool = True) -> str: + """Given a phone+profile object -> the short context string appended to + LLM system prompt / reasoning user_context when S2S_INJECT_PROFILE is on. + + Generic (deploy-agnostic): profile_obj is the JSON returned by + VOCERO_USER_PHONE_AUTH_URL (optionally nested under {"result": ...}). + """ + if not loaded or not profile_obj or not phone_number: + return "" + try: + serialized = json.dumps(profile_obj, ensure_ascii=False) + except Exception: + serialized = str(profile_obj) + if len(serialized) > 1600: + serialized = serialized[:1600] + "..." + return f"{VOCERO_USER_CONTEXT_HINT}{serialized}. Use this as trusted caller context when relevant." + + +def _resolve_latest_summary_context(summary_value: str | dict | None) -> str: + """Normalize the payload returned by S2S_LATEST_SUMMARY endpoint into a + single context string to be appended next to the profile block. + + Accepts plain string / object (dumped JSON) / None. Capped at 1600 chars. + Wrapped with LATEST_SUMMARY_BEGIN/END markers so log inspection is easy. + Returns "" when no summary is available. + """ + if summary_value is None: + return "" + if isinstance(summary_value, dict) or isinstance(summary_value, list): + try: + serialized = json.dumps(summary_value, ensure_ascii=False) + except Exception: + serialized = str(summary_value) + else: + serialized = str(summary_value) + trimmed = serialized.strip() + if not trimmed: + return "" + if len(trimmed) > 1600: + trimmed = trimmed[:1600] + "..." + return f"{LATEST_SUMMARY_HINT}<<>>\n{trimmed}\n<<>>. Use this as trusted history when the user references prior calls." + + +def _parse_date_key(value: object) -> tuple: + """Return a sortable tuple for an arbitrary date-like value. + + Sort order is DESC (newest first). Non-dates / empty sort to the end (lowest priority). + Accepts ISO strings ("YYYY-MM-DD", "YYYY-MM-DDTHH:MM:SS"), Python datetime objects, + int/float unix timestamps, numbers. + """ + import datetime as _dt + if value is None: + return (1, 0) + if isinstance(value, _dt.datetime): + try: + return (0, -int(value.timestamp())) + except Exception: + return (1, 0) + if isinstance(value, _dt.date): + try: + ts = int(_dt.datetime.combine(value, _dt.time.min).timestamp()) + return (0, -ts) + except Exception: + return (1, 0) + if isinstance(value, (int, float)): + try: + return (0, -int(value)) + except Exception: + return (1, 0) + if isinstance(value, str): + s = value.strip() + if not s: + return (1, 0) + # Try ISO parse first (handles most formats) + try: + dt = _dt.datetime.fromisoformat(s.replace("Z", "+00:00")) + return (0, -int(dt.timestamp())) + except Exception: + pass + # Fallback: date-only YYYY-MM-DD + try: + dt = _dt.datetime.strptime(s[:10], "%Y-%m-%d") + return (0, -int(dt.timestamp())) + except Exception: + pass + # Last resort: lexicographic sort DESC (newest first still works for ISO-ish) + return (0, tuple(-ord(c) for c in s[:32])) + return (1, 0) + + +def _resolve_reports_context(raw_value: object) -> str: + """Normalize / filter the S2S_REPORT_LIST response into a prompt block. + + Expects an array/list (or dict {"result": [...]} unwrapped by the fetcher). + For each dict item keeps only fields: sourceScheduleLabel, date, podcastScript. + 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. + """ + if raw_value is None: + return "" + items_list: list + if isinstance(raw_value, list): + items_list = raw_value + elif isinstance(raw_value, dict) and isinstance(raw_value.get("result"), list): + items_list = raw_value["result"] + else: + return "" + # Keep only dict items; for each project ONLY the three required fields (in order). + filtered: list[dict] = [] + for item in items_list: + 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"] + 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"))) + # Serialize: compact JSON (pretty-ish but one line per report saves space) + entries_lines = [] + for idx, r in enumerate(filtered, start=1): + try: + 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." + if len(wrapped) > 12000: + # Truncate body preserving markers + hint + budget = 12000 - len(REPORTS_LIST_HINT) - len("\n<<>>\n") - len("\n<<>>...truncated.") + 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." + return wrapped + + +async def _fetch_reports_list(phone_number: str | None = None, auth_token: str | None = None) -> list | dict | None: + """Fetch scheduled reports list from S2S_REPORT_LIST. + + Completely generic / deploy-agnostic: performs GET ${S2S_REPORT_LIST} and appends + ?phoneNumber= if a phone is provided. Accepts JSON (optional {"result": [...]} + wrapper) and returns the list or unwrapped list. On any error logs and returns None + (never fails the proxy / reasoning tunnel). + """ + if not S2S_REPORT_LIST: + return None + from urllib.parse import urlencode + if phone_number: + sep = "&" if "?" in S2S_REPORT_LIST else "?" + target_url = f"{S2S_REPORT_LIST}{sep}{urlencode({'phoneNumber': phone_number})}" + else: + target_url = S2S_REPORT_LIST + logger.info( + "[INJECT-REPORTS] fetch upstream URL=%s phoneLen=%s bearer=%s", + S2S_REPORT_LIST, + len(phone_number) if phone_number else "none", + "yes" if auth_token else "no", + ) + try: + headers = {} + if auth_token: + headers["Authorization"] = f"Bearer {auth_token}" + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http: + resp = await http.get(target_url, headers=headers or None) + if resp.status_code != 200: + logger.warning( + "[INJECT-REPORTS] ⛔ upstream HTTP %s response=%.250s", + resp.status_code, resp.text, + ) + return None + text = (resp.text or "").strip() + if not text: + logger.info("[INJECT-REPORTS] ⏭️ upstream returned empty body.") + return None + try: + payload = resp.json() + except Exception: + logger.warning( + "[INJECT-REPORTS] ⛔ upstream returned non-JSON body len=%d; cannot parse list. response=%.250s", + len(text), text, + ) + return None + except Exception as exc: + logger.warning( + "[INJECT-REPORTS] ⛔ fetch unreachable error=%r", + exc, + ) + return None + # Unwrap {"result": [...]} or other common list-wrapping dict keys + # (e.g. {"data": [...], "reports": [...], "items": [...], "content": [...], "value": [...]}). + unwrapped: object = payload + if isinstance(payload, dict): + # Log full structure for debugging if enabled + if S2S_DEBUG_PROMPT: + try: + keys_preview = ",".join(sorted(str(k) for k in payload.keys())) + preview = json.dumps(payload, ensure_ascii=False) + preview = preview[:4000] + logger.info( + "[DEBUG-REPORTS][fetch] upstream response dict keys=[%s] full PREVIEW (len=%d):\n%s\n", + keys_preview, len(preview), preview, + ) + except Exception: + pass + # Preferred explicit "result" key first + if "result" in payload and isinstance(payload["result"], list): + unwrapped = payload["result"] + else: + # Scan all common list-wrapper keys (case-insensitive match on the key) + wrapper_keys = {k.lower() for k in ( + "data", "reports", "reportList", "report_list", "report", + "items", "itemList", "item_list", "list", "content", + "scheduled", "scheduledReports", "scheduled_reports", + "podcastList", "podcast_list", "podcasts", "entries", + "records", "rows", "pageContent", "page_content", "value", + )} + for k, v in payload.items(): + if isinstance(k, str) and isinstance(v, list) and k.lower() in wrapper_keys: + unwrapped = v + break + # Fallback: if it's still a dict with only ONE list-valued key, + # assume that's the list (common pattern like {"data": [...]} with + # unusual key name we don't hardcode). + if not isinstance(unwrapped, list): + list_valued_keys = [ + k for k, v in payload.items() if isinstance(v, list) and len(v) > 0 + ] + if len(list_valued_keys) == 1: + unwrapped = payload[list_valued_keys[0]] + # Validate array shape + if isinstance(unwrapped, list): + n_items = len(unwrapped) + n_dict = sum(1 for x in unwrapped if isinstance(x, dict)) + logger.info( + "[INJECT-REPORTS] ✅ reports list loaded OK (list len=%d dictItems=%d)", + n_items, n_dict, + ) + return unwrapped + logger.info( + "[INJECT-REPORTS] ⏭️ upstream returned type=%s (not list); nothing to inject.", + type(unwrapped).__name__, + ) + if S2S_DEBUG_PROMPT and isinstance(payload, dict): + # Log final top-level keys to help diagnose wrapper shape + logger.info( + "[DEBUG-REPORTS][fetch] wrapper not detected — top-level keys of dict response: [%s] " + "(list values under keys: %s)", + ",".join(sorted(str(k) for k in payload.keys())), + ",".join( + sorted(str(k) + "=list[" + str(len(v)) + "]" for k, v in payload.items() if isinstance(v, list)) + ) or "none", + ) + return None + + +def _extract_login_token(login_payload: dict | None) -> str | None: + """Given the parsed response from the S2S_LOGIN_BY_PHONE endpoint (already + {"result":…}-unwrapped), try to extract an auth / session token. + Looks under common names (case-insensitive). Returns None if nothing found. + """ + if not login_payload or not isinstance(login_payload, dict): + return None + # Unwrap one level of nesting if result is still an dict: handled by caller already. + candidates = {c.lower() for c in ( + "token", "jwt", "access_token", "accessToken", "auth_token", + "authToken", "session_token", "sessionToken", "id_token", "idToken", + "bearer", "bearer_token", "bearerToken", "authentication_token", + )} + for k, v in login_payload.items(): + if isinstance(k, str) and k.lower() in candidates and isinstance(v, str) and v.strip(): + return v.strip() + # Second pass: nested dict {"data": {"token": …}} or {"auth": {"jwt": …}} + for k, v in login_payload.items(): + if isinstance(v, dict): + nested = _extract_login_token(v) + if nested: + return nested + return None + + +async def _login_by_phone(phone_number: str) -> tuple[str | None, dict | None]: + """Perform login-by-phone: POST S2S_LOGIN_BY_PHONE with JSON + {"phoneNumber": phone_number}. Returns (bearer_token_or_None, response_obj_or_None). + + Never fails: on any HTTP/parse error logs a warning and returns (None, None) + so subsequent profile/summary calls simply proceed without a Bearer token. + """ + if not S2S_LOGIN_BY_PHONE or not phone_number: + return None, None + body = {"phoneNumber": phone_number} + logger.info( + "[INJECT-LOGIN] POST upstream URL=%s phoneLen=%d", + S2S_LOGIN_BY_PHONE, + len(phone_number), + ) + try: + async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http: + resp = await http.post( + S2S_LOGIN_BY_PHONE, + json=body, + headers={"Content-Type": "application/json"}, + ) + if resp.status_code != 200: + logger.warning( + "[INJECT-LOGIN] ⛔ upstream HTTP %s phoneLen=%d response=%.250s", + resp.status_code, len(phone_number), resp.text, + ) + return None, None + text = (resp.text or "").strip() + if not text: + logger.info("[INJECT-LOGIN] ⏭️ upstream returned empty body; no login token available.") + return None, None + try: + payload = resp.json() + except Exception: + logger.warning( + "[INJECT-LOGIN] ⛔ upstream returned non-JSON body len=%d; cannot extract token. response=%.250s", + len(text), text, + ) + return None, None + except Exception as exc: + logger.warning( + "[INJECT-LOGIN] ⛔ fetch unreachable phoneLen=%d error=%r", + len(phone_number), exc, + ) + return None, None + + # Unwrap {"result": …} if present (common wrapper convention). + if isinstance(payload, dict) and isinstance(payload.get("result"), dict): + unwrapped = payload["result"] + else: + unwrapped = payload if isinstance(payload, dict) else None + token = _extract_login_token(unwrapped) if isinstance(unwrapped, dict) else _extract_login_token( + payload if isinstance(payload, dict) else None + ) + if isinstance(unwrapped, dict): + logger.info( + "[INJECT-LOGIN] ✅ login response OK keys=%s token=%s", + ",".join(sorted(str(k) for k in unwrapped.keys())), + "present" if token else "missing", + ) + if S2S_DEBUG_PROMPT: + logger.info( + "[DEBUG-LOGIN][response] FULL login payload (len=%d):\n%s\n", + len(json.dumps(unwrapped, ensure_ascii=False)), + json.dumps(unwrapped, ensure_ascii=False, indent=2), + ) + return token, unwrapped + # Fallback: non-dict payload (string/list/number) — still log it, but no token expected. + logger.info( + "[INJECT-LOGIN] ⏭️ login response parsed but type=%s (not dict); no token extracted.", + type(payload).__name__, + ) + if S2S_DEBUG_PROMPT: + logger.info( + "[DEBUG-LOGIN][response] FULL login payload (len=%d):\n%s\n", + len(str(payload)), payload, + ) + return None, None + + +async def _fetch_latest_summary_for_phone(phone_number: str, auth_token: str | None = None) -> str | dict | None: + """Fetch the latest caller summary via S2S_LATEST_SUMMARY URL. + + Generic / completely deploy-agnostic: plain GET + S2S_LATEST_SUMMARY?phoneNumber=; accepts JSON (optional {"result": …} + wrapper) or plain text. Never fails the proxy / reasoning; returns None on + any error and logs it. If `auth_token` is provided it is sent as + Authorization: Bearer . + """ + if not S2S_LATEST_SUMMARY or not phone_number: + return None + from urllib.parse import urlencode + target_url = f"{S2S_LATEST_SUMMARY}?{urlencode({'phoneNumber': phone_number})}" + logger.info( + "[INJECT-SUMMARY] fetch upstream URL=%s phoneLen=%d bearer=%s", + S2S_LATEST_SUMMARY, + len(phone_number), + "yes" if auth_token else "no", + ) + try: + headers = {} + if auth_token: + headers["Authorization"] = f"Bearer {auth_token}" + async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http: + resp = await http.get(target_url, headers=headers or None) + if resp.status_code != 200: + logger.warning( + "[INJECT-SUMMARY] ⛔ upstream HTTP %s phoneLen=%d response=%.200s", + resp.status_code, len(phone_number), resp.text, + ) + return None + # Accept JSON first, then fall back to plain text (some summary APIs + # return text/plain). + text = (resp.text or "").strip() + if not text: + return "" + try: + payload = resp.json() + except Exception: + logger.info( + "[INJECT-SUMMARY] upstream returned non-JSON response (len=%d); treating as plain string summary.", + len(text), + ) + return text + except Exception as exc: + logger.warning( + "[INJECT-SUMMARY] ⛔ fetch unreachable phoneLen=%d error=%r", + len(phone_number), exc, + ) + return None + # Unwrap {"result": …} just like profile fetch (common wrapper pattern). + if isinstance(payload, dict) and "result" in payload: + payload = payload["result"] + # Filter out empty dict/list / empty string after unwrap. + 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-SUMMARY] ⏭️ upstream returned empty summary; nothing to inject.") + return None + if isinstance(payload, dict): + logger.info( + "[INJECT-SUMMARY] ✅ summary loaded OK (object) keys=%s", + ",".join(sorted(str(k) for k in payload.keys())), + ) + elif isinstance(payload, list): + logger.info( + "[INJECT-SUMMARY] ✅ summary loaded OK (list) len=%d", + len(payload), + ) + else: + logger.info( + "[INJECT-SUMMARY] ✅ summary 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 + then suppressed (never fail the proxy / reasoning because of this). + + When `auth_token` is provided it is forwarded as Authorization: Bearer + to the upstream profile endpoint. + """ + if not VOCERO_USER_PHONE_AUTH_URL or not phone_number: + return None + from urllib.parse import urlencode + target_url = f"{VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone_number})}" + logger.info( + "[INJECT-PROFILE] fetch upstream URL=%s phoneLen=%d bearer=%s", + VOCERO_USER_PHONE_AUTH_URL, + len(phone_number), + "yes" if auth_token else "no", + ) + try: + headers = {} + if auth_token: + headers["Authorization"] = f"Bearer {auth_token}" + async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http: + resp = await http.get(target_url, headers=headers or None) + if resp.status_code != 200: + logger.info( + "[INJECT-PROFILE] ⛔ upstream HTTP %s phoneLen=%d response=%.200s", + resp.status_code, len(phone_number), resp.text, + ) + return None + payload = resp.json() + except Exception as exc: + logger.warning( + "[INJECT-PROFILE] ⛔ fetch unreachable phoneLen=%d error=%r", + len(phone_number), exc, + ) + return None + if (isinstance(payload, dict) + and "result" in payload + and isinstance(payload["result"], dict)): + profile_obj = payload["result"] + elif isinstance(payload, dict) and not isinstance(payload, list): + profile_obj = payload + else: + logger.info( + "[INJECT-PROFILE] ⛔ upstream returned non-object JSON type=%s keys=?", + type(payload).__name__, + ) + return None + logger.info( + "[INJECT-PROFILE] ✅ profile loaded OK keys=%s", + ",".join(sorted(profile_obj.keys())), + ) + 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 + + (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) + Preserves the existing instructions text. + """ + base = (base_instructions 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] + if not contexts: + return base_instructions or "" + augmented = base + for ctx in contexts: + 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, + ) + if S2S_DEBUG_PROMPT and reports_ctx: + logger.info( + "[DEBUG-REPORTS][instructions augment] FULL reports list block (len=%d):\n%s\n", + len(reports_ctx), reports_ctx, + ) + if S2S_DEBUG_PROMPT and summary_ctx: + logger.info( + "[DEBUG-SUMMARY][instructions augment] FULL latest summary block (len=%d):\n%s\n", + len(summary_ctx), summary_ctx, + ) + 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: + """If raw_text is a client session.update JSON and S2S_INJECT_PROFILE is + true, append 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 + - S2S_INJECT_PROFILE is False + - parse fails + """ + # Step 1: only consider session.update frames. All other frame types + # (response.create, conversation.item.*, audio_* etc.) pass through + # SILENTLY — we never emit logs for them, which avoids spamming the + # console with 6-20 identical "SKIPPED" lines per session. + try: + payload = json.loads(raw_text) + except Exception: + return raw_text + if not isinstance(payload, dict) or payload.get("type") != "session.update": + return raw_text + + # From here on: we know this is a real session.update (typically emitted + # just once or twice per realtime session). We can log safely — 1 line, + # not 20 repeated lines. + if not S2S_INJECT_PROFILE: + logger.info( + "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (S2S_INJECT_PROFILE=false). " + "WS stays pure passthrough." + ) + return raw_text + 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): + 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 / " + "endpoints or S2S_LATEST_SUMMARY / S2S_REPORT_LIST returned empty result." + ) + return raw_text + if has_summary and not has_profile: + 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: + logger.info( + "[INJECT-REPORTS] profile missing, but REPORTS LIST block loaded. " + "Will inject reports alone into session.update." + ) + top_session = payload.get("session") + if not isinstance(top_session, dict): + return raw_text + current_instructions = ( + top_session.get("instructions") + if isinstance(top_session.get("instructions"), str) + else "" + ) + augmented = _augment_instructions_with_profile( + current_instructions, user_context_text, reports_context_text, summary_context_text + ) + if S2S_DEBUG_PROMPT: + logger.info( + "[DEBUG-PROMPT][WS session.update] BEFORE instructions (len=%d):\n%s\n", + len(current_instructions), current_instructions, + ) + if augmented == current_instructions: + logger.info( + "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (augmented == current; " + "profile already injected or context empty)" + ) + if S2S_DEBUG_PROMPT and augmented.strip(): + logger.info( + "[DEBUG-PROMPT][WS session.update] FINAL unchanged (len=%d):\n%s\n", + len(augmented), augmented, + ) + return raw_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", + len(current_instructions), + len(augmented), + (len(augmented) - len(current_instructions)), + 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 reports_context_text and reports_context_text.strip(): + logger.info( + "[DEBUG-REPORTS][WS session.update] FULL reports list block (len=%d):\n%s\n", + len(reports_context_text.strip()), reports_context_text.strip(), + ) + # Also log the FINAL upstream instructions when S2S_DEBUG_PROMPT is on and + # this is a session.update (so we see the EXACT text reaching the model even + # when S2S_INJECT_PROFILE is off and no patch happened.) + if (not S2S_INJECT_PROFILE) and S2S_DEBUG_PROMPT and current_instructions.strip(): + logger.info( + "[DEBUG-PROMPT][WS session.update] FINAL upstream instructions (injectProfile=false, len=%d):\n%s\n", + len(current_instructions), current_instructions, + ) + try: + return json.dumps(payload) + except Exception: + logger.info( + "[INJECT-PROFILE] ⛔ JSON re-serialize failed on session.update. " + "Forwarding unpatched." + ) + return raw_text class ReasoningRequest(BaseModel): @@ -429,393 +1090,24 @@ class ReasoningRequest(BaseModel): instructions: str = "" language: str = "" user_context: str = "" - # Phone number of the caller (same as URL param). When present and the - # reasoning mode is NODE_RED, it is forwarded to Node-RED so that flows - # can fetch the caller profile via Aquachile /auth/user-by-phone. + # Optional phone number. When S2S_INJECT_PROFILE is true the proxy fetches + # the caller profile from VOCERO_USER_PHONE_AUTH_URL and: + # 1. prepends the serialized profile to user_context before feeding the + # engine (NODE_RED or OPENAI); + # 2. appends the profile context to the LLM instructions prompt. phoneNumber: str | None = None -_agent_profile_lock = asyncio.Lock() -_agent_profile_cached: tuple[float, str, list[dict]] = (0.0, "", []) -_insights_lock = asyncio.Lock() -# cache: (cached_at_seconds, cache_date_str_YYYY_MM_DD, list[dict] | None, formatted_prompt_block_str) -_insights_cached: tuple[float, str, list[dict] | None, str] = (0.0, "", None, "") -async def _agent_profile_data() -> tuple[str, list[dict]]: - if not S2S_AGENT_PROFILE: - logger.debug("[EXT][AQUACHILE] agent_profile: endpoint not configured, skipping") - return "", [] - now = asyncio.get_running_loop().time() - cached_at, cached_prompt, cached_tools = _agent_profile_cached - if (cached_prompt or cached_tools) and (now - cached_at) < 60: - logger.debug( - "[EXT][AQUACHILE] agent_profile: cache HIT age=%.1fs promptLen=%d toolCount=%d", - now - cached_at, len(cached_prompt), len(cached_tools), - ) - return cached_prompt, cached_tools - async with _agent_profile_lock: - cached_at, cached_prompt, cached_tools = _agent_profile_cached - if (cached_prompt or cached_tools) and (now - cached_at) < 60: - return cached_prompt, cached_tools - logger.debug("[EXT][AQUACHILE] agent_profile GET %s (cache miss)", S2S_AGENT_PROFILE) - try: - async with httpx.AsyncClient(timeout=10.0) as http: - resp = await http.get(S2S_AGENT_PROFILE) - logger.debug( - "[EXT][AQUACHILE] agent_profile RESP status=%s len=%dB", - resp.status_code, len(resp.content or b""), - ) - if resp.status_code != 200: - logger.warning("Agent profile fetch failed %s: %s", resp.status_code, resp.text[:300]) - _set_agent_profile_cache(now, "", []) - return "", [] - data = resp.json() - except Exception as exc: - logger.warning("Agent profile fetch failed: %r", exc) - _set_agent_profile_cache(now, "", []) - return "", [] - prompt = _extract_agent_prompt(data) - tools = _extract_agent_tools(data) - logger.debug( - "[EXT][AQUACHILE] agent_profile PARSED promptLen=%d toolCount=%d toolNames=%s", - len(prompt), len(tools), [t.get("name") for t in tools if isinstance(t, dict)], - ) - _set_agent_profile_cache(now, prompt, tools) - return prompt, tools -def _set_agent_profile_cache(ts: float, prompt: str, tools: list[dict]) -> None: - global _agent_profile_cached - _agent_profile_cached = (ts, prompt, tools) -def _set_insights_cache(ts: float, date_str: str, entries: list[dict] | None, formatted_block: str) -> None: - global _insights_cached - _insights_cached = (ts, date_str, entries, formatted_block) -def _format_insights_for_prompt(entries: list[dict]) -> str: - """ - Serializza una lista di insight Aquachile in formato testo leggibile - da appendere alle agent instructions. Priorità visuale: HIGH → MEDIUM → LOW. - Es. output: - ### Contexto operativo del día (2026-08-25) — 18 insights: - 1. [HIGH · Operations · Center: Arica · Jaula: C-07] Título breve - - Descrizione del insight (trunc 200c). - - Recomendación: azione consigliata (trunc 200c). - ... - """ - if not entries: - return "" - today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") - _priority_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2} - try: - sorted_entries = sorted( - entries, - key=lambda e: ( - _priority_order.get((e.get("priority") or "").upper(), 99), - e.get("createdAt") or "", - ), - reverse=False, - ) - except Exception: - sorted_entries = list(entries) - lines: list[str] = [] - total = len(sorted_entries) - lines.append(f"### Contexto operativo del día ({today_str}) — {total} insights:") - for idx, e in enumerate(sorted_entries[:MAX_INSIGHTS_IN_CONTEXT], start=1): - if not isinstance(e, dict): - continue - priority = str(e.get("priority") or "UNKNOWN").upper() - audience = e.get("audience") - if isinstance(audience, list): - audience_str = "/".join(str(a) for a in audience if a)[:40] - elif isinstance(audience, str): - audience_str = audience[:40] - else: - audience_str = "General" - center = str(e.get("center") or "—")[:40] - jaula = str(e.get("jaula") or "—")[:24] - title = (e.get("title") or "(No title)").strip() - description = (e.get("description") or "").strip() - recommendation = (e.get("recommendation") or "").strip() - header = f"{idx}. [{priority} · {audience_str} · Centro: {center} · Jaula: {jaula}] {title}" - lines.append(header) - if description: - desc_trunc = description if len(description) <= 240 else description[:237] + "..." - lines.append(f" • Situación: {desc_trunc}") - if recommendation: - rec_trunc = recommendation if len(recommendation) <= 200 else recommendation[:197] + "..." - lines.append(f" • Recomendación: {rec_trunc}") - if total > MAX_INSIGHTS_IN_CONTEXT: - lines.append( - f" … ({total - MAX_INSIGHTS_IN_CONTEXT} insights adicionales omitidos para mantener el contexto compacto. " - f"Si el usuario necesita más detalles, puede consultarlos explícitamente via tool call list_recent_reports.)" - ) - return "\n".join(lines).strip() -async def _fetch_insights_for_today() -> tuple[list[dict] | None, str]: - """ - Fetch diario degli insights Aquachile via GET S2S_INSIGHTS_URL con - start_date == end_date == oggi (UTC). Cache 60s TTL con anti-thundering-herd - lock, esattamente come _agent_profile_data. Ritorna (entries_list o None, - formatted_block_str). Se fallisce → ritorna (None, "") e logga WARNING - ma NON solleva eccezioni (never-block-audio). - """ - if not INSIGHTS_ENABLED: - return None, "" - today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") - now = asyncio.get_running_loop().time() - cached_at, cached_date, cached_entries, cached_block = _insights_cached - # Cache HIT solo se: stessa data di oggi e age < 60s - if cached_date == today_str and cached_block and (now - cached_at) < 60: - logger.debug( - "[INSIGHTS FETCH] cache HIT date=%s age=%.1fs entries=%d blockLen=%d", - cached_date, now - cached_at, len(cached_entries or []), len(cached_block), - ) - return cached_entries, cached_block - async with _insights_lock: - cached_at, cached_date, cached_entries, cached_block = _insights_cached - if cached_date == today_str and cached_block and (now - cached_at) < 60: - return cached_entries, cached_block - url = S2S_INSIGHTS_URL.rstrip("/") - params = {"start_date": today_str, "end_date": today_str} - headers = {"Accept": "application/json"} - if S2S_INSIGHTS_API_KEY: - headers["X-API-KEY"] = S2S_INSIGHTS_API_KEY - if S2S_INSIGHTS_API_SECRET: - headers["X-API-SECRET"] = S2S_INSIGHTS_API_SECRET - logger.info( - "[INSIGHTS FETCH] → GET today=%s url=%s params=%s apiKey=%s", - today_str, url, json.dumps(params, ensure_ascii=True), - "set" if S2S_INSIGHTS_API_KEY else "not-set (public OK)", - ) - try: - async with httpx.AsyncClient(timeout=15.0) as http: - resp = await http.get(url, params=params, headers=headers) - except Exception as exc: - logger.warning("[INSIGHTS FETCH] ERROR (continuo senza insights, non blocco audio): %r", exc) - _set_insights_cache(now, today_str, None, "") - return None, "" - logger.debug( - "[INSIGHTS FETCH] ← RESP status=%s len=%dB", - resp.status_code, len(resp.content or b""), - ) - if resp.status_code != 200: - logger.warning( - "[INSIGHTS FETCH] HTTP %s (non 200, skip — non blocco audio). Preview body: %s", - resp.status_code, (resp.text or "")[:300], - ) - _set_insights_cache(now, today_str, None, "") - return None, "" - try: - data = resp.json() - except Exception as exc: - logger.warning("[INSIGHTS FETCH] JSON parse fallito: %r. Body preview=%s", exc, (resp.text or "")[:300]) - _set_insights_cache(now, today_str, None, "") - return None, "" - if isinstance(data, dict) and isinstance(data.get("insights"), list): - entries = [e for e in data["insights"] if isinstance(e, dict)] - elif isinstance(data, list): - entries = [e for e in data if isinstance(e, dict)] - else: - logger.warning( - "[INSIGHTS FETCH] formato response inatteso (keys=%s). Skip senza errori.", - sorted(data.keys()) if isinstance(data, dict) else type(data).__name__, - ) - _set_insights_cache(now, today_str, None, "") - return None, "" - # Ordina e formatta per injection nel prompt. - priority_counts: dict[str, int] = {} - for e in entries: - p = str(e.get("priority") or "UNKNOWN").upper() - priority_counts[p] = priority_counts.get(p, 0) + 1 - formatted_block = _format_insights_for_prompt(entries) - logger.info( - "[INSIGHTS FETCH] ✓ PARSED OK today=%s total=%d byPriority=%s formattedLen=%d maxCap=%d (truncated=%s)", - today_str, - len(entries), - json.dumps(priority_counts, ensure_ascii=True), - len(formatted_block), - MAX_INSIGHTS_IN_CONTEXT, - "YES" if len(entries) > MAX_INSIGHTS_IN_CONTEXT else "NO", - ) - # Log dettaglio anteprima lista insights (title/priority/center) per auditoria - # INFO level: sempre visibile nel container (audit quali insights sono stati considerati). - _preview_cap = min(10, len(entries)) - _preview_list = [] - for e in entries[:_preview_cap]: - _preview_list.append( - { - "priority": str(e.get("priority") or "?").upper(), - "center": str(e.get("center") or "?")[:30], - "jaula": str(e.get("jaula") or "?")[:20], - "title": (str(e.get("title") or "?")[:80]), - } - ) - # 👉 Pattern singolo %s + f-string: nessun rischio che % dentro agli insights - # (es. -1.65%) vengano interpretati come placeholder da logging — BUG FIX. - _msg_preview = ( - f"[INSIGHTS FETCH] PREVIEW lista primi {_preview_cap}/{len(entries)} insights " - f"JSON={json.dumps(_preview_list, ensure_ascii=True)}" - ) - logger.info("%s", _msg_preview) - _set_insights_cache(now, today_str, entries, formatted_block) - return entries, formatted_block - - -def _extract_agent_prompt(data) -> str: - try: - if isinstance(data, dict): - agent = data.get("agent") - if isinstance(agent, dict): - desc = agent.get("instructions") - if isinstance(desc, str): - return desc.strip() - desc = agent.get("description") - if isinstance(desc, str): - return desc.strip() - desc = data.get("instructions") - if isinstance(desc, str): - return desc.strip() - desc = data.get("description") - if isinstance(desc, str): - return desc.strip() - except Exception: - return "" - return "" - - -def _extract_agent_tools(data) -> list[dict]: - try: - if isinstance(data, dict): - agent = data.get("agent") - if isinstance(agent, dict): - tools = agent.get("tools") - if isinstance(tools, list): - return [tool for tool in tools if isinstance(tool, dict) and tool.get("enabled", True)] - except Exception: - return [] - return [] - - -def _normalize_tools_for_realtime(raw_tools: list[dict]) -> list[dict]: - """ - Converte tool definitions Aquachile (qualsiasi variante di struttura) in - formato OpenAI Realtime API GA standard, valido per session.update -> tools[]. - - Formato atteso Realtime API (ogni tool): - { - "type": "function", # fisso, unico supportato oggi - "name": "snake_case_tool_name", # <64 chars, a-z0-9_ - "description": "frase che spiega quando usarlo", # stringa, NOT null - "parameters": { # JSON Schema Object, MAY BE {} vuoto - "type": "object", - "properties": { "arg1": {"type":"string","description":"…"}, … }, - "required": ["arg1"] # (opzionale, array di stringhe) - } - } - - Ritorna SEMPRE list[dict] (non torna mai None, non alza eccezioni). - Tool invalidi vengono saltati con logger.warning ma non bloccano gli altri. - """ - normalized: list[dict] = [] - seen_names: set[str] = set() - if not isinstance(raw_tools, list): - return normalized - for idx, tool in enumerate(raw_tools): - if not isinstance(tool, dict): - logger.warning("[MCP TOOLS] skip tool at index %s: not a dict (got %s)", idx, type(tool).__name__) - continue - name = "" - for key in ("name", "function_name", "toolName", "id"): - val = tool.get(key) - if isinstance(val, str) and val.strip(): - name = val.strip() - break - if not name: - logger.warning("[MCP TOOLS] skip tool at index %s: missing name keys=%s", idx, sorted(tool.keys())) - continue - # sanitize nome -> solo [a-z0-9_] (regex OpenAI) - clean_name = re.sub(r"[^a-zA-Z0-9_]", "_", name).lower().strip("_") - if not clean_name: - clean_name = f"tool_{idx}" - if clean_name in seen_names: - clean_name = f"{clean_name}_{idx}" - seen_names.add(clean_name) - description = "" - for key in ("description", "desc", "summary", "instructions", "purpose"): - val = tool.get(key) - if isinstance(val, str) and val.strip(): - description = val.strip() - break - if not description: - description = f"Tool {clean_name}" - # parameters JSON Schema - raw_params = None - for key in ("parameters", "params", "input_schema", "schema", "argument_schema"): - val = tool.get(key) - if isinstance(val, dict) and val: - raw_params = val - break - parameters: dict - if raw_params: - parameters = dict(raw_params) - parameters.setdefault("type", "object") - if "properties" not in parameters or not isinstance(parameters["properties"], dict): - parameters["properties"] = {} - if "required" in parameters and not isinstance(parameters["required"], list): - parameters["required"] = [] - else: - # parametri assenti: oggetto vuoto permesso da Realtime API - parameters = {"type": "object", "properties": {}} - normalized.append({ - "type": "function", - "name": clean_name, - "description": description, - "parameters": parameters, - }) - logger.debug( - "[MCP TOOLS] normalized count=%d raw_count=%d names=%s", - len(normalized), len(raw_tools), [t["name"] for t in normalized][:10], - ) - return normalized - - -def _mcp_headers() -> dict[str, str]: - headers = { - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - } - if S2S_TOOL_EXECUTION_MCP_API_KEY: - headers["X-API-KEY"] = S2S_TOOL_EXECUTION_MCP_API_KEY - if S2S_TOOL_EXECUTION_MCP_API_SECRET: - headers["X-API-SECRET"] = S2S_TOOL_EXECUTION_MCP_API_SECRET - return headers - - -def _parse_mcp_response(body: str) -> dict: - text = (body or "").strip() - if not text: - return {} - if text.startswith("{"): - try: - parsed = json.loads(text) - return parsed if isinstance(parsed, dict) else {} - except Exception: - return {} - for chunk in text.split("\n\n"): - for line in chunk.splitlines(): - if line.startswith("data: "): - payload = line[6:].strip() - try: - parsed = json.loads(payload) - return parsed if isinstance(parsed, dict) else {} - except Exception: - return {} - return {} def _url_with_query(url: str, **params: str) -> str: @@ -860,146 +1152,28 @@ async def config(request: Request): whether HF sign-in is available, and whether the user may instead set a direct s2s server URL. The LB address itself is intentionally NOT included.""" logger.debug("[INTERNAL] /api/config requested client=%s", request.client.host if request.client else "?") - agent_prompt, agent_tools_raw = await _agent_profile_data() - # ⛔ MCP MASTER DISABLE: se S2S_TOOL_EXECUTION_MCP_URL non è in .env - # nascondi catalogo 79 tool ANCHE lato client (evita ranking/pulsanti che fallirebbero 503) - if MCP_EXEC_ENABLED: - agent_tools_for_client = agent_tools_raw - search_mcp_for_client = bool(S2S_SEARCH_MCP) - else: - agent_tools_for_client = [] - search_mcp_for_client = False - primary_prompt = agent_prompt or S2S_PRIMARY_PROMPT + primary_prompt = S2S_PRIMARY_PROMPT out = { "search": bool(SERPER_KEY), "lb": bool(LOAD_BALANCER_URL), "allowDirect": not LOAD_BALANCER_URL, - # Deploy-pinned direct s2s URL (empty when unset). Not a secret: the - # browser dials it itself, and Settings shows it locked. "s2sUrl": _effective_s2s_url(request), "primaryPrompt": primary_prompt, - "agentProfile": S2S_AGENT_PROFILE, - "agentTools": agent_tools_for_client, - "searchMcp": search_mcp_for_client, "reasoningType": S2S_REASONING_TYPE, "auth": AUTH_ENABLED, + "injectProfile": S2S_INJECT_PROFILE, + "debugPrompt": S2S_DEBUG_PROMPT, } logger.debug( - "[INTERNAL] /api/config returned promptLen=%d toolCount=%d (raw=%d before master gate) reasoning=%s searchMcp=%s MCP_EXEC_ENABLED=%s", + "[INTERNAL] /api/config returned promptLen=%d reasoning=%s", len(primary_prompt), - len(agent_tools_for_client), - len(agent_tools_raw), S2S_REASONING_TYPE, - search_mcp_for_client, - MCP_EXEC_ENABLED, ) return out -@app.get("/api/user/phone") -async def user_by_phone(phoneNumber: str = ""): - phone_number = phoneNumber.strip() - if not phone_number: - raise HTTPException(status_code=400, detail="phoneNumber is required.") - if not VOCERO_USER_PHONE_AUTH_URL: - raise HTTPException(status_code=503, detail="VOCERO_USER_PHONE_AUTH_URL is not configured.") - target_url = _url_with_query(VOCERO_USER_PHONE_AUTH_URL, phoneNumber=phone_number) - logger.info("[Vocero USER AUTH] phoneNumber=%s target=%s", phone_number, target_url) - logger.debug("[EXT][AQUACHILE] user_by_phone GET %s", target_url) - try: - async with httpx.AsyncClient(timeout=20.0) as http: - resp = await http.get(target_url, headers={"Accept": "application/json"}) - except httpx.RequestError as exc: - logger.warning("User phone auth failed: %r", exc) - raise HTTPException(status_code=502, detail="User phone auth unreachable.") - logger.debug( - "[EXT][AQUACHILE] user_by_phone RESP status=%s len=%dB", - resp.status_code, len(resp.content or b""), - ) - if resp.status_code != 200: - logger.warning("User phone auth failed %s: %s", resp.status_code, resp.text[:400]) - raise HTTPException(status_code=502, detail=f"User phone auth failed ({resp.status_code}).") - try: - payload = resp.json() - except Exception: - logger.warning("User phone auth returned invalid JSON: %s", resp.text[:400]) - raise HTTPException(status_code=502, detail="User phone auth returned invalid JSON.") - logger.info("[Vocero USER AUTH] phoneNumber=%s preview=%s", phone_number, json.dumps(payload, ensure_ascii=True)[:800]) - if isinstance(payload, dict): - logger.debug( - "[EXT][AQUACHILE] user_by_phone PARSED keys=%s", - sorted(payload.keys()), - ) - return JSONResponse({"result": payload}) -@app.post("/api/mcp/tool") -async def mcp_tool(req: McpToolCallRequest): - if not MCP_EXEC_ENABLED: - logger.warning( - "[MCP DISABLED] ⛔ /api/mcp/tool called but S2S_TOOL_EXECUTION_MCP_URL is empty/commented in .env. " - "Rejecting with 503. name=%s args=%s", - req.name, - json.dumps(req.arguments or {}, ensure_ascii=True)[:200], - ) - raise HTTPException( - status_code=503, - detail="MCP tool execution is disabled: S2S_TOOL_EXECUTION_MCP_URL is not configured in .env.", - ) - target = S2S_TOOL_EXECUTION_MCP_URL.rstrip("/") + "/" - body = { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": req.name, - "arguments": req.arguments or {}, - }, - } - args_json = json.dumps(req.arguments or {}, ensure_ascii=True) - logger.info( - "[Vocero MCP CALL] name=%s target=%s args=%s", - req.name, target, args_json[:800], - ) - logger.debug( - "[EXT][AQUACHILE] mcp_tool POST %s method=%s tool=%s argsLen=%dB body=%s", - target, body["method"], req.name, len(args_json), - json.dumps(body, ensure_ascii=True)[:800], - ) - try: - async with httpx.AsyncClient(timeout=45.0) as http: - resp = await http.post( - target, - headers=_mcp_headers(), - json=body, - ) - except httpx.RequestError as exc: - logger.warning("MCP tool call failed: %r", exc) - raise HTTPException(status_code=502, detail="MCP tool execution unreachable.") - logger.info("[Vocero MCP HTTP] name=%s status=%s", req.name, resp.status_code) - logger.debug( - "[EXT][AQUACHILE] mcp_tool RESP tool=%s status=%s len=%dB raw=%s", - req.name, resp.status_code, len(resp.content or b""), (resp.text or "")[:800], - ) - if resp.status_code != 200: - logger.warning("MCP tool call failed %s: %s", resp.status_code, resp.text[:400]) - raise HTTPException(status_code=502, detail=f"MCP tool execution failed ({resp.status_code}).") - payload = _parse_mcp_response(resp.text) - if payload.get("error"): - message = payload["error"].get("message") if isinstance(payload["error"], dict) else "MCP tool execution failed." - logger.warning("[Vocero MCP OUT] name=%s error=%s", req.name, message) - raise HTTPException(status_code=502, detail=message or "MCP tool execution failed.") - result = payload.get("result") - result_json = json.dumps(result, ensure_ascii=True) if result is not None else "null" - logger.info("[Vocero MCP OUT] name=%s preview=%s", req.name, result_json[:800]) - logger.debug( - "[EXT][AQUACHILE] mcp_tool PARSED tool=%s resultKeys=%s resultLen=%dB", - req.name, - sorted(result.keys()) if isinstance(result, dict) else "N/A", - len(result_json), - ) - return JSONResponse({"result": result}) - @app.post("/api/reasoning") async def reasoning(req: ReasoningRequest): @@ -1009,6 +1183,141 @@ 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 ── + # (When S2S_INJECT_PROFILE=false this entire block is a no-op; user_context + # and instructions are taken verbatim from the request body.) + inject_profile_text = "" + inject_reports_text = "" + inject_summary_text = "" + phone = (req.phoneNumber or "").strip() + if not S2S_INJECT_PROFILE: + logger.info( + "[INJECT-PROFILE] ⏭️ REASONING: skipped (S2S_INJECT_PROFILE=false). " + "Using verbatim request body; no fetch, no augmentation." + ) + 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_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): + 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." + ) + elif not phone: + 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.)" + ) + elif not can_profile_ok: + logger.info( + "[INJECT-PROFILE] ⏭️ REASONING: skipped (env for phone empty and no reports/summary/login API)." + ) + else: + # ################################################################### + # 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env set) + # Obtains a bearer token that will be forwarded for profile/reports/summary fetches. + # ################################################################### + login_token = None + if S2S_LOGIN_BY_PHONE and phone: + login_token, _login_resp = await _login_by_phone(phone) + # ################################################################### + # 1) Profile fetch (optional — only if env is configured) + # ################################################################### + if needs_phone_authed and phone: + profile_obj = await _fetch_user_profile_for_phone(phone, auth_token=login_token) + inject_profile_text = _resolve_user_context_text(phone, profile_obj, loaded=True) + if inject_profile_text.strip(): + logger.info( + "[INJECT-PROFILE] ✅ REASONING: profile READY phoneLen=%d contextLen=%d -> augmenting instructions + user_context before sending upstream.", + len(phone), len(inject_profile_text), + ) + else: + logger.info( + "[INJECT-PROFILE] ⏭️ REASONING: profile fetch OK but resolved context empty." + ) + # ################################################################### + # 1.5) Scheduled reports / podcast scripts list (optional — S2S_REPORT_LIST) + # ################################################################### + if needs_reports: + if S2S_DEBUG_PROMPT: + logger.info( + "[INJECT-REPORTS] starting REASONING fetch S2S_REPORT_LIST (phoneLen=%s, bearer=%s)...", + len(phone) if phone else "none", + "yes" if login_token else "no", + ) + reports_raw = await _fetch_reports_list(phone_number=phone if phone else None, auth_token=login_token) + inject_reports_text = _resolve_reports_context(reports_raw) + if inject_reports_text.strip(): + logger.info( + "[INJECT-REPORTS] ✅ REASONING: reports list READY wrappedLen=%d -> augmenting instructions + user_context before sending upstream.", + len(inject_reports_text), + ) + else: + logger.info( + "[INJECT-REPORTS] ⏭️ REASONING: reports list skipped (context empty / upstream returned empty list / fetch failed)." + ) + # ################################################################### + # 2) Latest summary fetch (optional — only if env is configured) + # ################################################################### + if needs_summary and phone: + if S2S_DEBUG_PROMPT: + logger.info( + "[INJECT-SUMMARY] starting REASONING fetch (reasoning summary... S2S_LATEST_SUMMARY configured; phoneLen=%d.", + len(phone), + ) + latest_summary_raw = await _fetch_latest_summary_for_phone(phone, auth_token=login_token) + inject_summary_text = _resolve_latest_summary_context(latest_summary_raw) + if inject_summary_text.strip(): + logger.info( + "[INJECT-SUMMARY] ✅ REASONING: latest summary READY phoneLen=%d wrappedLen=%d -> augmenting instructions + user_context before sending upstream.", + len(phone), len(inject_summary_text), + ) + else: + logger.info( + "[INJECT-SUMMARY] ⏭️ REASONING: latest summary skipped (context empty / upstream returned empty summary / fetch failed.)" + ) + user_context_parts: list[str] = [] + if inject_profile_text: + user_context_parts.append(inject_profile_text) + if inject_reports_text: + user_context_parts.append(inject_reports_text) + if inject_summary_text: + user_context_parts.append(inject_summary_text) + original_user_context = (req.user_context or "").strip() + if original_user_context: + 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) + if any_injected: + logger.info( + "[INJECT-PROFILE] REASONING user_context final parts=%d serverProfileLen=%d serverReportsLen=%d serverSummaryLen=%d browserUserCtxLen=%d joinedLen=%d", + len(user_context_parts), + 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_reports_text.strip(): + logger.info( + "[DEBUG-REPORTS][REASONING augment] FULL reports list block (len=%d):\n%s\n", + len(inject_reports_text), inject_reports_text, + ) + if S2S_DEBUG_PROMPT and inject_summary_text.strip(): + logger.info( + "[DEBUG-SUMMARY][REASONING augment] FULL latest summary block (len=%d):\n%s\n", + 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 + ) + else: + effective_instructions = (req.instructions or "").strip() + # ──────────────────────────────────────────────────────────────────── # Modalità A: NODE_RED — inoltra TUTTO il payload a Node-RED # Il flow Node-RED decide autonomamente. @@ -1023,71 +1332,26 @@ async def reasoning(req: ReasoningRequest): status_code=503, 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), + ) upstream_body = { "transcript": transcript, - "instructions": (req.instructions or "").strip(), + "instructions": effective_instructions, "language": (req.language or "").strip(), - "user_context": (req.user_context or "").strip(), - "phoneNumber": (req.phoneNumber or "").strip() or None, + "user_context": effective_user_context, } - _instructions_str = upstream_body["instructions"] or "" - _has_mcp_search_hint = "Available MCP tools:" in _instructions_str - _has_ranking_hint = "Client-side ranking hint only:" in _instructions_str - _top3_suggested = [] - if _has_ranking_hint: - _rank_chunk = None - for _sep in [". Use this hint", " Use this hint", " — Use this hint", ". Use it only", "\nUser profile:", "\nCurrent date:", " Use "]: - _pos = _instructions_str.find(_sep, _instructions_str.find("Client-side ranking hint only:")) - if _pos > 0: - _start = _instructions_str.find("Client-side ranking hint only:") + len("Client-side ranking hint only:") - _rank_chunk = _instructions_str[_start:_pos].strip(" \n\t:-—.") - break - if _rank_chunk is None: - _start = _instructions_str.find("Client-side ranking hint only:") + len("Client-side ranking hint only:") - _rank_chunk = _instructions_str[_start:_start + 1500].strip() - for _rm in re.finditer(r"\s*(\d+)\.\s*([^::]+?)[::]\s*", _rank_chunk): - _rn = int(_rm.group(1)) - _nm = _rm.group(2).strip(" \n\t`*•") - _rest_start = _rm.end() - _rest_chunk = _rank_chunk[_rest_start:_rest_start + 300] - _next_match = re.search(r"\s*\d+\.\s*[^::]+?[::]\s*", _rest_chunk) - if _next_match: - _descr = _rest_chunk[:_next_match.start()].strip(" \n\t;.,—-") - else: - _descr = _rest_chunk.strip(" \n\t;.,—-") - if _nm: - _top3_suggested.append({"rank": _rn, "name": _nm, "description": _descr[:80]}) - _mcp_catalog_len = 0 - if _has_mcp_search_hint: - _cat_start = _instructions_str.find("Available MCP tools:") + len("Available MCP tools:") - _cat_end_candidates = [ - _instructions_str.find(_s, _cat_start) for _s in - [" Current user request:", "\nCurrent user request:", " Current user request\n", " User profile:", "\nUser profile:", " When the user's request", " Use "] - ] - _cat_end_candidates = [_p for _p in _cat_end_candidates if _p > _cat_start] - _cat_end = min(_cat_end_candidates) if _cat_end_candidates else (_cat_start + 20000) - _cat_chunk = _instructions_str[_cat_start:_cat_end].strip(" \n\t:.,—-") - _semicolon_count = _cat_chunk.count("; ") - if _semicolon_count: - _mcp_catalog_len = _semicolon_count + 1 - else: - _tool_mentions = re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]{2,50}\b\s*:\s", _cat_chunk) - _mcp_catalog_len = len(_tool_mentions) - logger.info( - "[MCP RANK] reasoning payload transcript=%s instructionsLen=%d hasMcpCatalogHint=%s hasRankingHint=%s mcpCatalogTools=%d top3Suggested=%s", - transcript[:200], - len(_instructions_str), - _has_mcp_search_hint, - _has_ranking_hint, - _mcp_catalog_len, - json.dumps(_top3_suggested, ensure_ascii=True) if _top3_suggested else "none", - ) + if S2S_DEBUG_PROMPT: + logger.info( + "[DEBUG-PROMPT][REASONING → NODE_RED] FULL upstream_body JSON:\n%s\n", + json.dumps(upstream_body, ensure_ascii=False, indent=2), + ) logger.debug( - "[EXT][NODE_RED] reasoning POST %s/api/reasoning lang=%s transcriptLen=%d phone=%s", + "[EXT][NODE_RED] reasoning POST %s/api/reasoning lang=%s transcriptLen=%d", NODE_RED_REASONING_URL, (req.language or "")[:20] or "auto", len(transcript), - (req.phoneNumber or "")[:16] or "none", ) try: async with httpx.AsyncClient(timeout=120.0) as http: @@ -1135,7 +1399,7 @@ async def reasoning(req: ReasoningRequest): usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), usage.get("total_tokens", 0), ) extra = {} - for key in ("latency_ms", "intent", "mcp_tools_used", "caller_profile_keys", "debug"): + for key in ("latency_ms", "intent", "caller_profile_keys", "debug"): if isinstance(payload, dict) and key in payload: extra[key] = payload[key] if log_only: @@ -1164,11 +1428,11 @@ async def reasoning(req: ReasoningRequest): if not OPENAI_API_KEY: raise HTTPException(status_code=503, detail="OPENAI_API_KEY is not configured.") system_parts = [] - instructions = (req.instructions or "").strip() + instructions = effective_instructions or ((req.instructions or "").strip()) if instructions: system_parts.append(instructions) - if req.user_context.strip(): - system_parts.append(f"User context:\n{req.user_context.strip()}") + if effective_user_context.strip(): + system_parts.append(f"User context:\n{effective_user_context.strip()}") if req.language.strip(): system_parts.append( "Reply only with the final assistant answer text. " @@ -1176,13 +1440,30 @@ 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", + len(system_parts), + len(effective_instructions), + len(effective_user_context), + len(phone), + ) + system_msg = "\n\n".join(system_parts) body = { "model": OPENAI_REASONING_MODEL, "messages": [ - {"role": "system", "content": "\n\n".join(system_parts)}, + {"role": "system", "content": system_msg}, {"role": "user", "content": transcript}, ], } + if S2S_DEBUG_PROMPT: + logger.info( + "[DEBUG-PROMPT][REASONING → OPENAI] FULL SYSTEM message (len=%d):\n%s\n", + len(system_msg), system_msg, + ) + logger.info( + "[DEBUG-PROMPT][REASONING → OPENAI] USER transcript (len=%d):\n%s\n", + len(transcript), transcript, + ) logger.debug( "[EXT][OPENAI] reasoning POST %s/chat/completions model=%s systemLen=%d transcriptLen=%d language=%s", OPENAI_BASE_URL, OPENAI_REASONING_MODEL, @@ -1281,10 +1562,125 @@ async def realtime_proxy(ws: WebSocket): await ws.accept() upstream_url = _proxy_upstream_url(ws) subprotocols = _subprotocols(ws) - logger.debug( - "[EXT][S2S-PROXY] client=%s CONNECT upstream=%s subprotocols=%s", - client_ip, upstream_url, subprotocols, + + # ── 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. + # When S2S_INJECT_PROFILE=false or envs missing -> ctx fields stay empty + # and the tunnel stays 100% pure passthrough (original behaviour). + injected_profile_context = "" + injected_reports_context = "" + injected_summary_context = "" + inject_phone = "" + 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." + ) + 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 + 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." + ) + 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." + ) + 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), + ) + 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)." + ) + 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", + bool(injected_profile_context), + bool(injected_reports_context), + bool(injected_summary_context), + ("token-present" if login_token else "skipped"), + len(inject_phone), + len(injected_profile_context), + len(injected_reports_context), + len(injected_summary_context), + upstream_url, ) + 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", + len(injected_reports_context), injected_reports_context, + ) + if S2S_DEBUG_PROMPT and injected_summary_context.strip(): + logger.info( + "[DEBUG-SUMMARY][WS connect] FULL latest summary block loaded (len=%d):\n%s\n", + len(injected_summary_context), injected_summary_context, + ) try: async with websockets.connect( upstream_url, @@ -1292,149 +1688,16 @@ async def realtime_proxy(ws: WebSocket): open_timeout=15, max_size=None, ) as upstream: - logger.debug("[EXT][S2S-PROXY] client=%s upstream TUNNEL ESTABLISHED", client_ip) - # ── Inject strumenti MCP Aquachile + istruzioni agente a S2S upstream ── - # Prima dei task di streaming: session.update con tools[] e instructions - # native. Se l'upstream supporta il formato → emette response.function_call_arguments.done - # e la catena toolcall nativa del client si attiva automaticamente. - # - # ★ FORMATO CORRETTO OpenAI Realtime GA Standard: - # {"type":"session.update","session":{"instructions":"…","tools":[…],"tool_choice":"auto"}} - # I campi sono SEMPRE annidati dentro "session": Pydantic strict reject extra fields - # se li metti alla radice, causando "Unknown or invalid event: session.update". - try: - _agent_prompt, _agent_raw_tools = await _agent_profile_data() - if MCP_EXEC_ENABLED: - _realtime_tools = _normalize_tools_for_realtime(_agent_raw_tools) - else: - _realtime_tools = [] - logger.info( - "[MCP INJECT] ⛔ SKIPPED tool injection (MCP_EXEC_ENABLED=False — S2S_TOOL_EXECUTION_MCP_URL not set in .env). " - "Sending only instructions, no tools[]. Agent prompt length=%d.", - len(_agent_prompt or ""), - ) - # --- Insight diario Aquachile → append a instructions --- - _insights_entries: list[dict] | None = None - _insights_block: str = "" - if INSIGHTS_ENABLED: - try: - _insights_entries, _insights_block = await _fetch_insights_for_today() - except Exception as _insights_err: - # NON bloccare mai la sessione audio per fallimento insights fetch - logger.warning( - "[INSIGHTS APPEND] eccezione in _fetch_insights_for_today (ignorata, continuo senza insights): %r", - _insights_err, - ) - _insights_entries, _insights_block = None, "" - else: - logger.debug( - "[INSIGHTS APPEND] skipped (INSIGHTS_ENABLED=False — S2S_INSIGHTS_URL vuoto/non settato in .env)." - ) - # --- Merge finale instructions: profilo agente + insights diario --- - if _agent_prompt and _insights_block: - _final_instructions = ( - _agent_prompt.rstrip() + "\n\n" + _insights_block + "\n" - ) - elif _agent_prompt: - _final_instructions = _agent_prompt.strip() - elif _insights_block: - _final_instructions = _insights_block - else: - _final_instructions = "" - _insights_count_for_log = len(_insights_entries) if _insights_entries else 0 - if _insights_block: - logger.info( - "[INSIGHTS APPEND] ✓ aggiunti %d insights al contesto agente (blockLen=%d, truncated=%s). " - "Ora l'assistente dispone del contexto operativo del día UTC=%s.", - _insights_count_for_log, - len(_insights_block), - "YES" if _insights_entries and len(_insights_entries) > MAX_INSIGHTS_IN_CONTEXT else "NO", - datetime.now(timezone.utc).strftime("%Y-%m-%d"), - ) - # 📝 Log ESATTO del blocco che viene passato nel session.instructions - # (prime ~45 righe o ~3500 chars per auditoria; non tronca MAI l'header). - _all_ins_lines = _insights_block.splitlines() - _cap_chars = 3500 - _cap_lines = min(45, len(_all_ins_lines)) - _truncated_preview = False - _joined = "\n".join(_all_ins_lines[:_cap_lines]) - if len(_joined) > _cap_chars: - _joined = _joined[:_cap_chars].rsplit("\n", 1)[0] - if len(_all_ins_lines) > _cap_lines or len(_insights_block) > _cap_chars: - _truncated_preview = True - _shown_lines = _cap_lines if _truncated_preview else len(_all_ins_lines) - _shown_chars = len(_joined) if _truncated_preview else len(_insights_block) - _trunc_msg = ( - " [TRUNCATED — per vedere tutto imposta MAX_INSIGHTS_IN_CONTEXT maggiore " - "oppure consulta endpoint /api/v1/insights]" - if _truncated_preview else "" - ) - # 👉 Pattern singolo %s + f-string: ELIMINA DEFUNTIVAMENTE il bug - # TypeError: not enough arguments for format string (mismatch args/placeholder) - # e protegge da qualsiasi % dentro gli insights (es. "-1.65%" -36.34%"). - _msg_append = ( - f"[INSIGHTS APPEND] 📋 TESTO INJETTATO NEL PROMPT agent instructions" - f"{_trunc_msg} " - f"(lines={_shown_lines}/{len(_all_ins_lines)}, " - f"chars={_shown_chars}/{len(_insights_block)}, " - f"capChunks=45lines/3500chars):" - f"\n{_joined}" - ) - logger.info("%s", _msg_append) - elif INSIGHTS_ENABLED: - logger.info( - "[INSIGHTS APPEND] SKIP (INSIGHTS_ENABLED=True ma nessun insight disponibile oggi " - "oppure fetch fallito per endpoint non raggiungibile. Non è un errore bloccante: " - "le istruzioni agente contengono solo il profilo standard)." - ) - # unisci instructions: profilo agente Aquachile + default esistente da client - # (il client manderà un secondo session.update con le proprie; - # upstream le mergea. Per sicurezza mandiamo prima il profilo server-side) - _inject_payload: dict = {"type": "session.update", "session": {}} - _session_inner = _inject_payload["session"] - if _final_instructions.strip(): - _session_inner["instructions"] = _final_instructions.strip() - if _realtime_tools: - _session_inner["tools"] = _realtime_tools - _session_inner["tool_choice"] = "auto" - # cap: se 100+ tool upstream potrebbe rifiutare: log warning ma non falliamo - if len(_realtime_tools) > 80: - logger.warning( - "[MCP INJECT] troppi tools (%d) — upstream potrebbe rifiutare. " - "Se non vedi response.function_call_arguments.done prova a ridurre con S2S_REASONING_NODE_RED_MAX_TOOLS o filtro lato Aquachile.", - len(_realtime_tools), - ) - logger.info( - "[MCP INJECT] session.update upstream → client=%s tools=%d hasAgentPrompt=%s tool_choice=%s insights=%d fmt=nested_session", - client_ip, - len(_realtime_tools), - bool(_agent_prompt.strip()), - _session_inner.get("tool_choice", "none"), - _insights_count_for_log, - ) - if _realtime_tools: - logger.debug( - "[MCP INJECT] tool names preview=%s", - json.dumps([t["name"] for t in _realtime_tools][:20], ensure_ascii=True), - ) - await upstream.send(json.dumps(_inject_payload, ensure_ascii=True)) - logger.info("[MCP INJECT] ✓ session.update upstream inviato (tools=%d, insights=%d, fmt=nested_session OK)", len(_realtime_tools), _insights_count_for_log) - except Exception as _inject_err: - # NON blocchiamo mai l'audio per fallimento inject tools - logger.warning( - "[MCP INJECT] FAILED (ignored, continuo senza tools nativi): %r", - _inject_err, - ) + logger.info("[INJECT-PROFILE] WS tunnel ESTABLISHED client=%s", client_ip) session_ctx = { "last_user_transcript": "", "last_item_id": None, - # 🔥 FIX RACE CONDITION session.update: servono a _client_to_upstream - # per mergiare profile+insights+tools IN OGNI session.update CHE - # ARRIVA DAL CLIENT (invece di fare solo inject upfront che - # viene SOVRASCRITTO dal 2ndo session.update del browser 200ms dopo). - "mcp_agent_prompt": _agent_prompt or "", - "mcp_insights_block": _insights_block or "", - "mcp_realtime_tools": _realtime_tools, + "injected_profile_context": injected_profile_context, + "injected_reports_context": injected_reports_context, + "injected_summary_context": injected_summary_context, + "inject_phone": inject_phone, + "login_token": login_token, + "login_response": login_response_obj, } client_to_upstream = asyncio.create_task( _client_to_upstream(ws, upstream, client_ip, session_ctx) @@ -1467,6 +1730,72 @@ async def realtime_proxy(ws: WebSocket): logger.debug("[EXT][S2S-PROXY] client=%s TUNNEL CLOSED", client_ip) +@app.get("/api/user/phone") +async def user_by_phone(phoneNumber: str): + """HTTP passthrough: fetches caller identity / profile from the external + info endpoint set via VOCERO_USER_PHONE_AUTH_URL and forwards the raw + JSON to the browser. + + The proxy NEVER inspects this payload nor injects it into the realtime + WebSocket session. It is only used by: + - the browser UI to render a caller name header (#ident-user-name) + - the /api/reasoning HTTP body user_context field (browser's choice). + + When VOCERO_USER_PHONE_AUTH_URL is unset we return 503 so the UI can + silently skip identity loading (backward compatible with generic deploys). + + When S2S_LOGIN_BY_PHONE is set, the proxy first performs a login-by-phone + POST with the caller phone and (if a bearer token is extracted) forwards + it as Authorization: Bearer on the subsequent profile GET. + """ + from urllib.parse import urlencode + + if not VOCERO_USER_PHONE_AUTH_URL: + raise HTTPException( + status_code=503, + detail="VOCERO_USER_PHONE_AUTH_URL is not configured on this deploy.", + ) + phone = (phoneNumber or "").strip() + if not phone: + raise HTTPException(status_code=400, detail="phoneNumber is required.") + + # 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set). + login_token = None + if S2S_LOGIN_BY_PHONE: + login_token, _login_obj = await _login_by_phone(phone) + + target_url = f"{VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone})}" + logger.debug( + "[EXT][USER-BY-PHONE] GET upstream=%s phoneLen=%d bearer=%s", + VOCERO_USER_PHONE_AUTH_URL, + len(phone), + "yes" if login_token else "no", + ) + try: + headers = {} + if login_token: + headers["Authorization"] = f"Bearer {login_token}" + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http: + resp = await http.get(target_url, headers=headers or None) + except httpx.RequestError as exc: + logger.warning("[EXT][USER-BY-PHONE] unreachable: %r", exc) + raise HTTPException(status_code=502, detail="Upstream user-info endpoint is unreachable.") + # Forward the upstream status + JSON verbatim; never rewrite the payload. + if resp.status_code >= 500: + logger.warning( + "[EXT][USER-BY-PHONE] upstream HTTP %s: %.200s", + resp.status_code, resp.text, + ) + try: + payload = resp.json() + except Exception: + raise HTTPException( + status_code=502, + detail="Upstream user-info endpoint returned invalid JSON.", + ) + return JSONResponse(status_code=resp.status_code if resp.status_code != 204 else 200, content=payload) + + @app.get("/api/me") async def me(request: Request): """Login state, tier, and remaining daily budget. Only meaningful in LB mode; @@ -1830,72 +2159,7 @@ def _proxy_public_url(request: Request) -> str: return f"{ws_scheme}://{forwarded_host}{root_path}/v1/realtime" -def _augment_client_session_update( - event: dict, - agent_prompt: str, - insights_block: str, - realtime_tools: list[dict], -) -> tuple[dict, bool, dict]: - """ - 🔥 FIX RACE: intercetta ogni session.update proveniente dal browser client - PRIMA che venga mandato a ai-server e ne MENGIA istruzioni lato client - con: server profile Aquachile + insights diario oggi + tools catalog MCP. - Regola merge per instructions (ordine significativo, prima = più basso peso in LLM): - [1] istruzioni client originali ("Eres el asistente de voz oficial de Aquachile..." + Voice Rules) - [2] "\n\n## Aquachile Agent Profile Server-Side" + profile - [3] "\n\n" + insights_block già formattato con ### Contexto operativo del día - - Tools + tool_choice: imposto solo se realtime_tools non vuoto (MCP_EXEC_ENABLED=True e 79 tool disponibili). - - Ritorna: - (event_patched, was_patched, stats_dict) - - event_patched: dict modificato, o lo stesso oggetto originario se niente da mergiare - - was_patched: True solo se abbiamo toccato il dict - """ - stats = {"merged_instructions_len": 0, "appended_profile": False, "appended_insights": False, "tools_injected_count": 0} - if not isinstance(event, dict) or event.get("type") != "session.update": - return event, False, stats - _has_anything = bool(agent_prompt or insights_block or realtime_tools) - if not _has_anything: - return event, False, stats - # Assicura session wrapper esista (standard GA nested) - if not isinstance(event.get("session"), dict): - event["session"] = {} - sess = event["session"] - base_instructions = sess.get("instructions") or "" - if isinstance(base_instructions, str): - base = base_instructions.strip() - else: - base = "" - extra_parts: list[str] = [] - if agent_prompt and agent_prompt.strip(): - extra_parts.append( - "## Aquachile Agent Profile (injected by S2S proxy server-side)\n" - + agent_prompt.strip() - ) - stats["appended_profile"] = True - if insights_block and insights_block.strip(): - extra_parts.append(insights_block.strip()) - stats["appended_insights"] = True - if base and extra_parts: - merged = base + "\n\n" + "\n\n".join(extra_parts) - elif base: - merged = base - elif extra_parts: - merged = "\n\n".join(extra_parts) - else: - merged = "" - merged = merged.strip() - if merged: - sess["instructions"] = merged - stats["merged_instructions_len"] = len(merged) - if realtime_tools: - sess["tools"] = realtime_tools - sess["tool_choice"] = "auto" - stats["tools_injected_count"] = len(realtime_tools) - was_patched = bool(stats["merged_instructions_len"] or stats["tools_injected_count"]) - return event, was_patched, stats def _proxy_upstream_url(ws: WebSocket) -> str: @@ -1963,54 +2227,18 @@ async def _client_to_upstream(ws: WebSocket, upstream, client_ip: str, session_c client_ip, event_type, json.dumps(summary, ensure_ascii=True)[:300], ) # #endregion - # --- 🔥 FIX RACE: PATCH SESSION.UPDATE CLIENT PRIMA DI INOLTRE A UPSTREAM --- - # Il browser manda un suo session.update (con instructions base + voice) - # POCHI ms DOPO il nostro inject upfront. Senza questo patch, quello - # del client SOVRASCRIVE instructions e tools completamente (handle_session_update - # usa deep merge: instructions campo scalare viene sovrascritto, non concatenato). - _raw_text = message["text"] - _forward_this = _raw_text - _was_patched = False - try: - _evt_client = json.loads(_raw_text) - if ( - isinstance(_evt_client, dict) - and _evt_client.get("type") == "session.update" - ): - _a_prompt = session_ctx.get("mcp_agent_prompt", "") or "" - _a_insights = session_ctx.get("mcp_insights_block", "") or "" - _a_tools: list[dict] = session_ctx.get("mcp_realtime_tools") or [] - _evt_patched, _was_patched, _patch_stats = _augment_client_session_update( - _evt_client, _a_prompt, _a_insights, _a_tools - ) - if _was_patched: - _forward_this = json.dumps(_evt_patched, ensure_ascii=True) - logger.info( - "[MCP INJECT] PATCHED client session.update →UPSTREAM: " - "mergedInstructions=%dB appendedProfile=%s appendedInsights=%s " - "toolsInjected=%s (prima era senza insights/tools — era la causa del " - "PROMPT LLM senza insights in ai-server log).", - _patch_stats["merged_instructions_len"], - bool(_patch_stats["appended_profile"]), - bool(_patch_stats["appended_insights"]), - _patch_stats["tools_injected_count"] or "NONE", - ) - else: - logger.info( - "[MCP INJECT] PASSTHROUGH client session.update →UPSTREAM: " - "no server data to merge (nothing enabled / empty profile / insights disabled).", - ) - except Exception as _patch_err: - # NEVER-BREAK-AUDIO: qualunque errore nel parsing o nel patching, - # inoltro il messaggio originale così com'era. - logger.warning( - "[MCP INJECT] errore non bloccante in patching client session.update " - "(fallback to passthrough original message): %r", - _patch_err, - ) - _forward_this = _raw_text - _was_patched = False - await upstream.send(_forward_this) + # --- S2S_INJECT_PROFILE: if this is a session.update client message + # and we have a resolved caller profile / latest caller summary, + # patch instructions to include them before forwarding upstream. + # Pure no-op when S2S_INJECT_PROFILE=false (original pure + # passthrough behaviour). + ctx_profile_context = session_ctx.get("injected_profile_context") if isinstance(session_ctx, dict) else "" + ctx_reports_context = session_ctx.get("injected_reports_context") if isinstance(session_ctx, dict) else "" + ctx_summary_context = session_ctx.get("injected_summary_context") if isinstance(session_ctx, dict) else "" + forward_text = _patch_client_session_update_with_profile( + message["text"], ctx_profile_context or "", ctx_reports_context or "", ctx_summary_context or "" + ) + await upstream.send(forward_text) elif message.get("bytes") is not None: # #region debug-point B:client-bytes-forward await _report_server_debug( @@ -2225,18 +2453,18 @@ async def _upstream_to_client(ws: WebSocket, upstream, client_ip: str, session_c ) if _suppress: logger.info( - "[MCP INJECT] suppressed upstream error (non mandato al browser): %s", + "[WS SUPPRESS] suppressed upstream error (non mandato al browser): %s", _suppress_reason, ) logger.debug( - "[MCP INJECT] full upstream suppressed error payload=%s", + "[WS SUPPRESS] full upstream suppressed error payload=%s", json.dumps(_evt, ensure_ascii=True)[:600], ) return # ── Log ispezione: se arriviamo qua l'errore NON è stato soppresso. # Facciamo sempre vedere message+type per capire se aggiungere nuove regole. logger.debug( - "[MCP INJECT] upstream error NOT suppressed (passthrough to browser) — " + "[WS SUPPRESS] upstream error NOT suppressed (passthrough to browser) — " "evt.error.message=%s evt.error.type=%s evtTopKeys=%s", _err_msg[:200] if _err_msg else "(empty)", _err_type or "(none)", diff --git a/demo/style.css b/s2s-client/style.css similarity index 100% rename from demo/style.css rename to s2s-client/style.css diff --git a/demo/ui/account.js b/s2s-client/ui/account.js similarity index 100% rename from demo/ui/account.js rename to s2s-client/ui/account.js diff --git a/demo/ui/chat.js b/s2s-client/ui/chat.js similarity index 100% rename from demo/ui/chat.js rename to s2s-client/ui/chat.js diff --git a/demo/ui/dom.js b/s2s-client/ui/dom.js similarity index 100% rename from demo/ui/dom.js rename to s2s-client/ui/dom.js diff --git a/demo/worklets/audio-playback.js b/s2s-client/worklets/audio-playback.js similarity index 100% rename from demo/worklets/audio-playback.js rename to s2s-client/worklets/audio-playback.js diff --git a/demo/worklets/mic-capture.js b/s2s-client/worklets/mic-capture.js similarity index 100% rename from demo/worklets/mic-capture.js rename to s2s-client/worklets/mic-capture.js diff --git a/demo/ws/codec.js b/s2s-client/ws/codec.js similarity index 100% rename from demo/ws/codec.js rename to s2s-client/ws/codec.js diff --git a/demo/ws/orb-visualizer.js b/s2s-client/ws/orb-visualizer.js similarity index 100% rename from demo/ws/orb-visualizer.js rename to s2s-client/ws/orb-visualizer.js diff --git a/demo/ws/s2s-ws-client.js b/s2s-client/ws/s2s-ws-client.js similarity index 99% rename from demo/ws/s2s-ws-client.js rename to s2s-client/ws/s2s-ws-client.js index f1e0ac5..64f055d 100644 --- a/demo/ws/s2s-ws-client.js +++ b/s2s-client/ws/s2s-ws-client.js @@ -666,7 +666,7 @@ export class S2sWsRealtimeClient extends EventTarget { toolNames = names.join(","); } console.debug( - `[Vocero MCP WS] session.updated tool_choice=${event.session?.tool_choice ?? ""} ` + + `[Vocero WS] session.updated tool_choice=${event.session?.tool_choice ?? ""} ` + `tool_count=${Array.isArray(event.session?.tools) ? event.session.tools.length : 0} ` + `tools=${toolNames || "none"}` ); @@ -711,7 +711,7 @@ export class S2sWsRealtimeClient extends EventTarget { // #endregion if (this._debug) { console.debug( - `[Vocero MCP WS] response.created id=${event.response?.id ?? event.response_id ?? ""} ` + + `[Vocero WS] response.created id=${event.response?.id ?? event.response_id ?? ""} ` + `status=${event.response?.status ?? ""}` ); } @@ -812,7 +812,7 @@ export class S2sWsRealtimeClient extends EventTarget { }); // #endregion console.debug( - `[Vocero MCP WS] function_call_arguments.done name=${name || "unknown"} ` + + `[Vocero WS] function_call_arguments.done name=${name || "unknown"} ` + `callId=${JSON.stringify(callId)} args=${args}` ); if (name) { @@ -1067,7 +1067,7 @@ export class S2sWsRealtimeClient extends EventTarget { if (!callId) return; // Can't target a result without the call id. if (this._debug) { console.debug( - `[Vocero MCP WS] function_call_output callId=${JSON.stringify(callId)} ` + + `[Vocero WS] function_call_output callId=${JSON.stringify(callId)} ` + `outputLength=${(output || "").length}` ); } @@ -1181,7 +1181,7 @@ export class S2sWsRealtimeClient extends EventTarget { if (opts.image) this.sendUserImage(opts.image); this._createInFlight = true; if (this._debug) { - console.debug(`[Vocero MCP WS] response.create image=${!!opts.image} openResponses=${this._openResponses}`); + console.debug(`[Vocero WS] response.create image=${!!opts.image} openResponses=${this._openResponses}`); } this._send({ type: "response.create" }); } @@ -1342,7 +1342,7 @@ export class S2sWsRealtimeClient extends EventTarget { } if (this._debug && (event.type === "session.update" || event.type === "conversation.item.create")) { const preview = (payload || JSON.stringify(event)).slice(0, 400); - console.debug(`[Vocero MCP WS OUT] type=${event.type} payload=${preview}`); + console.debug(`[Vocero WS OUT] type=${event.type} payload=${preview}`); } this._ws.send(payload || JSON.stringify(event)); }