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

BREAKING CHANGE: NODE_RED reasoning is no longer supported. Unsupported
mode values fall back to PROXY. PROXY and OPENAI remain available.
This commit is contained in:
2026-10-06 18:58:54 -03:00
parent 77b60622e9
commit e0304b2bdb
31 changed files with 129 additions and 1138 deletions
+3 -2
View File
@@ -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.
+1 -7
View File
@@ -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.
+1 -2
View File
@@ -19,9 +19,8 @@ _STARTUP_APIPILOT_EXEC_URL = _resolve_exec_url_for_startup_banner()
del _resolve_exec_url_for_startup_banner
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),
+1 -1
View File
@@ -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
+1 -7
View File
@@ -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
+1 -1
View File
@@ -24,7 +24,7 @@ async def _fetch_reports_list(phone_number: str | None = None, auth_token: str |
endpoint is never hit, not even for a "cheap empty list"; combined with
_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).
-65
View File
@@ -17,7 +17,6 @@ from s2s_client.providers import apipilot as providers_apipilot
from s2s_client.providers import caller as providers_caller
from s2s_client.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 -312
View File
@@ -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
View File
@@ -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\"}",
+54 -40
View File
@@ -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()
+20 -1
View File
@@ -24,7 +24,7 @@ from s2s_client.models.responses import (
(AllocationResponse, {"state": [1, None], "connectUrl": "wss://host", "future": {"x": 1.0}}),
(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