Files
alvaro dfed03ac7c chore(vocero): remove the legacy ApiPilot integration (#3)
Vocero now retrieves AquaPortal content through authenticated MCP. This removes the obsolete ApiPilot integration from the API and browser while preserving the separately deployed speech pipeline.

Release notes:

- Removed ApiPilot endpoints, configuration, credential overrides, and HTTP/realtime tool execution.
- Removed browser ApiPilot catalog loading and prompt generation while retaining authenticated MCP discovery.
- Updated regression coverage and operating documentation for the retired routes and settings.

Reviewed-on: AquaPortal/s2s#3
2026-10-08 11:23:41 -04:00

401 lines
21 KiB
Python

from __future__ import annotations
import json
import httpx
from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse
from vocero_api.api.responses import validated_json_response
from vocero_api.core import config as settings
from vocero_api.core import logging as app_logging
from vocero_api.models import requests as models_requests
from vocero_api.models.responses import ReasoningResponse
from vocero_api.providers import caller as providers_caller
from vocero_api.services import context as services_context
from vocero_api.services import mcp_reasoning
def _openai_headers() -> dict[str, str]:
if not settings.OPENAI_API_KEY:
raise RuntimeError("OPENAI_API_KEY is not configured.")
headers = {
"Authorization": f"Bearer {settings.OPENAI_API_KEY}",
"Content-Type": "application/json",
}
if settings.OPENAI_ORGANIZATION:
headers["OpenAI-Organization"] = settings.OPENAI_ORGANIZATION
if settings.OPENAI_PROJECT:
headers["OpenAI-Project"] = settings.OPENAI_PROJECT
return headers
async def reasoning(req: models_requests.ReasoningRequest, request: Request) -> JSONResponse:
if settings.S2S_REASONING_TYPE == "PROXY":
raise HTTPException(status_code=409, detail="El servicio de razonamiento está deshabilitado en el modo PROXY.")
if settings.S2S_MCP_URL:
if not settings.OPENAI_API_KEY:
raise HTTPException(503, "El servicio de razonamiento no está configurado.")
return await mcp_reasoning.reply(req, request, _openai_headers())
transcript = (req.transcript or "").strip()
if not transcript:
raise HTTPException(status_code=400, detail="Se requiere el campo transcript.")
# ── S2S_INJECT_PROFILE: resolve podcast episode + caller profile + reports list + latest summary ──
# (When S2S_INJECT_PROFILE=false this entire block is a no-op; user_context
# and instructions are taken verbatim from the request body.)
inject_episode_text = ""
inject_profile_text = ""
inject_reports_text = ""
inject_summary_text = ""
phone = (req.phoneNumber or "").strip()
episode_id = (req.episode_id or "").strip()
if not settings.S2S_INJECT_PROFILE:
app_logging.logger.info(
"[INJECT-PROFILE] ⏭️ REASONING: skipped (S2S_INJECT_PROFILE=false). "
"Using verbatim request body; no fetch, no augmentation."
)
else:
needs_phone_authed = bool(settings.VOCERO_USER_PHONE_AUTH_URL.strip())
# 2026-09-11 user fix: remove mass injection of scheduled reports.
# treats S2S_REPORT_LIST as "need to fetch reports" ONLY when BOTH the URL is
# configured AND the new opt-in S2S_INJECT_REPORTS flag is true. When
# S2S_INJECT_REPORTS=false (default) needs_reports stays False here → we skip
# the branch entirely, no HTTP call, no context string, no prompt bloat.
needs_reports = bool(settings.S2S_REPORT_LIST.strip()) and settings.S2S_INJECT_REPORTS
needs_summary = bool(settings.S2S_LATEST_SUMMARY.strip()) or bool(settings.S2S_LATEST_SUMMARY_TEXT)
needs_login = bool(settings.S2S_LOGIN_BY_PHONE.strip())
can_inject = needs_phone_authed or needs_reports or needs_summary or needs_login or bool(episode_id)
if (
(not needs_phone_authed)
and (not needs_reports)
and (not needs_summary)
and (not needs_login)
and (not episode_id)
):
app_logging.logger.info(
"[INJECT-PROFILE] ⏭️ REASONING: skipped (VOCERO_USER_PHONE_AUTH_URL, S2S_REPORT_LIST, S2S_LATEST_SUMMARY/S2S_LATEST_SUMMARY_TEXT and S2S_LOGIN_BY_PHONE all empty, AND no episode_id in request; OR S2S_INJECT_REPORTS is false so reports injection is globally disabled). "
"Set at least one AND set S2S_INJECT_REPORTS=true (if you want scheduled reports) to enable login / fetch episode/profile/reports/summary for REASONING endpoint."
)
elif (not phone) and (not needs_reports) and (not episode_id) and (not settings.S2S_LATEST_SUMMARY_TEXT):
app_logging.logger.info(
"[INJECT-PROFILE] ⏭️ REASONING: skipped (phoneNumber missing from request body AND no episode_id). "
"Browser-side issue: check VoiceController passes phoneNumber and/or episode_id in the JSON body. (Note: S2S_REPORT_LIST fetch works even without phone.)"
)
elif not can_inject:
app_logging.logger.info(
"[INJECT-PROFILE] ⏭️ REASONING: skipped (env for phone empty and no reports/summary/login API and no episode_id)."
)
else:
# ###################################################################
# 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env set)
# Obtains a bearer token that will be forwarded for profile/reports/summary fetches.
# ###################################################################
login_token = None
if settings.S2S_LOGIN_BY_PHONE and phone:
login_token, _login_resp = await providers_caller._login_by_phone(phone)
# ###################################################################
# 0.5) Podcast episode fetch (independent of phone — uses req.episode_id)
# ###################################################################
if episode_id:
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[INJECT-EPISODE] starting REASONING fetch podcast episode episodeId=%.80s...",
episode_id,
)
episode_raw = await providers_caller._fetch_podcast_episode(episode_id)
inject_episode_text = services_context._resolve_episode_context(episode_raw)
if inject_episode_text.strip():
app_logging.logger.info(
"[INJECT-EPISODE] ✅ REASONING: PODCAST EPISODE READY episodeId=%.80s wrappedLen=%d -> augmenting instructions + user_context before sending upstream.",
episode_id,
len(inject_episode_text),
)
else:
app_logging.logger.info(
"[INJECT-EPISODE] ⏭️ REASONING: episode skipped (context empty / upstream returned empty / fetch failed)."
)
# ###################################################################
# 1) Profile fetch (optional — only if env is configured)
# ###################################################################
if needs_phone_authed and phone:
profile_obj = await providers_caller._fetch_user_profile_for_phone(phone, auth_token=login_token)
inject_profile_text = services_context._resolve_user_context_text(phone, profile_obj, loaded=True)
if inject_profile_text.strip():
app_logging.logger.info(
"[INJECT-PROFILE] ✅ REASONING: profile READY phoneLen=%d contextLen=%d -> augmenting instructions + user_context before sending upstream.",
len(phone),
len(inject_profile_text),
)
else:
app_logging.logger.info(
"[INJECT-PROFILE] ⏭️ REASONING: profile fetch OK but resolved context empty."
)
# ###################################################################
# 1.5) Scheduled reports / podcast scripts list (optional — S2S_REPORT_LIST)
# ###################################################################
if needs_reports:
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[INJECT-REPORTS] starting REASONING fetch S2S_REPORT_LIST (phoneLen=%s, bearer=%s)...",
len(phone) if phone else "none",
"yes" if login_token else "no",
)
reports_raw = await providers_caller._fetch_reports_list(
phone_number=phone if phone else None, auth_token=login_token
)
inject_reports_text = services_context._resolve_reports_context(reports_raw)
if inject_reports_text.strip():
app_logging.logger.info(
"[INJECT-REPORTS] ✅ REASONING: reports list READY wrappedLen=%d -> augmenting instructions + user_context before sending upstream.",
len(inject_reports_text),
)
else:
app_logging.logger.info(
"[INJECT-REPORTS] ⏭️ REASONING: reports list skipped (context empty / upstream returned empty list / fetch failed)."
)
# ###################################################################
# 2) Latest summary fetch (optional — only if env is configured)
# ###################################################################
if needs_summary and (phone or settings.S2S_LATEST_SUMMARY_TEXT):
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[INJECT-SUMMARY] starting REASONING fetch (reasoning summary... S2S_LATEST_SUMMARY=%s S2S_LATEST_SUMMARY_TEXT=%s phoneLen=%s.",
bool(settings.S2S_LATEST_SUMMARY),
bool(settings.S2S_LATEST_SUMMARY_TEXT),
len(phone) if phone else "none",
)
latest_summary_raw = await providers_caller._fetch_latest_summary_for_phone(
phone, auth_token=login_token
)
inject_summary_text = services_context._resolve_latest_summary_context(latest_summary_raw)
if inject_summary_text.strip():
app_logging.logger.info(
"[INJECT-SUMMARY] ✅ REASONING: latest summary READY phoneLen=%s summaryTextOverride=%s wrappedLen=%d -> augmenting instructions + user_context before sending upstream.",
len(phone) if phone else "none",
bool(settings.S2S_LATEST_SUMMARY_TEXT),
len(inject_summary_text),
)
else:
app_logging.logger.info(
"[INJECT-SUMMARY] ⏭️ REASONING: latest summary skipped (context empty / upstream returned empty summary / fetch failed.)"
)
user_context_parts: list[str] = []
# ─── USER FIX BUG 3: TODAY fixed context block → PREPEND to user_context too
# (the same anti-hallucination block gets prefixed in the SYSTEM instructions
# path via _augment_instructions_with_profile; we also prepend here so the
# OPENAI/ANYSCALE messages[] array (where caller context is user_message[0])
# ALSO gets the same guard.)
# ─── USER FIX 2026-09-11 HALLUCINATION: voice_lock_block FIRST position
voice_lock_block = services_context._build_voice_rules_head_lock_block(profile=profile_obj)
if voice_lock_block.strip():
user_context_parts.append(voice_lock_block)
today_block = services_context._build_today_fixed_context_block()
if today_block.strip():
user_context_parts.append(today_block)
if inject_episode_text:
user_context_parts.append(inject_episode_text)
if inject_profile_text:
user_context_parts.append(inject_profile_text)
if inject_reports_text:
user_context_parts.append(inject_reports_text)
if inject_summary_text:
user_context_parts.append(inject_summary_text)
original_user_context = (req.user_context or "").strip()
if original_user_context:
user_context_parts.append(original_user_context)
user_context_parts = services_context._dedup_context_blocks_by_type(user_context_parts)
if original_user_context:
server_injected_types_in_client_ctx: set = set()
for part in user_context_parts:
if original_user_context and (part or "").strip() == original_user_context:
continue
tid = services_context._classify_context_block(part)
if tid is None:
continue
for marker_tid, marker in services_context._CONTEXT_DEDUPE_MARKERS:
if marker_tid == tid and marker and marker in original_user_context:
server_injected_types_in_client_ctx.add(tid)
break
if server_injected_types_in_client_ctx:
new_parts: list[str] = []
for part in user_context_parts:
if (part or "").strip() == original_user_context:
new_parts.append(part)
continue
tid = services_context._classify_context_block(part)
if tid in server_injected_types_in_client_ctx:
continue
new_parts.append(part)
if settings.S2S_DEBUG_PROMPT and new_parts != user_context_parts:
app_logging.logger.info(
"[INJECT-DEDUPE] dropped server-injected profile/episode/reports/summary because the same block type was already present in req.user_context client-side context=%s kept=%d",
", ".join(sorted(server_injected_types_in_client_ctx)),
len(new_parts),
)
user_context_parts = new_parts
# ─── 2026-09-11 MARKER LEAK GUARD: strip invisible internal sentinels from EACH part.
stripped_parts: list[str] = []
for p in user_context_parts:
sp = services_context._strip_internal_markers(p)
if sp:
stripped_parts.append(sp)
user_context_parts = stripped_parts
effective_user_context = "\n\n".join(user_context_parts)
if settings.S2S_INJECT_PROFILE:
any_injected = bool(inject_episode_text or inject_profile_text or inject_reports_text or inject_summary_text)
if any_injected:
app_logging.logger.info(
"[INJECT-PROFILE] REASONING user_context final parts=%d serverEpisodeLen=%d serverProfileLen=%d serverReportsLen=%d serverSummaryLen=%d browserUserCtxLen=%d joinedLen=%d",
len(user_context_parts),
len(inject_episode_text),
len(inject_profile_text),
len(inject_reports_text),
len(inject_summary_text),
len(original_user_context),
len(effective_user_context),
)
if settings.S2S_DEBUG_PROMPT and voice_lock_block.strip():
app_logging.logger.info(
"[DEBUG-VOICELOCK][REASONING augment] FULL voice lock block (len=%d):\n%s\n",
len(voice_lock_block),
services_context._strip_internal_markers(voice_lock_block),
)
if settings.S2S_DEBUG_PROMPT and inject_episode_text.strip():
app_logging.logger.info(
"[DEBUG-EPISODE][REASONING augment] FULL podcast episode block (len=%d):\n%s\n",
len(inject_episode_text),
services_context._strip_internal_markers(inject_episode_text),
)
if settings.S2S_DEBUG_PROMPT and today_block.strip():
app_logging.logger.info(
"[DEBUG-TODAY][REASONING augment] FULL today block (len=%d):\n%s\n",
len(today_block),
services_context._strip_internal_markers(today_block),
)
if settings.S2S_DEBUG_PROMPT and inject_reports_text.strip():
app_logging.logger.info(
"[DEBUG-REPORTS][REASONING augment] FULL reports list block (len=%d):\n%s\n",
len(inject_reports_text),
services_context._strip_internal_markers(inject_reports_text),
)
if settings.S2S_DEBUG_PROMPT and inject_summary_text.strip():
app_logging.logger.info(
"[DEBUG-SUMMARY][REASONING augment] FULL latest summary block (len=%d):\n%s\n",
len(inject_summary_text),
services_context._strip_internal_markers(inject_summary_text),
)
if settings.S2S_DEBUG_PROMPT and inject_profile_text.strip():
app_logging.logger.info(
"[DEBUG-PROFILE][REASONING augment] FULL profile block (len=%d):\n%s\n",
len(inject_profile_text),
services_context._strip_internal_markers(inject_profile_text),
)
effective_instructions = 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:
effective_instructions = (req.instructions or "").strip()
# 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 = []
instructions = effective_instructions or ((req.instructions or "").strip())
if instructions:
system_parts.append(instructions)
if effective_user_context.strip():
system_parts.append(f"User context:\n{effective_user_context.strip()}")
if req.language.strip():
system_parts.append(
"Reply only with the final assistant answer text. "
f"Keep the answer natural and concise in this language: {req.language.strip()}."
)
else:
system_parts.append("Reply only with the final assistant answer text. Keep the answer natural and concise.")
app_logging.logger.info(
"[INJECT-PROFILE] REASONING->OPENAI system message parts=%d instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d",
len(system_parts),
len(effective_instructions),
len(effective_user_context),
len(phone),
len(episode_id),
)
system_msg = "\n\n".join(system_parts)
body = {
"model": settings.OPENAI_REASONING_MODEL,
"messages": [
{"role": "system", "content": system_msg},
{"role": "user", "content": transcript},
],
}
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[DEBUG-PROMPT][REASONING → OPENAI] FULL SYSTEM message (len=%d):\n%s\n",
len(system_msg),
system_msg,
)
app_logging.logger.info(
"[DEBUG-PROMPT][REASONING → OPENAI] USER transcript (len=%d):\n%s\n",
len(transcript),
transcript,
)
app_logging.logger.debug(
"[EXT][OPENAI] reasoning POST %s/chat/completions model=%s systemLen=%d transcriptLen=%d language=%s",
settings.OPENAI_BASE_URL,
settings.OPENAI_REASONING_MODEL,
len(system_parts[0]) if system_parts else 0,
len(transcript),
(req.language or "")[:20] or "auto",
)
try:
async with httpx.AsyncClient(timeout=30.0) as http:
resp = await http.post(
f"{settings.OPENAI_BASE_URL}/chat/completions",
headers=_openai_headers(),
json=body,
)
except httpx.RequestError as exc:
app_logging.logger.warning("OpenAI reasoning unreachable: %r", exc)
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de razonamiento de OpenAI.") # noqa: B904
app_logging.logger.debug(
"[EXT][OPENAI] reasoning RESP status=%s len=%dB",
resp.status_code,
len(resp.content or b""),
)
if resp.status_code != 200:
app_logging.logger.warning("OpenAI reasoning failed %s: %s", resp.status_code, resp.text[:400])
raise HTTPException(status_code=502, detail=f"El razonamiento de OpenAI falló ({resp.status_code}).")
try:
payload = resp.json()
except Exception:
app_logging.logger.warning("OpenAI reasoning returned invalid JSON: %s", resp.text[:400])
raise HTTPException(status_code=502, detail="El servicio de razonamiento de OpenAI devolvió un JSON no válido.") # noqa: B904
answer = ""
choices = payload.get("choices")
if isinstance(choices, list) and choices:
message = choices[0].get("message") if isinstance(choices[0], dict) else None
if isinstance(message, dict):
content = message.get("content")
if isinstance(content, str):
answer = content.strip()
usage = payload.get("usage")
if isinstance(usage, dict):
app_logging.logger.debug(
"[EXT][OPENAI] reasoning USAGE prompt=%d completion=%d total=%d",
usage.get("prompt_tokens", 0),
usage.get("completion_tokens", 0),
usage.get("total_tokens", 0),
)
if not answer:
app_logging.logger.warning(
"OpenAI reasoning returned empty answer: %s", json.dumps(payload, ensure_ascii=True)[:800]
)
raise HTTPException(status_code=502, detail="El servicio de razonamiento de OpenAI no devolvió una respuesta.")
app_logging.logger.info("[Vocero REASONING] engine=OPENAI transcript=%s answer=%s", transcript[:300], answer[:500])
return validated_json_response({"answer": answer}, ReasoningResponse)