Files
vocero-client/vocero-api/vocero_api/services/reasoning.py
T
alvaro abd03ed07d refactor: rename API and frontend projects to Vocero
Rename the project directories, Python distribution and import package, and npm package. Update launcher, Docker, tooling, tests, lockfiles, and documentation while preserving existing API contracts.

Make AGENTS.md self-contained and remove external skill and workflow references.
2026-10-06 19:26:55 -03:00

652 lines
33 KiB
Python

from __future__ import annotations
import json
import httpx
from fastapi import HTTPException
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 apipilot as providers_apipilot
from vocero_api.providers import caller as providers_caller
from vocero_api.services import context as services_context
from vocero_api.services import tool_context as services_tool_context
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) -> JSONResponse:
if settings.S2S_REASONING_TYPE == "PROXY":
raise HTTPException(status_code=409, detail="El servicio de razonamiento está deshabilitado en el modo PROXY.")
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.)"
)
# Load dynamic ApiPilot tools before building context, independently of
# S2S_INJECT_PROFILE.
needs_apipilot = bool(settings.S2S_APIPILOT_TOOLS_URL) or bool(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL)
apipilot_tools_pass1: list[dict] = []
apipilot_auth_header: str | None = None
if isinstance(login_token, str) and login_token.strip():
tk = login_token.strip()
if tk.lower().startswith("bearer "):
apipilot_auth_header = tk
else:
apipilot_auth_header = f"Bearer {tk}"
# S2S_LOGIN_BY_PHONE resolves an AquaPortal JWT, used for requires_auth=true
# execution. S2S_APIPILOT_SERVICE_TOKEN overrides it when configured.
if settings.S2S_APIPILOT_SERVICE_TOKEN:
_svc = settings.S2S_APIPILOT_SERVICE_TOKEN.strip()
if _svc:
apipilot_auth_header = f"Bearer {_svc}" if not _svc.lower().startswith("bearer ") else _svc
if needs_apipilot:
try:
apipilot_t0 = providers_apipilot._monotonic_now()
apipilot_tools_pass1 = await providers_apipilot._apipilot_fetch_tools(
auth_header=apipilot_auth_header,
demo_session=None,
force_refresh=False,
)
app_logging.logger.info(
"[APIPILOT] REASONING PASS-1: tool list ready N=%d t=%dms auth=%s",
len(apipilot_tools_pass1),
int((providers_apipilot._monotonic_now() - apipilot_t0) * 1000),
"Y" if apipilot_auth_header else "-",
)
except Exception as exc:
app_logging.logger.warning("[APIPILOT] REASONING: tool-list fetch failed, ignoring: %s", exc)
apipilot_tools_pass1 = []
inject_apipilot_text_pass1 = services_tool_context._resolve_apipilot_reasoning_context_block(
apipilot_tools_pass1,
call_result=None,
)
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)
if inject_apipilot_text_pass1:
user_context_parts.append(inject_apipilot_text_pass1)
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),
)
if settings.S2S_DEBUG_PROMPT and inject_apipilot_text_pass1.strip():
app_logging.logger.info(
"[DEBUG-APIPILOT][REASONING augment] FULL ApiPilot tools list block (len=%d):\n%s\n",
len(inject_apipilot_text_pass1),
services_context._strip_internal_markers(inject_apipilot_text_pass1),
)
if needs_apipilot and inject_apipilot_text_pass1.strip():
app_logging.logger.info(
"[INJECT-APIPILOT] REASONING PASS-1: inject tool list (len=%d, Ntools=%d) into instructions and user_context.",
len(inject_apipilot_text_pass1),
len(apipilot_tools_pass1),
)
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,
)
if inject_apipilot_text_pass1.strip():
effective_instructions = (
effective_instructions + "\n\n" if effective_instructions else ""
) + inject_apipilot_text_pass1
else:
effective_instructions = (req.instructions or "").strip()
if inject_apipilot_text_pass1.strip():
app_logging.logger.info(
"[INJECT-APIPILOT] REASONING PASS-1: inject tool list (len=%d, Ntools=%d) into instructions (inject_profile=false).",
len(inject_apipilot_text_pass1),
len(apipilot_tools_pass1),
)
effective_instructions = (
effective_instructions + "\n\n" if effective_instructions else ""
) + inject_apipilot_text_pass1
if settings.S2S_DEBUG_PROMPT and inject_apipilot_text_pass1.strip():
app_logging.logger.info(
"[DEBUG-APIPILOT][REASONING augment] FULL ApiPilot tools list block (len=%d):\n%s\n",
len(inject_apipilot_text_pass1),
services_context._strip_internal_markers(inject_apipilot_text_pass1),
)
# 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 inject_apipilot_text_pass1:
system_parts.append(inject_apipilot_text_pass1)
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])
# Capture the ApiPilot marker, execute its tool and run OpenAI pass 2.
tool_name_p1_oai, tool_args_p1_oai = services_tool_context._apipilot_parse_call_marker(answer)
if tool_name_p1_oai:
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_TRIGGER name=%r argsKeys=%s",
tool_name_p1_oai,
sorted(list((tool_args_p1_oai or {}).keys())),
)
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 trigger OPENAI marker name=%s args=%s",
tool_name_p1_oai,
json.dumps(tool_args_p1_oai or {}, ensure_ascii=True)[:300],
)
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_EXEC_HELPER name=%r → forward to _apipilot_execute_tool",
tool_name_p1_oai,
)
tool_env_oai_p1 = await providers_apipilot._apipilot_execute_tool(
auth_header=apipilot_auth_header,
demo_session=None,
name=tool_name_p1_oai,
arguments=tool_args_p1_oai or {},
)
is_tool_err_oai = not bool(tool_env_oai_p1.get("success"))
tool_data_oai = tool_env_oai_p1.get("data")
if isinstance(tool_data_oai, str):
tool_text_oai = tool_data_oai
elif tool_data_oai is None:
tool_text_oai = ""
else:
try:
tool_text_oai = json.dumps(tool_data_oai, ensure_ascii=False, indent=2)
except Exception:
tool_text_oai = str(tool_data_oai)
if is_tool_err_oai and isinstance(tool_env_oai_p1.get("error"), dict):
err_oai = tool_env_oai_p1["error"]
err_oai_head = f"[{err_oai.get('code')}] {err_oai.get('message')}"
tool_text_oai = (err_oai_head + ("\n" + tool_text_oai if tool_text_oai.strip() else "")).strip()
inject_apipilot_pass2_oai = services_tool_context._resolve_apipilot_reasoning_context_block(
[],
call_result={
"name": tool_name_p1_oai,
"text": tool_text_oai,
"error": is_tool_err_oai,
},
)
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 OPENAI execute name=%s success=%s resultLen=%d → second chat/completions call",
tool_name_p1_oai,
str(not is_tool_err_oai).lower(),
len(tool_text_oai),
)
# Build pass 2 with the same instructions/context structure, replacing
# inject_apipilot_text_pass1 with the result block inject_apipilot_pass2_oai.
user_ctx_oai_2: list[str] = []
if voice_lock_block.strip():
user_ctx_oai_2.append(voice_lock_block)
if today_block.strip():
user_ctx_oai_2.append(today_block)
if inject_episode_text:
user_ctx_oai_2.append(inject_episode_text)
if inject_profile_text:
user_ctx_oai_2.append(inject_profile_text)
if inject_reports_text:
user_ctx_oai_2.append(inject_reports_text)
if inject_summary_text:
user_ctx_oai_2.append(inject_summary_text)
if inject_apipilot_pass2_oai:
user_ctx_oai_2.append(inject_apipilot_pass2_oai)
if original_user_context:
user_ctx_oai_2.append(original_user_context)
user_ctx_oai_2 = services_context._dedup_context_blocks_by_type(user_ctx_oai_2)
stripped_oai_2: list[str] = []
for p in user_ctx_oai_2:
sp = services_context._strip_internal_markers(p)
if sp:
stripped_oai_2.append(sp)
user_ctx_oai_2 = stripped_oai_2
eff_user_ctx_oai_2 = "\n\n".join(user_ctx_oai_2)
if settings.S2S_INJECT_PROFILE:
eff_inst_oai_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_oai_2 = (req.instructions or "").strip()
sys_parts_oai_2: list[str] = []
instr_oai_2 = eff_inst_oai_2 or ((req.instructions or "").strip())
if instr_oai_2:
sys_parts_oai_2.append(instr_oai_2)
if eff_user_ctx_oai_2.strip():
sys_parts_oai_2.append(f"User context:\n{eff_user_ctx_oai_2.strip()}")
if inject_apipilot_pass2_oai:
sys_parts_oai_2.append(inject_apipilot_pass2_oai)
if req.language.strip():
sys_parts_oai_2.append(
"Reply only with the final assistant answer text. "
f"Keep the answer natural and concise in this language: {req.language.strip()}."
)
else:
sys_parts_oai_2.append(
"Reply only with the final assistant answer text. Keep the answer natural and concise."
)
system_msg_oai_2 = "\n\n".join(sys_parts_oai_2)
body_oai_2 = {
"model": settings.OPENAI_REASONING_MODEL,
"messages": [
{"role": "system", "content": system_msg_oai_2},
{"role": "user", "content": transcript},
],
}
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[DEBUG-PROMPT][REASONING → OPENAI PASS-2] FULL SYSTEM message (len=%d):\n%s\n",
len(system_msg_oai_2),
system_msg_oai_2,
)
try:
async with httpx.AsyncClient(timeout=30.0) as http_oai2:
resp_oai_2 = await http_oai2.post(
f"{settings.OPENAI_BASE_URL}/chat/completions",
headers=_openai_headers(),
json=body_oai_2,
)
except httpx.RequestError as exc:
app_logging.logger.warning("[EXT][OPENAI PASS-2] unreachable: %r", exc)
raise HTTPException( # noqa: B904
status_code=502,
detail="No se puede conectar con el servicio de razonamiento de OpenAI (segunda pasada).",
)
if resp_oai_2.status_code != 200:
app_logging.logger.warning(
"[EXT][OPENAI PASS-2] failed %s: %s", resp_oai_2.status_code, resp_oai_2.text[:400]
)
raise HTTPException(
status_code=502,
detail=f"La segunda pasada del razonamiento de OpenAI falló ({resp_oai_2.status_code}).",
)
try:
payload_oai_2 = resp_oai_2.json()
except Exception:
app_logging.logger.warning("[EXT][OPENAI PASS-2] invalid JSON: %s", resp_oai_2.text[:400])
raise HTTPException( # noqa: B904
status_code=502,
detail="El servicio de razonamiento de OpenAI devolvió un JSON no válido (segunda pasada).",
)
final_oai_ans = ""
choices_oai_2 = payload_oai_2.get("choices")
if isinstance(choices_oai_2, list) and choices_oai_2:
msg_oai_2 = choices_oai_2[0].get("message") if isinstance(choices_oai_2[0], dict) else None
if isinstance(msg_oai_2, dict):
content_oai_2 = msg_oai_2.get("content")
if isinstance(content_oai_2, str):
final_oai_ans = content_oai_2.strip()
usage_oai_2 = payload_oai_2.get("usage")
if not final_oai_ans:
# Last-resort fallback: raw tool-result text.
app_logging.logger.warning("[EXT][OPENAI PASS-2] empty answer, falling back to raw tool-result text")
final_oai_ans = (tool_text_oai or answer).strip()
final_out_oai: dict = {"answer": final_oai_ans}
if usage_oai_2:
final_out_oai["usage_pass2"] = usage_oai_2
final_out_oai["apipilot_invoked"] = {
"name": tool_name_p1_oai,
"success": not is_tool_err_oai,
"result_length": len(tool_text_oai),
}
app_logging.logger.info(
"[Vocero REASONING] engine=OPENAI PASS-2 transcript=%s finalAnswer=%s apipilot=%s",
transcript[:300],
final_oai_ans[:500],
json.dumps(final_out_oai["apipilot_invoked"], ensure_ascii=True),
)
return validated_json_response(final_out_oai, ReasoningResponse)
# Without a marker, return the original pass-1 answer.
return validated_json_response({"answer": answer}, ReasoningResponse)