refactor(reasoning)!: remove Node-RED engine and integrations

BREAKING CHANGE: NODE_RED reasoning is no longer supported. Unsupported
mode values fall back to PROXY. PROXY and OPENAI remain available.
This commit is contained in:
2026-10-06 18:58:54 -03:00
parent 77b60622e9
commit e0304b2bdb
31 changed files with 129 additions and 1138 deletions
-1
View File
@@ -11,7 +11,6 @@
**/.venv/ **/.venv/
**/venv/ **/venv/
**/node_modules/ **/node_modules/
**/.node-red/
**/.cache/ **/.cache/
**/*.log **/*.log
**/uploads/ **/uploads/
-9
View File
@@ -122,14 +122,6 @@ CVS/
*.trae *.trae
.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 # Client backend and speech-service runtime artifacts
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -140,5 +132,4 @@ s2s-client/*.log
speech_to_speech/*.log speech_to_speech/*.log
speech_to_speech/sessions/ speech_to_speech/sessions/
speech_to_speech/.cache/ speech_to_speech/.cache/
s2s-reasoning-engine/*.log
debug_files debug_files
+4 -6
View File
@@ -3,7 +3,7 @@
## Scope and working conventions ## Scope and working conventions
This repository is Vocero: a browser voice client, its FastAPI integration 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 configuration, and documentation within this checkout. Do not assume other
repositories or developer-specific directories are available. 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-client/s2s_client/realtime/` | WebSocket relay, event handling, marker filtering, and audio forwarding |
| `s2s-frontend/` | React, TypeScript, Tailwind, and Vite browser application | | `s2s-frontend/` | React, TypeScript, Tailwind, and Vite browser application |
| `speech_to_speech/` | Separately run VAD → STT → LLM → TTS pipeline and realtime API | | `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 | | `start_ui.sh` | Dependency sync and supervision of FastAPI plus Vite |
| `docs/` | Maintained technical documentation, examples, and historical proposals | | `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 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. 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 URL does not prove which source revision or implementation runs there.
`SPEECH_TO_SPEECH_URL` pins a direct browser connection; `S2S_PROXY_TARGET` enables `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 the local relay. Either disables load-balancer mode. Reasoning supports `PROXY`
`s2s-reasoning-engine/flows.json` are log-only; preserve the meaning of `log_only` and `OPENAI`; unknown `S2S_REASONING_TYPE` values fall back to `PROXY`.
responses.
The backend Docker image serves APIs, WebSockets, and root `docs/`, not the React 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 UI. Build with the repository root as context and `s2s-client/Dockerfile`; the
+2 -3
View File
@@ -1,7 +1,7 @@
# Vocero speech-to-speech # Vocero speech-to-speech
Vocero provides a browser voice interface, a Python speech pipeline, and an Vocero provides a browser voice interface, a FastAPI integration server, and a
optional Node-RED reasoning service. The interface defaults to Spanish; Python speech pipeline. The interface defaults to Spanish;
documentation, source identifiers, comments, and internal instructions use English. documentation, source identifiers, comments, and internal instructions use English.
| Component | Purpose | | 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-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 | | [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 | | [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 Start with [client setup and usage](docs/client/usage.md), then read the
[architecture](docs/client/architecture.md) and [documentation index](docs/README.md). [architecture](docs/client/architecture.md) and [documentation index](docs/README.md).
+5 -8
View File
@@ -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 `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; loopback port `8421`. Vite forwards backend routes and provides hot reload;
Uvicorn reloads Python edits. The launcher supervises both processes. 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`. Runtime configuration comes from environment variables and the root `.env`.
```mermaid ```mermaid
@@ -14,7 +14,7 @@ flowchart LR
Vite[Vite on public port 8420] Vite[Vite on public port 8420]
Server[Client FastAPI server] Server[Client FastAPI server]
Speech[Realtime speech backend] Speech[Realtime speech backend]
Reasoning[Node-RED or HTTP reasoning provider] Reasoning[OpenAI-compatible HTTP reasoning provider]
Tools[ApiPilot backend] Tools[ApiPilot backend]
Browser <-->|Frontend and HMR| Vite Browser <-->|Frontend and HMR| Vite
Vite -->|HTTP configuration and proxies| Server 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 | | [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 | | [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 | | [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 ## 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 | | `GET /api/config` | Connection settings, primary prompt, reasoning/theme/start modes, and feature flags |
| `WS /v1/realtime` | Local realtime proxy to `S2S_PROXY_TARGET` | | `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/phone` | Configured caller profile lookup |
| `GET /api/user/reports` | Configured scheduled report lookup, subject to report-injection controls | | `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 | | `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 ## Reasoning and context
The browser triggers external reasoning on completed user transcripts only in 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 `OPENAI` mode. A non-empty answer replaces the native response through the
output running; an answer-producing response uses the external-answer path. external-answer path. These HTTP operations supplement the realtime audio path.
The server can independently forward assistant text to Node-RED for paired
conversation logs. These HTTP operations supplement the realtime audio path.
Read [reasoning](reasoning.md), [context injection](context-injection.md), and Read [reasoning](reasoning.md), [context injection](context-injection.md), and
[tool calling](tool-calling.md) for the conditions and limitations of each path. [tool calling](tool-calling.md) for the conditions and limitations of each path.
+3 -1
View File
@@ -62,11 +62,13 @@ existing error shapes; there is no new common error envelope.
## Compatibility evidence ## Compatibility evidence
`tests/fixtures/legacy-contracts.json` was captured from the original monolithic `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 including exact response text, statuses, selected headers, and outgoing mock
HTTP requests. They also compare request OpenAPI definitions and representative HTTP requests. They also compare request OpenAPI definitions and representative
realtime frames. Additional tests cover response validation and permissive realtime frames. Additional tests cover response validation and permissive
upstream values; the original local HTTP/WebSocket tests remain in the suite. 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 The captures deliberately preserve unknown fields, omitted fields, unusual JSON
values, queue beacon edge cases, permissive tool arguments, and known failures. values, queue beacon edge cases, permissive tool arguments, and known failures.
-1
View File
@@ -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 | | Claim | The transition from a waiting ticket to an allocated realtime session |
| At capacity | A full waiting queue that cannot accept another ticket | | 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 | | 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 |
+11 -34
View File
@@ -1,17 +1,15 @@
# Reasoning modes # Reasoning modes
Reasoning determines the assistant's response text. The realtime backend always Reasoning determines the assistant's response text. The realtime backend always
provides the speech channel; optional HTTP reasoning can observe a transcript or provides the speech channel; optional HTTP reasoning produces a separate answer
produce a separate answer. Set `S2S_REASONING_TYPE` to select the mode. from a transcript. Set `S2S_REASONING_TYPE` to select the mode.
| Mode | Response owner | HTTP hook | | Mode | Response owner | HTTP hook |
|---|---|---| |---|---|---|
| `PROXY` (default) | Realtime backend | Browser does not call `/api/reasoning`; that endpoint returns 409 | | `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` | | `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 The mode is normalized case-insensitively. Unknown values resolve to `PROXY`.
`NODE_RED`; unknown values resolve to `PROXY`.
## Shared instruction assembly ## 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 instructions still apply, but server-side session context and marker execution
require the local `/v1/realtime` route. 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 ## OpenAI-compatible HTTP reasoning
```env ```env
@@ -76,13 +50,17 @@ OPENAI_REASONING_MODEL=gpt-4.1-mini
`OPENAI_ORGANIZATION` and `OPENAI_PROJECT` are optional headers. The model value `OPENAI_ORGANIZATION` and `OPENAI_PROJECT` are optional headers. The model value
is a runtime default, not a recommendation about provider availability. 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 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 append discovery instructions, execute a detected call marker, and request a
second response based on the tool result. The endpoint does not forward the second response based on the tool result, including available `usage_pass2`
browser's native `session.tools` catalog as OpenAI Chat Completions `tools`. 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 response and appends the external assistant text. It uses browser
`SpeechSynthesis` when available. The React client adds the answer to the realtime `SpeechSynthesis` when available. The React client adds the answer to the realtime
conversation as an assistant item; without browser speech synthesis it remains 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 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 HTTP provider messages | `reasoning()` in `s2s-client/s2s_client/services/reasoning.py` |
| Change external-answer rendering | `VoiceController.reason()` and `RealtimeClient.renderExternalAnswer()` | | 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 | | Change native model/tool generation | `speech_to_speech/LLM` and realtime handlers |
+1 -3
View File
@@ -109,9 +109,7 @@ before changing audio timing based on those observations.
## External reasoning and startup ## External reasoning and startup
`/api/reasoning` returning 409 is expected in `PROXY` mode. In `NODE_RED`, check `/api/reasoning` returning 409 is expected in `PROXY` mode.
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.
In `OPENAI`, check the configured endpoint/model/key and provider logs; HTTP In `OPENAI`, check the configured endpoint/model/key and provider logs; HTTP
reasoning does not use the native session's tool array. reasoning does not use the native session's tool array.
+3 -2
View File
@@ -9,6 +9,9 @@ the implementation at the recorded date; current operating instructions live in
### Changed ### 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 - Filter the specific Starlette TestClient / AnyIO `BlockingPortal` deprecation
in pytest while retaining the runtime versions used for compatibility captures. 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 - 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 - Translated developer comments, diagnostics, internal model instructions, and
local identifiers into English. Sanitization accepts both the former Spanish local identifiers into English. Sanitization accepts both the former Spanish
and new English internal tool-result wrappers. 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, - Standardized browser labels, accessibility text, account/profile dialogs,
session feedback, and built-in API errors in Spanish. The interface declares session feedback, and built-in API errors in Spanish. The interface declares
`lang="es"`; internal instructions and diagnostics remain English. `lang="es"`; internal instructions and diagnostics remain English.
+1 -7
View File
@@ -15,8 +15,6 @@ def _normalize_reasoning_type(value: str) -> str:
normalized = (value or "").strip().strip('"').strip("'").replace("-", "_").upper() normalized = (value or "").strip().strip('"').strip("'").replace("-", "_").upper()
if normalized == "OPENAI": if normalized == "OPENAI":
return "OPENAI" return "OPENAI"
if normalized in {"NODE_RED", "NODERED"}:
return "NODE_RED"
return "PROXY" 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 # Feature flag: when "true" the proxy logs the FULL PROMPT TEXT (not just
# lengths) going to the upstream LLM/realtime backends. Includes: # lengths) going to the upstream LLM/realtime backends. Includes:
# - WS session.update session.instructions (before/after patch) # - 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). # Default = off; only length summaries are logged (current behaviour).
# WARNING: setting this on will log potentially PII / sensitive caller # WARNING: setting this on will log potentially PII / sensitive caller
# profile data / user transcripts. Use only in development / debugging. # 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 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 # Speech-to-speech load balancer URL. When set, the browser POSTs /api/session
# (which proxies <lb>/session here, server-side) and connects to the URL the LB # (which proxies <lb>/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. # returns (the original flow). The LB address itself is never sent to the browser.
+1 -2
View File
@@ -19,9 +19,8 @@ _STARTUP_APIPILOT_EXEC_URL = _resolve_exec_url_for_startup_banner()
del _resolve_exec_url_for_startup_banner del _resolve_exec_url_for_startup_banner
app_logging.logger.info( 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.S2S_REASONING_TYPE,
settings.NODE_RED_REASONING_URL or "-",
bool(settings.SERPER_KEY), bool(settings.SERPER_KEY),
bool(settings.LOAD_BALANCER_URL), bool(settings.LOAD_BALANCER_URL),
bool(settings.S2S_PROXY_TARGET), bool(settings.S2S_PROXY_TARGET),
+1 -1
View File
@@ -20,7 +20,7 @@ class ReasoningRequest(BaseModel):
# Optional phone number. When S2S_INJECT_PROFILE is true the proxy fetches # Optional phone number. When S2S_INJECT_PROFILE is true the proxy fetches
# the caller profile from VOCERO_USER_PHONE_AUTH_URL and: # the caller profile from VOCERO_USER_PHONE_AUTH_URL and:
# 1. prepends the serialized profile to user_context before feeding the # 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. # 2. appends the profile context to the LLM instructions prompt.
phoneNumber: str | None = None phoneNumber: str | None = None
# Optional podcast episode ID. When set the proxy fetches the episode from # Optional podcast episode ID. When set the proxy fetches the episode from
+1 -7
View File
@@ -21,7 +21,7 @@ class ConfigResponse(OpenResponse):
allowDirect: bool allowDirect: bool
s2sUrl: str s2sUrl: str
primaryPrompt: str primaryPrompt: str
reasoningType: Literal["PROXY", "OPENAI", "NODE_RED"] reasoningType: Literal["PROXY", "OPENAI"]
injectProfile: bool injectProfile: bool
debugPrompt: bool debugPrompt: bool
startSessionMode: str startSessionMode: str
@@ -69,13 +69,7 @@ class ToolInvocation(OpenResponse):
class ReasoningResponse(OpenResponse): class ReasoningResponse(OpenResponse):
answer: str answer: str
log_only: bool | None = None
usage: Any = None
usage_pass2: 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 apipilot_invoked: ToolInvocation | None = None
+1 -1
View File
@@ -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 endpoint is never hit, not even for a "cheap empty list"; combined with
_resolve_reports_context → "" above, this guarantees ZERO scheduled-report _resolve_reports_context → "" above, this guarantees ZERO scheduled-report
contents reach any LLM prompt anywhere (WS session instructions patch, 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). lookup response).
• S2S_INJECT_REPORTS=true restores prior behaviour (both the HTTP fetch AND • S2S_INJECT_REPORTS=true restores prior behaviour (both the HTTP fetch AND
the full injection into every prompt path). the full injection into every prompt path).
-65
View File
@@ -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.providers import caller as providers_caller
from s2s_client.realtime import events as realtime_events from s2s_client.realtime import events as realtime_events
from s2s_client.services import context as services_context 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 from s2s_client.services import tool_context as services_tool_context
@@ -408,8 +407,6 @@ async def realtime_proxy(ws: WebSocket) -> None:
) as upstream: ) as upstream:
app_logging.logger.info("[INJECT-PROFILE] WS tunnel ESTABLISHED client=%s", client_ip) app_logging.logger.info("[INJECT-PROFILE] WS tunnel ESTABLISHED client=%s", client_ip)
session_ctx = { session_ctx = {
"last_user_transcript": "",
"last_item_id": None,
"injected_episode_context": injected_episode_context, "injected_episode_context": injected_episode_context,
"injected_profile_context": injected_profile_context, "injected_profile_context": injected_profile_context,
"injected_reports_context": injected_reports_context, "injected_reports_context": injected_reports_context,
@@ -1166,32 +1163,22 @@ async def _upstream_to_client(
user_text = "" user_text = ""
if event_type == "conversation.item.input_audio_transcription.completed": if event_type == "conversation.item.input_audio_transcription.completed":
user_text = (summary.get("transcript") or "").strip() user_text = (summary.get("transcript") or "").strip()
item_id = summary.get("item_id")
if user_text: if user_text:
app_logging.logger.info( app_logging.logger.info(
"[USER INPUT] client=%s stt=%s", "[USER INPUT] client=%s stt=%s",
client_ip, client_ip,
user_text, 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": elif event_type == "conversation.item.created" and summary.get("role") == "user":
user_text = (summary.get("text") or "").strip() user_text = (summary.get("text") or "").strip()
item_id = summary.get("item_id") or summary.get("id")
if user_text: if user_text:
app_logging.logger.info( app_logging.logger.info(
"[USER INPUT] client=%s text=%s", "[USER INPUT] client=%s text=%s",
client_ip, client_ip,
user_text, 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 output, including all event variants.
agent_text = "" 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"}: if event_type in {"response.audio_transcript.done", "response.output_audio_transcript.done"}:
# Pass-2 accumulation already retained the original text. # Pass-2 accumulation already retained the original text.
# This sanitized agent_text is only for UI and logs. # This sanitized agent_text is only for UI and logs.
@@ -1202,19 +1189,6 @@ async def _upstream_to_client(
client_ip, client_ip,
agent_text, 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: else:
raw_preview = summary.get("_raw_preview") or json.dumps(summary, ensure_ascii=True) raw_preview = summary.get("_raw_preview") or json.dumps(summary, ensure_ascii=True)
app_logging.logger.debug( app_logging.logger.debug(
@@ -1232,19 +1206,6 @@ async def _upstream_to_client(
client_ip, client_ip,
agent_text, 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": elif event_type == "response.output_item.done" and summary.get("role") == "assistant":
agent_text = (summary.get("text") or "").strip() agent_text = (summary.get("text") or "").strip()
if agent_text: if agent_text:
@@ -1253,19 +1214,6 @@ async def _upstream_to_client(
client_ip, client_ip,
agent_text, 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": elif event_type == "conversation.item.created" and summary.get("role") == "assistant":
agent_text = (summary.get("text") or "").strip() agent_text = (summary.get("text") or "").strip()
if agent_text: if agent_text:
@@ -1274,19 +1222,6 @@ async def _upstream_to_client(
client_ip, client_ip,
agent_text, 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: if event_type not in realtime_events._NOISY_REALTIME_EVENT_TYPES:
app_logging.logger.debug( app_logging.logger.debug(
"[EXT][S2S-PROXY] client=%s UPSTREAM→ event=%s summary=%s", "[EXT][S2S-PROXY] client=%s UPSTREAM→ event=%s summary=%s",
+1 -312
View File
@@ -1,8 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from typing import Any
import httpx import httpx
from fastapi import HTTPException 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), services_context._strip_internal_markers(inject_apipilot_text_pass1),
) )
# ──────────────────────────────────────────────────────────────────── # Call the OpenAI-compatible Chat Completions provider.
# 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.
# ────────────────────────────────────────────────────────────────────
if not settings.OPENAI_API_KEY: if not settings.OPENAI_API_KEY:
raise HTTPException(status_code=503, detail="OPENAI_API_KEY no está configurado.") raise HTTPException(status_code=503, detail="OPENAI_API_KEY no está configurado.")
system_parts = [] system_parts = []
@@ -924,39 +649,3 @@ async def reasoning(req: models_requests.ReasoningRequest) -> JSONResponse:
return validated_json_response(final_out_oai, ReasoningResponse) return validated_json_response(final_out_oai, ReasoningResponse)
# Without a marker, return the original pass-1 answer. # Without a marker, return the original pass-1 answer.
return validated_json_response({"answer": answer}, ReasoningResponse) 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)
-80
View File
@@ -744,14 +744,6 @@
}, },
"upstream": [] "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": { "reasoning-legacy-unbound-profile": {
"status": 500, "status": 500,
"body": "Internal Server Error", "body": "Internal Server Error",
@@ -760,78 +752,6 @@
}, },
"upstream": [] "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": { "reasoning-openai-answer": {
"status": 200, "status": 200,
"body": "{\"answer\":\"answer\"}", "body": "{\"answer\":\"answer\"}",
+47 -33
View File
@@ -11,7 +11,7 @@ import json
import os import os
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import patch
import httpx import httpx
from fastapi.testclient import TestClient 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.core import config as settings
from s2s_client.providers import apipilot from s2s_client.providers import apipilot
from s2s_client.realtime import proxy from s2s_client.realtime import proxy
from s2s_client.services import context, reasoning from s2s_client.services import context
ASYNC_CLIENT = httpx.AsyncClient ASYNC_CLIENT = httpx.AsyncClient
FIXTURE = Path(__file__).parent / "fixtures" / "legacy-contracts.json" FIXTURE = Path(__file__).parent / "fixtures" / "legacy-contracts.json"
@@ -47,7 +47,6 @@ BASE_CONFIG = {
"S2S_APIPILOT_SERVICE_TOKEN": "", "S2S_APIPILOT_SERVICE_TOKEN": "",
"S2S_APIPILOT_API_KEY": "", "S2S_APIPILOT_API_KEY": "",
"OPENAI_API_KEY": "", "OPENAI_API_KEY": "",
"NODE_RED_REASONING_URL": "",
"S2S_REPORTS_QUICK_HINT_MAX": 5, "S2S_REPORTS_QUICK_HINT_MAX": 5,
"S2S_REPORTS_INJECT_MODE": "minimal", "S2S_REPORTS_INJECT_MODE": "minimal",
} }
@@ -268,13 +267,12 @@ def cases():
for name, payload in (("missing", {}), ("proxy", {"transcript": "hello"}), ("proxy-blank", {"transcript": " "})): for name, payload in (("missing", {}), ("proxy", {"transcript": "hello"}), ("proxy-blank", {"transcript": " "})):
yield f"reasoning-{name}", "POST", "/api/reasoning", {"json": payload}, {}, [] yield f"reasoning-{name}", "POST", "/api/reasoning", {"json": payload}, {}, []
for mode in ("OPENAI", "NODE_RED"):
yield ( yield (
f"reasoning-{mode}-legacy-unbound-token", "reasoning-OPENAI-legacy-unbound-token",
"POST", "POST",
"/api/reasoning", "/api/reasoning",
{"json": {"transcript": "hello"}}, {"json": {"transcript": "hello"}},
{"S2S_REASONING_TYPE": mode}, {"S2S_REASONING_TYPE": "OPENAI"},
[], [],
) )
yield ( yield (
@@ -292,8 +290,7 @@ def cases():
reasoning_config = { reasoning_config = {
**profile_config, **profile_config,
"S2S_INJECT_PROFILE": True, "S2S_INJECT_PROFILE": True,
"S2S_REASONING_TYPE": "NODE_RED", "S2S_REASONING_TYPE": "OPENAI",
"NODE_RED_REASONING_URL": "http://reasoning.test",
} }
reasoning_request = { reasoning_request = {
"json": { "json": {
@@ -304,28 +301,6 @@ def cases():
"future": True, "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 ( yield (
"reasoning-openai-answer", "reasoning-openai-answer",
"POST", "POST",
@@ -333,7 +308,6 @@ def cases():
reasoning_request, reasoning_request,
{ {
**reasoning_config, **reasoning_config,
"S2S_REASONING_TYPE": "OPENAI",
"OPENAI_API_KEY": "fixture-key", "OPENAI_API_KEY": "fixture-key",
}, },
[ [
@@ -454,7 +428,6 @@ def capture_http():
"x-demo-session", "x-demo-session",
"x-api-key", "x-api-key",
"content-type", "content-type",
"x-s2s-caller",
) )
if key in request.headers if key in request.headers
}, },
@@ -534,7 +507,6 @@ async def capture_realtime():
yield json.dumps({"type": "error", "error": {"message": "Unknown or invalid event: session.update"}}) yield json.dumps({"type": "error", "error": {"message": "Unknown or invalid event: session.update"}})
yield json.dumps({"type": "future.after.suppressed.error"}) 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 return frames
@@ -548,6 +520,48 @@ def capture():
class LegacyCompatibilityTests(unittest.TestCase): 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): def test_wire_contracts_match_pre_migration_capture(self):
expected = json.loads(FIXTURE.read_text()) expected = json.loads(FIXTURE.read_text())
actual = capture() actual = capture()
+20 -1
View File
@@ -24,7 +24,7 @@ from s2s_client.models.responses import (
(AllocationResponse, {"state": [1, None], "connectUrl": "wss://host", "future": {"x": 1.0}}), (AllocationResponse, {"state": [1, None], "connectUrl": "wss://host", "future": {"x": 1.0}}),
(ToolExecutionResponse, {"success": True, "unknown": {"nested": [None, 1.0, False]}}), (ToolExecutionResponse, {"success": True, "unknown": {"nested": [None, 1.0, False]}}),
(ToolExecutionResponse, {"success": False, "data": 42, "error": "loose error", "future": []}), (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}]}), (SearchResponse, {"query": "q", "answer": {"x": 2}, "results": [{"title": [], "snippet": 7, "url": None}]}),
( (
ReportsResponse, ReportsResponse,
@@ -69,3 +69,22 @@ def test_http_routes_publish_response_schemas():
allocation = spec["components"]["schemas"]["AllocationResponse"] allocation = spec["components"]["schemas"]["AllocationResponse"]
assert not allocation.get("required") assert not allocation.get("required")
assert allocation["additionalProperties"] is True 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
+2 -3
View File
@@ -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, Tests cover session initialization, response sequencing, cumulative transcripts,
interruptions, queue polling, admission deadlines, cancellation, microphone-permission cancellation, tool 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. timeout, settings, RAG, profiles, and sanitized Markdown/export behavior.
Launcher tests start temporary local children to check readiness, environment Launcher tests start temporary local children to check readiness, environment
forwarding, failures, and shutdown of complete process groups. 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 Native speech stays at the current project's 16 kHz PCM rate. The backend
continues to own VAD, transcription, model inference, synthesis, upstream continues to own VAD, transcription, model inference, synthesis, upstream
credentials, and ApiPilot execution. Browser `SpeechSynthesis` is used only credentials, and ApiPilot execution. Browser `SpeechSynthesis` is used only
when the configured external reasoning service returns an answer instead of when the configured external reasoning service returns an answer.
`log_only`.
Session startup waits for audio initialization and `session.created`, then sends Session startup waits for audio initialization and `session.created`, then sends
`session.update` before microphone audio or the initial greeting. The speech `session.update` before microphone audio or the initial greeting. The speech
+1 -1
View File
@@ -79,7 +79,7 @@ export async function getTools(signal: AbortSignal, refresh = false) {
return result.data.tools; 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({ export const searchSchema = z.object({
answer: z.string().nullish(), answer: z.string().nullish(),
results: z.array(z.object({ title: z.string(), snippet: z.string().default(""), url: z.string() })).default([]), results: z.array(z.object({ title: z.string(), snippet: z.string().default(""), url: z.string() })).default([]),
+1 -1
View File
@@ -9,7 +9,7 @@ export const configSchema = z.object({
allowDirect: z.boolean().default(true), allowDirect: z.boolean().default(true),
s2sUrl: z.string().default(""), s2sUrl: z.string().default(""),
primaryPrompt: 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), injectProfile: z.boolean().default(false),
debugPrompt: z.boolean().default(false), debugPrompt: z.boolean().default(false),
startSessionMode: z.string().default(""), startSessionMode: z.string().default(""),
+15 -4
View File
@@ -63,7 +63,7 @@ beforeEach(() => {
} }
const pathname = new URL(path, "http://localhost").pathname; const pathname = new URL(path, "http://localhost").pathname;
const responses: Record<string, unknown> = { const responses: Record<string, unknown> = {
"/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/phone": { result: { name: "Ana" } },
"/api/user/reports": { count: 0 }, "/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(); const client = await start();
client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false }); 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); await vi.advanceTimersByTimeAsync(1);
const request = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning"); const request = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning");
expect(request?.[1]?.body).toContain('"phoneNumber":"1234"'); expect(request?.[1]?.body).toContain('"phoneNumber":"1234"');
expect(request?.[1]?.body).toContain("Caller profile resolved"); expect(request?.[1]?.body).toContain("Caller profile resolved");
expect(client.options.instructions).not.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(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 () => { it("discards external reasoning that finishes after the session was stopped", async () => {
const client = await start(); const client = await start();
+1 -1
View File
@@ -379,7 +379,7 @@ export class VoiceController {
}, },
this.requests.signal, this.requests.signal,
); );
if (this.client !== client || result.log_only) { if (this.client !== client) {
return; return;
} }
const answer = result.answer.trim(); const answer = result.answer.trim();
-39
View File
@@ -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
-29
View File
@@ -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"]
-254
View File
@@ -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: <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": []
}
]
-24
View File
@@ -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"
}
}
-114
View File
@@ -1,114 +0,0 @@
/**
* Node-RED 5.0 settings for s2s-reasoning-engine — local installation.
* File: <project_root>/s2s-reasoning-engine/.node-red/settings.js
* or <project_root>/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 <s2s-reasoning-engine>/.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" }
},
};
-84
View File
@@ -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"