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

BREAKING CHANGE: NODE_RED reasoning is no longer supported. Unsupported
mode values fall back to PROXY. PROXY and OPENAI remain available.
This commit is contained in:
2026-10-06 18:58:54 -03:00
parent 77b60622e9
commit e0304b2bdb
31 changed files with 129 additions and 1138 deletions
+1 -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)