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)