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.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,651 @@
|
||||
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)
|
||||
@@ -0,0 +1,629 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import cast
|
||||
|
||||
from vocero_api.core import logging as app_logging
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# APIPILOT — Reasoning engine integration (two passes)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deduplication/start/end markers used by _classify_context_block to avoid
|
||||
# injecting a block already supplied by the client or found in base_instructions.
|
||||
APIPILOT_PASS1_LIST_MARKER = "<<<APIPILOT_TOOLS_LIST_BEGIN>>>"
|
||||
APIPILOT_PASS2_RESULT_MARKER = "<<<APIPILOT_RESULT:"
|
||||
|
||||
# Flow:
|
||||
# PASS 1: inject {name,description,required,parameters} and marker rules.
|
||||
# The model matches descriptions and emits the CALL marker.
|
||||
# PASS 2: capture the marker, execute through S2S_APIPILOT_TOOLS_EXECUTE_URL,
|
||||
# inject the result text, and request a natural spoken response.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_APIPILOT_CALL_MARKER_RE = re.compile(
|
||||
r"[<]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[>]{1,3}",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# 2026-09-25: observed inverted marker (incorrect LLM syntax):
|
||||
# >>>APIPILOT_CALL:NAME=tool,ARGUMENTS=json<<<
|
||||
_APIPILOT_CALL_MARKER_INVERTED_RE = re.compile(
|
||||
r"[>]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[<]{1,3}",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _apipilot_parse_call_marker(answer_text: str, *, defaults: dict | None = None) -> tuple[str | None, dict | None]:
|
||||
"""Parse the mandatory CALL marker emitted by the LLM in pass 1.
|
||||
|
||||
Returns (tool_name, arguments_dict), or (None, None) when no marker exists.
|
||||
|
||||
Supports:
|
||||
(1) ARGUMENTS={"key":"val"}, valid JSON.
|
||||
(2) ARGUMENTS={}, a valid empty object.
|
||||
(3) ARGUMENTS= without braces, observed on realtime WebSocket sessions.
|
||||
(4) key=val,key2=val2 strings through a manual parsing fallback.
|
||||
(5) After parsing, fill missing required arguments from defaults such as
|
||||
phone and episode_id.
|
||||
"""
|
||||
if not isinstance(answer_text, str) or not answer_text.strip():
|
||||
app_logging.logger.info("[APIPILOT-CALL-NAME] USE=PARSE_EMPTY_INPUT name=None markerFound=N")
|
||||
return None, None
|
||||
m = _APIPILOT_CALL_MARKER_RE.search(answer_text.strip())
|
||||
inverted = False
|
||||
if not m:
|
||||
m = _APIPILOT_CALL_MARKER_INVERTED_RE.search(answer_text.strip())
|
||||
inverted = bool(m)
|
||||
if not m:
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT-CALL-NAME] USE=PARSE_NO_MATCH name=None markerFound=N textHead=%r", answer_text.strip()[:80]
|
||||
)
|
||||
return None, None
|
||||
name_raw = m.group(1).strip()
|
||||
args_raw = (m.group(2) if m.lastindex and m.lastindex >= 2 and isinstance(m.group(2), str) else "").strip()
|
||||
if not name_raw:
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT-CALL-NAME] USE=PARSE_EMPTY_NAME name=None markerFound=Y argsRawHead=%r inverted=%s",
|
||||
args_raw[:60],
|
||||
("Y" if inverted else "N"),
|
||||
)
|
||||
return None, None
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT-CALL-NAME] USE=PARSE_MARKER_OK name=%r argsRawLen=%d markerFound=Y inverted=%s",
|
||||
name_raw,
|
||||
len(args_raw),
|
||||
("Y" if inverted else "N"),
|
||||
)
|
||||
args: dict = {}
|
||||
# Parse args_raw into a dict, tolerating common formatting variations.
|
||||
if args_raw == "" or args_raw in {"{}", "null", "None"}:
|
||||
args = {}
|
||||
else:
|
||||
# Attempt 1: standard JSON, with or without braces.
|
||||
_candidate = args_raw
|
||||
try:
|
||||
args = json.loads(_candidate)
|
||||
except Exception:
|
||||
try:
|
||||
# Attempt 2: supply missing outer braces.
|
||||
args = json.loads("{" + _candidate + "}")
|
||||
except Exception:
|
||||
try:
|
||||
# Attempt 3: replace single quotes with double quotes.
|
||||
args = json.loads(_candidate.replace("'", '"'))
|
||||
except Exception:
|
||||
try:
|
||||
# Attempt 4: supply braces and replace single quotes.
|
||||
args = json.loads("{" + _candidate.replace("'", '"') + "}")
|
||||
except Exception:
|
||||
# Manual fallback 5: equals signs and commas.
|
||||
# Example: args_raw="phone=+56,name=Mario" → dict.
|
||||
args = {}
|
||||
for piece in args_raw.split(","):
|
||||
kv = piece.strip()
|
||||
if not kv or "=" not in kv:
|
||||
continue
|
||||
k, v = kv.split("=", 1)
|
||||
k = k.strip().strip('"').strip("'")
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k:
|
||||
args[k] = v
|
||||
if not isinstance(args, dict):
|
||||
args = {}
|
||||
# Stage 2: fill missing arguments from defaults (phone, etc.).
|
||||
if isinstance(defaults, dict) and defaults:
|
||||
for dk, dv in defaults.items():
|
||||
if dk not in args or args[dk] in (None, "", []):
|
||||
if dv not in (None, "", []):
|
||||
args[dk] = dv
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT-CALL-NAME] USE=PARSE_RETURN_FINAL name=%r finalArgsKeys=%s defaultsApplied=%s",
|
||||
name_raw,
|
||||
sorted(list(args.keys())),
|
||||
("Y" if isinstance(defaults, dict) and defaults else "N"),
|
||||
)
|
||||
return name_raw, args
|
||||
|
||||
|
||||
def _resolve_apipilot_reasoning_context_block(
|
||||
tools_list: list[dict] | None,
|
||||
*,
|
||||
call_result: dict | None = None,
|
||||
context_defaults: dict | None = None,
|
||||
) -> str:
|
||||
"""Build the ApiPilot context block for the reasoning prompt.
|
||||
|
||||
With call_result=None, pass 1 supplies the tool list and marker syntax
|
||||
the model must emit when a description matches.
|
||||
|
||||
With call_result={'name':str, 'text':str, 'error':bool}, pass 2 injects the
|
||||
execution result and requests a final spoken/text response for the user.
|
||||
|
||||
The optional context_defaults dict adds known default values to pass 1.
|
||||
The model must use these exact values for required arguments rather than
|
||||
inventing them. Usually contains {"phone": "+56..."} from inject_phone.
|
||||
"""
|
||||
# Pass 2: the tool result is already available.
|
||||
if isinstance(call_result, dict) and call_result.get("name"):
|
||||
name = call_result.get("name") or "?"
|
||||
text = (call_result.get("text") if isinstance(call_result.get("text"), str) else "") or ""
|
||||
is_err = bool(call_result.get("error"))
|
||||
status_label = "ERROR" if is_err else "OK"
|
||||
title = f"<<<APIPILOT_RESULT:STATUS={status_label},NAME={name}>>>"
|
||||
trailer = (
|
||||
"Now reinterpret THE RESULT TEXT (above) in clear, concise natural language, "
|
||||
"exclusively in the user's language (language field; Spanish by default). "
|
||||
"Do not mention the internal tool or technical details. "
|
||||
"Respond ONLY with the final content intended for the user."
|
||||
)
|
||||
return (
|
||||
f"{APIPILOT_PASS2_RESULT_MARKER}PASS2>>>\n"
|
||||
"### Invoked ApiPilot tool result\n"
|
||||
f"{title}\n"
|
||||
f"\n```tool-output\n"
|
||||
f"{text.rstrip() if text.strip() else '(empty output)'}\n"
|
||||
f"```\n\n"
|
||||
f"{trailer}\n"
|
||||
)
|
||||
# Pass 1: available tools and marker rules.
|
||||
tools = tools_list if isinstance(tools_list, list) else []
|
||||
# Limit to 100 tools, although the guide specifies at most 50 for the demo.
|
||||
tools_cap = tools[:100]
|
||||
if not tools_cap:
|
||||
return ""
|
||||
entries: list[str] = []
|
||||
for t in tools_cap:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
name = t.get("name") if isinstance(t.get("name"), str) else ""
|
||||
if not name:
|
||||
continue
|
||||
desc = cast(str, t.get("description")) if isinstance(t.get("description"), str) else ""
|
||||
params = t.get("parameters") if isinstance(t.get("parameters"), dict) else None
|
||||
requires_auth = bool(t.get("requires_auth"))
|
||||
required_fields: list[str] = []
|
||||
if isinstance(params, dict) and isinstance(params.get("required"), list):
|
||||
required_fields = [str(x) for x in cast(list, params.get("required")) if isinstance(x, str)]
|
||||
# Limit descriptions to about 500 characters to control prompt size.
|
||||
desc_cut = desc[:500].rstrip()
|
||||
if len(desc) > 500:
|
||||
desc_cut += "…"
|
||||
snippet = f"- name: {name}\n requires_auth: {('true' if requires_auth else 'false')}"
|
||||
if required_fields:
|
||||
snippet += f"\n required_parameters: [{', '.join(required_fields[:40])}]"
|
||||
if desc_cut:
|
||||
snippet += f"\n description: {desc_cut}"
|
||||
entries.append(snippet)
|
||||
if not entries:
|
||||
return ""
|
||||
rule = (
|
||||
f"{APIPILOT_PASS1_LIST_MARKER}\n"
|
||||
"### Available dynamic tools (ApiPilot AquaPortal)\n"
|
||||
"\n"
|
||||
"Each tool has a `name` field (a unique identifier: do NOT modify it, "
|
||||
"use camelCase, or translate it) and a `description` describing its use case.\n"
|
||||
"\n"
|
||||
"MANDATORY EXECUTION RULES:\n"
|
||||
"1. If the USER REQUEST (transcript) clearly matches the `description` of "
|
||||
"EXACTLY one tool below, emit ONLY one line as the FIRST line of YOUR RESPONSE, "
|
||||
"with this exact syntax:\n"
|
||||
' <<<APIPILOT_CALL:NAME=tool_name,ARGUMENTS={"parameter1":"value1","parameter2":"value2"}>>>\n'
|
||||
"2. ARGUMENTS={…} MUST be valid JSON (double quotes, commas, no comments). "
|
||||
"Include ONLY fields listed in `required_parameters:`, if present.\n"
|
||||
"3. Add NO text after the marker line: no explanations or previews. The engine "
|
||||
"will execute the tool and supply its result in a second call.\n"
|
||||
"4. If NO description matches, emit NO marker and respond normally in Spanish.\n"
|
||||
"5. If multiple descriptions match, choose the most specific one and use the marker.\n"
|
||||
"6. 🔴 EXCLUSIVE RESPONSE MODE (HIGHEST priority, mandatory): when using a CALL "
|
||||
"marker, YOUR ENTIRE RESPONSE MUST CONTAIN ONLY THE MARKER LINE. "
|
||||
"Write NOTHING BEFORE it: no 'Claro que sí', 'Voy a revisar', "
|
||||
"'Voy a generar tu briefing', greeting, or transition. "
|
||||
"Write NOTHING AFTER it: no comments, previews, or explanations. "
|
||||
"Exactly one line containing the marker. Additional text prevents the engine "
|
||||
"from recognizing the marker and executing the tool.\n"
|
||||
"8. INCORRECT examples (NEVER do this):\n"
|
||||
" 'Claro que sí, ahora ejecuto'\n"
|
||||
" 'Voy a generar tu briefing ejecutivo personal.'\n"
|
||||
" 'Claro que sí.\\n<<<APIPILOT_CALL:...>>>'\n"
|
||||
" '<<<APIPILOT_CALL:...>>>\\nAhora proceso el resultado.'\n"
|
||||
"9. 🔴 TTS ANTI-LEAK RULES (mandatory, HIGHEST audio priority):\n"
|
||||
" <<<APIPILOT_CALL:...>>> IS AN INTERNAL SYSTEM SIGNAL and MUST NEVER be spoken,\n"
|
||||
" read aloud, or synthesized by TTS. When emitting the marker:\n"
|
||||
" - INTERNAL REASONING (text/transcript) MUST contain the exact marker line.\n"
|
||||
" - FINAL TTS AUDIO heard by the user MUST contain ONLY this placeholder:\n"
|
||||
' "Voy a consultar tus datos, un momento por favor."\n'
|
||||
" - NEVER speak any of these technical terms in final TTS audio:\n"
|
||||
" APIPILOT, CALL, ARGUMENTS, tool, herramienta, ejecutar, function, function call,\n"
|
||||
" marcador, marker, even to explain or describe the call.\n"
|
||||
"\n"
|
||||
)
|
||||
# 2026-09-25 known defaults: inject values already resolved from the URL or
|
||||
# profile, such as phoneNumber, so tool arguments use those exact values.
|
||||
if isinstance(context_defaults, dict) and context_defaults:
|
||||
defaults_lines = []
|
||||
for dk, dv in context_defaults.items():
|
||||
if dv in (None, "", []):
|
||||
continue
|
||||
if isinstance(dv, (dict, list)):
|
||||
try:
|
||||
dv_repr = json.dumps(dv, ensure_ascii=False)
|
||||
except Exception:
|
||||
dv_repr = str(dv)
|
||||
else:
|
||||
dv_repr = str(dv)
|
||||
defaults_lines.append(f'- {dk} = "{dv_repr}"')
|
||||
if defaults_lines:
|
||||
rule += (
|
||||
"KNOWN DEFAULT VALUES (use these exact values in ARGUMENTS={…} when "
|
||||
"the corresponding tool lists these names in required_parameters; "
|
||||
"do not invent, translate, or normalize them):\n"
|
||||
f"{chr(10).join(defaults_lines)}\n"
|
||||
"\n"
|
||||
)
|
||||
rule += f"Tool list (name / description):\n{chr(10).join(entries)}\n<<<APIPILOT_TOOLS_LIST_END>>>"
|
||||
return rule
|
||||
|
||||
|
||||
def _sanitize_pass2_internal_context(text: str) -> str:
|
||||
"""Lightly sanitize the pass-2 block injected into the upstream model.
|
||||
|
||||
Unlike _sanitize_text_strip_apipilot_markers, preserve the pass-2 wrapper:
|
||||
the model needs it to recognize tool data. Limit sanitation to filesystem
|
||||
path leaks, fragmented marker tokens, and contextual phone leaks.
|
||||
"""
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return text
|
||||
# Block 3: Unix/Windows/TRAE path leaks, matching the user-output sanitizer.
|
||||
import re as _re_pass2
|
||||
|
||||
text = _re_pass2.sub(
|
||||
r"(?i)(?:/[A-Za-z0-9_.\-@~]+){2,}/(?:\.trae[^\s\"']*|workspaceStorage[^\s\"']*|User[^\s\"']*|home[^\s\"']*)",
|
||||
" ",
|
||||
text,
|
||||
)
|
||||
text = _re_pass2.sub(r"(?i)(?:[A-Z]:)?[\\/](?:Users|home|root|tmp|var|mnt|opt|srv)[\\/][^\s\"']{5,}", " ", text)
|
||||
text = _re_pass2.sub(r"(?i)(?:\.trae-server|\.trae[^\s\"']{0,40}|workspaceStorage[0-9a-f]+)", " ", text)
|
||||
text = _re_pass2.sub(r"\s{2,}", " ", text)
|
||||
# Block 4: fragmented marker leaks, tier 4.
|
||||
tier4_out_parts: list[str] = []
|
||||
for _line in text.splitlines(keepends=True):
|
||||
_orig = _line
|
||||
_low = _line.lower()
|
||||
if "apipilot" in _low and (
|
||||
("call" in _low and "name=" in _low) or ("arguments=" in _low) or ("result" in _low and "pass" in _low)
|
||||
):
|
||||
if "<<<" in _line or ">>>" in _line:
|
||||
_line = _re_pass2.sub(
|
||||
r"[<]{1,3}\s*APIPILOT_(?:CALL|RESULT)[^<>\n]{0,500}[>]{1,3}",
|
||||
" ",
|
||||
_line,
|
||||
flags=_re_pass2.IGNORECASE,
|
||||
)
|
||||
if "apipilot" in _line.lower():
|
||||
_line = _re_pass2.sub(
|
||||
r"(?i)\bAPIPILOT(?:_CALL|_RESULT)?\b",
|
||||
" ",
|
||||
_line,
|
||||
)
|
||||
_line = _re_pass2.sub(
|
||||
r"(?i)\b(?:NAME|ARGUMENTS|STATUS|REV|PASS2)\s*[:=]",
|
||||
" ",
|
||||
_line,
|
||||
)
|
||||
tier4_out_parts.append(_line if _line.strip() else _orig)
|
||||
text = "".join(tier4_out_parts)
|
||||
text = _re_pass2.sub(r"\s{2,}", " ", text)
|
||||
return text.strip() if isinstance(text, str) else text
|
||||
|
||||
|
||||
def _sanitize_text_strip_apipilot_markers(
|
||||
text: str,
|
||||
*,
|
||||
placeholder: str | None = None,
|
||||
) -> str:
|
||||
"""Remove all internal ApiPilot markers from user-visible chat and TTS text.
|
||||
|
||||
Replace pass-1 CALL markers with a Spanish placeholder. Silently remove
|
||||
pass-2 RESULT markers, their heading, tool-output block and instruction
|
||||
trailer. These server-only signals must never reach the user.
|
||||
|
||||
Args:
|
||||
text: Text to sanitize.
|
||||
placeholder: Optional replacement; defaults to the Spanish UI/voice message.
|
||||
"""
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
if placeholder is None:
|
||||
placeholder = "Voy a consultar tus datos, un momento por favor."
|
||||
|
||||
# ================================================================
|
||||
# Block 1: silently remove pass-2 RESULT markers and wrappers from TTS/UI.
|
||||
# ================================================================
|
||||
# (1a) Full pass-2 wrapper first: marker, heading, status, output, trailer.
|
||||
pass2_full_block_pattern = re.compile(
|
||||
r"[ \t]*(?:\r?\n|\r)?[ \t]*"
|
||||
r"<<<APIPILOT_RESULT:PASS2>>>[ \t]*(?:\r?\n|\r)+"
|
||||
r"###[ \t]+(?:Resultado de la herramienta ApiPilot invocada|Invoked ApiPilot tool result)[ \t]*(?:\r?\n|\r)+"
|
||||
r"<<<APIPILOT_RESULT:STATUS=[A-Za-z_]+,NAME=[A-Za-z0-9_.-]+>>>[ \t]*(?:\r?\n|\r)*"
|
||||
r"(?:\r?\n|\r)?"
|
||||
r"```tool-output[ \t]*(?:\r?\n|\r)?"
|
||||
r"(?P<tool_body>.*?)"
|
||||
r"(?:\r?\n|\r)?```[ \t]*(?:\r?\n|\r){0,3}"
|
||||
r"(?:"
|
||||
r"(?:Ahora debes reinterpretar EL TEXTO RESULTADO|Now reinterpret THE RESULT TEXT)[\s\S]{0,1200}?"
|
||||
r"(?:\r?\n|\r){0,2}"
|
||||
r")?"
|
||||
r"[ \t]*",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
text = pass2_full_block_pattern.sub("", text)
|
||||
|
||||
# (1b) Standalone PASS2 markers when the wrapper is incomplete or fragmented.
|
||||
pass2_marker_pattern = re.compile(
|
||||
r"[ \t]*(?:\r?\n|\r)?[ \t]*<<<APIPILOT_RESULT(?::PASS2|:STATUS=[A-Za-z_]+,NAME=[A-Za-z0-9_.-]+)?>>>[ \t]*(?:\r?\n|\r)?[ \t]*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
text = pass2_marker_pattern.sub("", text)
|
||||
|
||||
# (1c) Residual Markdown heading after the marker has been removed.
|
||||
pass2_title_pattern = re.compile(
|
||||
r"[ \t]*(?:\r?\n|\r)?[ \t]*###[ \t]+(?:Resultado de la herramienta ApiPilot invocada|Invoked ApiPilot tool result)[ \t]*(?:\r?\n|\r)?[ \t]*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
text = pass2_title_pattern.sub("", text)
|
||||
|
||||
# (1d) Standalone tool-output blocks without a PASS2 wrapper.
|
||||
pass2_codeblock_pattern = re.compile(
|
||||
r"```tool-output[ \t]*(?:\r?\n|\r)?.*?(?:\r?\n|\r)?```",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
text = pass2_codeblock_pattern.sub("", text)
|
||||
|
||||
# (1e) Residual instruction trailers missed by the full-wrapper pattern.
|
||||
pass2_trailer_pattern = re.compile(
|
||||
r"(?:Ahora debes reinterpretar EL TEXTO RESULTADO[\s\S]{0,1200}?contenido final que le quieres transmitir al usuario|Now reinterpret THE RESULT TEXT[\s\S]{0,1200}?final content intended for the user)\.",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
text = pass2_trailer_pattern.sub("", text)
|
||||
# Broader fallback also handles trailers truncated before their closing text.
|
||||
pass2_trailer_fallback = re.compile(
|
||||
r"(?:Ahora debes reinterpretar[\s\S]{0,1500}?(?:al usuario\.|$)|Now reinterpret THE RESULT TEXT[\s\S]{0,1500}?(?:for the user\.|$))",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
text = pass2_trailer_fallback.sub("", text)
|
||||
# Handle residual middle fragments after the trailer's opening was removed.
|
||||
pass2_trailer_broad = re.compile(
|
||||
r"(?:en lenguaje natural[\s\S]{0,800}?contenido final que le quieres transmitir al usuario|in clear, concise natural language[\s\S]{0,800}?final content intended for the user)\.?",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
text = pass2_trailer_broad.sub("", text)
|
||||
|
||||
# ================================================================
|
||||
# Block 2: pass-1 CALL marker replacement with a Spanish placeholder.
|
||||
# ================================================================
|
||||
marker_full_pattern = re.compile(
|
||||
r"<<<APIPILOT_CALL:NAME=[A-Za-z0-9_.-]+,ARGUMENTS=.*?>>>",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
line_wrapped_pattern = re.compile(
|
||||
r"[ \t]*(?:\r?\n|\r)?[ \t]*<<<APIPILOT_CALL:NAME=[A-Za-z0-9_.-]+,ARGUMENTS=.*?>>>[ \t]*(?:\r?\n|\r)?[ \t]*",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
def _replacement_full(_m: re.Match) -> str:
|
||||
"""Replace standalone or inline markers with the spoken placeholder."""
|
||||
return placeholder
|
||||
|
||||
text = line_wrapped_pattern.sub(_replacement_full, text)
|
||||
text = marker_full_pattern.sub(_replacement_full, text)
|
||||
|
||||
# Remove truncated CALL markers such as <<<APIPILOT_CALL:NAME=tool,ARGS….
|
||||
trunc_call_pattern = re.compile(
|
||||
r"<<<APIPILOT_CALL(?::NAME=[A-Za-z0-9_.-]+,?[^>\n]{0,500}?)?>>*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
text = trunc_call_pattern.sub(_replacement_full, text)
|
||||
|
||||
# Make closing brackets optional to remove RESULT markers truncated by TTS,
|
||||
# such as <<<APIPILOT_RESULT:STATUS=OK,NAM without closing >>>.
|
||||
trunc_result_pattern = re.compile(
|
||||
r"<<<APIPILOT_RESULT(?::[A-Za-z0-9_=,:.\- ]{0,800}?)?>?>?>?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
text = trunc_result_pattern.sub("", text)
|
||||
# Remove remaining APIPILOT_RESULT fragments after partial sanitation.
|
||||
trunc_result_residual = re.compile(
|
||||
r"<{0,3}APIPILOT_RESULT(?::[^\s<]{0,800})?<{0,3}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
text = trunc_result_residual.sub("", text)
|
||||
|
||||
# Collapse whitespace left by pass-2 block removal.
|
||||
text = re.sub(r"[ \t]{2,}", " ", text)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
# Remove residual trailer fragments and empty parentheses before TTS.
|
||||
text = re.sub(r"\s*\((?:arriba|above)\)\s*", " ", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\(\s*\)", "", text)
|
||||
# ================================================================
|
||||
# Block 3: remove filesystem paths and host information leaked into TTS.
|
||||
# Match long absolute Unix/Windows/mixed paths and known workspace tokens.
|
||||
# ================================================================
|
||||
# (3a) Absolute Unix paths and nested paths with multiple separators.
|
||||
unix_path_pattern = re.compile(
|
||||
r"\b(?:/(?:home|tmp|var|root|opt|mnt|media|srv|proc|sys|etc|usr|Users|trae)[/\w@.+\-:#$%^&()[\]{}!~=,]*){2,}[A-Za-z0-9_./\-+@#$%^&()[\]{}!~=,:]*\.(?:txt|md|log|json|csv|pdf|doc|docx|py|js|ts|html|css|sh|yaml|yml|env|conf|cfg|ini|mp3|wav|ogg|png|jpg|jpeg|gif)\b"
|
||||
r"|"
|
||||
# Broad fallback for long absolute Unix paths under known directories,
|
||||
# including paths without extensions; avoid short URLs such as /api/v1.
|
||||
r"(?:^|\s+|\(|[,.;:!?])(/(?:home|tmp|var|root|opt|mnt|media|srv|proc|sys|etc|usr|Users|trae)[/A-Za-z0-9_\-+@#$%^&()[\]{}!~=,.]{10,})(?=$|\s|\)|[,.;:!?])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
text = unix_path_pattern.sub("", text)
|
||||
# (3b) Windows paths with single/double backslashes and mixed separators.
|
||||
win_path_pattern = re.compile(
|
||||
r"(?:^|\s|\(|[,.;:])"
|
||||
r"([A-Za-z]:[\\/]"
|
||||
r"|(?:[\\/]{1,2}(?:home|Users|Windows|Program|Program\sFiles|ProgramData|Users|trae|AppData)[\\/])"
|
||||
r"|(?:[\\/]{1,2}\.(?:trae|trae-server|vscode|config|cache)[\\/])"
|
||||
r")"
|
||||
r"[A-Za-z0-9_@.+\-:#$%^&()[\]{}!~=,\\/\s]{10,}"
|
||||
r"(?:\.(?:txt|md|log|json|csv|pdf|doc|docx|py|js|ts|html|css|sh|yaml|yml|env|conf|cfg|ini|mp3|wav|ogg|png|jpg|jpeg|gif))?"
|
||||
r"(?=$|\s|\)|[,.;:!?])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
text = win_path_pattern.sub("", text)
|
||||
# (3c) TRAE workspace tokens, including fragments without complete paths.
|
||||
trae_leak_tokens = re.compile(
|
||||
r"\b(?:workspaceStorage|long-text|\.trae-server|\.trae|ckg_server|ai-agent)\b[^\s()'\"`,.]{0,80}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
text = trae_leak_tokens.sub("", text)
|
||||
# (3d) Remove long tokens containing mixed separators or many path segments.
|
||||
mixed_path_pattern = re.compile(
|
||||
r"(?=\S{0,200})"
|
||||
r"(?=\S*(?:[/\\].*){5,})"
|
||||
r"(?:\S{10,})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
def _mixed_safe(m: re.Match[str]) -> str:
|
||||
s = m.group(0)
|
||||
has_slash = "/" in s
|
||||
has_back = "\\" in s
|
||||
if (has_slash and has_back) or s.count("/") + s.count("\\") >= 6:
|
||||
return ""
|
||||
return s
|
||||
|
||||
text = mixed_path_pattern.sub(_mixed_safe, text)
|
||||
# ================================================================
|
||||
# Block 4: remove fragmented APIPILOT CALL text from TTS. The upstream
|
||||
# pipeline (start_pipeline_rag / Qwen3-TTS) can remove <<< >>> and read:
|
||||
# "APIPILOT CALL name get_user_briefing arguments phone +56912345678"
|
||||
# The bracketed patterns cannot catch this, so use contextual fallback tiers.
|
||||
# ================================================================
|
||||
# Tier 1: full normalized form without braces, through the sentence ending.
|
||||
call_normalized_full = re.compile(
|
||||
r"(?:^|\s+|\(|[,.;:!?\"\'])\s*"
|
||||
r"(?:"
|
||||
r"APIPILOT[\s_\-]+CALL\s+(?:marker\s+)?(?:de\s+)?(?:señal\s+)?(?:herramienta\s+)?(?:funci[oó]n\s+)?"
|
||||
r"|marcador\s+APIPILOT(?:\s+CALL)?\s*"
|
||||
r"|señal\s+de\s+llamada\s+APIPILOT\s*"
|
||||
r"|llamada\s+(?:a\s+)?(?:la\s+)?(?:herramienta|funci[oó]n)\s+APIPILOT\s*"
|
||||
r")"
|
||||
r"(?:\s*(?:con\s+)?(?:identificador|ID|name|nombre)\s*[:=]?\s*[A-Za-z0-9_.-]+)?"
|
||||
r"(?:\s*(?:y\s+)?(?:con\s+)?(?:argumentos|ARGUMENTS|params|par[áa]metros)\s*[:=]?\s*(?:\{[^{}]{0,800}\}|[\"'][^\"']{0,200}[\"']|[^\s.!,?;]{3,100}(?:\s*[,:]\s*[^\s.!,?;]{3,100})*\s*)?)?"
|
||||
r"(?:\s*(?:phone|tel[ée]fono|correo|email)\s*[:=]?\s*[+0-9A-Za-z._@\-]{6,50})?"
|
||||
r"(?=$|\s+|\)|[,.;:!?\"\'])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
text = call_normalized_full.sub(placeholder, text)
|
||||
# Tier 2: shortened forms with APIPILOT_CALL or adjacent APIPILOT and CALL.
|
||||
apipilot_call_token = re.compile(
|
||||
r"(?:^|\s|\(|[,.;:!?\"\'])\s*"
|
||||
r"APIPILOT(?:[\s_\-]+CALL|_CALL|\s+llamada|\s+marker)"
|
||||
r"(?:[^.!,?;\n]{0,600})?"
|
||||
r"(?=$|\s|\)|[,.;:!?\"\'])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
text = apipilot_call_token.sub(placeholder, text)
|
||||
# Tier 3: standalone service tokens in call/name/argument contexts.
|
||||
service_token_context = re.compile(
|
||||
r"(?:"
|
||||
r"ARGUMENTS\s*[:=]\s*(?:\{[^{}]{0,800}\}|[^\s,.;!?]{3,100})"
|
||||
r"|\barguments\s+(?:de\s+)?(?:la\s+)?(?:llamada|herramienta|funci[oó]n)\b[^\n.]{0,200}"
|
||||
r")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
text = service_token_context.sub("", text)
|
||||
|
||||
# Tier 4: remove sentences containing APIPILOT with technical call context.
|
||||
def _apipilot_sentence(m: re.Match[str]) -> str:
|
||||
sentence = m.group(0)
|
||||
lowered = sentence.lower()
|
||||
# Preserve a sentence already containing the Spanish CALL placeholder,
|
||||
# including any natural text surrounding it.
|
||||
if "voy a consultar tus datos" in lowered:
|
||||
return sentence
|
||||
ctx_kw = (
|
||||
"name",
|
||||
"argument",
|
||||
"arg",
|
||||
"herramienta",
|
||||
"tool",
|
||||
"funci",
|
||||
"phone",
|
||||
"tel",
|
||||
"+56",
|
||||
"+44",
|
||||
"marker",
|
||||
"call",
|
||||
"llamada",
|
||||
"señal",
|
||||
"marcador",
|
||||
)
|
||||
if any(k in lowered for k in ctx_kw) and "apipilot" in lowered:
|
||||
return ""
|
||||
return sentence
|
||||
|
||||
catchall_sentence = re.compile(
|
||||
r"[^.?!;\n]{0,600}APIPILOT[^.?!;\n]{0,600}[.?!;\n]{0,1}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
text = catchall_sentence.sub(_apipilot_sentence, text)
|
||||
# Tier 5: replace any remaining APIPILOT CALL tokens, even without context.
|
||||
final_apipilot_leak = re.compile(
|
||||
r"\bAPIPILOT[\s_\-]+CALL\b|\bAPIPILOT_CALL\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
text = final_apipilot_leak.sub(placeholder, text)
|
||||
# Tier 6: remove residual fake/real phone numbers from internal call text.
|
||||
# Match compact and spaced forms to tolerate TTS normalization.
|
||||
fake_phone_pattern = re.compile(
|
||||
r"("
|
||||
# Compact forms without spaces:
|
||||
r"\+56912345678"
|
||||
r"|\+1234567890"
|
||||
r"|\+447432477433"
|
||||
r"|56912345678"
|
||||
r"|1234567890"
|
||||
r"|447432477433"
|
||||
# Spaced forms with punctuation:
|
||||
r"|\+?\s*56\s*[\s\-()]?9\s*[\s\-()]?1\s*[\s\-()]?2\s*[\s\-()]?3\s*[\s\-()]?4\s*[\s\-()]?5\s*[\s\-()]?6\s*[\s\-()]?7\s*[\s\-()]?8"
|
||||
r"|\+?\s*1\s*[\s\-()]?2\s*[\s\-()]?3\s*[\s\-()]?4\s*[\s\-()]?5\s*[\s\-()]?6\s*[\s\-()]?7\s*[\s\-()]?8\s*[\s\-()]?9\s*[\s\-()]?0"
|
||||
r"|\+?\s*44\s*[\s\-()]?7\s*[\s\-()]?4\s*[\s\-()]?3\s*[\s\-()]?2\s*[\s\-()]?4\s*[\s\-()]?7\s*[\s\-()]?7\s*[\s\-()]?4\s*[\s\-()]?3\s*[\s\-()]?3"
|
||||
# Generic labeled forms (English or Spanish phone labels):
|
||||
r"|phone\s*[:=]\s*[+()\-\s]*[0-9][+()\-\s0-9]{6,}"
|
||||
r"|tel(?:éfono|efono)?\s*[:=]\s*[+()\-\s]*[0-9][+()\-\s0-9]{6,}"
|
||||
r")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Strip numbers only from lines with technical call context or the Spanish
|
||||
# placeholder, indicating that the number belongs to a replaced CALL marker.
|
||||
lines = text.splitlines(keepends=True)
|
||||
_phone_ctx = (
|
||||
"apipilot",
|
||||
"call",
|
||||
"arguments",
|
||||
"marker",
|
||||
"herramienta",
|
||||
"funcion",
|
||||
"función",
|
||||
"ejecutar",
|
||||
"llamada",
|
||||
"marcador",
|
||||
"señal",
|
||||
"voy a consultar tus datos",
|
||||
)
|
||||
|
||||
def _phone_line_safe(line: str) -> str:
|
||||
lowered = line.lower()
|
||||
has_ctx = any(c in lowered for c in _phone_ctx)
|
||||
if has_ctx:
|
||||
# Remove phone numbers in this line (strip also surrounding space)
|
||||
return fake_phone_pattern.sub(" ", line)
|
||||
return line
|
||||
|
||||
text = "".join(_phone_line_safe(line) for line in lines)
|
||||
# Collapse whitespace left by marker and path removal.
|
||||
text = re.sub(r"[ \t]{2,}", " ", text)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
Reference in New Issue
Block a user