ajuste no contas
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 []
|
||||
|
||||
Reference in New Issue
Block a user