diff --git a/.dockerignore b/.dockerignore index 6826e5c..3fff974 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,7 +11,6 @@ **/.venv/ **/venv/ **/node_modules/ -**/.node-red/ **/.cache/ **/*.log **/uploads/ diff --git a/.gitignore b/.gitignore index 98f6366..f2b3fda 100644 --- a/.gitignore +++ b/.gitignore @@ -122,14 +122,6 @@ CVS/ *.trae .trae/ -# ───────────────────────────────────────────────────────────── -# Reasoning engine (Node-RED) local state -# ───────────────────────────────────────────────────────────── -s2s-reasoning-engine/node_modules/ -s2s-reasoning-engine/.node-red/ -s2s-reasoning-engine/lib/flows_cred.json -s2s-reasoning-engine/.config.* - # ───────────────────────────────────────────────────────────── # Client backend and speech-service runtime artifacts # ───────────────────────────────────────────────────────────── @@ -140,5 +132,4 @@ s2s-client/*.log speech_to_speech/*.log speech_to_speech/sessions/ speech_to_speech/.cache/ -s2s-reasoning-engine/*.log debug_files diff --git a/AGENTS.md b/AGENTS.md index aa8fe74..405a325 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Scope and working conventions This repository is Vocero: a browser voice client, its FastAPI integration -server, a speech pipeline, and optional Node-RED flows. Use the code, commands, +server, and a speech pipeline. Use the code, commands, configuration, and documentation within this checkout. Do not assume other repositories or developer-specific directories are available. @@ -48,7 +48,6 @@ root. HTTP routes mentioned below are URLs, not filesystem paths. | `s2s-client/s2s_client/realtime/` | WebSocket relay, event handling, marker filtering, and audio forwarding | | `s2s-frontend/` | React, TypeScript, Tailwind, and Vite browser application | | `speech_to_speech/` | Separately run VAD → STT → LLM → TTS pipeline and realtime API | -| `s2s-reasoning-engine/` | Optional Node-RED reasoning/logging service | | `start_ui.sh` | Dependency sync and supervision of FastAPI plus Vite | | `docs/` | Maintained technical documentation, examples, and historical proposals | @@ -201,12 +200,11 @@ sync; a separate sync is optional to prepare dependencies before restarting. Ensure the required uv and Node/npm are on that process's PATH. Verify the live PM2 script path and environment before making claims about a deployment. -The launcher does not start or restart `speech_to_speech/` or Node-RED. A configured +The launcher does not start or restart `speech_to_speech/`. A configured speech URL does not prove which source revision or implementation runs there. `SPEECH_TO_SPEECH_URL` pins a direct browser connection; `S2S_PROXY_TARGET` enables -the local relay. Either disables load-balancer mode. The Node-RED flows in -`s2s-reasoning-engine/flows.json` are log-only; preserve the meaning of `log_only` -responses. +the local relay. Either disables load-balancer mode. Reasoning supports `PROXY` +and `OPENAI`; unknown `S2S_REASONING_TYPE` values fall back to `PROXY`. The backend Docker image serves APIs, WebSockets, and root `docs/`, not the React UI. Build with the repository root as context and `s2s-client/Dockerfile`; the diff --git a/README.md b/README.md index 9046a04..702b693 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Vocero speech-to-speech -Vocero provides a browser voice interface, a Python speech pipeline, and an -optional Node-RED reasoning service. The interface defaults to Spanish; +Vocero provides a browser voice interface, a FastAPI integration server, and a +Python speech pipeline. The interface defaults to Spanish; documentation, source identifiers, comments, and internal instructions use English. | Component | Purpose | @@ -9,7 +9,6 @@ documentation, source identifiers, comments, and internal instructions use Engli | [s2s-frontend](s2s-frontend) | React, TypeScript, and Tailwind browser UI, served by Vite with hot reload | | [s2s-client](s2s-client) | FastAPI backend, realtime WebSocket proxy, search and ApiPilot bridges | | [speech_to_speech](speech_to_speech) | Queue-driven VAD, speech recognition, language model, and speech synthesis pipeline | -| [s2s-reasoning-engine](s2s-reasoning-engine) | Optional Node-RED HTTP reasoning and conversation logging flows | Start with [client setup and usage](docs/client/usage.md), then read the [architecture](docs/client/architecture.md) and [documentation index](docs/README.md). diff --git a/docs/client/architecture.md b/docs/client/architecture.md index a385020..b4a1d56 100644 --- a/docs/client/architecture.md +++ b/docs/client/architecture.md @@ -5,7 +5,7 @@ The [React browser client](../../s2s-frontend) uses the `start_ui.sh` serves Vite on the existing public port `8420` and FastAPI on loopback port `8421`. Vite forwards backend routes and provides hot reload; Uvicorn reloads Python edits. The launcher supervises both processes. -The speech pipeline and optional Node-RED reasoning service run separately. +The speech pipeline runs separately. Runtime configuration comes from environment variables and the root `.env`. ```mermaid @@ -14,7 +14,7 @@ flowchart LR Vite[Vite on public port 8420] Server[Client FastAPI server] Speech[Realtime speech backend] - Reasoning[Node-RED or HTTP reasoning provider] + Reasoning[OpenAI-compatible HTTP reasoning provider] Tools[ApiPilot backend] Browser <-->|Frontend and HMR| Vite Vite -->|HTTP configuration and proxies| Server @@ -58,7 +58,6 @@ audio. It is not an unconditional byte-for-byte tunnel. | [components](../../s2s-frontend/src/components) | Chat, caller profile, settings and voice components | | [audio](../../s2s-frontend/src/audio) | TypeScript capture/playback worklets, PCM codec, and visualizer | | [legacy-theme.css](../../s2s-frontend/src/styles/legacy-theme.css) | Retained themes and responsive visual styles alongside Tailwind | -| [flows.json](../../s2s-reasoning-engine/flows.json) | Node-RED reasoning and assistant-response logging | ## Server routes @@ -66,7 +65,7 @@ audio. It is not an unconditional byte-for-byte tunnel. |---|---| | `GET /api/config` | Connection settings, primary prompt, reasoning/theme/start modes, and feature flags | | `WS /v1/realtime` | Local realtime proxy to `S2S_PROXY_TARGET` | -| `POST /api/reasoning` | Node-RED or OpenAI-compatible HTTP reasoning; disabled in `PROXY` mode | +| `POST /api/reasoning` | OpenAI-compatible HTTP reasoning; disabled in `PROXY` mode | | `GET /api/user/phone` | Configured caller profile lookup | | `GET /api/user/reports` | Configured scheduled report lookup, subject to report-injection controls | | `GET /api/apipilot/tools` | Tool discovery with optional authorization and cache refresh | @@ -104,10 +103,8 @@ and cancellation model is documented in [realtime API](../speech/realtime-api.md ## Reasoning and context The browser triggers external reasoning on completed user transcripts only in -`OPENAI` or `NODE_RED` mode. Node-RED `log_only=true` responses leave native speech -output running; an answer-producing response uses the external-answer path. -The server can independently forward assistant text to Node-RED for paired -conversation logs. These HTTP operations supplement the realtime audio path. +`OPENAI` mode. A non-empty answer replaces the native response through the +external-answer path. These HTTP operations supplement the realtime audio path. Read [reasoning](reasoning.md), [context injection](context-injection.md), and [tool calling](tool-calling.md) for the conditions and limitations of each path. diff --git a/docs/client/development.md b/docs/client/development.md index cd58267..f5068a7 100644 --- a/docs/client/development.md +++ b/docs/client/development.md @@ -62,11 +62,13 @@ existing error shapes; there is no new common error envelope. ## Compatibility evidence `tests/fixtures/legacy-contracts.json` was captured from the original monolithic -server at commit `3d42df5`, before refactoring. Tests compare 86 HTTP scenarios, +server at commit `3d42df5`, before refactoring. Tests compare 82 HTTP scenarios, including exact response text, statuses, selected headers, and outgoing mock HTTP requests. They also compare request OpenAPI definitions and representative realtime frames. Additional tests cover response validation and permissive upstream values; the original local HTTP/WebSocket tests remain in the suite. +Four captures belonging exclusively to the retired workflow reasoning service +were removed with that integration; all retained captures are unchanged. The captures deliberately preserve unknown fields, omitted fields, unusual JSON values, queue beacon edge cases, permissive tool arguments, and known failures. diff --git a/docs/client/glossary.md b/docs/client/glossary.md index 914cb79..d5498db 100644 --- a/docs/client/glossary.md +++ b/docs/client/glossary.md @@ -37,4 +37,3 @@ names shown in a deployment are configuration examples, not definitions. | Claim | The transition from a waiting ticket to an allocated realtime session | | At capacity | A full waiting queue that cannot accept another ticket | | Barge-in | User speech interrupts an active assistant response, coordinating playback clearing and backend cancellation | -| Log-only reasoning | External transcript processing that observes/logs the turn without replacing native speech output | diff --git a/docs/client/reasoning.md b/docs/client/reasoning.md index 2e0bbed..fe69a0b 100644 --- a/docs/client/reasoning.md +++ b/docs/client/reasoning.md @@ -1,17 +1,15 @@ # Reasoning modes Reasoning determines the assistant's response text. The realtime backend always -provides the speech channel; optional HTTP reasoning can observe a transcript or -produce a separate answer. Set `S2S_REASONING_TYPE` to select the mode. +provides the speech channel; optional HTTP reasoning produces a separate answer +from a transcript. Set `S2S_REASONING_TYPE` to select the mode. | Mode | Response owner | HTTP hook | |---|---|---| | `PROXY` (default) | Realtime backend | Browser does not call `/api/reasoning`; that endpoint returns 409 | -| `NODE_RED` | Realtime backend for `log_only=true`; external answer otherwise | Server forwards to `S2S_REASONING_NODE_RED_URL/api/reasoning` | | `OPENAI` | OpenAI-compatible Chat Completions response | Server calls `OPENAI_BASE_URL/chat/completions` | -The mode is normalized case-insensitively. `NODERED` and `NODE-RED` resolve to -`NODE_RED`; unknown values resolve to `PROXY`. +The mode is normalized case-insensitively. Unknown values resolve to `PROXY`. ## Shared instruction assembly @@ -40,30 +38,6 @@ Using a direct browser URL bypasses local WebSocket proxy processing. Browser instructions still apply, but server-side session context and marker execution require the local `/v1/realtime` route. -## Node-RED reasoning - -Configure a Node-RED base URL reachable by the UI server: - -```env -S2S_REASONING_TYPE=NODE_RED -S2S_REASONING_NODE_RED_URL=http://localhost:1881 -``` - -`VoiceController.reason()` in [controller.ts](../../s2s-frontend/src/realtime/controller.ts) -handles both external modes. It runs after a complete user transcript and posts a body with -`transcript`, `instructions`, `language`, `user_context`, `phoneNumber`, and -`episode_id`. The server builds context and forwards the reasoning fields to -Node-RED with a 30-second timeout. - -The checked-in Node-RED flow exposes `POST /api/reasoning` and -`POST /api/log/system-response`. Its reasoning response uses `log_only=true` and -an empty answer. The browser returns early in that case, preserving the native -backend's response. Assistant-text logging is forwarded separately by the relay -and is not an audio transport. - -An answer-producing flow must return a non-empty `answer` and omit `log_only` or -set it to `false`. That activates the external-answer behavior below. - ## OpenAI-compatible HTTP reasoning ```env @@ -76,13 +50,17 @@ OPENAI_REASONING_MODEL=gpt-4.1-mini `OPENAI_ORGANIZATION` and `OPENAI_PROJECT` are optional headers. The model value is a runtime default, not a recommendation about provider availability. +`VoiceController.reason()` in [controller.ts](../../s2s-frontend/src/realtime/controller.ts) +runs after a complete user transcript and posts a body with `transcript`, +`instructions`, `language`, `user_context`, `phoneNumber`, and `episode_id`. The server combines instructions and context into model messages, requests a -final answer, and returns `answer` with available usage metadata. ApiPilot can +final answer with a 30-second timeout, and returns `answer`. ApiPilot can append discovery instructions, execute a detected call marker, and request a -second response based on the tool result. The endpoint does not forward the -browser's native `session.tools` catalog as OpenAI Chat Completions `tools`. +second response based on the tool result, including available `usage_pass2` +metadata. The endpoint does not forward the browser's native `session.tools` +catalog as OpenAI Chat Completions `tools`. -For a non-log-only answer, the browser cancels/suppresses the active native +For an external answer, the browser cancels/suppresses the active native response and appends the external assistant text. It uses browser `SpeechSynthesis` when available. The React client adds the answer to the realtime conversation as an assistant item; without browser speech synthesis it remains @@ -101,5 +79,4 @@ realtime backend instead. | Change browser context assembly | `buildInstructions()` in `s2s-frontend/src/lib/context.ts` | | Change HTTP provider messages | `reasoning()` in `s2s-client/s2s_client/services/reasoning.py` | | Change external-answer rendering | `VoiceController.reason()` and `RealtimeClient.renderExternalAnswer()` | -| Change Node-RED processing | `s2s-reasoning-engine/flows.json` | | Change native model/tool generation | `speech_to_speech/LLM` and realtime handlers | diff --git a/docs/client/troubleshooting.md b/docs/client/troubleshooting.md index cf6434e..05bb4ed 100644 --- a/docs/client/troubleshooting.md +++ b/docs/client/troubleshooting.md @@ -109,9 +109,7 @@ before changing audio timing based on those observations. ## External reasoning and startup -`/api/reasoning` returning 409 is expected in `PROXY` mode. In `NODE_RED`, check -that `S2S_REASONING_NODE_RED_URL` is reachable from the server and whether the -response is `log_only=true`. An empty answer is valid only in log-only mode. +`/api/reasoning` returning 409 is expected in `PROXY` mode. In `OPENAI`, check the configured endpoint/model/key and provider logs; HTTP reasoning does not use the native session's tool array. diff --git a/s2s-client/CHANGELOG.md b/s2s-client/CHANGELOG.md index 6918067..3b97d9f 100644 --- a/s2s-client/CHANGELOG.md +++ b/s2s-client/CHANGELOG.md @@ -9,6 +9,9 @@ the implementation at the recorded date; current operating instructions live in ### Changed +- Removed the optional workflow reasoning service, its configuration, transcript + forwarding, and browser integration. Reasoning supports `PROXY` and `OPENAI`; + unsupported mode values fall back to `PROXY`. - Filter the specific Starlette TestClient / AnyIO `BlockingPortal` deprecation in pytest while retaining the runtime versions used for compatibility captures. - Added a uv-managed Python 3.12 environment and Ruff, mypy, and pytest quality @@ -33,8 +36,6 @@ the implementation at the recorded date; current operating instructions live in - Translated developer comments, diagnostics, internal model instructions, and local identifiers into English. Sanitization accepts both the former Spanish and new English internal tool-result wrappers. -- Renamed the reasoning engine's log-only acknowledgment field to `received` - and its debug phase to `log_only_phase1`. - Standardized browser labels, accessibility text, account/profile dialogs, session feedback, and built-in API errors in Spanish. The interface declares `lang="es"`; internal instructions and diagnostics remain English. diff --git a/s2s-client/s2s_client/core/config.py b/s2s-client/s2s_client/core/config.py index 134425d..26e2e64 100644 --- a/s2s-client/s2s_client/core/config.py +++ b/s2s-client/s2s_client/core/config.py @@ -15,8 +15,6 @@ def _normalize_reasoning_type(value: str) -> str: normalized = (value or "").strip().strip('"').strip("'").replace("-", "_").upper() if normalized == "OPENAI": return "OPENAI" - if normalized in {"NODE_RED", "NODERED"}: - return "NODE_RED" return "PROXY" @@ -124,8 +122,7 @@ if S2S_REPORTS_INJECT_MODE not in {"minimal", "full", "off", "none"}: # 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 +# - /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. @@ -186,9 +183,6 @@ APIPILOT_HTTP_TIMEOUT = max(1.0, min(120.0, _APIPILOT_TO_RAW)) del _APIPILOT_TO_RAW -# Node-RED reasoning engine endpoint (NODE_RED mode). -# Examples: http://localhost:1881 or http://s2s-reasoning:1880 (Docker network). -NODE_RED_REASONING_URL = os.environ.get("S2S_REASONING_NODE_RED_URL", "").strip().rstrip("/") # Speech-to-speech load balancer URL. When set, the browser POSTs /api/session # (which proxies /session here, server-side) and connects to the URL the LB # returns (the original flow). The LB address itself is never sent to the browser. diff --git a/s2s-client/s2s_client/core/diagnostics.py b/s2s-client/s2s_client/core/diagnostics.py index 33e39fc..846650f 100644 --- a/s2s-client/s2s_client/core/diagnostics.py +++ b/s2s-client/s2s_client/core/diagnostics.py @@ -19,9 +19,8 @@ _STARTUP_APIPILOT_EXEC_URL = _resolve_exec_url_for_startup_banner() del _resolve_exec_url_for_startup_banner app_logging.logger.info( - "[STARTUP] Other externals — reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s", + "[STARTUP] Other externals — reasoning=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s", settings.S2S_REASONING_TYPE, - settings.NODE_RED_REASONING_URL or "-", bool(settings.SERPER_KEY), bool(settings.LOAD_BALANCER_URL), bool(settings.S2S_PROXY_TARGET), diff --git a/s2s-client/s2s_client/models/requests.py b/s2s-client/s2s_client/models/requests.py index 7cf93b1..35b8691 100644 --- a/s2s-client/s2s_client/models/requests.py +++ b/s2s-client/s2s_client/models/requests.py @@ -20,7 +20,7 @@ class ReasoningRequest(BaseModel): # 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); + # OpenAI-compatible provider; # 2. appends the profile context to the LLM instructions prompt. phoneNumber: str | None = None # Optional podcast episode ID. When set the proxy fetches the episode from diff --git a/s2s-client/s2s_client/models/responses.py b/s2s-client/s2s_client/models/responses.py index 1c25dbe..f694a80 100644 --- a/s2s-client/s2s_client/models/responses.py +++ b/s2s-client/s2s_client/models/responses.py @@ -21,7 +21,7 @@ class ConfigResponse(OpenResponse): allowDirect: bool s2sUrl: str primaryPrompt: str - reasoningType: Literal["PROXY", "OPENAI", "NODE_RED"] + reasoningType: Literal["PROXY", "OPENAI"] injectProfile: bool debugPrompt: bool startSessionMode: str @@ -69,13 +69,7 @@ class ToolInvocation(OpenResponse): class ReasoningResponse(OpenResponse): answer: str - log_only: bool | None = None - usage: Any = None usage_pass2: Any = None - latency_ms: Any = None - intent: Any = None - caller_profile_keys: Any = None - debug: Any = None apipilot_invoked: ToolInvocation | None = None diff --git a/s2s-client/s2s_client/providers/caller.py b/s2s-client/s2s_client/providers/caller.py index be45a96..453abd4 100644 --- a/s2s-client/s2s_client/providers/caller.py +++ b/s2s-client/s2s_client/providers/caller.py @@ -24,7 +24,7 @@ async def _fetch_reports_list(phone_number: str | None = None, auth_token: str | endpoint is never hit, not even for a "cheap empty list"; combined with _resolve_reports_context → "" above, this guarantees ZERO scheduled-report contents reach any LLM prompt anywhere (WS session instructions patch, - /api/reasoning user_context, NODE_RED instructions augment, quick reports + /api/reasoning user_context, reasoning instructions augment, quick reports lookup response). • S2S_INJECT_REPORTS=true restores prior behaviour (both the HTTP fetch AND the full injection into every prompt path). diff --git a/s2s-client/s2s_client/realtime/proxy.py b/s2s-client/s2s_client/realtime/proxy.py index 0c42b59..05afea7 100644 --- a/s2s-client/s2s_client/realtime/proxy.py +++ b/s2s-client/s2s_client/realtime/proxy.py @@ -17,7 +17,6 @@ from s2s_client.providers import apipilot as providers_apipilot from s2s_client.providers import caller as providers_caller from s2s_client.realtime import events as realtime_events from s2s_client.services import context as services_context -from s2s_client.services import reasoning as services_reasoning from s2s_client.services import tool_context as services_tool_context @@ -408,8 +407,6 @@ async def realtime_proxy(ws: WebSocket) -> None: ) as upstream: app_logging.logger.info("[INJECT-PROFILE] WS tunnel ESTABLISHED client=%s", client_ip) session_ctx = { - "last_user_transcript": "", - "last_item_id": None, "injected_episode_context": injected_episode_context, "injected_profile_context": injected_profile_context, "injected_reports_context": injected_reports_context, @@ -1166,32 +1163,22 @@ async def _upstream_to_client( user_text = "" if event_type == "conversation.item.input_audio_transcription.completed": user_text = (summary.get("transcript") or "").strip() - item_id = summary.get("item_id") if user_text: app_logging.logger.info( "[USER INPUT] client=%s stt=%s", client_ip, user_text, ) - session_ctx["last_user_transcript"] = user_text - if item_id: - session_ctx["last_item_id"] = item_id elif event_type == "conversation.item.created" and summary.get("role") == "user": user_text = (summary.get("text") or "").strip() - item_id = summary.get("item_id") or summary.get("id") if user_text: app_logging.logger.info( "[USER INPUT] client=%s text=%s", client_ip, user_text, ) - session_ctx["last_user_transcript"] = user_text - if item_id: - session_ctx["last_item_id"] = item_id # Agent output, including all event variants. agent_text = "" - response_id = summary.get("response_id") or summary.get("id") - item_id = summary.get("item_id") if event_type in {"response.audio_transcript.done", "response.output_audio_transcript.done"}: # Pass-2 accumulation already retained the original text. # This sanitized agent_text is only for UI and logs. @@ -1202,19 +1189,6 @@ async def _upstream_to_client( client_ip, agent_text, ) - asyncio.create_task( - services_reasoning._forward_agent_text_to_nodered( - { - "tts": agent_text, - "response_id": response_id, - "item_id": item_id, - "client_ip": client_ip, - "role": "assistant", - "source_event": event_type, - "user_transcript": session_ctx.get("last_user_transcript") or "", - } - ) - ) else: raw_preview = summary.get("_raw_preview") or json.dumps(summary, ensure_ascii=True) app_logging.logger.debug( @@ -1232,19 +1206,6 @@ async def _upstream_to_client( client_ip, agent_text, ) - asyncio.create_task( - services_reasoning._forward_agent_text_to_nodered( - { - "tts": agent_text, - "response_id": response_id, - "item_id": item_id, - "client_ip": client_ip, - "role": "assistant", - "source_event": event_type, - "user_transcript": session_ctx.get("last_user_transcript") or "", - } - ) - ) elif event_type == "response.output_item.done" and summary.get("role") == "assistant": agent_text = (summary.get("text") or "").strip() if agent_text: @@ -1253,19 +1214,6 @@ async def _upstream_to_client( client_ip, agent_text, ) - asyncio.create_task( - services_reasoning._forward_agent_text_to_nodered( - { - "tts": agent_text, - "response_id": response_id, - "item_id": item_id, - "client_ip": client_ip, - "role": "assistant", - "source_event": event_type, - "user_transcript": session_ctx.get("last_user_transcript") or "", - } - ) - ) elif event_type == "conversation.item.created" and summary.get("role") == "assistant": agent_text = (summary.get("text") or "").strip() if agent_text: @@ -1274,19 +1222,6 @@ async def _upstream_to_client( client_ip, agent_text, ) - asyncio.create_task( - services_reasoning._forward_agent_text_to_nodered( - { - "tts": agent_text, - "response_id": response_id, - "item_id": item_id, - "client_ip": client_ip, - "role": "assistant", - "source_event": event_type, - "user_transcript": session_ctx.get("last_user_transcript") or "", - } - ) - ) if event_type not in realtime_events._NOISY_REALTIME_EVENT_TYPES: app_logging.logger.debug( "[EXT][S2S-PROXY] client=%s UPSTREAM→ event=%s summary=%s", diff --git a/s2s-client/s2s_client/services/reasoning.py b/s2s-client/s2s_client/services/reasoning.py index b678c64..288d63b 100644 --- a/s2s-client/s2s_client/services/reasoning.py +++ b/s2s-client/s2s_client/services/reasoning.py @@ -1,8 +1,6 @@ from __future__ import annotations import json -import logging -from typing import Any import httpx from fastapi import HTTPException @@ -373,280 +371,7 @@ async def reasoning(req: models_requests.ReasoningRequest) -> JSONResponse: services_context._strip_internal_markers(inject_apipilot_text_pass1), ) - # ──────────────────────────────────────────────────────────────────── - # Mode A: NODE_RED forwards the complete payload; the flow decides behavior. - # Stage 1 (log-only) returns {answer: "", log_only: true}, allowing the browser - # to leave speech generation to the S2S backend. Later stages can return - # {answer: "...", log_only: false}. Expected response fields include answer, - # log_only, usage, debug and latency_ms. - # ──────────────────────────────────────────────────────────────────── - if settings.S2S_REASONING_TYPE == "NODE_RED": - if not settings.NODE_RED_REASONING_URL: - raise HTTPException( - status_code=503, - detail="S2S_REASONING_NODE_RED_URL no está configurado. Configure esta variable de entorno con la dirección de Node-RED (por ejemplo, http://localhost:1881).", - ) - app_logging.logger.info( - "[INJECT-PROFILE] REASONING->NODE_RED body fields. instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d", - len(effective_instructions), - len(effective_user_context), - len(phone), - len(episode_id), - ) - upstream_body = { - "transcript": transcript, - "instructions": effective_instructions, - "language": (req.language or "").strip(), - "user_context": effective_user_context, - } - if settings.S2S_DEBUG_PROMPT: - app_logging.logger.info( - "[DEBUG-PROMPT][REASONING → NODE_RED] FULL upstream_body JSON:\n%s\n", - json.dumps(upstream_body, ensure_ascii=False, indent=2), - ) - app_logging.logger.debug( - "[EXT][NODE_RED] reasoning POST %s/api/reasoning lang=%s transcriptLen=%d", - settings.NODE_RED_REASONING_URL, - (req.language or "")[:20] or "auto", - len(transcript), - ) - try: - async with httpx.AsyncClient(timeout=120.0) as http: - resp = await http.post( - f"{settings.NODE_RED_REASONING_URL}/api/reasoning", - json=upstream_body, - headers={ - "Content-Type": "application/json", - "X-S2S-Caller": "demo-server", - }, - ) - except httpx.RequestError as exc: - app_logging.logger.warning("[EXT][NODE_RED] unreachable: %r", exc) - raise HTTPException( # noqa: B904 - status_code=502, detail="No se puede conectar con el motor de razonamiento de Node-RED." - ) - app_logging.logger.debug( - "[EXT][NODE_RED] reasoning RESP status=%s len=%dB", - resp.status_code, - len(resp.content or b""), - ) - if resp.status_code != 200: - app_logging.logger.warning("[EXT][NODE_RED] failed %s: %s", resp.status_code, resp.text[:500]) - raise HTTPException( - status_code=502, - detail=f"El razonamiento de Node-RED falló (HTTP {resp.status_code}).", - ) - try: - payload = resp.json() - except Exception: - app_logging.logger.warning("[EXT][NODE_RED] invalid JSON: %s", resp.text[:500]) - raise HTTPException(status_code=502, detail="Node-RED devolvió un JSON no válido.") # noqa: B904 - answer = payload.get("answer") if isinstance(payload, dict) else None - log_only = bool(isinstance(payload, dict) and payload.get("log_only") is True) - if not isinstance(answer, str): - app_logging.logger.warning( - "[EXT][NODE_RED] invalid answer type: %s", json.dumps(payload, ensure_ascii=True)[:800] - ) - raise HTTPException(status_code=502, detail="Node-RED devolvió un campo answer no válido.") - answer_stripped = answer.strip() - # An empty answer is intentional in log-only mode; forward the flag. - if not answer_stripped and not log_only: - app_logging.logger.warning( - "[EXT][NODE_RED] empty answer and !log_only: %s", json.dumps(payload, ensure_ascii=True)[:800] - ) - raise HTTPException(status_code=502, detail="Node-RED no devolvió una respuesta ni la marca log_only.") - usage = payload.get("usage") if isinstance(payload, dict) else None - if isinstance(usage, dict): - app_logging.logger.debug( - "[EXT][NODE_RED] reasoning USAGE prompt=%d completion=%d total=%d", - usage.get("prompt_tokens", 0), - usage.get("completion_tokens", 0), - usage.get("total_tokens", 0), - ) - extra = {} - 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: - app_logging.logger.info( - "[Vocero REASONING] engine=NODE_RED mode=LOG_ONLY transcript=%s extra=%s", - transcript[:300], - json.dumps({k: v for k, v in extra.items() if k != "debug"}, ensure_ascii=True)[:400], - ) - else: - app_logging.logger.info( - "[Vocero REASONING] engine=NODE_RED transcript=%s answer=%s extra=%s", - transcript[:300], - answer_stripped[:500], - json.dumps({k: v for k, v in extra.items() if k != "debug"}, ensure_ascii=True)[:400], - ) - out: dict[str, Any] = {"answer": answer_stripped} - if log_only: - out["log_only"] = True - if usage: - out["usage"] = usage - out.update({k: v for k, v in extra.items() if k != "debug" or app_logging.logger.level <= logging.DEBUG}) - # Capture the ApiPilot marker, execute its tool and run reasoning pass 2. - tool_name_p1, tool_args_p1 = services_tool_context._apipilot_parse_call_marker(answer_stripped) - if tool_name_p1: - app_logging.logger.info( - "[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_TRIGGER name=%r argsKeys=%s", - tool_name_p1, - sorted(list((tool_args_p1 or {}).keys())), - ) - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 trigger NODE_RED marker name=%s args=%s", - tool_name_p1, - json.dumps(tool_args_p1 or {}, ensure_ascii=True)[:300], - ) - app_logging.logger.info( - "[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_EXEC_HELPER name=%r → forward to _apipilot_execute_tool", - tool_name_p1, - ) - tool_env_p1 = await providers_apipilot._apipilot_execute_tool( - auth_header=apipilot_auth_header, - demo_session=None, - name=tool_name_p1, - arguments=tool_args_p1 or {}, - ) - is_tool_err = not bool(tool_env_p1.get("success")) - tool_data = tool_env_p1.get("data") - if isinstance(tool_data, str): - tool_text_p1 = tool_data - elif tool_data is None: - tool_text_p1 = "" - else: - try: - tool_text_p1 = json.dumps(tool_data, ensure_ascii=False, indent=2) - except Exception: - tool_text_p1 = str(tool_data) - if is_tool_err and isinstance(tool_env_p1.get("error"), dict): - err = tool_env_p1["error"] - err_head = f"[{err.get('code')}] {err.get('message')}" - tool_text_p1 = (err_head + ("\n" + tool_text_p1 if tool_text_p1.strip() else "")).strip() - inject_apipilot_text_pass2 = services_tool_context._resolve_apipilot_reasoning_context_block( - [], - call_result={ - "name": tool_name_p1, - "text": tool_text_p1, - "error": is_tool_err, - }, - ) - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 execute name=%s success=%s httpResultLen=%d trigger second Node-RED call", - tool_name_p1, - str(not is_tool_err).lower(), - len(tool_text_p1), - ) - # Pass-2 context replaces the tool list with the execution result, - # preserving voice/date/profile context. - user_ctx_parts_2: list[str] = [] - if voice_lock_block.strip(): - user_ctx_parts_2.append(voice_lock_block) - if today_block.strip(): - user_ctx_parts_2.append(today_block) - if inject_episode_text: - user_ctx_parts_2.append(inject_episode_text) - if inject_profile_text: - user_ctx_parts_2.append(inject_profile_text) - if inject_reports_text: - user_ctx_parts_2.append(inject_reports_text) - if inject_summary_text: - user_ctx_parts_2.append(inject_summary_text) - if inject_apipilot_text_pass2: - user_ctx_parts_2.append(inject_apipilot_text_pass2) - if original_user_context: - user_ctx_parts_2.append(original_user_context) - user_ctx_parts_2 = services_context._dedup_context_blocks_by_type(user_ctx_parts_2) - stripped_2: list[str] = [] - for p in user_ctx_parts_2: - sp = services_context._strip_internal_markers(p) - if sp: - stripped_2.append(sp) - user_ctx_parts_2 = stripped_2 - effective_user_ctx_2 = "\n\n".join(user_ctx_parts_2) - # Preserve augmentation without CALL-marker instructions; the result - # block supplies the pass-2 response instructions. - if settings.S2S_INJECT_PROFILE: - eff_inst_2 = services_context._augment_instructions_with_profile( - req.instructions or "", - inject_profile_text, - inject_reports_text, - inject_summary_text, - inject_episode_text, - profile_obj=profile_obj, - ) - else: - eff_inst_2 = (req.instructions or "").strip() - if inject_apipilot_text_pass2.strip(): - eff_inst_2 = (eff_inst_2 + "\n\n" if eff_inst_2 else "") + inject_apipilot_text_pass2 - upstream_body_2 = { - "transcript": transcript, - "instructions": eff_inst_2, - "language": (req.language or "").strip(), - "user_context": effective_user_ctx_2, - } - if settings.S2S_DEBUG_PROMPT: - app_logging.logger.info( - "[DEBUG-PROMPT][REASONING → NODE_RED PASS-2] FULL upstream_body_2 JSON:\n%s\n", - json.dumps(upstream_body_2, ensure_ascii=False, indent=2), - ) - try: - async with httpx.AsyncClient(timeout=120.0) as http2: - resp2 = await http2.post( - f"{settings.NODE_RED_REASONING_URL}/api/reasoning", - json=upstream_body_2, - headers={ - "Content-Type": "application/json", - "X-S2S-Caller": "demo-server-pass2", - }, - ) - except httpx.RequestError as exc: - app_logging.logger.warning("[EXT][NODE_RED PASS-2] unreachable: %r", exc) - raise HTTPException( # noqa: B904 - status_code=502, - detail="No se puede conectar con el motor de razonamiento de Node-RED (segunda pasada).", - ) - if resp2.status_code != 200: - app_logging.logger.warning("[EXT][NODE_RED PASS-2] failed %s: %s", resp2.status_code, resp2.text[:500]) - raise HTTPException( - status_code=502, - detail=f"La segunda pasada del razonamiento de Node-RED falló (HTTP {resp2.status_code}).", - ) - try: - payload2 = resp2.json() - except Exception: - app_logging.logger.warning("[EXT][NODE_RED PASS-2] invalid JSON: %s", resp2.text[:500]) - raise HTTPException(status_code=502, detail="Node-RED devolvió un JSON no válido (segunda pasada).") # noqa: B904 - final_ans = payload2.get("answer") if isinstance(payload2, dict) else None - if not isinstance(final_ans, str): - # Fallback: the first message and raw result. - app_logging.logger.warning("[EXT][NODE_RED PASS-2] invalid answer, falling back to raw result text") - final_ans = tool_text_p1 or answer_stripped - final_stripped = final_ans.strip() - # Pass 2 always sets log_only=False: its final answer is ready for speech. - out2: dict = {"answer": final_stripped} - usage2 = payload2.get("usage") if isinstance(payload2, dict) else None - if usage2: - out2["usage_pass2"] = usage2 - out2["apipilot_invoked"] = { - "name": tool_name_p1, - "success": not is_tool_err, - "result_length": len(tool_text_p1), - } - app_logging.logger.info( - "[Vocero REASONING] engine=NODE_RED PASS-2 transcript=%s finalAnswer=%s apipilot=%s", - transcript[:300], - final_stripped[:500], - json.dumps(out2["apipilot_invoked"], ensure_ascii=True), - ) - return validated_json_response(out2, ReasoningResponse) - # Without a marker, return the original pass-1 answer. - return validated_json_response(out, ReasoningResponse) - - # ──────────────────────────────────────────────────────────────────── - # Mode B: OPENAI calls OpenAI Chat Completions directly. - # ──────────────────────────────────────────────────────────────────── + # Call the OpenAI-compatible Chat Completions provider. if not settings.OPENAI_API_KEY: raise HTTPException(status_code=503, detail="OPENAI_API_KEY no está configurado.") system_parts = [] @@ -924,39 +649,3 @@ async def reasoning(req: models_requests.ReasoningRequest) -> JSONResponse: return validated_json_response(final_out_oai, ReasoningResponse) # Without a marker, return the original pass-1 answer. return validated_json_response({"answer": answer}, ReasoningResponse) - - -async def _forward_agent_text_to_nodered(payload: dict) -> None: - """Forward native-backend TTS responses to Node-RED's stage-1 logging endpoint. - - Ignore unavailable or unconfigured Node-RED so the realtime loop continues. - """ - if settings.S2S_REASONING_TYPE != "NODE_RED" or not settings.NODE_RED_REASONING_URL: - return - url = f"{settings.NODE_RED_REASONING_URL}/api/log/system-response" - app_logging.logger.debug( - "[EXT][NODE_RED] forward system-response POST %s len=%d source=%s", - settings.NODE_RED_REASONING_URL, - len(str(payload.get("tts", "")) or 0), # type: ignore[arg-type] # Preserve the legacy empty-TTS failure. - payload.get("source_event") or "-", - ) - try: - async with httpx.AsyncClient(timeout=5.0) as http: - resp = await http.post( - url, - json=payload, - headers={ - "Content-Type": "application/json", - "X-S2S-Caller": "demo-server", - }, - ) - if resp.status_code != 200: - app_logging.logger.debug( - "[EXT][NODE_RED] forward system-response non-200 status=%s body=%s", - resp.status_code, - (resp.text or "")[:300], - ) - except httpx.RequestError as exc: - app_logging.logger.debug("[EXT][NODE_RED] forward system-response unreachable: %r", exc) - except Exception as exc: # noqa: BLE001 — never interrupt the realtime loop - app_logging.logger.debug("[EXT][NODE_RED] forward system-response unexpected: %r", exc) diff --git a/s2s-client/tests/fixtures/legacy-contracts.json b/s2s-client/tests/fixtures/legacy-contracts.json index 4b49916..d7d4e68 100644 --- a/s2s-client/tests/fixtures/legacy-contracts.json +++ b/s2s-client/tests/fixtures/legacy-contracts.json @@ -744,14 +744,6 @@ }, "upstream": [] }, - "reasoning-NODE_RED-legacy-unbound-token": { - "status": 500, - "body": "Internal Server Error", - "headers": { - "content-type": "text/plain; charset=utf-8" - }, - "upstream": [] - }, "reasoning-legacy-unbound-profile": { "status": 500, "body": "Internal Server Error", @@ -760,78 +752,6 @@ }, "upstream": [] }, - "reasoning-node-answer": { - "status": 200, - "body": "{\"answer\":\"answer\",\"usage\":{\"future\":true},\"latency_ms\":\"loose\",\"intent\":{\"future\":1}}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://profile.test/user?phoneNumber=123", - "body": "", - "headers": {} - }, - { - "method": "POST", - "url": "http://reasoning.test/api/reasoning", - "body": "{\"transcript\":\"hello\",\"instructions\":\"Fixture instructions \\nPRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details. Fixture current date: 2026-01-04 \\n Caller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\",\"language\":\"es\",\"user_context\":\"PRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details.\\n\\nFixture current date: 2026-01-04\\n\\nCaller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\"}", - "headers": { - "content-type": "application/json", - "x-s2s-caller": "demo-server" - } - } - ] - }, - "reasoning-node-log-only": { - "status": 200, - "body": "{\"answer\":\"\",\"log_only\":true,\"debug\":{\"future\":true}}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://profile.test/user?phoneNumber=123", - "body": "", - "headers": {} - }, - { - "method": "POST", - "url": "http://reasoning.test/api/reasoning", - "body": "{\"transcript\":\"hello\",\"instructions\":\"Fixture instructions \\nPRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details. Fixture current date: 2026-01-04 \\n Caller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\",\"language\":\"es\",\"user_context\":\"PRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details.\\n\\nFixture current date: 2026-01-04\\n\\nCaller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\"}", - "headers": { - "content-type": "application/json", - "x-s2s-caller": "demo-server" - } - } - ] - }, - "reasoning-node-invalid-answer": { - "status": 502, - "body": "{\"detail\":\"Node-RED devolvió un campo answer no válido.\"}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://profile.test/user?phoneNumber=123", - "body": "", - "headers": {} - }, - { - "method": "POST", - "url": "http://reasoning.test/api/reasoning", - "body": "{\"transcript\":\"hello\",\"instructions\":\"Fixture instructions \\nPRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details. Fixture current date: 2026-01-04 \\n Caller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\",\"language\":\"es\",\"user_context\":\"PRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details.\\n\\nFixture current date: 2026-01-04\\n\\nCaller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\"}", - "headers": { - "content-type": "application/json", - "x-s2s-caller": "demo-server" - } - } - ] - }, "reasoning-openai-answer": { "status": 200, "body": "{\"answer\":\"answer\"}", diff --git a/s2s-client/tests/test_compatibility.py b/s2s-client/tests/test_compatibility.py index c25baf3..b329c7b 100644 --- a/s2s-client/tests/test_compatibility.py +++ b/s2s-client/tests/test_compatibility.py @@ -11,7 +11,7 @@ import json import os import unittest from pathlib import Path -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import httpx from fastapi.testclient import TestClient @@ -21,7 +21,7 @@ with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_ from s2s_client.core import config as settings from s2s_client.providers import apipilot from s2s_client.realtime import proxy - from s2s_client.services import context, reasoning + from s2s_client.services import context ASYNC_CLIENT = httpx.AsyncClient FIXTURE = Path(__file__).parent / "fixtures" / "legacy-contracts.json" @@ -47,7 +47,6 @@ BASE_CONFIG = { "S2S_APIPILOT_SERVICE_TOKEN": "", "S2S_APIPILOT_API_KEY": "", "OPENAI_API_KEY": "", - "NODE_RED_REASONING_URL": "", "S2S_REPORTS_QUICK_HINT_MAX": 5, "S2S_REPORTS_INJECT_MODE": "minimal", } @@ -268,15 +267,14 @@ def cases(): for name, payload in (("missing", {}), ("proxy", {"transcript": "hello"}), ("proxy-blank", {"transcript": " "})): yield f"reasoning-{name}", "POST", "/api/reasoning", {"json": payload}, {}, [] - for mode in ("OPENAI", "NODE_RED"): - yield ( - f"reasoning-{mode}-legacy-unbound-token", - "POST", - "/api/reasoning", - {"json": {"transcript": "hello"}}, - {"S2S_REASONING_TYPE": mode}, - [], - ) + yield ( + "reasoning-OPENAI-legacy-unbound-token", + "POST", + "/api/reasoning", + {"json": {"transcript": "hello"}}, + {"S2S_REASONING_TYPE": "OPENAI"}, + [], + ) yield ( "reasoning-legacy-unbound-profile", "POST", @@ -292,8 +290,7 @@ def cases(): reasoning_config = { **profile_config, "S2S_INJECT_PROFILE": True, - "S2S_REASONING_TYPE": "NODE_RED", - "NODE_RED_REASONING_URL": "http://reasoning.test", + "S2S_REASONING_TYPE": "OPENAI", } reasoning_request = { "json": { @@ -304,28 +301,6 @@ def cases(): "future": True, } } - for name, payload in ( - ( - "answer", - { - "answer": " answer ", - "latency_ms": "loose", - "intent": {"future": 1}, - "usage": {"future": True}, - "future": True, - }, - ), - ("log-only", {"answer": "", "log_only": True, "debug": {"future": True}}), - ("invalid-answer", {"answer": 5}), - ): - yield ( - f"reasoning-node-{name}", - "POST", - "/api/reasoning", - reasoning_request, - reasoning_config, - [(200, {"name": "Caller"}), (200, payload)], - ) yield ( "reasoning-openai-answer", "POST", @@ -333,7 +308,6 @@ def cases(): reasoning_request, { **reasoning_config, - "S2S_REASONING_TYPE": "OPENAI", "OPENAI_API_KEY": "fixture-key", }, [ @@ -454,7 +428,6 @@ def capture_http(): "x-demo-session", "x-api-key", "content-type", - "x-s2s-caller", ) if key in request.headers }, @@ -534,8 +507,7 @@ async def capture_realtime(): yield json.dumps({"type": "error", "error": {"message": "Unknown or invalid event: session.update"}}) yield json.dumps({"type": "future.after.suppressed.error"}) - with patch.object(reasoning, "_forward_agent_text_to_nodered", new_callable=AsyncMock): - await proxy._upstream_to_client(Socket(), upstream(), "fixture", {}) + await proxy._upstream_to_client(Socket(), upstream(), "fixture", {}) return frames @@ -548,6 +520,48 @@ def capture(): class LegacyCompatibilityTests(unittest.TestCase): + def test_native_transcripts_are_relayed_without_external_http_calls(self): + events = [ + {"type": "conversation.item.input_audio_transcription.completed", "transcript": "Hola", "item_id": "u1"}, + {"type": "response.audio_transcript.done", "transcript": "Respuesta", "response_id": "r1"}, + {"type": "response.output_audio_transcript.done", "transcript": "Respuesta", "response_id": "r1"}, + {"type": "response.content_part.done", "part": {"type": "text", "text": "Respuesta"}}, + *[ + { + "type": event_type, + "item": {"id": "a1", "role": "assistant", "content": [{"type": "text", "text": "Respuesta"}]}, + } + for event_type in ("response.output_item.done", "conversation.item.created") + ], + {"type": "response.text.done", "text": "Respuesta", "response_id": "r1"}, + ] + messages = [json.dumps(event) for event in events] + forwarded = [] + + class Socket: + async def send_text(self, message): + forwarded.append(message) + + async def upstream(): + for message in messages: + yield message + + async def relay(): + await proxy._upstream_to_client(Socket(), upstream(), "fixture", {}) + # Allow any accidentally scheduled background HTTP work to run. + await asyncio.sleep(0) + + for mode in ("PROXY", "OPENAI"): + forwarded.clear() + with ( + self.subTest(mode=mode), + patch.multiple(settings, **{**BASE_CONFIG, "S2S_REASONING_TYPE": mode}), + patch.object(httpx, "AsyncClient", side_effect=AssertionError("Unexpected HTTP call")) as http, + ): + asyncio.run(relay()) + self.assertEqual(forwarded, messages) + http.assert_not_called() + def test_wire_contracts_match_pre_migration_capture(self): expected = json.loads(FIXTURE.read_text()) actual = capture() diff --git a/s2s-client/tests/test_models.py b/s2s-client/tests/test_models.py index e09b750..35f2b8d 100644 --- a/s2s-client/tests/test_models.py +++ b/s2s-client/tests/test_models.py @@ -24,7 +24,7 @@ from s2s_client.models.responses import ( (AllocationResponse, {"state": [1, None], "connectUrl": "wss://host", "future": {"x": 1.0}}), (ToolExecutionResponse, {"success": True, "unknown": {"nested": [None, 1.0, False]}}), (ToolExecutionResponse, {"success": False, "data": 42, "error": "loose error", "future": []}), - (ReasoningResponse, {"answer": "á", "usage": [1, None], "debug": False, "future": True}), + (ReasoningResponse, {"answer": "á", "usage_pass2": [1, None], "future": True}), (SearchResponse, {"query": "q", "answer": {"x": 2}, "results": [{"title": [], "snippet": 7, "url": None}]}), ( ReportsResponse, @@ -69,3 +69,22 @@ def test_http_routes_publish_response_schemas(): allocation = spec["components"]["schemas"]["AllocationResponse"] assert not allocation.get("required") assert allocation["additionalProperties"] is True + config = spec["components"]["schemas"]["ConfigResponse"] + assert config["properties"]["reasoningType"]["enum"] == ["PROXY", "OPENAI"] + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("OPENAI", "OPENAI"), + (" 'openai' ", "OPENAI"), + ('"OPENAI"', "OPENAI"), + ("PROXY", "PROXY"), + ("", "PROXY"), + ("UNSUPPORTED", "PROXY"), + ], +) +def test_reasoning_configuration_preserves_normalization_and_native_fallback(value, expected): + from s2s_client.core.config import _normalize_reasoning_type + + assert _normalize_reasoning_type(value) == expected diff --git a/s2s-frontend/README.md b/s2s-frontend/README.md index 22d5f2e..03bafef 100644 --- a/s2s-frontend/README.md +++ b/s2s-frontend/README.md @@ -115,7 +115,7 @@ by Git. The launcher serves source through Vite; it does not use `dist/` or Tests cover session initialization, response sequencing, cumulative transcripts, interruptions, queue polling, admission deadlines, cancellation, microphone-permission cancellation, tool -results, external reasoning and `LOG_ONLY`, late-response cleanup, the idle +results, external reasoning, late-response cleanup, the idle timeout, settings, RAG, profiles, and sanitized Markdown/export behavior. Launcher tests start temporary local children to check readiness, environment forwarding, failures, and shutdown of complete process groups. @@ -144,8 +144,7 @@ quick actions remain absent, matching their disabled state in the old UI. Native speech stays at the current project's 16 kHz PCM rate. The backend continues to own VAD, transcription, model inference, synthesis, upstream credentials, and ApiPilot execution. Browser `SpeechSynthesis` is used only -when the configured external reasoning service returns an answer instead of -`log_only`. +when the configured external reasoning service returns an answer. Session startup waits for audio initialization and `session.created`, then sends `session.update` before microphone audio or the initial greeting. The speech diff --git a/s2s-frontend/src/lib/api.ts b/s2s-frontend/src/lib/api.ts index 1e7052c..8144f79 100644 --- a/s2s-frontend/src/lib/api.ts +++ b/s2s-frontend/src/lib/api.ts @@ -79,7 +79,7 @@ export async function getTools(signal: AbortSignal, refresh = false) { return result.data.tools; } -export const reasoningSchema = z.object({ answer: z.string().default(""), log_only: z.boolean().default(false) }); +export const reasoningSchema = z.object({ answer: z.string().default("") }); export const searchSchema = z.object({ answer: z.string().nullish(), results: z.array(z.object({ title: z.string(), snippet: z.string().default(""), url: z.string() })).default([]), diff --git a/s2s-frontend/src/lib/contracts.ts b/s2s-frontend/src/lib/contracts.ts index 916a97f..4b5feb5 100644 --- a/s2s-frontend/src/lib/contracts.ts +++ b/s2s-frontend/src/lib/contracts.ts @@ -9,7 +9,7 @@ export const configSchema = z.object({ allowDirect: z.boolean().default(true), s2sUrl: z.string().default(""), primaryPrompt: z.string().default(""), - reasoningType: z.enum(["PROXY", "OPENAI", "NODE_RED"]).catch("PROXY"), + reasoningType: z.enum(["PROXY", "OPENAI"]).catch("PROXY"), injectProfile: z.boolean().default(false), debugPrompt: z.boolean().default(false), startSessionMode: z.string().default(""), diff --git a/s2s-frontend/src/realtime/controller.test.ts b/s2s-frontend/src/realtime/controller.test.ts index fc8dc1a..c8f3ff9 100644 --- a/s2s-frontend/src/realtime/controller.test.ts +++ b/s2s-frontend/src/realtime/controller.test.ts @@ -63,7 +63,7 @@ beforeEach(() => { } const pathname = new URL(path, "http://localhost").pathname; const responses: Record = { - "/api/config": { reasoningType: "NODE_RED", injectProfile: false, s2sUrl: "ws://localhost/v1/realtime" }, + "/api/config": { reasoningType: "OPENAI", injectProfile: false, s2sUrl: "ws://localhost/v1/realtime" }, "/api/user/phone": { result: { name: "Ana" } }, "/api/user/reports": { count: 0 }, }; @@ -98,17 +98,28 @@ describe("voice application lifecycle", () => { ]); }); - it("keeps LOG_ONLY reasoning on the native voice path and preserves separate profile context", async () => { + it("renders an external answer and preserves separate profile context", async () => { const client = await start(); client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false }); - pendingReasoning?.(new Response(JSON.stringify({ log_only: true, answer: "" }))); + pendingReasoning?.(new Response(JSON.stringify({ answer: " Hola, Ana " }))); await vi.advanceTimersByTimeAsync(1); const request = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning"); expect(request?.[1]?.body).toContain('"phoneNumber":"1234"'); expect(request?.[1]?.body).toContain("Caller profile resolved"); expect(client.options.instructions).not.toContain("Caller profile resolved"); + expect(client.renderExternalAnswer).toHaveBeenCalledWith("Hola, Ana"); + expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Hola", "Hola, Ana"]); + }); + it.each(["PROXY", "UNSUPPORTED"])("keeps native reasoning for mode %s", async (reasoningType) => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ reasoningType, s2sUrl: "ws://localhost/v1/realtime" })), + ); + const client = await start(); + client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false }); + await vi.advanceTimersByTimeAsync(1); + expect(fetchMock.mock.calls.some(([path]) => path === "/api/reasoning")).toBe(false); expect(client.renderExternalAnswer).not.toHaveBeenCalled(); - expect(controller.getSnapshot().messages).toHaveLength(1); + expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Hola"]); }); it("discards external reasoning that finishes after the session was stopped", async () => { const client = await start(); diff --git a/s2s-frontend/src/realtime/controller.ts b/s2s-frontend/src/realtime/controller.ts index 1f99a2c..8348e54 100644 --- a/s2s-frontend/src/realtime/controller.ts +++ b/s2s-frontend/src/realtime/controller.ts @@ -379,7 +379,7 @@ export class VoiceController { }, this.requests.signal, ); - if (this.client !== client || result.log_only) { + if (this.client !== client) { return; } const answer = result.answer.trim(); diff --git a/s2s-reasoning-engine/.env.example b/s2s-reasoning-engine/.env.example deleted file mode 100644 index f6dec51..0000000 --- a/s2s-reasoning-engine/.env.example +++ /dev/null @@ -1,39 +0,0 @@ -# Environment configuration for s2s-reasoning-engine -# -# Copy this file to .env in the project root or s2s-reasoning-engine directory -# and adjust the values for your deployment. - -# ───────────────────────────────────────────────────────────── -# 1) LLM (OpenAI-compatible) -# ───────────────────────────────────────────────────────────── -OPENAI_API_KEY=sk-.................................... -OPENAI_BASE_URL=https://api.openai.com/v1 -OPENAI_REASONING_MODEL=gpt-4.1-mini -OPENAI_ORGANIZATION= -OPENAI_PROJECT= -OPENAI_REASONING_TEMPERATURE=0.2 -OPENAI_REASONING_MAX_TOKENS=512 - -# ───────────────────────────────────────────────────────────── -# 2) Aquachile (agent profile, MCP tools, authentication) -# ───────────────────────────────────────────────────────────── -S2S_AGENT_PROFILE=https://aquachile.azurianlab.com/api/v1/agents/profile -S2S_USER_PHONE_AUTH_URL=https://aquachile.azurianlab.com/api/v1/auth/user-by-phone -S2S_TOOL_EXECUTION_MCP_URL=https://aquachile.azurianlab.com/api/v1/mcp -S2S_TOOL_EXECUTION_MCP_API_KEY=.................................... -S2S_TOOL_EXECUTION_MCP_API_SECRET=.................................... -S2S_SEARCH_MCP=true - -# ───────────────────────────────────────────────────────────── -# 3) Node-RED runtime -# ───────────────────────────────────────────────────────────── -NODE_RED_PORT=1881 -NODE_RED_LOG_LEVEL=info -NODE_RED_CREDENTIAL_SECRET=change-me-in-production - -# ───────────────────────────────────────────────────────────── -# 4) S2S UI integration (server.py) -# ───────────────────────────────────────────────────────────── -# URL reachable from the Python UI container -# (for a shared Docker network, use http://s2s-reasoning:1880). -S2S_REASONING_NODE_RED_URL=http://localhost:1881 diff --git a/s2s-reasoning-engine/Dockerfile b/s2s-reasoning-engine/Dockerfile deleted file mode 100644 index 959855f..0000000 --- a/s2s-reasoning-engine/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -# s2s-reasoning-engine: Node-RED 5.0 custom image - -# Use the official Node-RED 5.x Alpine image to keep the image small. -FROM nodered/node-red:5.0.3 - -# Default Node-RED port, exposed to the container network. -EXPOSE 1880 - -# 1) Install additional nodes in the Node-RED user data directory. -# - node-red-contrib-openai: native Chat Completions / Embeddings integration -# - node-red-contrib-cache: optional in-memory cache for Aquachile data -# - node-red-node-rbe: report-by-exception event deduplication -RUN cd /data && npm install --no-audit --no-fund \ - node-red-contrib-openai@0.11.0 \ - node-red-contrib-cache@1.1.1 \ - node-red-node-rbe@0.5.0 - -# 2) Copy the configuration and initial flows. -COPY settings.js /data/settings.js -COPY flows.json /data/flows.json - -# 3) Load static flows with projects and authentication disabled for development. -# Enable adminAuth in settings.js for production. -ENV NODE_RED_FLOWS_FILE=/data/flows.json -ENV NODE_RED_CREDENTIAL_SECRET=s2s-reasoning-dev-secret -ENV FLOWS=/data/flows.json - -# Start Node-RED. -CMD ["npm", "start", "--", "--userDir", "/data", "--settings", "/data/settings.js"] diff --git a/s2s-reasoning-engine/flows.json b/s2s-reasoning-engine/flows.json deleted file mode 100644 index f7701c9..0000000 --- a/s2s-reasoning-engine/flows.json +++ /dev/null @@ -1,254 +0,0 @@ -[ - { - "id": "s2s-reasoning-tab", - "type": "tab", - "label": "S2S Reasoning", - "disabled": false, - "info": "# S2S reasoning flow — phase 1: log only\n\nThis flow does not generate answers or change the prompt. It:\n 1. Receives POST /api/reasoning from the client server.\n 2. Extracts the transcript, language, and phoneNumber.\n 3. Logs the user request to the console and debug sidebar.\n 4. Returns HTTP 200 with the standard contract and `log_only: true`.\n\nResponse contract (phase 1):\n { answer: \"\", log_only: true, intent: \"log_only\", usage: {prompt_tokens:0,completion_tokens:0,total_tokens:0}, latency_ms: }\n", - "wires": [] - }, - { - "id": "http-in-reasoning", - "type": "http in", - "z": "s2s-reasoning-tab", - "name": "POST /api/reasoning", - "url": "/api/reasoning", - "method": "post", - "upload": false, - "swaggerDoc": "", - "x": 160, - "y": 160, - "wires": [ - [ - "parse-request" - ] - ] - }, - { - "id": "parse-request", - "type": "function", - "z": "s2s-reasoning-tab", - "name": "Parse request (log only)", - "func": "// Phase 1: log only; no answer is generated.\n// Validate and normalize the payload received from server.py.\n// msg.req.body = { transcript, instructions, language, user_context, phoneNumber? }\n\nconst body = msg.req?.body || {};\nconst startedAt = Date.now();\n\nconst transcript = String(body.transcript || \"\").trim();\nconst instructions = String(body.instructions || \"\").trim();\nconst language = String(body.language || \"\").trim();\nconst userContext = String(body.user_context || body.user_context || \"\").trim();\nconst phoneNumber = String(body.phoneNumber || body.phone_number || msg.req?.query?.phoneNumber || \"\").trim();\n\nif (!transcript) {\n msg.statusCode = 400;\n msg.payload = { error: \"missing_transcript\", detail: \"transcript field is required\" };\n return [null, msg]; // Output 2 is the error path.\n}\n\nconst detectedLang = language || (transcript.match(/[áéíóúñ¿¡]/i) ? \"es-CL\" : \"en-US\");\n\nmsg.state = {\n startedAt,\n transcript,\n instructionsLen: instructions.length,\n language: detectedLang,\n userContextLen: userContext.length,\n phoneNumber: phoneNumber || null,\n intent: \"log_only\",\n};\n\nmsg.payload = {\n transcript,\n language: detectedLang,\n phone: phoneNumber || \"none\",\n};\n\nreturn [msg, null];", - "outputs": 2, - "timeout": 0, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 400, - "y": 160, - "wires": [ - [ - "log-transcript-console", - "debug-parsed" - ], - [ - "http-response-error" - ] - ] - }, - { - "id": "log-transcript-console", - "type": "function", - "z": "s2s-reasoning-tab", - "name": "[LOG] user request", - "func": "// Phase 1: log the user request to the Node-RED console and debug sidebar.\n// The native S2S backend generates the response; this node does not.\n\nconst state = msg.state || {};\nconst transcript = state.transcript || \"\";\nconst lang = state.language || \"\";\nconst phone = state.phoneNumber || \"none\";\nconst ts = new Date(state.startedAt || Date.now()).toISOString();\n\nconst line = `[S2S NODE_RED LOG_ONLY] ts=${ts} lang=${lang} phone=${phone} transcript=\"${transcript.replace(/\"/g, '\\\\\"')}\"`;\n\nnode.warn(line);\n\nmsg.logLine = line;\nreturn msg;", - "outputs": 1, - "timeout": 0, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 660, - "y": 120, - "wires": [ - [ - "build-response" - ] - ] - }, - { - "id": "debug-parsed", - "type": "debug", - "z": "s2s-reasoning-tab", - "name": "[Debug] parsed state", - "active": true, - "tosidebar": true, - "console": true, - "tostatus": false, - "complete": "state", - "targetType": "msg", - "status": "", - "x": 650, - "y": 200, - "wires": [] - }, - { - "id": "build-response", - "type": "function", - "z": "s2s-reasoning-tab", - "name": "Build 200 (log_only)", - "func": "// Phase 1: build the HTTP 200 response with an empty answer and log_only=true.\n// The client leaves the native S2S response untouched: it does not call\n// cancelActiveResponse(), appendAssistantText(), or speechSynthesis.\n\nconst state = msg.state || {};\nconst latencyMs = Math.max(0, Date.now() - (state.startedAt || Date.now()));\n\nmsg.statusCode = 200;\nmsg.headers = {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n};\nmsg.payload = {\n answer: \"\",\n log_only: true,\n intent: \"log_only\",\n usage: {\n prompt_tokens: 0,\n completion_tokens: 0,\n total_tokens: 0,\n },\n latency_ms: latencyMs,\n mcp_tools_used: [],\n caller_profile_keys: [],\n debug: {\n phase: \"log_only_phase1\",\n language: state.language || \"\",\n phone: state.phoneNumber || null,\n transcript_len: (state.transcript || \"\").length,\n },\n};\n\nreturn msg;", - "outputs": 1, - "timeout": 0, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 920, - "y": 160, - "wires": [ - [ - "http-response-ok", - "debug-response" - ] - ] - }, - { - "id": "http-response-ok", - "type": "http response", - "z": "s2s-reasoning-tab", - "name": "HTTP 200 OK", - "statusCode": "", - "headers": {}, - "x": 1160, - "y": 120, - "wires": [] - }, - { - "id": "debug-response", - "type": "debug", - "z": "s2s-reasoning-tab", - "name": "[Debug] response payload", - "active": true, - "tosidebar": true, - "console": false, - "tostatus": false, - "complete": "payload", - "targetType": "msg", - "status": "", - "x": 1180, - "y": 200, - "wires": [] - }, - { - "id": "http-response-error", - "type": "http response", - "z": "s2s-reasoning-tab", - "name": "HTTP Error", - "statusCode": "", - "headers": {}, - "x": 400, - "y": 240, - "wires": [] - }, - { - "id": "http-in-log-response", - "type": "http in", - "z": "s2s-reasoning-tab", - "name": "POST /api/log/system-response", - "url": "/api/log/system-response", - "method": "post", - "upload": false, - "swaggerDoc": "", - "x": 160, - "y": 360, - "wires": [ - [ - "parse-log-response" - ] - ] - }, - { - "id": "parse-log-response", - "type": "function", - "z": "s2s-reasoning-tab", - "name": "Parse system-response", - "func": "// Receive the native S2S backend's TTS response from server.py.\n// msg.req.body = { tts, response_id?, item_id?, client_ip?, role?, source_event?, user_transcript?, phone? }\n// Phase 1: log only; no other side effects.\n\nconst body = msg.req?.body || {};\nconst startedAt = Date.now();\n\nconst tts = String(body.tts || body.text || body.response || \"\").trim();\nconst responseId = String(body.response_id || body.responseId || \"\").trim() || null;\nconst itemId = String(body.item_id || body.itemId || \"\").trim() || null;\nconst clientIp = String(body.client_ip || body.clientIp || body.ip || \"\").trim() || null;\nconst role = String(body.role || \"assistant\").trim() || \"assistant\";\nconst sourceEvent = String(body.source_event || body.sourceEvent || \"\").trim() || null;\nconst userTranscript = String(body.user_transcript || body.userTranscript || \"\").trim() || null;\nconst phone = String(body.phone || body.phoneNumber || \"\").trim() || null;\n\nif (!tts) {\n msg.statusCode = 400;\n msg.headers = { \"Content-Type\": \"application/json\" };\n msg.payload = { error: \"missing_tts\", detail: \"field 'tts' (or 'text'/'response') is required\" };\n return [null, msg];\n}\n\nmsg.state = {\n startedAt,\n kind: \"system_response_log\",\n role,\n tts,\n responseId,\n itemId,\n clientIp,\n sourceEvent,\n userTranscript,\n phone,\n};\n\nmsg.payload = { tts, role, source: sourceEvent, client: clientIp };\nreturn [msg, null];", - "outputs": 2, - "timeout": 0, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 420, - "y": 360, - "wires": [ - [ - "log-system-response", - "debug-system-response" - ], - [ - "http-response-error" - ] - ] - }, - { - "id": "log-system-response", - "type": "function", - "z": "s2s-reasoning-tab", - "name": "[LOG] system response", - "func": "// Log the native backend's TTS response to Node-RED.\n// Use one searchable line per response.\nconst state = msg.state || {};\nconst tts = state.tts || \"\";\nconst role = state.role || \"assistant\";\nconst src = state.sourceEvent || \"\";\nconst user = state.userTranscript || \"\";\nconst phone = state.phone || \"none\";\nconst ts = new Date(state.startedAt || Date.now()).toISOString();\n\nconst userShort = user.length > 120 ? user.slice(0, 117) + \"...\" : user;\nconst ttsShort = tts.length > 240 ? tts.slice(0, 237) + \"...\" : tts;\nconst esc = (s) => String(s || \"\").replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n\nconst line = `[S2S NODE_RED SYSTEM_RESPONSE] ts=${ts} role=${role} phone=${esc(phone)} user=\"${esc(userShort)}\" tts=\"${esc(ttsShort)}\"`;\n\nnode.warn(line);\nmsg.logLine = line;\nreturn msg;", - "outputs": 1, - "timeout": 0, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 690, - "y": 320, - "wires": [ - [ - "build-log-ack" - ] - ] - }, - { - "id": "debug-system-response", - "type": "debug", - "z": "s2s-reasoning-tab", - "name": "[Debug] system-response state", - "active": true, - "tosidebar": true, - "console": true, - "tostatus": false, - "complete": "state", - "targetType": "msg", - "status": "", - "x": 710, - "y": 400, - "wires": [] - }, - { - "id": "build-log-ack", - "type": "function", - "z": "s2s-reasoning-tab", - "name": "Build 200 (ack)", - "func": "// Return a minimal HTTP 200 acknowledgment with received=true.\n// No response data is returned.\nconst state = msg.state || {};\nconst latencyMs = Math.max(0, Date.now() - (state.startedAt || Date.now()));\nmsg.statusCode = 200;\nmsg.headers = {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n};\nmsg.payload = {\n received: true,\n log_only: true,\n intent: \"system_response_log\",\n latency_ms: latencyMs,\n};\nreturn msg;", - "outputs": 1, - "timeout": 0, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 950, - "y": 360, - "wires": [ - [ - "http-response-log-ack" - ] - ] - }, - { - "id": "http-response-log-ack", - "type": "http response", - "z": "s2s-reasoning-tab", - "name": "HTTP 200 ACK", - "statusCode": "", - "headers": {}, - "x": 1180, - "y": 360, - "wires": [] - } -] diff --git a/s2s-reasoning-engine/package.json b/s2s-reasoning-engine/package.json deleted file mode 100644 index 15b66c2..0000000 --- a/s2s-reasoning-engine/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "s2s-reasoning-engine", - "version": "1.0.0", - "description": "Node-RED 5.0 reasoning engine for S2S/Aquachile", - "main": "settings.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "keywords": [], - "author": "", - "license": "ISC", - "type": "commonjs", - "private": "true", - "engines": { - "node": ">=18" - }, - "dependencies": { - "dotenv": "^16.4.7", - "node-red": "^5.0.3", - "node-red-contrib-cache": "^2.0.3", - "node-red-contrib-openai": "^1.0.3", - "node-red-node-rbe": "^0.5.0" - } -} diff --git a/s2s-reasoning-engine/settings.js b/s2s-reasoning-engine/settings.js deleted file mode 100644 index 979f2e2..0000000 --- a/s2s-reasoning-engine/settings.js +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Node-RED 5.0 settings for s2s-reasoning-engine — local installation. - * File: /s2s-reasoning-engine/.node-red/settings.js - * or /s2s-reasoning-engine/settings.js (source) - * - * start.sh copies this into .node-red/ when missing or older than the source. - */ - -const path = require("path"); -const fs = require("fs"); - -// start.sh uses /.node-red as its local user directory. -const ENGINE_ROOT = path.resolve(__dirname, ".."); -const LOCAL_NODE_MODULES = path.join(ENGINE_ROOT, "node_modules"); -const USERDIR_NODE_MODULES = path.join(__dirname, "node_modules"); - -// Fix the editor's "tslib.es6.js 404 / net::ERR_ABORTED" error. -// Flat npm installations place tslib in the root node_modules/tslib directory -// rather than @node-red/editor-client/node_modules/tslib. The Node-RED 5 editor -// loads /tslib.es6.js from the HTTP root. Refresh the editor's public files at -// startup so the workaround survives dependency reinstallation. -(function _installTslibVendor() { - try { - const tslibRoot = path.join(LOCAL_NODE_MODULES, "tslib"); - if (!fs.existsSync(path.join(tslibRoot, "tslib.es6.js"))) return; - const pub = path.join(LOCAL_NODE_MODULES, "@node-red", "editor-client", "public"); - if (!fs.existsSync(pub)) return; - for (const f of ["tslib.es6.js", "tslib.js", "tslib.mjs"]) { - const src = path.join(tslibRoot, f); - const dst = path.join(pub, f); - if (!fs.existsSync(src)) continue; - try { - const s = fs.statSync(src); - const d = fs.existsSync(dst) ? fs.statSync(dst) : null; - if (!d || d.size !== s.size || d.mtimeMs < s.mtimeMs) { - fs.copyFileSync(src, dst); - } - } catch (e) { /* ignore */ } - } - } catch (e) { /* no-op */ } -})(); - -module.exports = { - uiPort: process.env.PORT || process.env.NODE_RED_PORT || 1881, - uiHost: "0.0.0.0", - - httpRequestTimeout: 120000, - httpRequestLegacyMsgOverrides: true, - - // Native HTTP helpers for function nodes, without externalModules. - // functionExternalModules can stall palette loading in Node-RED 5. - // Load core Node.js modules here before startup and expose them through - // functionGlobalContext, accessed with global.get("_core_https") etc. - functionModuleRequireAllowList: ["https","http","url","path","util","fs","os","buffer","zlib","stream","crypto"], - functionGlobalContext: { - _core_https: require("https"), - _core_http: require("http"), - _core_url: require("url"), - }, - - // adminAuth: {...} // Uncomment and configure for production. - - // HTTPS (optional for production). - // https: { - // key: require("fs").readFileSync(path.join(__dirname, "privkey.pem")), - // cert: require("fs").readFileSync(path.join(__dirname, "fullchain.pem")) - // }, - - flowFile: process.env.FLOWS || path.join(__dirname, "flows.json"), - flowFilePretty: true, - credentialSecret: process.env.NODE_RED_CREDENTIAL_SECRET || "s2s-reasoning-local-dev", - - httpNodeCors: { - origin: "*", - methods: "GET,PUT,POST,DELETE,OPTIONS", - credentials: true, - }, - csrf: false, - - // Additional node loading: Node-RED 5 looks for plugins in: - // 1) {userDir}/node_modules (our .node-red/node_modules) - // 2) {nodesDir} (the project-root installation) - // 3) npm global - // Set nodesDir so packages installed at the project root are discovered. - nodesDir: LOCAL_NODE_MODULES, - - externalModules: { - autoInstall: true, // Install missing nodes at startup. - autoInstallRetry: 3, - palette: { - allowInstall: true, // Allow installation from the editor. - allowUpload: true, - modules: ["node-red-contrib-openai", "node-red-contrib-cache", "node-red-node-rbe"], - allowList: ["node-red-contrib-*", "node-red-node-*"], - denyList: [] - } - }, - - editorTheme: { projects: { enabled: false } }, - - logging: { - console: { - level: process.env.NODE_RED_LOG_LEVEL || process.env.LOG_LEVEL || "info", - metrics: false, - audit: false, - timestamp: true, - } - }, - - contextStorage: { - default: { module: "memory" }, - file: { module: "localfilesystem" } - }, -}; diff --git a/s2s-reasoning-engine/start.sh b/s2s-reasoning-engine/start.sh deleted file mode 100755 index 6299059..0000000 --- a/s2s-reasoning-engine/start.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash -# ============================================================= -# start.sh — Run Node-RED 5.0 locally for s2s-reasoning-engine -# -# Usage: -# ./start.sh # default port 1881 -# PORT=1900 ./start.sh # custom port -# LOG_LEVEL=debug ./start.sh # detailed logging -# DEBUG=nodes:* ./start.sh # internal Node-RED diagnostics -# Stop: Ctrl+C -# ============================================================= -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PARENT_DIR="$(dirname "$ROOT_DIR")" - -cd "$ROOT_DIR" - -# ---------------------------------------------------------- -# 1) Load the local .env first, then the parent ../.env when present. -# The parent file takes precedence when both define the same variable. -# ---------------------------------------------------------- -set -a -if [ -f "$ROOT_DIR/.env" ]; then - echo "[env] loaded $ROOT_DIR/.env" - # shellcheck disable=SC1091 - source "$ROOT_DIR/.env" -fi -if [ -f "$PARENT_DIR/.env" ]; then - echo "[env] loaded $PARENT_DIR/.env" - # shellcheck disable=SC1091 - source "$PARENT_DIR/.env" -fi -set +a - -# ---------------------------------------------------------- -# 2) Node-RED runtime parameters -# ---------------------------------------------------------- -PORT="${PORT:-${NODE_RED_PORT:-1881}}" -export PORT -export NODE_RED_PORT="$PORT" -export NODE_RED_LOG_LEVEL="${LOG_LEVEL:-${NODE_RED_LOG_LEVEL:-info}}" -export NODE_RED_CREDENTIAL_SECRET="${NODE_RED_CREDENTIAL_SECRET:-s2s-reasoning-local-dev}" -export FLOWS="${FLOWS:-$ROOT_DIR/.node-red/flows.json}" - -# Local user directory; keep state here rather than in ~/.node-red. -USERDIR="${USERDIR:-$ROOT_DIR/.node-red}" -SETTINGS="${SETTINGS:-$USERDIR/settings.js}" - -# Create the user directory and refresh its configuration from the source files. -mkdir -p "$USERDIR" -if [ ! -f "$SETTINGS" ] || [ "$ROOT_DIR/settings.js" -nt "$SETTINGS" ]; then - cp -f "$ROOT_DIR/settings.js" "$SETTINGS" - echo "[init] copied settings.js -> $SETTINGS" -fi -if [ ! -f "$FLOWS" ] || [ "$ROOT_DIR/flows.json" -nt "$FLOWS" ]; then - cp -f "$ROOT_DIR/flows.json" "$FLOWS" - echo "[init] copied flows.json -> $FLOWS" -fi - -# Check that the local Node-RED dependencies are installed. -if [ ! -d "$ROOT_DIR/node_modules/node-red" ]; then - echo "[init] node_modules was not found. Running npm install..." - npm install --no-audit --no-fund -fi - -echo "" -echo "╔════════════════════════════════════════════════════════════╗" -echo "║ s2s-reasoning-engine — Local Node-RED v5 ║" -echo "╠════════════════════════════════════════════════════════════╣" -echo "║ Editor: http://localhost:${PORT}/ ║" -echo "║ API: http://localhost:${PORT}/api/reasoning ║" -echo "║ UserDir: ${USERDIR}" -echo "║ LogLevel: ${NODE_RED_LOG_LEVEL}" -echo "╚════════════════════════════════════════════════════════════╝" -echo "" - -# ---------------------------------------------------------- -# 3) Start Node-RED -# ---------------------------------------------------------- -exec "$ROOT_DIR/node_modules/.bin/node-red" \ - --userDir "$USERDIR" \ - --settings "$SETTINGS" \ - --port "$PORT"