ajuste no contas

This commit is contained in:
T3782834
2026-09-01 11:24:03 -03:00
parent 9ed4782f9d
commit 397b831fd3
428 changed files with 2294 additions and 4648 deletions

View File

@@ -147,10 +147,36 @@ def evaluate(state: dict[str, Any]) -> PolicyDecision | None:
method = str(route_decision.get("method") or "")
intent = str(state.get("intent") or route_decision.get("intent") or "")
previous_count = int(state.get("no_match_count") or 0)
reset_patch: dict[str, Any] = {"no_match_count": 0} if previous_count else {}
# Explicit conversational closure is a lifecycle signal, not a new business
# intent. It must take precedence over router no-match so deterministic closure
# phrases are not turned into generic fallback responses. Never consume it while
# a transaction/workflow is still live.
if _CLOSE_RE.search(text) and not _has_live_transaction(state):
return PolicyDecision(
route="end_session", intent="contas_conversation_close",
reason="explicit_customer_closure",
patch={**reset_patch, "terminal_status": "resolvido",
"conversation_terminal_message": "Atendimento encerrado. Obrigado pelo contato."},
)
# The first utterance after a completed operational boundary is a fresh
# interaction, never a resume of the workflow that just ended. A short
# re-engagement such as "ah espera" may legitimately have no business intent
# yet; answer with a neutral invitation instead of treating it as an error.
if intent == "contas_no_match" and bool(state.get("operational_context_reset")):
return PolicyDecision(
route="conversation_policy_response", intent="contas_post_terminal_reentry",
answer="Claro. Pode falar. Em que posso ajudar agora?",
reason="fresh_interaction_after_operational_boundary",
patch={**reset_patch, "no_match_count": 0},
)
# 1) Consecutive incomprehensible utterances. A router fallback is the generic,
# architecture-native signal that no intent was understood. Any understood turn
# resets the counter. Default requirement is three consecutive failures.
previous_count = int(state.get("no_match_count") or 0)
if intent == "contas_no_match":
count = previous_count + 1
limit = max(1, int(os.getenv("CONTAS_NO_MATCH_MAX_CONSECUTIVE", "3")))
@@ -167,18 +193,6 @@ def evaluate(state: dict[str, Any]) -> PolicyDecision | None:
reason="no_match_retry", patch={"no_match_count": count},
)
reset_patch: dict[str, Any] = {"no_match_count": 0} if previous_count else {}
# Explicit conversational closure is a lifecycle signal, not a new business
# intent. Never consume it while a transaction/workflow is still live.
if _CLOSE_RE.search(text) and not _has_live_transaction(state):
return PolicyDecision(
route="end_session", intent="contas_conversation_close",
reason="explicit_customer_closure",
patch={**reset_patch, "terminal_status": "resolvido",
"conversation_terminal_message": "Atendimento encerrado. Obrigado pelo contato."},
)
# A short plural continuation after an invoice explanation belongs to that
# explanation; it must not be mistaken for generic finalization merely because
# it contains an affirmative such as "pode seguir". The invoice agent keeps

View File

@@ -244,6 +244,96 @@ def _money(value: Any) -> str:
return f"{d:.2f}".replace(".", ",")
def _invoice_focus_normalize(value: Any) -> str:
"""Normalize customer/item wording only for conservative invoice focus matching.
This does not infer a charge. It is used only to narrow an already fetched
current-invoice payload to an item whose meaningful name tokens are all present
in the customer's utterance. The backend data remains the authority.
"""
import unicodedata
raw = unicodedata.normalize("NFKD", str(value or "").lower().replace("+", " mais "))
raw = "".join(ch for ch in raw if not unicodedata.combining(ch))
return " ".join(re.sub(r"[^a-z0-9]+", " ", raw).split())
def _focused_invoice_explanation(user_text: str, billing_analysis: Any) -> str:
"""Return a factual explanation focused on invoice items named by the user.
The legacy Billing Analysis ``invoiceExplanation`` is a global variation summary
and can omit a specifically questioned item even when that item exists in
``currentInvoice``. In that case the generic summary is the wrong customer
answer and can trigger AOFERTA for mentioning unrelated services.
We narrow only when the user's utterance contains every meaningful token of an
authoritative current-invoice item name (e.g. ``VOD + Canais abertos``).
Otherwise the existing global explanation remains untouched.
"""
if not isinstance(billing_analysis, dict):
return ""
user_norm = _invoice_focus_normalize(user_text)
if not user_norm:
return ""
user_tokens = set(user_norm.split())
stop = {"de", "do", "da", "dos", "das", "e", "mais", "a", "o", "um", "uma"}
matches: list[dict[str, Any]] = []
for section in billing_analysis.get("currentInvoice") or []:
if not isinstance(section, dict):
continue
for item in section.get("items") or []:
if not isinstance(item, dict):
continue
desc = str(item.get("desc") or "").strip()
if not desc:
continue
desc_tokens = [t for t in _invoice_focus_normalize(desc).split() if t not in stop and len(t) > 1]
# Require at least two meaningful tokens. This avoids narrowing on
# generic one-word coincidences such as "plano" or "servico".
if len(desc_tokens) < 2 or not all(t in user_tokens for t in desc_tokens):
continue
matches.append(item)
if not matches:
return ""
# Keep a single canonical item family. If the utterance happens to fully match
# more than one distinct name, do not guess which one the customer meant.
names = {}
for item in matches:
key = _invoice_focus_normalize(item.get("desc"))
names.setdefault(key, []).append(item)
if len(names) != 1:
return ""
items = next(iter(names.values()))
canonical = str(items[0].get("desc") or "cobrança").strip()
def fmt_date(value: Any) -> str:
raw = str(value or "").strip()
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})", raw)
if m:
return f"{m.group(3)}/{m.group(2)}/{m.group(1)}"
return raw
details = []
for item in items:
value = _money(item.get("value"))
date = fmt_date(item.get("date"))
details.append(f"R$ {value}" + (f" em {date}" if date else ""))
count = len(details)
if count == 1:
prefix = f"Na fatura atual, identifiquei uma cobrança de {canonical}: "
else:
prefix = f"Na fatura atual, identifiquei {count} cobranças de {canonical}: "
if count == 1:
detail_text = details[0]
else:
detail_text = ", ".join(details[:-1]) + " e " + details[-1]
return (
prefix + detail_text + ". "
"Os dados consultados comprovam esses lançamentos na fatura, mas não informam "
"a origem ou contratação além do item faturado."
)
def _normalize_contestation_name(value: Any) -> str:
import unicodedata
text = unicodedata.normalize("NFKD", str(value or "").strip())
@@ -641,7 +731,9 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
call_args = dict(params)
call_args.pop("msisdn", None)
evidence = service.invoice_explanation(msisdn=msisdn, **call_args)
text = _extract_invoice_text(evidence.get("billing_analysis"))
billing_analysis = evidence.get("billing_analysis") if isinstance(evidence, dict) else None
user_text = str(_first(params, state, "message", "text", "customer_message", "user_text") or "").strip()
text = _focused_invoice_explanation(user_text, billing_analysis) or _extract_invoice_text(billing_analysis)
except TimApiError as exc:
transport = {"_transport": {"rct_operation": "base_conhecimento", "attempts": list(exc.attempts or [])}}
rct_events = _transport_rct_events(transport)
@@ -1104,7 +1196,7 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
concurrency = max(1, int(os.getenv("TIM_CANCELAMENTO_BATCH_CONCURRENCY", "5")))
semaphore = asyncio.Semaphore(concurrency)
async def _process_item(item: dict[str, Any]) -> dict[str, Any]:
async def _process_item_impl(item: dict[str, Any]) -> dict[str, Any]:
async with semaphore:
msisdn = str(item.get("msisdn") or _first(params, state, "msisdn"))
subject = str(item.get("name") or item.get("desc") or item.get("subject") or "")
@@ -1165,7 +1257,37 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
await idempotency_store.set(key, item_result)
return item_result
except Exception as exc:
return {"success": False, "msisdn": msisdn, "subject": subject, "error": str(exc), "protocol": protocol}
return {
"success": False,
"msisdn": msisdn,
"subject": subject,
"reason": "cancel_vas_failed",
"error": str(exc),
"protocol": protocol,
"recoverable": True,
}
async def _process_item(item: dict[str, Any]) -> dict[str, Any]:
"""Boundary de robustez: nenhuma falha técnica de um item derruba o batch/request."""
try:
return await _process_item_impl(item)
except Exception as exc:
msisdn = str(item.get("msisdn") or _first(params, state, "msisdn"))
subject = str(item.get("name") or item.get("desc") or item.get("subject") or "")
logger.exception(
"Falha não tratada no cancelamento VAS; convertendo em resultado recuperável msisdn=%s subject=%s",
msisdn,
subject,
)
return {
"success": False,
"msisdn": msisdn,
"subject": subject,
"reason": "internal_processing_failed",
"error": str(exc),
"recoverable": True,
"fallback_applied": True,
}
valid_items = [dict(x) for x in items if isinstance(x, dict)]
results = list(await asyncio.gather(*[_process_item(item) for item in valid_items])) if valid_items else []