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:
@@ -11,7 +11,6 @@
|
||||
**/.venv/
|
||||
**/venv/
|
||||
**/node_modules/
|
||||
**/.node-red/
|
||||
**/.cache/
|
||||
**/*.log
|
||||
**/uploads/
|
||||
|
||||
@@ -122,14 +122,6 @@ CVS/
|
||||
*.trae
|
||||
.trae/
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Reasoning engine (Node-RED) local state
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
s2s-reasoning-engine/node_modules/
|
||||
s2s-reasoning-engine/.node-red/
|
||||
s2s-reasoning-engine/lib/flows_cred.json
|
||||
s2s-reasoning-engine/.config.*
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Client backend and speech-service runtime artifacts
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -140,5 +132,4 @@ s2s-client/*.log
|
||||
speech_to_speech/*.log
|
||||
speech_to_speech/sessions/
|
||||
speech_to_speech/.cache/
|
||||
s2s-reasoning-engine/*.log
|
||||
debug_files
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Scope and working conventions
|
||||
|
||||
This repository is Vocero: a browser voice client, its FastAPI integration
|
||||
server, a speech pipeline, and optional Node-RED flows. Use the code, commands,
|
||||
server, and a speech pipeline. Use the code, commands,
|
||||
configuration, and documentation within this checkout. Do not assume other
|
||||
repositories or developer-specific directories are available.
|
||||
|
||||
@@ -48,7 +48,6 @@ root. HTTP routes mentioned below are URLs, not filesystem paths.
|
||||
| `s2s-client/s2s_client/realtime/` | WebSocket relay, event handling, marker filtering, and audio forwarding |
|
||||
| `s2s-frontend/` | React, TypeScript, Tailwind, and Vite browser application |
|
||||
| `speech_to_speech/` | Separately run VAD → STT → LLM → TTS pipeline and realtime API |
|
||||
| `s2s-reasoning-engine/` | Optional Node-RED reasoning/logging service |
|
||||
| `start_ui.sh` | Dependency sync and supervision of FastAPI plus Vite |
|
||||
| `docs/` | Maintained technical documentation, examples, and historical proposals |
|
||||
|
||||
@@ -201,12 +200,11 @@ sync; a separate sync is optional to prepare dependencies before restarting.
|
||||
Ensure the required uv and Node/npm are on that process's PATH. Verify the live
|
||||
PM2 script path and environment before making claims about a deployment.
|
||||
|
||||
The launcher does not start or restart `speech_to_speech/` or Node-RED. A configured
|
||||
The launcher does not start or restart `speech_to_speech/`. A configured
|
||||
speech URL does not prove which source revision or implementation runs there.
|
||||
`SPEECH_TO_SPEECH_URL` pins a direct browser connection; `S2S_PROXY_TARGET` enables
|
||||
the local relay. Either disables load-balancer mode. The Node-RED flows in
|
||||
`s2s-reasoning-engine/flows.json` are log-only; preserve the meaning of `log_only`
|
||||
responses.
|
||||
the local relay. Either disables load-balancer mode. Reasoning supports `PROXY`
|
||||
and `OPENAI`; unknown `S2S_REASONING_TYPE` values fall back to `PROXY`.
|
||||
|
||||
The backend Docker image serves APIs, WebSockets, and root `docs/`, not the React
|
||||
UI. Build with the repository root as context and `s2s-client/Dockerfile`; the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Vocero speech-to-speech
|
||||
|
||||
Vocero provides a browser voice interface, a Python speech pipeline, and an
|
||||
optional Node-RED reasoning service. The interface defaults to Spanish;
|
||||
Vocero provides a browser voice interface, a FastAPI integration server, and a
|
||||
Python speech pipeline. The interface defaults to Spanish;
|
||||
documentation, source identifiers, comments, and internal instructions use English.
|
||||
|
||||
| Component | Purpose |
|
||||
@@ -9,7 +9,6 @@ documentation, source identifiers, comments, and internal instructions use Engli
|
||||
| [s2s-frontend](s2s-frontend) | React, TypeScript, and Tailwind browser UI, served by Vite with hot reload |
|
||||
| [s2s-client](s2s-client) | FastAPI backend, realtime WebSocket proxy, search and ApiPilot bridges |
|
||||
| [speech_to_speech](speech_to_speech) | Queue-driven VAD, speech recognition, language model, and speech synthesis pipeline |
|
||||
| [s2s-reasoning-engine](s2s-reasoning-engine) | Optional Node-RED HTTP reasoning and conversation logging flows |
|
||||
|
||||
Start with [client setup and usage](docs/client/usage.md), then read the
|
||||
[architecture](docs/client/architecture.md) and [documentation index](docs/README.md).
|
||||
|
||||
@@ -5,7 +5,7 @@ The [React browser client](../../s2s-frontend) uses the
|
||||
`start_ui.sh` serves Vite on the existing public port `8420` and FastAPI on
|
||||
loopback port `8421`. Vite forwards backend routes and provides hot reload;
|
||||
Uvicorn reloads Python edits. The launcher supervises both processes.
|
||||
The speech pipeline and optional Node-RED reasoning service run separately.
|
||||
The speech pipeline runs separately.
|
||||
Runtime configuration comes from environment variables and the root `.env`.
|
||||
|
||||
```mermaid
|
||||
@@ -14,7 +14,7 @@ flowchart LR
|
||||
Vite[Vite on public port 8420]
|
||||
Server[Client FastAPI server]
|
||||
Speech[Realtime speech backend]
|
||||
Reasoning[Node-RED or HTTP reasoning provider]
|
||||
Reasoning[OpenAI-compatible HTTP reasoning provider]
|
||||
Tools[ApiPilot backend]
|
||||
Browser <-->|Frontend and HMR| Vite
|
||||
Vite -->|HTTP configuration and proxies| Server
|
||||
@@ -58,7 +58,6 @@ audio. It is not an unconditional byte-for-byte tunnel.
|
||||
| [components](../../s2s-frontend/src/components) | Chat, caller profile, settings and voice components |
|
||||
| [audio](../../s2s-frontend/src/audio) | TypeScript capture/playback worklets, PCM codec, and visualizer |
|
||||
| [legacy-theme.css](../../s2s-frontend/src/styles/legacy-theme.css) | Retained themes and responsive visual styles alongside Tailwind |
|
||||
| [flows.json](../../s2s-reasoning-engine/flows.json) | Node-RED reasoning and assistant-response logging |
|
||||
|
||||
## Server routes
|
||||
|
||||
@@ -66,7 +65,7 @@ audio. It is not an unconditional byte-for-byte tunnel.
|
||||
|---|---|
|
||||
| `GET /api/config` | Connection settings, primary prompt, reasoning/theme/start modes, and feature flags |
|
||||
| `WS /v1/realtime` | Local realtime proxy to `S2S_PROXY_TARGET` |
|
||||
| `POST /api/reasoning` | Node-RED or OpenAI-compatible HTTP reasoning; disabled in `PROXY` mode |
|
||||
| `POST /api/reasoning` | OpenAI-compatible HTTP reasoning; disabled in `PROXY` mode |
|
||||
| `GET /api/user/phone` | Configured caller profile lookup |
|
||||
| `GET /api/user/reports` | Configured scheduled report lookup, subject to report-injection controls |
|
||||
| `GET /api/apipilot/tools` | Tool discovery with optional authorization and cache refresh |
|
||||
@@ -104,10 +103,8 @@ and cancellation model is documented in [realtime API](../speech/realtime-api.md
|
||||
## Reasoning and context
|
||||
|
||||
The browser triggers external reasoning on completed user transcripts only in
|
||||
`OPENAI` or `NODE_RED` mode. Node-RED `log_only=true` responses leave native speech
|
||||
output running; an answer-producing response uses the external-answer path.
|
||||
The server can independently forward assistant text to Node-RED for paired
|
||||
conversation logs. These HTTP operations supplement the realtime audio path.
|
||||
`OPENAI` mode. A non-empty answer replaces the native response through the
|
||||
external-answer path. These HTTP operations supplement the realtime audio path.
|
||||
|
||||
Read [reasoning](reasoning.md), [context injection](context-injection.md), and
|
||||
[tool calling](tool-calling.md) for the conditions and limitations of each path.
|
||||
|
||||
@@ -62,11 +62,13 @@ existing error shapes; there is no new common error envelope.
|
||||
## Compatibility evidence
|
||||
|
||||
`tests/fixtures/legacy-contracts.json` was captured from the original monolithic
|
||||
server at commit `3d42df5`, before refactoring. Tests compare 86 HTTP scenarios,
|
||||
server at commit `3d42df5`, before refactoring. Tests compare 82 HTTP scenarios,
|
||||
including exact response text, statuses, selected headers, and outgoing mock
|
||||
HTTP requests. They also compare request OpenAPI definitions and representative
|
||||
realtime frames. Additional tests cover response validation and permissive
|
||||
upstream values; the original local HTTP/WebSocket tests remain in the suite.
|
||||
Four captures belonging exclusively to the retired workflow reasoning service
|
||||
were removed with that integration; all retained captures are unchanged.
|
||||
|
||||
The captures deliberately preserve unknown fields, omitted fields, unusual JSON
|
||||
values, queue beacon edge cases, permissive tool arguments, and known failures.
|
||||
|
||||
@@ -37,4 +37,3 @@ names shown in a deployment are configuration examples, not definitions.
|
||||
| Claim | The transition from a waiting ticket to an allocated realtime session |
|
||||
| At capacity | A full waiting queue that cannot accept another ticket |
|
||||
| Barge-in | User speech interrupts an active assistant response, coordinating playback clearing and backend cancellation |
|
||||
| Log-only reasoning | External transcript processing that observes/logs the turn without replacing native speech output |
|
||||
|
||||
+11
-34
@@ -1,17 +1,15 @@
|
||||
# Reasoning modes
|
||||
|
||||
Reasoning determines the assistant's response text. The realtime backend always
|
||||
provides the speech channel; optional HTTP reasoning can observe a transcript or
|
||||
produce a separate answer. Set `S2S_REASONING_TYPE` to select the mode.
|
||||
provides the speech channel; optional HTTP reasoning produces a separate answer
|
||||
from a transcript. Set `S2S_REASONING_TYPE` to select the mode.
|
||||
|
||||
| Mode | Response owner | HTTP hook |
|
||||
|---|---|---|
|
||||
| `PROXY` (default) | Realtime backend | Browser does not call `/api/reasoning`; that endpoint returns 409 |
|
||||
| `NODE_RED` | Realtime backend for `log_only=true`; external answer otherwise | Server forwards to `S2S_REASONING_NODE_RED_URL/api/reasoning` |
|
||||
| `OPENAI` | OpenAI-compatible Chat Completions response | Server calls `OPENAI_BASE_URL/chat/completions` |
|
||||
|
||||
The mode is normalized case-insensitively. `NODERED` and `NODE-RED` resolve to
|
||||
`NODE_RED`; unknown values resolve to `PROXY`.
|
||||
The mode is normalized case-insensitively. Unknown values resolve to `PROXY`.
|
||||
|
||||
## Shared instruction assembly
|
||||
|
||||
@@ -40,30 +38,6 @@ Using a direct browser URL bypasses local WebSocket proxy processing. Browser
|
||||
instructions still apply, but server-side session context and marker execution
|
||||
require the local `/v1/realtime` route.
|
||||
|
||||
## Node-RED reasoning
|
||||
|
||||
Configure a Node-RED base URL reachable by the UI server:
|
||||
|
||||
```env
|
||||
S2S_REASONING_TYPE=NODE_RED
|
||||
S2S_REASONING_NODE_RED_URL=http://localhost:1881
|
||||
```
|
||||
|
||||
`VoiceController.reason()` in [controller.ts](../../s2s-frontend/src/realtime/controller.ts)
|
||||
handles both external modes. It runs after a complete user transcript and posts a body with
|
||||
`transcript`, `instructions`, `language`, `user_context`, `phoneNumber`, and
|
||||
`episode_id`. The server builds context and forwards the reasoning fields to
|
||||
Node-RED with a 30-second timeout.
|
||||
|
||||
The checked-in Node-RED flow exposes `POST /api/reasoning` and
|
||||
`POST /api/log/system-response`. Its reasoning response uses `log_only=true` and
|
||||
an empty answer. The browser returns early in that case, preserving the native
|
||||
backend's response. Assistant-text logging is forwarded separately by the relay
|
||||
and is not an audio transport.
|
||||
|
||||
An answer-producing flow must return a non-empty `answer` and omit `log_only` or
|
||||
set it to `false`. That activates the external-answer behavior below.
|
||||
|
||||
## OpenAI-compatible HTTP reasoning
|
||||
|
||||
```env
|
||||
@@ -76,13 +50,17 @@ OPENAI_REASONING_MODEL=gpt-4.1-mini
|
||||
`OPENAI_ORGANIZATION` and `OPENAI_PROJECT` are optional headers. The model value
|
||||
is a runtime default, not a recommendation about provider availability.
|
||||
|
||||
`VoiceController.reason()` in [controller.ts](../../s2s-frontend/src/realtime/controller.ts)
|
||||
runs after a complete user transcript and posts a body with `transcript`,
|
||||
`instructions`, `language`, `user_context`, `phoneNumber`, and `episode_id`.
|
||||
The server combines instructions and context into model messages, requests a
|
||||
final answer, and returns `answer` with available usage metadata. ApiPilot can
|
||||
final answer with a 30-second timeout, and returns `answer`. ApiPilot can
|
||||
append discovery instructions, execute a detected call marker, and request a
|
||||
second response based on the tool result. The endpoint does not forward the
|
||||
browser's native `session.tools` catalog as OpenAI Chat Completions `tools`.
|
||||
second response based on the tool result, including available `usage_pass2`
|
||||
metadata. The endpoint does not forward the browser's native `session.tools`
|
||||
catalog as OpenAI Chat Completions `tools`.
|
||||
|
||||
For a non-log-only answer, the browser cancels/suppresses the active native
|
||||
For an external answer, the browser cancels/suppresses the active native
|
||||
response and appends the external assistant text. It uses browser
|
||||
`SpeechSynthesis` when available. The React client adds the answer to the realtime
|
||||
conversation as an assistant item; without browser speech synthesis it remains
|
||||
@@ -101,5 +79,4 @@ realtime backend instead.
|
||||
| Change browser context assembly | `buildInstructions()` in `s2s-frontend/src/lib/context.ts` |
|
||||
| Change HTTP provider messages | `reasoning()` in `s2s-client/s2s_client/services/reasoning.py` |
|
||||
| Change external-answer rendering | `VoiceController.reason()` and `RealtimeClient.renderExternalAnswer()` |
|
||||
| Change Node-RED processing | `s2s-reasoning-engine/flows.json` |
|
||||
| Change native model/tool generation | `speech_to_speech/LLM` and realtime handlers |
|
||||
|
||||
@@ -109,9 +109,7 @@ before changing audio timing based on those observations.
|
||||
|
||||
## External reasoning and startup
|
||||
|
||||
`/api/reasoning` returning 409 is expected in `PROXY` mode. In `NODE_RED`, check
|
||||
that `S2S_REASONING_NODE_RED_URL` is reachable from the server and whether the
|
||||
response is `log_only=true`. An empty answer is valid only in log-only mode.
|
||||
`/api/reasoning` returning 409 is expected in `PROXY` mode.
|
||||
In `OPENAI`, check the configured endpoint/model/key and provider logs; HTTP
|
||||
reasoning does not use the native session's tool array.
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ the implementation at the recorded date; current operating instructions live in
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the optional workflow reasoning service, its configuration, transcript
|
||||
forwarding, and browser integration. Reasoning supports `PROXY` and `OPENAI`;
|
||||
unsupported mode values fall back to `PROXY`.
|
||||
- Filter the specific Starlette TestClient / AnyIO `BlockingPortal` deprecation
|
||||
in pytest while retaining the runtime versions used for compatibility captures.
|
||||
- Added a uv-managed Python 3.12 environment and Ruff, mypy, and pytest quality
|
||||
@@ -33,8 +36,6 @@ the implementation at the recorded date; current operating instructions live in
|
||||
- Translated developer comments, diagnostics, internal model instructions, and
|
||||
local identifiers into English. Sanitization accepts both the former Spanish
|
||||
and new English internal tool-result wrappers.
|
||||
- Renamed the reasoning engine's log-only acknowledgment field to `received`
|
||||
and its debug phase to `log_only_phase1`.
|
||||
- Standardized browser labels, accessibility text, account/profile dialogs,
|
||||
session feedback, and built-in API errors in Spanish. The interface declares
|
||||
`lang="es"`; internal instructions and diagnostics remain English.
|
||||
|
||||
@@ -15,8 +15,6 @@ def _normalize_reasoning_type(value: str) -> str:
|
||||
normalized = (value or "").strip().strip('"').strip("'").replace("-", "_").upper()
|
||||
if normalized == "OPENAI":
|
||||
return "OPENAI"
|
||||
if normalized in {"NODE_RED", "NODERED"}:
|
||||
return "NODE_RED"
|
||||
return "PROXY"
|
||||
|
||||
|
||||
@@ -124,8 +122,7 @@ if S2S_REPORTS_INJECT_MODE not in {"minimal", "full", "off", "none"}:
|
||||
# Feature flag: when "true" the proxy logs the FULL PROMPT TEXT (not just
|
||||
# lengths) going to the upstream LLM/realtime backends. Includes:
|
||||
# - WS session.update session.instructions (before/after patch)
|
||||
# - /api/reasoning NODE_RED instructions + user_context full text
|
||||
# - /api/reasonING OPENAI full system message + user transcript
|
||||
# - /api/reasoning OPENAI full system message + user transcript
|
||||
# Default = off; only length summaries are logged (current behaviour).
|
||||
# WARNING: setting this on will log potentially PII / sensitive caller
|
||||
# profile data / user transcripts. Use only in development / debugging.
|
||||
@@ -186,9 +183,6 @@ APIPILOT_HTTP_TIMEOUT = max(1.0, min(120.0, _APIPILOT_TO_RAW))
|
||||
del _APIPILOT_TO_RAW
|
||||
|
||||
|
||||
# Node-RED reasoning engine endpoint (NODE_RED mode).
|
||||
# Examples: http://localhost:1881 or http://s2s-reasoning:1880 (Docker network).
|
||||
NODE_RED_REASONING_URL = os.environ.get("S2S_REASONING_NODE_RED_URL", "").strip().rstrip("/")
|
||||
# Speech-to-speech load balancer URL. When set, the browser POSTs /api/session
|
||||
# (which proxies <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.
|
||||
|
||||
@@ -19,9 +19,8 @@ _STARTUP_APIPILOT_EXEC_URL = _resolve_exec_url_for_startup_banner()
|
||||
del _resolve_exec_url_for_startup_banner
|
||||
|
||||
app_logging.logger.info(
|
||||
"[STARTUP] Other externals — reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s",
|
||||
"[STARTUP] Other externals — reasoning=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s",
|
||||
settings.S2S_REASONING_TYPE,
|
||||
settings.NODE_RED_REASONING_URL or "-",
|
||||
bool(settings.SERPER_KEY),
|
||||
bool(settings.LOAD_BALANCER_URL),
|
||||
bool(settings.S2S_PROXY_TARGET),
|
||||
|
||||
@@ -20,7 +20,7 @@ class ReasoningRequest(BaseModel):
|
||||
# Optional phone number. When S2S_INJECT_PROFILE is true the proxy fetches
|
||||
# the caller profile from VOCERO_USER_PHONE_AUTH_URL and:
|
||||
# 1. prepends the serialized profile to user_context before feeding the
|
||||
# engine (NODE_RED or OPENAI);
|
||||
# OpenAI-compatible provider;
|
||||
# 2. appends the profile context to the LLM instructions prompt.
|
||||
phoneNumber: str | None = None
|
||||
# Optional podcast episode ID. When set the proxy fetches the episode from
|
||||
|
||||
@@ -21,7 +21,7 @@ class ConfigResponse(OpenResponse):
|
||||
allowDirect: bool
|
||||
s2sUrl: str
|
||||
primaryPrompt: str
|
||||
reasoningType: Literal["PROXY", "OPENAI", "NODE_RED"]
|
||||
reasoningType: Literal["PROXY", "OPENAI"]
|
||||
injectProfile: bool
|
||||
debugPrompt: bool
|
||||
startSessionMode: str
|
||||
@@ -69,13 +69,7 @@ class ToolInvocation(OpenResponse):
|
||||
|
||||
class ReasoningResponse(OpenResponse):
|
||||
answer: str
|
||||
log_only: bool | None = None
|
||||
usage: Any = None
|
||||
usage_pass2: Any = None
|
||||
latency_ms: Any = None
|
||||
intent: Any = None
|
||||
caller_profile_keys: Any = None
|
||||
debug: Any = None
|
||||
apipilot_invoked: ToolInvocation | None = None
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ async def _fetch_reports_list(phone_number: str | None = None, auth_token: str |
|
||||
endpoint is never hit, not even for a "cheap empty list"; combined with
|
||||
_resolve_reports_context → "" above, this guarantees ZERO scheduled-report
|
||||
contents reach any LLM prompt anywhere (WS session instructions patch,
|
||||
/api/reasoning user_context, NODE_RED instructions augment, quick reports
|
||||
/api/reasoning user_context, reasoning instructions augment, quick reports
|
||||
lookup response).
|
||||
• S2S_INJECT_REPORTS=true restores prior behaviour (both the HTTP fetch AND
|
||||
the full injection into every prompt path).
|
||||
|
||||
@@ -17,7 +17,6 @@ from s2s_client.providers import apipilot as providers_apipilot
|
||||
from s2s_client.providers import caller as providers_caller
|
||||
from s2s_client.realtime import events as realtime_events
|
||||
from s2s_client.services import context as services_context
|
||||
from s2s_client.services import reasoning as services_reasoning
|
||||
from s2s_client.services import tool_context as services_tool_context
|
||||
|
||||
|
||||
@@ -408,8 +407,6 @@ async def realtime_proxy(ws: WebSocket) -> None:
|
||||
) as upstream:
|
||||
app_logging.logger.info("[INJECT-PROFILE] WS tunnel ESTABLISHED client=%s", client_ip)
|
||||
session_ctx = {
|
||||
"last_user_transcript": "",
|
||||
"last_item_id": None,
|
||||
"injected_episode_context": injected_episode_context,
|
||||
"injected_profile_context": injected_profile_context,
|
||||
"injected_reports_context": injected_reports_context,
|
||||
@@ -1166,32 +1163,22 @@ async def _upstream_to_client(
|
||||
user_text = ""
|
||||
if event_type == "conversation.item.input_audio_transcription.completed":
|
||||
user_text = (summary.get("transcript") or "").strip()
|
||||
item_id = summary.get("item_id")
|
||||
if user_text:
|
||||
app_logging.logger.info(
|
||||
"[USER INPUT] client=%s stt=%s",
|
||||
client_ip,
|
||||
user_text,
|
||||
)
|
||||
session_ctx["last_user_transcript"] = user_text
|
||||
if item_id:
|
||||
session_ctx["last_item_id"] = item_id
|
||||
elif event_type == "conversation.item.created" and summary.get("role") == "user":
|
||||
user_text = (summary.get("text") or "").strip()
|
||||
item_id = summary.get("item_id") or summary.get("id")
|
||||
if user_text:
|
||||
app_logging.logger.info(
|
||||
"[USER INPUT] client=%s text=%s",
|
||||
client_ip,
|
||||
user_text,
|
||||
)
|
||||
session_ctx["last_user_transcript"] = user_text
|
||||
if item_id:
|
||||
session_ctx["last_item_id"] = item_id
|
||||
# Agent output, including all event variants.
|
||||
agent_text = ""
|
||||
response_id = summary.get("response_id") or summary.get("id")
|
||||
item_id = summary.get("item_id")
|
||||
if event_type in {"response.audio_transcript.done", "response.output_audio_transcript.done"}:
|
||||
# Pass-2 accumulation already retained the original text.
|
||||
# This sanitized agent_text is only for UI and logs.
|
||||
@@ -1202,19 +1189,6 @@ async def _upstream_to_client(
|
||||
client_ip,
|
||||
agent_text,
|
||||
)
|
||||
asyncio.create_task(
|
||||
services_reasoning._forward_agent_text_to_nodered(
|
||||
{
|
||||
"tts": agent_text,
|
||||
"response_id": response_id,
|
||||
"item_id": item_id,
|
||||
"client_ip": client_ip,
|
||||
"role": "assistant",
|
||||
"source_event": event_type,
|
||||
"user_transcript": session_ctx.get("last_user_transcript") or "",
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
raw_preview = summary.get("_raw_preview") or json.dumps(summary, ensure_ascii=True)
|
||||
app_logging.logger.debug(
|
||||
@@ -1232,19 +1206,6 @@ async def _upstream_to_client(
|
||||
client_ip,
|
||||
agent_text,
|
||||
)
|
||||
asyncio.create_task(
|
||||
services_reasoning._forward_agent_text_to_nodered(
|
||||
{
|
||||
"tts": agent_text,
|
||||
"response_id": response_id,
|
||||
"item_id": item_id,
|
||||
"client_ip": client_ip,
|
||||
"role": "assistant",
|
||||
"source_event": event_type,
|
||||
"user_transcript": session_ctx.get("last_user_transcript") or "",
|
||||
}
|
||||
)
|
||||
)
|
||||
elif event_type == "response.output_item.done" and summary.get("role") == "assistant":
|
||||
agent_text = (summary.get("text") or "").strip()
|
||||
if agent_text:
|
||||
@@ -1253,19 +1214,6 @@ async def _upstream_to_client(
|
||||
client_ip,
|
||||
agent_text,
|
||||
)
|
||||
asyncio.create_task(
|
||||
services_reasoning._forward_agent_text_to_nodered(
|
||||
{
|
||||
"tts": agent_text,
|
||||
"response_id": response_id,
|
||||
"item_id": item_id,
|
||||
"client_ip": client_ip,
|
||||
"role": "assistant",
|
||||
"source_event": event_type,
|
||||
"user_transcript": session_ctx.get("last_user_transcript") or "",
|
||||
}
|
||||
)
|
||||
)
|
||||
elif event_type == "conversation.item.created" and summary.get("role") == "assistant":
|
||||
agent_text = (summary.get("text") or "").strip()
|
||||
if agent_text:
|
||||
@@ -1274,19 +1222,6 @@ async def _upstream_to_client(
|
||||
client_ip,
|
||||
agent_text,
|
||||
)
|
||||
asyncio.create_task(
|
||||
services_reasoning._forward_agent_text_to_nodered(
|
||||
{
|
||||
"tts": agent_text,
|
||||
"response_id": response_id,
|
||||
"item_id": item_id,
|
||||
"client_ip": client_ip,
|
||||
"role": "assistant",
|
||||
"source_event": event_type,
|
||||
"user_transcript": session_ctx.get("last_user_transcript") or "",
|
||||
}
|
||||
)
|
||||
)
|
||||
if event_type not in realtime_events._NOISY_REALTIME_EVENT_TYPES:
|
||||
app_logging.logger.debug(
|
||||
"[EXT][S2S-PROXY] client=%s UPSTREAM→ event=%s summary=%s",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
@@ -373,280 +371,7 @@ async def reasoning(req: models_requests.ReasoningRequest) -> JSONResponse:
|
||||
services_context._strip_internal_markers(inject_apipilot_text_pass1),
|
||||
)
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Mode A: NODE_RED forwards the complete payload; the flow decides behavior.
|
||||
# Stage 1 (log-only) returns {answer: "", log_only: true}, allowing the browser
|
||||
# to leave speech generation to the S2S backend. Later stages can return
|
||||
# {answer: "...", log_only: false}. Expected response fields include answer,
|
||||
# log_only, usage, debug and latency_ms.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
if settings.S2S_REASONING_TYPE == "NODE_RED":
|
||||
if not settings.NODE_RED_REASONING_URL:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="S2S_REASONING_NODE_RED_URL no está configurado. Configure esta variable de entorno con la dirección de Node-RED (por ejemplo, http://localhost:1881).",
|
||||
)
|
||||
app_logging.logger.info(
|
||||
"[INJECT-PROFILE] REASONING->NODE_RED body fields. instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d",
|
||||
len(effective_instructions),
|
||||
len(effective_user_context),
|
||||
len(phone),
|
||||
len(episode_id),
|
||||
)
|
||||
upstream_body = {
|
||||
"transcript": transcript,
|
||||
"instructions": effective_instructions,
|
||||
"language": (req.language or "").strip(),
|
||||
"user_context": effective_user_context,
|
||||
}
|
||||
if settings.S2S_DEBUG_PROMPT:
|
||||
app_logging.logger.info(
|
||||
"[DEBUG-PROMPT][REASONING → NODE_RED] FULL upstream_body JSON:\n%s\n",
|
||||
json.dumps(upstream_body, ensure_ascii=False, indent=2),
|
||||
)
|
||||
app_logging.logger.debug(
|
||||
"[EXT][NODE_RED] reasoning POST %s/api/reasoning lang=%s transcriptLen=%d",
|
||||
settings.NODE_RED_REASONING_URL,
|
||||
(req.language or "")[:20] or "auto",
|
||||
len(transcript),
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as http:
|
||||
resp = await http.post(
|
||||
f"{settings.NODE_RED_REASONING_URL}/api/reasoning",
|
||||
json=upstream_body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-S2S-Caller": "demo-server",
|
||||
},
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
app_logging.logger.warning("[EXT][NODE_RED] unreachable: %r", exc)
|
||||
raise HTTPException( # noqa: B904
|
||||
status_code=502, detail="No se puede conectar con el motor de razonamiento de Node-RED."
|
||||
)
|
||||
app_logging.logger.debug(
|
||||
"[EXT][NODE_RED] reasoning RESP status=%s len=%dB",
|
||||
resp.status_code,
|
||||
len(resp.content or b""),
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
app_logging.logger.warning("[EXT][NODE_RED] failed %s: %s", resp.status_code, resp.text[:500])
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"El razonamiento de Node-RED falló (HTTP {resp.status_code}).",
|
||||
)
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception:
|
||||
app_logging.logger.warning("[EXT][NODE_RED] invalid JSON: %s", resp.text[:500])
|
||||
raise HTTPException(status_code=502, detail="Node-RED devolvió un JSON no válido.") # noqa: B904
|
||||
answer = payload.get("answer") if isinstance(payload, dict) else None
|
||||
log_only = bool(isinstance(payload, dict) and payload.get("log_only") is True)
|
||||
if not isinstance(answer, str):
|
||||
app_logging.logger.warning(
|
||||
"[EXT][NODE_RED] invalid answer type: %s", json.dumps(payload, ensure_ascii=True)[:800]
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="Node-RED devolvió un campo answer no válido.")
|
||||
answer_stripped = answer.strip()
|
||||
# An empty answer is intentional in log-only mode; forward the flag.
|
||||
if not answer_stripped and not log_only:
|
||||
app_logging.logger.warning(
|
||||
"[EXT][NODE_RED] empty answer and !log_only: %s", json.dumps(payload, ensure_ascii=True)[:800]
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="Node-RED no devolvió una respuesta ni la marca log_only.")
|
||||
usage = payload.get("usage") if isinstance(payload, dict) else None
|
||||
if isinstance(usage, dict):
|
||||
app_logging.logger.debug(
|
||||
"[EXT][NODE_RED] reasoning USAGE prompt=%d completion=%d total=%d",
|
||||
usage.get("prompt_tokens", 0),
|
||||
usage.get("completion_tokens", 0),
|
||||
usage.get("total_tokens", 0),
|
||||
)
|
||||
extra = {}
|
||||
for key in ("latency_ms", "intent", "caller_profile_keys", "debug"):
|
||||
if isinstance(payload, dict) and key in payload:
|
||||
extra[key] = payload[key]
|
||||
if log_only:
|
||||
app_logging.logger.info(
|
||||
"[Vocero REASONING] engine=NODE_RED mode=LOG_ONLY transcript=%s extra=%s",
|
||||
transcript[:300],
|
||||
json.dumps({k: v for k, v in extra.items() if k != "debug"}, ensure_ascii=True)[:400],
|
||||
)
|
||||
else:
|
||||
app_logging.logger.info(
|
||||
"[Vocero REASONING] engine=NODE_RED transcript=%s answer=%s extra=%s",
|
||||
transcript[:300],
|
||||
answer_stripped[:500],
|
||||
json.dumps({k: v for k, v in extra.items() if k != "debug"}, ensure_ascii=True)[:400],
|
||||
)
|
||||
out: dict[str, Any] = {"answer": answer_stripped}
|
||||
if log_only:
|
||||
out["log_only"] = True
|
||||
if usage:
|
||||
out["usage"] = usage
|
||||
out.update({k: v for k, v in extra.items() if k != "debug" or app_logging.logger.level <= logging.DEBUG})
|
||||
# Capture the ApiPilot marker, execute its tool and run reasoning pass 2.
|
||||
tool_name_p1, tool_args_p1 = services_tool_context._apipilot_parse_call_marker(answer_stripped)
|
||||
if tool_name_p1:
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_TRIGGER name=%r argsKeys=%s",
|
||||
tool_name_p1,
|
||||
sorted(list((tool_args_p1 or {}).keys())),
|
||||
)
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT] REASONING PASS-2 trigger NODE_RED marker name=%s args=%s",
|
||||
tool_name_p1,
|
||||
json.dumps(tool_args_p1 or {}, ensure_ascii=True)[:300],
|
||||
)
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_EXEC_HELPER name=%r → forward to _apipilot_execute_tool",
|
||||
tool_name_p1,
|
||||
)
|
||||
tool_env_p1 = await providers_apipilot._apipilot_execute_tool(
|
||||
auth_header=apipilot_auth_header,
|
||||
demo_session=None,
|
||||
name=tool_name_p1,
|
||||
arguments=tool_args_p1 or {},
|
||||
)
|
||||
is_tool_err = not bool(tool_env_p1.get("success"))
|
||||
tool_data = tool_env_p1.get("data")
|
||||
if isinstance(tool_data, str):
|
||||
tool_text_p1 = tool_data
|
||||
elif tool_data is None:
|
||||
tool_text_p1 = ""
|
||||
else:
|
||||
try:
|
||||
tool_text_p1 = json.dumps(tool_data, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
tool_text_p1 = str(tool_data)
|
||||
if is_tool_err and isinstance(tool_env_p1.get("error"), dict):
|
||||
err = tool_env_p1["error"]
|
||||
err_head = f"[{err.get('code')}] {err.get('message')}"
|
||||
tool_text_p1 = (err_head + ("\n" + tool_text_p1 if tool_text_p1.strip() else "")).strip()
|
||||
inject_apipilot_text_pass2 = services_tool_context._resolve_apipilot_reasoning_context_block(
|
||||
[],
|
||||
call_result={
|
||||
"name": tool_name_p1,
|
||||
"text": tool_text_p1,
|
||||
"error": is_tool_err,
|
||||
},
|
||||
)
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT] REASONING PASS-2 execute name=%s success=%s httpResultLen=%d trigger second Node-RED call",
|
||||
tool_name_p1,
|
||||
str(not is_tool_err).lower(),
|
||||
len(tool_text_p1),
|
||||
)
|
||||
# Pass-2 context replaces the tool list with the execution result,
|
||||
# preserving voice/date/profile context.
|
||||
user_ctx_parts_2: list[str] = []
|
||||
if voice_lock_block.strip():
|
||||
user_ctx_parts_2.append(voice_lock_block)
|
||||
if today_block.strip():
|
||||
user_ctx_parts_2.append(today_block)
|
||||
if inject_episode_text:
|
||||
user_ctx_parts_2.append(inject_episode_text)
|
||||
if inject_profile_text:
|
||||
user_ctx_parts_2.append(inject_profile_text)
|
||||
if inject_reports_text:
|
||||
user_ctx_parts_2.append(inject_reports_text)
|
||||
if inject_summary_text:
|
||||
user_ctx_parts_2.append(inject_summary_text)
|
||||
if inject_apipilot_text_pass2:
|
||||
user_ctx_parts_2.append(inject_apipilot_text_pass2)
|
||||
if original_user_context:
|
||||
user_ctx_parts_2.append(original_user_context)
|
||||
user_ctx_parts_2 = services_context._dedup_context_blocks_by_type(user_ctx_parts_2)
|
||||
stripped_2: list[str] = []
|
||||
for p in user_ctx_parts_2:
|
||||
sp = services_context._strip_internal_markers(p)
|
||||
if sp:
|
||||
stripped_2.append(sp)
|
||||
user_ctx_parts_2 = stripped_2
|
||||
effective_user_ctx_2 = "\n\n".join(user_ctx_parts_2)
|
||||
# Preserve augmentation without CALL-marker instructions; the result
|
||||
# block supplies the pass-2 response instructions.
|
||||
if settings.S2S_INJECT_PROFILE:
|
||||
eff_inst_2 = services_context._augment_instructions_with_profile(
|
||||
req.instructions or "",
|
||||
inject_profile_text,
|
||||
inject_reports_text,
|
||||
inject_summary_text,
|
||||
inject_episode_text,
|
||||
profile_obj=profile_obj,
|
||||
)
|
||||
else:
|
||||
eff_inst_2 = (req.instructions or "").strip()
|
||||
if inject_apipilot_text_pass2.strip():
|
||||
eff_inst_2 = (eff_inst_2 + "\n\n" if eff_inst_2 else "") + inject_apipilot_text_pass2
|
||||
upstream_body_2 = {
|
||||
"transcript": transcript,
|
||||
"instructions": eff_inst_2,
|
||||
"language": (req.language or "").strip(),
|
||||
"user_context": effective_user_ctx_2,
|
||||
}
|
||||
if settings.S2S_DEBUG_PROMPT:
|
||||
app_logging.logger.info(
|
||||
"[DEBUG-PROMPT][REASONING → NODE_RED PASS-2] FULL upstream_body_2 JSON:\n%s\n",
|
||||
json.dumps(upstream_body_2, ensure_ascii=False, indent=2),
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as http2:
|
||||
resp2 = await http2.post(
|
||||
f"{settings.NODE_RED_REASONING_URL}/api/reasoning",
|
||||
json=upstream_body_2,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-S2S-Caller": "demo-server-pass2",
|
||||
},
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
app_logging.logger.warning("[EXT][NODE_RED PASS-2] unreachable: %r", exc)
|
||||
raise HTTPException( # noqa: B904
|
||||
status_code=502,
|
||||
detail="No se puede conectar con el motor de razonamiento de Node-RED (segunda pasada).",
|
||||
)
|
||||
if resp2.status_code != 200:
|
||||
app_logging.logger.warning("[EXT][NODE_RED PASS-2] failed %s: %s", resp2.status_code, resp2.text[:500])
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"La segunda pasada del razonamiento de Node-RED falló (HTTP {resp2.status_code}).",
|
||||
)
|
||||
try:
|
||||
payload2 = resp2.json()
|
||||
except Exception:
|
||||
app_logging.logger.warning("[EXT][NODE_RED PASS-2] invalid JSON: %s", resp2.text[:500])
|
||||
raise HTTPException(status_code=502, detail="Node-RED devolvió un JSON no válido (segunda pasada).") # noqa: B904
|
||||
final_ans = payload2.get("answer") if isinstance(payload2, dict) else None
|
||||
if not isinstance(final_ans, str):
|
||||
# Fallback: the first message and raw result.
|
||||
app_logging.logger.warning("[EXT][NODE_RED PASS-2] invalid answer, falling back to raw result text")
|
||||
final_ans = tool_text_p1 or answer_stripped
|
||||
final_stripped = final_ans.strip()
|
||||
# Pass 2 always sets log_only=False: its final answer is ready for speech.
|
||||
out2: dict = {"answer": final_stripped}
|
||||
usage2 = payload2.get("usage") if isinstance(payload2, dict) else None
|
||||
if usage2:
|
||||
out2["usage_pass2"] = usage2
|
||||
out2["apipilot_invoked"] = {
|
||||
"name": tool_name_p1,
|
||||
"success": not is_tool_err,
|
||||
"result_length": len(tool_text_p1),
|
||||
}
|
||||
app_logging.logger.info(
|
||||
"[Vocero REASONING] engine=NODE_RED PASS-2 transcript=%s finalAnswer=%s apipilot=%s",
|
||||
transcript[:300],
|
||||
final_stripped[:500],
|
||||
json.dumps(out2["apipilot_invoked"], ensure_ascii=True),
|
||||
)
|
||||
return validated_json_response(out2, ReasoningResponse)
|
||||
# Without a marker, return the original pass-1 answer.
|
||||
return validated_json_response(out, ReasoningResponse)
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Mode B: OPENAI calls OpenAI Chat Completions directly.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Call the OpenAI-compatible Chat Completions provider.
|
||||
if not settings.OPENAI_API_KEY:
|
||||
raise HTTPException(status_code=503, detail="OPENAI_API_KEY no está configurado.")
|
||||
system_parts = []
|
||||
@@ -924,39 +649,3 @@ async def reasoning(req: models_requests.ReasoningRequest) -> JSONResponse:
|
||||
return validated_json_response(final_out_oai, ReasoningResponse)
|
||||
# Without a marker, return the original pass-1 answer.
|
||||
return validated_json_response({"answer": answer}, ReasoningResponse)
|
||||
|
||||
|
||||
async def _forward_agent_text_to_nodered(payload: dict) -> None:
|
||||
"""Forward native-backend TTS responses to Node-RED's stage-1 logging endpoint.
|
||||
|
||||
Ignore unavailable or unconfigured Node-RED so the realtime loop continues.
|
||||
"""
|
||||
if settings.S2S_REASONING_TYPE != "NODE_RED" or not settings.NODE_RED_REASONING_URL:
|
||||
return
|
||||
url = f"{settings.NODE_RED_REASONING_URL}/api/log/system-response"
|
||||
app_logging.logger.debug(
|
||||
"[EXT][NODE_RED] forward system-response POST %s len=%d source=%s",
|
||||
settings.NODE_RED_REASONING_URL,
|
||||
len(str(payload.get("tts", "")) or 0), # type: ignore[arg-type] # Preserve the legacy empty-TTS failure.
|
||||
payload.get("source_event") or "-",
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as http:
|
||||
resp = await http.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-S2S-Caller": "demo-server",
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
app_logging.logger.debug(
|
||||
"[EXT][NODE_RED] forward system-response non-200 status=%s body=%s",
|
||||
resp.status_code,
|
||||
(resp.text or "")[:300],
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
app_logging.logger.debug("[EXT][NODE_RED] forward system-response unreachable: %r", exc)
|
||||
except Exception as exc: # noqa: BLE001 — never interrupt the realtime loop
|
||||
app_logging.logger.debug("[EXT][NODE_RED] forward system-response unexpected: %r", exc)
|
||||
|
||||
-80
@@ -744,14 +744,6 @@
|
||||
},
|
||||
"upstream": []
|
||||
},
|
||||
"reasoning-NODE_RED-legacy-unbound-token": {
|
||||
"status": 500,
|
||||
"body": "Internal Server Error",
|
||||
"headers": {
|
||||
"content-type": "text/plain; charset=utf-8"
|
||||
},
|
||||
"upstream": []
|
||||
},
|
||||
"reasoning-legacy-unbound-profile": {
|
||||
"status": 500,
|
||||
"body": "Internal Server Error",
|
||||
@@ -760,78 +752,6 @@
|
||||
},
|
||||
"upstream": []
|
||||
},
|
||||
"reasoning-node-answer": {
|
||||
"status": 200,
|
||||
"body": "{\"answer\":\"answer\",\"usage\":{\"future\":true},\"latency_ms\":\"loose\",\"intent\":{\"future\":1}}",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"upstream": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://profile.test/user?phoneNumber=123",
|
||||
"body": "",
|
||||
"headers": {}
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "http://reasoning.test/api/reasoning",
|
||||
"body": "{\"transcript\":\"hello\",\"instructions\":\"Fixture instructions \\nPRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details. Fixture current date: 2026-01-04 \\n Caller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\",\"language\":\"es\",\"user_context\":\"PRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details.\\n\\nFixture current date: 2026-01-04\\n\\nCaller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\"}",
|
||||
"headers": {
|
||||
"content-type": "application/json",
|
||||
"x-s2s-caller": "demo-server"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"reasoning-node-log-only": {
|
||||
"status": 200,
|
||||
"body": "{\"answer\":\"\",\"log_only\":true,\"debug\":{\"future\":true}}",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"upstream": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://profile.test/user?phoneNumber=123",
|
||||
"body": "",
|
||||
"headers": {}
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "http://reasoning.test/api/reasoning",
|
||||
"body": "{\"transcript\":\"hello\",\"instructions\":\"Fixture instructions \\nPRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details. Fixture current date: 2026-01-04 \\n Caller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\",\"language\":\"es\",\"user_context\":\"PRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details.\\n\\nFixture current date: 2026-01-04\\n\\nCaller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\"}",
|
||||
"headers": {
|
||||
"content-type": "application/json",
|
||||
"x-s2s-caller": "demo-server"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"reasoning-node-invalid-answer": {
|
||||
"status": 502,
|
||||
"body": "{\"detail\":\"Node-RED devolvió un campo answer no válido.\"}",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"upstream": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://profile.test/user?phoneNumber=123",
|
||||
"body": "",
|
||||
"headers": {}
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "http://reasoning.test/api/reasoning",
|
||||
"body": "{\"transcript\":\"hello\",\"instructions\":\"Fixture instructions \\nPRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details. Fixture current date: 2026-01-04 \\n Caller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\",\"language\":\"es\",\"user_context\":\"PRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\\n1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. No words, phrases or confirmations in another language.\\n2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\\n3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\\n4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. Use a couple of short sentences, then ask whether the user wants more details.\\n\\nFixture current date: 2026-01-04\\n\\nCaller profile resolved from the page phoneNumber: {\\\"name\\\": \\\"Caller\\\"}. Use this as trusted caller context when relevant. IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') or a name from any other language, respond ONLY in SPANISH (Castilian). Foreign names do NOT authorize switching languages. Never write phrases, words, questions or confirmations in Italian, English or another language. Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. PROPER NAME: never adapt, translate or normalize proper names (for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name.\"}",
|
||||
"headers": {
|
||||
"content-type": "application/json",
|
||||
"x-s2s-caller": "demo-server"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"reasoning-openai-answer": {
|
||||
"status": 200,
|
||||
"body": "{\"answer\":\"answer\"}",
|
||||
|
||||
@@ -11,7 +11,7 @@ import json
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -21,7 +21,7 @@ with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_
|
||||
from s2s_client.core import config as settings
|
||||
from s2s_client.providers import apipilot
|
||||
from s2s_client.realtime import proxy
|
||||
from s2s_client.services import context, reasoning
|
||||
from s2s_client.services import context
|
||||
|
||||
ASYNC_CLIENT = httpx.AsyncClient
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "legacy-contracts.json"
|
||||
@@ -47,7 +47,6 @@ BASE_CONFIG = {
|
||||
"S2S_APIPILOT_SERVICE_TOKEN": "",
|
||||
"S2S_APIPILOT_API_KEY": "",
|
||||
"OPENAI_API_KEY": "",
|
||||
"NODE_RED_REASONING_URL": "",
|
||||
"S2S_REPORTS_QUICK_HINT_MAX": 5,
|
||||
"S2S_REPORTS_INJECT_MODE": "minimal",
|
||||
}
|
||||
@@ -268,15 +267,14 @@ def cases():
|
||||
|
||||
for name, payload in (("missing", {}), ("proxy", {"transcript": "hello"}), ("proxy-blank", {"transcript": " "})):
|
||||
yield f"reasoning-{name}", "POST", "/api/reasoning", {"json": payload}, {}, []
|
||||
for mode in ("OPENAI", "NODE_RED"):
|
||||
yield (
|
||||
f"reasoning-{mode}-legacy-unbound-token",
|
||||
"POST",
|
||||
"/api/reasoning",
|
||||
{"json": {"transcript": "hello"}},
|
||||
{"S2S_REASONING_TYPE": mode},
|
||||
[],
|
||||
)
|
||||
yield (
|
||||
"reasoning-OPENAI-legacy-unbound-token",
|
||||
"POST",
|
||||
"/api/reasoning",
|
||||
{"json": {"transcript": "hello"}},
|
||||
{"S2S_REASONING_TYPE": "OPENAI"},
|
||||
[],
|
||||
)
|
||||
yield (
|
||||
"reasoning-legacy-unbound-profile",
|
||||
"POST",
|
||||
@@ -292,8 +290,7 @@ def cases():
|
||||
reasoning_config = {
|
||||
**profile_config,
|
||||
"S2S_INJECT_PROFILE": True,
|
||||
"S2S_REASONING_TYPE": "NODE_RED",
|
||||
"NODE_RED_REASONING_URL": "http://reasoning.test",
|
||||
"S2S_REASONING_TYPE": "OPENAI",
|
||||
}
|
||||
reasoning_request = {
|
||||
"json": {
|
||||
@@ -304,28 +301,6 @@ def cases():
|
||||
"future": True,
|
||||
}
|
||||
}
|
||||
for name, payload in (
|
||||
(
|
||||
"answer",
|
||||
{
|
||||
"answer": " answer ",
|
||||
"latency_ms": "loose",
|
||||
"intent": {"future": 1},
|
||||
"usage": {"future": True},
|
||||
"future": True,
|
||||
},
|
||||
),
|
||||
("log-only", {"answer": "", "log_only": True, "debug": {"future": True}}),
|
||||
("invalid-answer", {"answer": 5}),
|
||||
):
|
||||
yield (
|
||||
f"reasoning-node-{name}",
|
||||
"POST",
|
||||
"/api/reasoning",
|
||||
reasoning_request,
|
||||
reasoning_config,
|
||||
[(200, {"name": "Caller"}), (200, payload)],
|
||||
)
|
||||
yield (
|
||||
"reasoning-openai-answer",
|
||||
"POST",
|
||||
@@ -333,7 +308,6 @@ def cases():
|
||||
reasoning_request,
|
||||
{
|
||||
**reasoning_config,
|
||||
"S2S_REASONING_TYPE": "OPENAI",
|
||||
"OPENAI_API_KEY": "fixture-key",
|
||||
},
|
||||
[
|
||||
@@ -454,7 +428,6 @@ def capture_http():
|
||||
"x-demo-session",
|
||||
"x-api-key",
|
||||
"content-type",
|
||||
"x-s2s-caller",
|
||||
)
|
||||
if key in request.headers
|
||||
},
|
||||
@@ -534,8 +507,7 @@ async def capture_realtime():
|
||||
yield json.dumps({"type": "error", "error": {"message": "Unknown or invalid event: session.update"}})
|
||||
yield json.dumps({"type": "future.after.suppressed.error"})
|
||||
|
||||
with patch.object(reasoning, "_forward_agent_text_to_nodered", new_callable=AsyncMock):
|
||||
await proxy._upstream_to_client(Socket(), upstream(), "fixture", {})
|
||||
await proxy._upstream_to_client(Socket(), upstream(), "fixture", {})
|
||||
return frames
|
||||
|
||||
|
||||
@@ -548,6 +520,48 @@ def capture():
|
||||
|
||||
|
||||
class LegacyCompatibilityTests(unittest.TestCase):
|
||||
def test_native_transcripts_are_relayed_without_external_http_calls(self):
|
||||
events = [
|
||||
{"type": "conversation.item.input_audio_transcription.completed", "transcript": "Hola", "item_id": "u1"},
|
||||
{"type": "response.audio_transcript.done", "transcript": "Respuesta", "response_id": "r1"},
|
||||
{"type": "response.output_audio_transcript.done", "transcript": "Respuesta", "response_id": "r1"},
|
||||
{"type": "response.content_part.done", "part": {"type": "text", "text": "Respuesta"}},
|
||||
*[
|
||||
{
|
||||
"type": event_type,
|
||||
"item": {"id": "a1", "role": "assistant", "content": [{"type": "text", "text": "Respuesta"}]},
|
||||
}
|
||||
for event_type in ("response.output_item.done", "conversation.item.created")
|
||||
],
|
||||
{"type": "response.text.done", "text": "Respuesta", "response_id": "r1"},
|
||||
]
|
||||
messages = [json.dumps(event) for event in events]
|
||||
forwarded = []
|
||||
|
||||
class Socket:
|
||||
async def send_text(self, message):
|
||||
forwarded.append(message)
|
||||
|
||||
async def upstream():
|
||||
for message in messages:
|
||||
yield message
|
||||
|
||||
async def relay():
|
||||
await proxy._upstream_to_client(Socket(), upstream(), "fixture", {})
|
||||
# Allow any accidentally scheduled background HTTP work to run.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
for mode in ("PROXY", "OPENAI"):
|
||||
forwarded.clear()
|
||||
with (
|
||||
self.subTest(mode=mode),
|
||||
patch.multiple(settings, **{**BASE_CONFIG, "S2S_REASONING_TYPE": mode}),
|
||||
patch.object(httpx, "AsyncClient", side_effect=AssertionError("Unexpected HTTP call")) as http,
|
||||
):
|
||||
asyncio.run(relay())
|
||||
self.assertEqual(forwarded, messages)
|
||||
http.assert_not_called()
|
||||
|
||||
def test_wire_contracts_match_pre_migration_capture(self):
|
||||
expected = json.loads(FIXTURE.read_text())
|
||||
actual = capture()
|
||||
|
||||
@@ -24,7 +24,7 @@ from s2s_client.models.responses import (
|
||||
(AllocationResponse, {"state": [1, None], "connectUrl": "wss://host", "future": {"x": 1.0}}),
|
||||
(ToolExecutionResponse, {"success": True, "unknown": {"nested": [None, 1.0, False]}}),
|
||||
(ToolExecutionResponse, {"success": False, "data": 42, "error": "loose error", "future": []}),
|
||||
(ReasoningResponse, {"answer": "á", "usage": [1, None], "debug": False, "future": True}),
|
||||
(ReasoningResponse, {"answer": "á", "usage_pass2": [1, None], "future": True}),
|
||||
(SearchResponse, {"query": "q", "answer": {"x": 2}, "results": [{"title": [], "snippet": 7, "url": None}]}),
|
||||
(
|
||||
ReportsResponse,
|
||||
@@ -69,3 +69,22 @@ def test_http_routes_publish_response_schemas():
|
||||
allocation = spec["components"]["schemas"]["AllocationResponse"]
|
||||
assert not allocation.get("required")
|
||||
assert allocation["additionalProperties"] is True
|
||||
config = spec["components"]["schemas"]["ConfigResponse"]
|
||||
assert config["properties"]["reasoningType"]["enum"] == ["PROXY", "OPENAI"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("OPENAI", "OPENAI"),
|
||||
(" 'openai' ", "OPENAI"),
|
||||
('"OPENAI"', "OPENAI"),
|
||||
("PROXY", "PROXY"),
|
||||
("", "PROXY"),
|
||||
("UNSUPPORTED", "PROXY"),
|
||||
],
|
||||
)
|
||||
def test_reasoning_configuration_preserves_normalization_and_native_fallback(value, expected):
|
||||
from s2s_client.core.config import _normalize_reasoning_type
|
||||
|
||||
assert _normalize_reasoning_type(value) == expected
|
||||
|
||||
@@ -115,7 +115,7 @@ by Git. The launcher serves source through Vite; it does not use `dist/` or
|
||||
|
||||
Tests cover session initialization, response sequencing, cumulative transcripts,
|
||||
interruptions, queue polling, admission deadlines, cancellation, microphone-permission cancellation, tool
|
||||
results, external reasoning and `LOG_ONLY`, late-response cleanup, the idle
|
||||
results, external reasoning, late-response cleanup, the idle
|
||||
timeout, settings, RAG, profiles, and sanitized Markdown/export behavior.
|
||||
Launcher tests start temporary local children to check readiness, environment
|
||||
forwarding, failures, and shutdown of complete process groups.
|
||||
@@ -144,8 +144,7 @@ quick actions remain absent, matching their disabled state in the old UI.
|
||||
Native speech stays at the current project's 16 kHz PCM rate. The backend
|
||||
continues to own VAD, transcription, model inference, synthesis, upstream
|
||||
credentials, and ApiPilot execution. Browser `SpeechSynthesis` is used only
|
||||
when the configured external reasoning service returns an answer instead of
|
||||
`log_only`.
|
||||
when the configured external reasoning service returns an answer.
|
||||
|
||||
Session startup waits for audio initialization and `session.created`, then sends
|
||||
`session.update` before microphone audio or the initial greeting. The speech
|
||||
|
||||
@@ -79,7 +79,7 @@ export async function getTools(signal: AbortSignal, refresh = false) {
|
||||
return result.data.tools;
|
||||
}
|
||||
|
||||
export const reasoningSchema = z.object({ answer: z.string().default(""), log_only: z.boolean().default(false) });
|
||||
export const reasoningSchema = z.object({ answer: z.string().default("") });
|
||||
export const searchSchema = z.object({
|
||||
answer: z.string().nullish(),
|
||||
results: z.array(z.object({ title: z.string(), snippet: z.string().default(""), url: z.string() })).default([]),
|
||||
|
||||
@@ -9,7 +9,7 @@ export const configSchema = z.object({
|
||||
allowDirect: z.boolean().default(true),
|
||||
s2sUrl: z.string().default(""),
|
||||
primaryPrompt: z.string().default(""),
|
||||
reasoningType: z.enum(["PROXY", "OPENAI", "NODE_RED"]).catch("PROXY"),
|
||||
reasoningType: z.enum(["PROXY", "OPENAI"]).catch("PROXY"),
|
||||
injectProfile: z.boolean().default(false),
|
||||
debugPrompt: z.boolean().default(false),
|
||||
startSessionMode: z.string().default(""),
|
||||
|
||||
@@ -63,7 +63,7 @@ beforeEach(() => {
|
||||
}
|
||||
const pathname = new URL(path, "http://localhost").pathname;
|
||||
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/reports": { count: 0 },
|
||||
};
|
||||
@@ -98,17 +98,28 @@ describe("voice application lifecycle", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps LOG_ONLY reasoning on the native voice path and preserves separate profile context", async () => {
|
||||
it("renders an external answer and preserves separate profile context", async () => {
|
||||
const client = await start();
|
||||
client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false });
|
||||
pendingReasoning?.(new Response(JSON.stringify({ log_only: true, answer: "" })));
|
||||
pendingReasoning?.(new Response(JSON.stringify({ answer: " Hola, Ana " })));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
const request = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning");
|
||||
expect(request?.[1]?.body).toContain('"phoneNumber":"1234"');
|
||||
expect(request?.[1]?.body).toContain("Caller profile resolved");
|
||||
expect(client.options.instructions).not.toContain("Caller profile resolved");
|
||||
expect(client.renderExternalAnswer).toHaveBeenCalledWith("Hola, Ana");
|
||||
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Hola", "Hola, Ana"]);
|
||||
});
|
||||
it.each(["PROXY", "UNSUPPORTED"])("keeps native reasoning for mode %s", async (reasoningType) => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ reasoningType, s2sUrl: "ws://localhost/v1/realtime" })),
|
||||
);
|
||||
const client = await start();
|
||||
client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(fetchMock.mock.calls.some(([path]) => path === "/api/reasoning")).toBe(false);
|
||||
expect(client.renderExternalAnswer).not.toHaveBeenCalled();
|
||||
expect(controller.getSnapshot().messages).toHaveLength(1);
|
||||
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Hola"]);
|
||||
});
|
||||
it("discards external reasoning that finishes after the session was stopped", async () => {
|
||||
const client = await start();
|
||||
|
||||
@@ -379,7 +379,7 @@ export class VoiceController {
|
||||
},
|
||||
this.requests.signal,
|
||||
);
|
||||
if (this.client !== client || result.log_only) {
|
||||
if (this.client !== client) {
|
||||
return;
|
||||
}
|
||||
const answer = result.answer.trim();
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user