Files
vocero-client/vocero-api/vocero_api/services/tool_context.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

630 lines
28 KiB
Python

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()