diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc index 562654f..5318255 100644 Binary files a/app/__pycache__/__init__.cpython-313.pyc and b/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index a672e90..3696001 100644 Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/__pycache__/state.cpython-313.pyc b/app/__pycache__/state.cpython-313.pyc index d717a3c..402794e 100644 Binary files a/app/__pycache__/state.cpython-313.pyc and b/app/__pycache__/state.cpython-313.pyc differ diff --git a/app/agents/__pycache__/contas_prompting.cpython-313.pyc b/app/agents/__pycache__/contas_prompting.cpython-313.pyc index 636a781..f9af64f 100644 Binary files a/app/agents/__pycache__/contas_prompting.cpython-313.pyc and b/app/agents/__pycache__/contas_prompting.cpython-313.pyc differ diff --git a/app/agents/__pycache__/contestacao_agent.cpython-313.pyc b/app/agents/__pycache__/contestacao_agent.cpython-313.pyc index 2e94136..ab4790f 100644 Binary files a/app/agents/__pycache__/contestacao_agent.cpython-313.pyc and b/app/agents/__pycache__/contestacao_agent.cpython-313.pyc differ diff --git a/app/agents/__pycache__/faturas_agent.cpython-313.pyc b/app/agents/__pycache__/faturas_agent.cpython-313.pyc index e3329ed..80d6da8 100644 Binary files a/app/agents/__pycache__/faturas_agent.cpython-313.pyc and b/app/agents/__pycache__/faturas_agent.cpython-313.pyc differ diff --git a/app/agents/__pycache__/prompting.cpython-313.pyc b/app/agents/__pycache__/prompting.cpython-313.pyc index d5f57c0..86f6498 100644 Binary files a/app/agents/__pycache__/prompting.cpython-313.pyc and b/app/agents/__pycache__/prompting.cpython-313.pyc differ diff --git a/app/agents/__pycache__/runtime.cpython-313.pyc b/app/agents/__pycache__/runtime.cpython-313.pyc index ba22c1b..7a6d276 100644 Binary files a/app/agents/__pycache__/runtime.cpython-313.pyc and b/app/agents/__pycache__/runtime.cpython-313.pyc differ diff --git a/app/agents/__pycache__/suporte_contas_agent.cpython-313.pyc b/app/agents/__pycache__/suporte_contas_agent.cpython-313.pyc index 7bad766..c641fb9 100644 Binary files a/app/agents/__pycache__/suporte_contas_agent.cpython-313.pyc and b/app/agents/__pycache__/suporte_contas_agent.cpython-313.pyc differ diff --git a/app/agents/__pycache__/vas_agent.cpython-313.pyc b/app/agents/__pycache__/vas_agent.cpython-313.pyc index 01344c2..cbe1f48 100644 Binary files a/app/agents/__pycache__/vas_agent.cpython-313.pyc and b/app/agents/__pycache__/vas_agent.cpython-313.pyc differ diff --git a/app/domain/__pycache__/__init__.cpython-313.pyc b/app/domain/__pycache__/__init__.cpython-313.pyc index abfb0bf..bfcaf3c 100644 Binary files a/app/domain/__pycache__/__init__.cpython-313.pyc and b/app/domain/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/__init__.cpython-313.pyc b/app/domain/contas/__pycache__/__init__.cpython-313.pyc index e32cc5b..d82e866 100644 Binary files a/app/domain/contas/__pycache__/__init__.cpython-313.pyc and b/app/domain/contas/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/client.cpython-313.pyc b/app/domain/contas/__pycache__/client.cpython-313.pyc index 66f82ef..5c1a010 100644 Binary files a/app/domain/contas/__pycache__/client.cpython-313.pyc and b/app/domain/contas/__pycache__/client.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/contestation_rules.cpython-313.pyc b/app/domain/contas/__pycache__/contestation_rules.cpython-313.pyc index d187f0f..562cded 100644 Binary files a/app/domain/contas/__pycache__/contestation_rules.cpython-313.pyc and b/app/domain/contas/__pycache__/contestation_rules.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/contestation_validation.cpython-313.pyc b/app/domain/contas/__pycache__/contestation_validation.cpython-313.pyc index 7e25ba7..7adf0fa 100644 Binary files a/app/domain/contas/__pycache__/contestation_validation.cpython-313.pyc and b/app/domain/contas/__pycache__/contestation_validation.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/conversation_policy.cpython-313.pyc b/app/domain/contas/__pycache__/conversation_policy.cpython-313.pyc new file mode 100644 index 0000000..671d243 Binary files /dev/null and b/app/domain/contas/__pycache__/conversation_policy.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/ic_tags.cpython-313.pyc b/app/domain/contas/__pycache__/ic_tags.cpython-313.pyc index 1ef731d..3e338ee 100644 Binary files a/app/domain/contas/__pycache__/ic_tags.cpython-313.pyc and b/app/domain/contas/__pycache__/ic_tags.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/informational_context.cpython-313.pyc b/app/domain/contas/__pycache__/informational_context.cpython-313.pyc index 6d0e3e3..dc5cf8f 100644 Binary files a/app/domain/contas/__pycache__/informational_context.cpython-313.pyc and b/app/domain/contas/__pycache__/informational_context.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/invoice_context.cpython-313.pyc b/app/domain/contas/__pycache__/invoice_context.cpython-313.pyc index 746a7d7..bbe6ae3 100644 Binary files a/app/domain/contas/__pycache__/invoice_context.cpython-313.pyc and b/app/domain/contas/__pycache__/invoice_context.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/invoice_models.cpython-313.pyc b/app/domain/contas/__pycache__/invoice_models.cpython-313.pyc index 87a0191..fe4fdd9 100644 Binary files a/app/domain/contas/__pycache__/invoice_models.cpython-313.pyc and b/app/domain/contas/__pycache__/invoice_models.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/invoice_resolver.cpython-313.pyc b/app/domain/contas/__pycache__/invoice_resolver.cpython-313.pyc index a6d6def..dc0ba9a 100644 Binary files a/app/domain/contas/__pycache__/invoice_resolver.cpython-313.pyc and b/app/domain/contas/__pycache__/invoice_resolver.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/item_matcher.cpython-313.pyc b/app/domain/contas/__pycache__/item_matcher.cpython-313.pyc index 58d7bad..6801094 100644 Binary files a/app/domain/contas/__pycache__/item_matcher.cpython-313.pyc and b/app/domain/contas/__pycache__/item_matcher.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/line_reference.cpython-313.pyc b/app/domain/contas/__pycache__/line_reference.cpython-313.pyc index 049e820..db4d142 100644 Binary files a/app/domain/contas/__pycache__/line_reference.cpython-313.pyc and b/app/domain/contas/__pycache__/line_reference.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/pro_rata_rules.cpython-313.pyc b/app/domain/contas/__pycache__/pro_rata_rules.cpython-313.pyc index 04ee788..f579a96 100644 Binary files a/app/domain/contas/__pycache__/pro_rata_rules.cpython-313.pyc and b/app/domain/contas/__pycache__/pro_rata_rules.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/protocol_triplets.cpython-313.pyc b/app/domain/contas/__pycache__/protocol_triplets.cpython-313.pyc index cd0c697..18f1c80 100644 Binary files a/app/domain/contas/__pycache__/protocol_triplets.cpython-313.pyc and b/app/domain/contas/__pycache__/protocol_triplets.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/rct_policy.cpython-313.pyc b/app/domain/contas/__pycache__/rct_policy.cpython-313.pyc index 24d7f69..4fe21bc 100644 Binary files a/app/domain/contas/__pycache__/rct_policy.cpython-313.pyc and b/app/domain/contas/__pycache__/rct_policy.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/service.cpython-313.pyc b/app/domain/contas/__pycache__/service.cpython-313.pyc index 44803c0..ab27cd0 100644 Binary files a/app/domain/contas/__pycache__/service.cpython-313.pyc and b/app/domain/contas/__pycache__/service.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/string_metrics.cpython-313.pyc b/app/domain/contas/__pycache__/string_metrics.cpython-313.pyc index a21b708..d1f8b7d 100644 Binary files a/app/domain/contas/__pycache__/string_metrics.cpython-313.pyc and b/app/domain/contas/__pycache__/string_metrics.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/vas_cancellation_message.cpython-313.pyc b/app/domain/contas/__pycache__/vas_cancellation_message.cpython-313.pyc index 3d0b6a0..5be3a9e 100644 Binary files a/app/domain/contas/__pycache__/vas_cancellation_message.cpython-313.pyc and b/app/domain/contas/__pycache__/vas_cancellation_message.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/vas_variation.cpython-313.pyc b/app/domain/contas/__pycache__/vas_variation.cpython-313.pyc index ada21a1..8193567 100644 Binary files a/app/domain/contas/__pycache__/vas_variation.cpython-313.pyc and b/app/domain/contas/__pycache__/vas_variation.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/workflow_actions.cpython-313.pyc b/app/domain/contas/__pycache__/workflow_actions.cpython-313.pyc index 338c89a..40cd839 100644 Binary files a/app/domain/contas/__pycache__/workflow_actions.cpython-313.pyc and b/app/domain/contas/__pycache__/workflow_actions.cpython-313.pyc differ diff --git a/app/domain/contas/conversation_policy.py b/app/domain/contas/conversation_policy.py new file mode 100644 index 0000000..c34784d --- /dev/null +++ b/app/domain/contas/conversation_policy.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from typing import Any, Iterable + +from .invoice_resolver import InvoiceResolver +from .vas_variation import varied_avulso_items + +_REGULATORY_RE = re.compile(r"\b(anatel|procon|justi[cç]a|judicial|advogad[oa]|meus direitos|processar)\b", re.I) +_HUMAN_RE = re.compile(r"\b(atendente|humano|pessoa|operador|supervisor)\b", re.I) +_YES_RE = re.compile(r"^\s*(sim|s|pode|pode sim|isso|isso mesmo|quero|claro|ok|confirmo|vamos)\b", re.I) +_NO_RE = re.compile(r"^\s*(n[aã]o|nao|n|negativo|deixa|deixe|prefiro n[aã]o|quero atendente|atendente)\b", re.I) +_CLOSE_RE = re.compile(r"\b(era s[oó] isso|s[oó] isso mesmo|obrigad[oa].*era isso|entendi.*obrigad[oa]|pode encerrar|pode finalizar)\b", re.I) +_PLURAL_CONTINUE_RE = re.compile(r"\b(as duas|os dois|ambas|ambos)\b.*\b(pode|seguir|isso|sim|mesmo)\b", re.I) + + +@dataclass(frozen=True) +class PolicyDecision: + route: str | None = None + intent: str | None = None + answer: str | None = None + reason: str | None = None + patch: dict[str, Any] | None = None + + +def _walk_dicts(value: Any) -> Iterable[dict[str, Any]]: + if isinstance(value, dict): + yield value + for child in value.values(): + yield from _walk_dicts(child) + elif isinstance(value, list): + for child in value: + yield from _walk_dicts(child) + + +def _first_dict_value(state: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any] | None: + for obj in _walk_dicts(state): + for key in keys: + value = obj.get(key) + if isinstance(value, dict) and value: + return value + return None + + +def _explicit_answer(text: str) -> str | None: + raw = str(text or "").strip() + if _YES_RE.search(raw): + return "YES" + if _NO_RE.search(raw): + return "NO" + return None + + +def _previous_assistant_intent(state: dict[str, Any]) -> str: + for item in reversed(state.get("history") or []): + if not isinstance(item, dict) or str(item.get("role") or "") != "assistant": + continue + metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {} + direct = str(metadata.get("intent") or "").strip() + if direct: + return direct + decision = metadata.get("route_decision") + if isinstance(decision, dict): + return str(decision.get("intent") or "").strip() + return "" + + +def _has_live_transaction(state: dict[str, Any]) -> bool: + status = str(state.get("transaction_status") or "").upper() + if status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION", "WORKFLOW_PAUSED", "TOOL_RESULT_CLARIFICATION"}: + return True + return bool(state.get("active_transaction") or state.get("pending_tool_call") or state.get("pending_domain_workflow")) + + +def is_regulatory_threat(text: str) -> bool: + return bool(_REGULATORY_RE.search(str(text or ""))) + + +def _focused_subjects(state: dict[str, Any]) -> list[str]: + out: list[str] = [] + candidates = [ + state.get("active_transaction"), state.get("last_transaction"), + state.get("selected_tool_call"), state.get("pending_tool_call"), + (state.get("context") or {}).get("focused_items") if isinstance(state.get("context"), dict) else None, + ] + for candidate in candidates: + if isinstance(candidate, list): + for item in candidate: + if isinstance(item, str) and item.strip(): + out.append(item.strip()) + continue + if not isinstance(candidate, dict): + continue + args = candidate.get("arguments") if isinstance(candidate.get("arguments"), dict) else candidate + for key in ("subject", "item", "service", "name"): + value = args.get(key) if isinstance(args, dict) else None + if isinstance(value, str) and value.strip(): + out.append(value.strip()) + values = args.get("items") if isinstance(args, dict) else None + if isinstance(values, list): + for item in values: + if isinstance(item, dict): + value = item.get("name") or item.get("desc") or item.get("subject") + if value: + out.append(str(value).strip()) + return list(dict.fromkeys(x for x in out if x)) + + +def _retention_items(state: dict[str, Any]) -> list[dict[str, Any]]: + invoice_detail = _first_dict_value(state, ("invoice_detail", "invoiceDetail")) + if not invoice_detail: + return [] + invoice_variation = _first_dict_value(state, ("invoice_variation", "invoiceVariation", "billing_analysis")) + try: + items = varied_avulso_items( + invoice_variation=invoice_variation, + invoice_detail=invoice_detail, + resolver=InvoiceResolver(), + by_charge=True, + ) + except Exception: + return [] + if not items: + return [] + return [ + { + "name": item.canonical_name, + "msisdn": item.msisdn, + "value": str(item.value) if item.value is not None else None, + "charge_date": item.charge_date, + } + for item in items if item.msisdn and item.canonical_name + ] + + +def evaluate(state: dict[str, Any]) -> PolicyDecision | None: + """Contas-only conversational policy executed after generic routing. + + It never performs a side effect. It may only (a) answer/reprompt, (b) enrich + routing, or (c) prepare explicit domain context consumed by normal framework + transaction machinery. + """ + text = str(state.get("sanitized_input") or state.get("user_text") or "").strip() + route_decision = state.get("route_decision") if isinstance(state.get("route_decision"), dict) else {} + method = str(route_decision.get("method") or "") + intent = str(state.get("intent") or route_decision.get("intent") or "") + + # 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"))) + if count >= limit: + return PolicyDecision( + route="end_session", intent="contas_no_match_terminal", + reason="no_match_limit_reached", + patch={"no_match_count": count, "terminal_status": "erro_no_match", + "conversation_terminal_message": "Não consegui compreender sua solicitação após três tentativas. Vou encerrar este atendimento para evitar uma ação incorreta."}, + ) + return PolicyDecision( + route="conversation_policy_response", intent="contas_no_match_retry", + answer="Desculpe, não entendi. Poderia repetir de outra forma?", + 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 + # the prior evidence/context and can explain the two referenced charges. + previous_intent = _previous_assistant_intent(state) + if ( + previous_intent == "contas_invoice_explanation" + and _PLURAL_CONTINUE_RE.search(text) + and not _has_live_transaction(state) + ): + return PolicyDecision( + route="faturas_agent", intent="contas_invoice_explanation", + reason="invoice_plural_context_continuation", + patch={**reset_patch, "mcp_tools": ["invoice_explanation"], + "_route_metadata": {"contextual_reentry": True, "original_input": text}}, + ) + + # 2) Regulatory/legal wording is not itself an action. Only preserve action + # semantics when an entity is already concretely in focus in transaction state. + if is_regulatory_threat(text): + focused = _focused_subjects(state) + if focused: + subject = focused[0] + return PolicyDecision( + route="contestacao_agent", intent="contas_vas_cancel", + reason="regulatory_threat_with_focused_entity", + patch={**reset_patch, "regulatory_context": {"threat": True, "focused_items": focused}, + "mcp_tools": ["consultar_vas", "cancelar_vas_avulso"], + "context": {**(state.get("context") or {}), "focused_items": focused, "regulatory_threat": True}}, + ) + # Broad complaint: never invent an item/action from the invoice. + return PolicyDecision( + route="suporte_contas_agent", intent="contas_regulatory_complaint", + reason="regulatory_threat_without_focused_entity", + patch={**reset_patch, "regulatory_context": {"threat": True, "focused_items": []}, "mcp_tools": []}, + ) + + # 3) Two-step human-retention policy. It is only eligible when the router would + # hand off AND authoritative invoice evidence proves a varied cancellable VAS. + retention = state.get("contas_retention") if isinstance(state.get("contas_retention"), dict) else {} + pending = str(retention.get("pending_stage") or "") + offered = list(retention.get("stages_offered") or []) + retained_items = list(retention.get("items") or []) + + if pending: + answer_kind = _explicit_answer(text) + if pending == "explain": + if answer_kind == "YES": + return PolicyDecision( + route="faturas_agent", intent="contas_invoice_explanation", + reason="human_retention_explanation_accepted", + patch={**reset_patch, "mcp_tools": ["invoice_explanation"], + "contas_retention": {"pending_stage": None, "stages_offered": offered, "items": retained_items}}, + ) + if answer_kind == "NO" or _HUMAN_RE.search(text): + names = ", ".join(str(x.get("name")) for x in retained_items if isinstance(x, dict) and x.get("name")) + return PolicyDecision( + route="conversation_policy_response", intent="contas_retention_continue", + answer=(f"Antes de transferir, posso tratar o cancelamento dos serviços que participaram da variação da conta: {names}. Deseja que eu prossiga?" if names else "Antes de transferir, posso tratar os serviços que participaram da variação da conta. Deseja que eu prossiga?"), + reason="human_retention_offer_continue", + patch={**reset_patch, "contas_retention": {"pending_stage": "continue", "stages_offered": list(dict.fromkeys([*offered, "continue"])), "items": retained_items}}, + ) + elif pending == "continue": + if answer_kind == "YES": + names = [str(x.get("name")) for x in retained_items if isinstance(x, dict) and x.get("name")] + context = {**(state.get("context") or {}), "retention_items": retained_items, "focused_items": names} + # Route through the normal transactional agent. No side effect here. + semantic_request = "cancelar " + " e ".join(names) if names else text + return PolicyDecision( + route="contestacao_agent", intent="contas_vas_cancel", + reason="human_retention_cancel_accepted", + patch={**reset_patch, "mcp_tools": ["consultar_vas", "cancelar_vas_avulso"], "context": context, + "_route_metadata": {"contextual_reentry": True, "original_input": semantic_request, + "relevant_conversation_context": "Serviços avulsos comprovados na variação: " + ", ".join(names)}, + "contas_retention": {"pending_stage": None, "stages_offered": offered, "items": retained_items}}, + ) + if answer_kind == "NO" or _HUMAN_RE.search(text): + return PolicyDecision( + route="human_handoff", intent="human_handoff", reason="human_retention_declined", + patch={**reset_patch, "contas_retention": {"pending_stage": None, "stages_offered": offered, "items": retained_items}}, + ) + + if state.get("route") == "human_handoff" and "explain" not in offered: + items = _retention_items(state) + if items: + return PolicyDecision( + route="conversation_policy_response", intent="contas_retention_explain", + answer="Antes de transferir, identifiquei serviços avulsos que participaram da variação da sua conta. Posso primeiro explicar essa variação para você?", + reason="human_retention_offer_explain", + patch={**reset_patch, "human_handoff_requested": False, "session_control": "", + "contas_retention": {"pending_stage": "explain", "stages_offered": ["explain"], "items": items}}, + ) + + if reset_patch: + return PolicyDecision(patch=reset_patch) + return None diff --git a/app/domain/contas/integrations/__pycache__/secure_pdf_crypto.cpython-313.pyc b/app/domain/contas/integrations/__pycache__/secure_pdf_crypto.cpython-313.pyc index 420fc86..a76bd2b 100644 Binary files a/app/domain/contas/integrations/__pycache__/secure_pdf_crypto.cpython-313.pyc and b/app/domain/contas/integrations/__pycache__/secure_pdf_crypto.cpython-313.pyc differ diff --git a/app/domain/contas/parsers/__pycache__/__init__.cpython-313.pyc b/app/domain/contas/parsers/__pycache__/__init__.cpython-313.pyc index 4d216e8..37aa58e 100644 Binary files a/app/domain/contas/parsers/__pycache__/__init__.cpython-313.pyc and b/app/domain/contas/parsers/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/domain/contas/parsers/__pycache__/bill_parser.cpython-313.pyc b/app/domain/contas/parsers/__pycache__/bill_parser.cpython-313.pyc index 4e7f464..c8981b2 100644 Binary files a/app/domain/contas/parsers/__pycache__/bill_parser.cpython-313.pyc and b/app/domain/contas/parsers/__pycache__/bill_parser.cpython-313.pyc differ diff --git a/app/domain/contas/parsers/__pycache__/bill_processor.cpython-313.pyc b/app/domain/contas/parsers/__pycache__/bill_processor.cpython-313.pyc index e5e0ce8..6209bb8 100644 Binary files a/app/domain/contas/parsers/__pycache__/bill_processor.cpython-313.pyc and b/app/domain/contas/parsers/__pycache__/bill_processor.cpython-313.pyc differ diff --git a/app/extensions/__pycache__/__init__.cpython-313.pyc b/app/extensions/__pycache__/__init__.cpython-313.pyc index 1d3b65b..21b2bbf 100644 Binary files a/app/extensions/__pycache__/__init__.cpython-313.pyc and b/app/extensions/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/extensions/__pycache__/tim_guardrails.cpython-313.pyc b/app/extensions/__pycache__/tim_guardrails.cpython-313.pyc index b4fbe73..cac4ac2 100644 Binary files a/app/extensions/__pycache__/tim_guardrails.cpython-313.pyc and b/app/extensions/__pycache__/tim_guardrails.cpython-313.pyc differ diff --git a/app/extensions/__pycache__/tim_judges.cpython-313.pyc b/app/extensions/__pycache__/tim_judges.cpython-313.pyc index 017cdc7..17ccdc3 100644 Binary files a/app/extensions/__pycache__/tim_judges.cpython-313.pyc and b/app/extensions/__pycache__/tim_judges.cpython-313.pyc differ diff --git a/app/extensions/tim_prompts/__pycache__/__init__.cpython-313.pyc b/app/extensions/tim_prompts/__pycache__/__init__.cpython-313.pyc index dcb0854..1446445 100644 Binary files a/app/extensions/tim_prompts/__pycache__/__init__.cpython-313.pyc and b/app/extensions/tim_prompts/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/extensions/tim_prompts/__pycache__/aluc.cpython-313.pyc b/app/extensions/tim_prompts/__pycache__/aluc.cpython-313.pyc index c2d759e..fffd320 100644 Binary files a/app/extensions/tim_prompts/__pycache__/aluc.cpython-313.pyc and b/app/extensions/tim_prompts/__pycache__/aluc.cpython-313.pyc differ diff --git a/app/extensions/tim_prompts/__pycache__/ausencia_oferta_proativa.cpython-313.pyc b/app/extensions/tim_prompts/__pycache__/ausencia_oferta_proativa.cpython-313.pyc index 7183c95..6728381 100644 Binary files a/app/extensions/tim_prompts/__pycache__/ausencia_oferta_proativa.cpython-313.pyc and b/app/extensions/tim_prompts/__pycache__/ausencia_oferta_proativa.cpython-313.pyc differ diff --git a/app/extensions/tim_prompts/__pycache__/fraseologia.cpython-313.pyc b/app/extensions/tim_prompts/__pycache__/fraseologia.cpython-313.pyc index b8ff33a..51aeaaa 100644 Binary files a/app/extensions/tim_prompts/__pycache__/fraseologia.cpython-313.pyc and b/app/extensions/tim_prompts/__pycache__/fraseologia.cpython-313.pyc differ diff --git a/app/extensions/tim_prompts/__pycache__/out_of_scope.cpython-313.pyc b/app/extensions/tim_prompts/__pycache__/out_of_scope.cpython-313.pyc index 0a7201a..a2da261 100644 Binary files a/app/extensions/tim_prompts/__pycache__/out_of_scope.cpython-313.pyc and b/app/extensions/tim_prompts/__pycache__/out_of_scope.cpython-313.pyc differ diff --git a/app/extensions/tim_prompts/__pycache__/revprec.cpython-313.pyc b/app/extensions/tim_prompts/__pycache__/revprec.cpython-313.pyc index 0478eca..713221a 100644 Binary files a/app/extensions/tim_prompts/__pycache__/revprec.cpython-313.pyc and b/app/extensions/tim_prompts/__pycache__/revprec.cpython-313.pyc differ diff --git a/app/extensions/tim_prompts/__pycache__/rqlt.cpython-313.pyc b/app/extensions/tim_prompts/__pycache__/rqlt.cpython-313.pyc index 54e04f2..4fda7af 100644 Binary files a/app/extensions/tim_prompts/__pycache__/rqlt.cpython-313.pyc and b/app/extensions/tim_prompts/__pycache__/rqlt.cpython-313.pyc differ diff --git a/app/observability/__pycache__/__init__.cpython-313.pyc b/app/observability/__pycache__/__init__.cpython-313.pyc index 2c18533..4fe4eea 100644 Binary files a/app/observability/__pycache__/__init__.cpython-313.pyc and b/app/observability/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/observability/__pycache__/telemetry_observer.cpython-313.pyc b/app/observability/__pycache__/telemetry_observer.cpython-313.pyc index def8ec7..5542061 100644 Binary files a/app/observability/__pycache__/telemetry_observer.cpython-313.pyc and b/app/observability/__pycache__/telemetry_observer.cpython-313.pyc differ diff --git a/app/presentation/__pycache__/__init__.cpython-313.pyc b/app/presentation/__pycache__/__init__.cpython-313.pyc index 2c9878a..2ee6a3b 100644 Binary files a/app/presentation/__pycache__/__init__.cpython-313.pyc and b/app/presentation/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/presentation/__pycache__/tool_renderers.cpython-313.pyc b/app/presentation/__pycache__/tool_renderers.cpython-313.pyc index cf7326b..c4c0123 100644 Binary files a/app/presentation/__pycache__/tool_renderers.cpython-313.pyc and b/app/presentation/__pycache__/tool_renderers.cpython-313.pyc differ diff --git a/app/state.py b/app/state.py index 7122992..1bed096 100644 --- a/app/state.py +++ b/app/state.py @@ -62,3 +62,9 @@ class AgentState(TypedDict, total=False): long_term_memory_load_error: str operational_context_boundary_pending: bool operational_context_reset: bool + no_match_count: int + contas_retention: dict[str, Any] + regulatory_context: dict[str, Any] + terminal_status: str + conversation_terminal_message: str + diff --git a/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/app/workflows/__pycache__/agent_graph.cpython-313.pyc index f2ff450..7a62a87 100644 Binary files a/app/workflows/__pycache__/agent_graph.cpython-313.pyc and b/app/workflows/__pycache__/agent_graph.cpython-313.pyc differ diff --git a/app/workflows/agent_graph.py b/app/workflows/agent_graph.py index 209bcc0..77ee272 100644 --- a/app/workflows/agent_graph.py +++ b/app/workflows/agent_graph.py @@ -7,6 +7,7 @@ from agent_framework.guardrails.rail_action import RailAction from agent_framework.guardrails.rail_result import RailResult from agent_framework.judges.judge import JudgePipeline from agent_framework.routing.enterprise_router import EnterpriseRouter +from app.domain.contas.conversation_policy import evaluate as evaluate_contas_conversation_policy from agent_framework.supervisor.supervisor import Supervisor from agent_framework.observability.workflow_events import WorkflowTelemetry from agent_framework.observability.guardrail_events import GuardrailTelemetry @@ -292,6 +293,8 @@ class AgentWorkflow: builder.add_node("input_guardrails", self._node("input_guardrails", self.input_guardrails)) builder.add_node("load_long_term_memory", self._node("load_long_term_memory", self.load_long_term_memory)) builder.add_node("routing_decision", self._node("routing_decision", self.routing_decision)) + builder.add_node("conversation_policy", self._node("conversation_policy", self.conversation_policy)) + builder.add_node("conversation_policy_response", self._node("conversation_policy_response", self.conversation_policy_response)) builder.add_node("faturas_agent", self._node("faturas_agent", self.faturas_agent)) builder.add_node("vas_agent", self._node("vas_agent", self.vas_agent)) builder.add_node("contestacao_agent", self._node("contestacao_agent", self.contestacao_agent)) @@ -316,8 +319,9 @@ class AgentWorkflow: {"blocked": "output_guardrails", "continue": "load_long_term_memory"}, ) builder.add_edge("load_long_term_memory", "routing_decision") + builder.add_edge("routing_decision", "conversation_policy") builder.add_conditional_edges( - "routing_decision", + "conversation_policy", lambda s: s.get("route", "faturas_agent"), { "faturas_agent": "faturas_agent", @@ -328,6 +332,7 @@ class AgentWorkflow: "human_handoff": "human_handoff", "end_session": "end_session", "supervisor_agent": "supervisor_agent", + "conversation_policy_response": "conversation_policy_response", }, ) builder.add_edge("faturas_agent", "output_supervisor") @@ -338,6 +343,7 @@ class AgentWorkflow: builder.add_edge("human_handoff", "output_supervisor") builder.add_edge("end_session", "output_supervisor") builder.add_edge("supervisor_agent", "output_supervisor") + builder.add_edge("conversation_policy_response", "output_supervisor") builder.add_edge("output_supervisor", "output_guardrails") builder.add_conditional_edges( "output_guardrails", @@ -676,6 +682,26 @@ class AgentWorkflow: } if decision.method == "continuity" else {}, } + async def conversation_policy(self, state): + decision = evaluate_contas_conversation_policy(state) + if decision is None: + return {} + patch = dict(decision.patch or {}) + route_metadata_patch = patch.pop("_route_metadata", {}) if isinstance(patch.get("_route_metadata", {}), dict) else {} + if decision.route: + patch["route"] = decision.route + if decision.intent: + patch["intent"] = decision.intent + if decision.answer is not None: + patch["answer"] = decision.answer + if decision.reason: + current = state.get("route_decision") if isinstance(state.get("route_decision"), dict) else {} + patch["route_decision"] = {**current, "route": patch.get("route", state.get("route")), "intent": patch.get("intent", state.get("intent")), "reason": decision.reason, "metadata": {**(current.get("metadata") or {}), **route_metadata_patch, "contas_conversation_policy": decision.reason}} + return patch + + async def conversation_policy_response(self, state): + return {"answer": str(state.get("answer") or ""), "next_state": state.get("next_state") or "CONVERSATION_POLICY"} + async def faturas_agent(self, state): async with self.telemetry.span( "workflow.agent.billing", @@ -820,13 +846,13 @@ class AgentWorkflow: runtime_context = (state.get("context") or {}).get("business_context") or {} await self.tool_router.call( "finalizar_atendimento", - {"status": "resolvido", "summary": "Encerramento solicitado pelo agent_framework_oci", "confirmed": True}, + {"status": str(state.get("terminal_status") or "resolvido"), "summary": "Encerramento solicitado pelo agent_framework_oci", "confirmed": True}, business_context=runtime_context, original_context=state.get("context") or {}, ) except Exception: pass - answer = str(getattr(self.settings, "END_SESSION_MESSAGE", "Atendimento encerrado. Obrigado pelo contato.")) + answer = str(state.get("conversation_terminal_message") or getattr(self.settings, "END_SESSION_MESSAGE", "Atendimento encerrado. Obrigado pelo contato.")) await self.telemetry.event( "session.end.requested", { @@ -840,7 +866,7 @@ class AgentWorkflow: "answer": answer, "session_control": "END_SESSION", "session_ended": True, - "terminal_status": "resolvido", + "terminal_status": str(state.get("terminal_status") or "resolvido"), "human_handoff_requested": False, "next_state": "SESSION_ENDED", } diff --git a/config/routing.yaml b/config/routing.yaml index 6ff01cd..696ff77 100644 --- a/config/routing.yaml +++ b/config/routing.yaml @@ -50,6 +50,17 @@ state_policies: - state: COLLECTING_SUPORTE_CONTAS_PARAMETERS agent: suporte_contas_agent intents: +- name: contas_no_match + domain: telecom_contas + agent: suporte_contas_agent + priority: 200 + description: Fala realmente incompreensível, sem conteúdo semântico recuperável ou transcrição corrompida. Use somente quando não for possível entender nenhuma solicitação. Não use para assunto fora de escopo, reclamação, pedido de humano, jurídico/Anatel/Procon ou frase compreensível que apenas não corresponda a outra intent. + mcp_tools: [] + keywords: [] + examples: + - "asdkj qweoi zxcm" + - "hã trrr blá qx" + - "[fala sem conteúdo inteligível]" - name: contas_plan_information domain: telecom_contas agent: faturas_agent diff --git a/config/tools.yaml b/config/tools.yaml index 0df1bd2..8f38845 100644 --- a/config/tools.yaml +++ b/config/tools.yaml @@ -111,9 +111,10 @@ tools: args_schema: subject: type: string - description: Referência a um serviço, produto ou benefício concreto e identificável nas evidências VAS/fatura do cliente. - Categorias ou referências genéricas não identificam uma entidade por si só; se o texto não permitir resolver univocamente - para um item real, mantenha subject ausente/null. + description: Referência a um ou mais serviços, produtos ou benefícios concretos e identificáveis nas evidências VAS/fatura do cliente. + Preserve referências múltiplas e anafóricas quando o contexto imediato as resolver (por exemplo, "os dois", "ambos", + "Netflix e HBO", "todos os VAS avulsos"). Categorias genéricas sem foco conversacional não identificam entidade; se o texto + e o contexto não permitirem resolução segura para um item real nas evidências autorizadas, mantenha subject ausente/null. user_prompt: Qual serviço você deseja cancelar? selection_keywords: - cancelar serviço diff --git a/contas_mcp/__pycache__/__init__.cpython-313.pyc b/contas_mcp/__pycache__/__init__.cpython-313.pyc index 76871f4..6eaf3bb 100644 Binary files a/contas_mcp/__pycache__/__init__.cpython-313.pyc and b/contas_mcp/__pycache__/__init__.cpython-313.pyc differ diff --git a/contas_mcp/servers/__pycache__/__init__.cpython-313.pyc b/contas_mcp/servers/__pycache__/__init__.cpython-313.pyc index 635bb83..403be7b 100644 Binary files a/contas_mcp/servers/__pycache__/__init__.cpython-313.pyc and b/contas_mcp/servers/__pycache__/__init__.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/__pycache__/__init__.cpython-313.pyc b/contas_mcp/servers/contas_mcp_server/__pycache__/__init__.cpython-313.pyc index cdf344e..bf41d42 100644 Binary files a/contas_mcp/servers/contas_mcp_server/__pycache__/__init__.cpython-313.pyc and b/contas_mcp/servers/contas_mcp_server/__pycache__/__init__.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/__pycache__/authorized_lines_service.cpython-313.pyc b/contas_mcp/servers/contas_mcp_server/__pycache__/authorized_lines_service.cpython-313.pyc index f66c778..28a0d79 100644 Binary files a/contas_mcp/servers/contas_mcp_server/__pycache__/authorized_lines_service.cpython-313.pyc and b/contas_mcp/servers/contas_mcp_server/__pycache__/authorized_lines_service.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/__pycache__/discount_history_service.cpython-313.pyc b/contas_mcp/servers/contas_mcp_server/__pycache__/discount_history_service.cpython-313.pyc index 63d5cc1..e9abe15 100644 Binary files a/contas_mcp/servers/contas_mcp_server/__pycache__/discount_history_service.cpython-313.pyc and b/contas_mcp/servers/contas_mcp_server/__pycache__/discount_history_service.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy.cpython-313.pyc b/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy.cpython-313.pyc index 8cde6fa..73f0215 100644 Binary files a/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy.cpython-313.pyc and b/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt1.cpython-313.pyc b/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt1.cpython-313.pyc index 0087c75..3ab582d 100644 Binary files a/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt1.cpython-313.pyc and b/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt1.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt2.cpython-313.pyc b/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt2.cpython-313.pyc index 8afa03c..e8416ea 100644 Binary files a/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt2.cpython-313.pyc and b/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt2.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/__pycache__/main.cpython-313.pyc b/contas_mcp/servers/contas_mcp_server/__pycache__/main.cpython-313.pyc index be3fe13..8f4e764 100644 Binary files a/contas_mcp/servers/contas_mcp_server/__pycache__/main.cpython-313.pyc and b/contas_mcp/servers/contas_mcp_server/__pycache__/main.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/main.py b/contas_mcp/servers/contas_mcp_server/main.py index 6dda7f5..b6f9357 100644 --- a/contas_mcp/servers/contas_mcp_server/main.py +++ b/contas_mcp/servers/contas_mcp_server/main.py @@ -244,6 +244,19 @@ def _preflight_subject(name: str, args: dict[str, Any]) -> dict[str, Any] | None return None if bool(args.get("clarification_resolved")): return None + + # A pre-validation transaction boundary may already have resolved a multi-item + # cancellation into authoritative ``items[]``. At execution time that list is + # the source of truth; re-parsing the presentation ``subject`` (for example + # ``"Tamboro Mensal, Paramount+"``) would incorrectly turn two canonical + # entities back into one ambiguous free-text reference. + if name == "cancelar_vas_avulso": + resolved_items = [ + item for item in (args.get("items") or []) + if isinstance(item, dict) and str(item.get("name") or item.get("subject") or "").strip() + ] + if len(resolved_items) >= 2: + return None msisdn = str(args.get("msisdn") or "").strip() subject = str(args.get("subject") or "").strip() if not msisdn or not subject: @@ -986,6 +999,49 @@ async def _validate_contestation(args: dict[str, Any]) -> dict[str, Any]: "entity_resolution": "invoice_evidence", }, } + + # A claimed amount larger/different from the authoritative billed amount is + # recoverable input, not a reason to emit a generic safety failure. Keep the + # subject frozen and recollect only ``valor``, while telling the customer the + # concrete amount found in the invoice evidence. + amount_mismatch = next(( + entry for entry in (validation_log or []) + if isinstance(entry, dict) and str(entry.get("erro") or "") in { + "valor_ajuste_maior_que_item", "valor_ajuste_divergente_item" + } + ), None) + if amount_mismatch is not None: + billed = amount_mismatch.get("valor_item_fatura") + canonical_subject = str( + amount_mismatch.get("item_fatura_resolvido") + or args.get("resolved_subject") or args.get("subject") or "item" + ).strip() + billed_ptbr = _money_ptbr(billed) if billed not in (None, "") else "" + message = ( + f"O valor encontrado na fatura para {canonical_subject} é R$ {billed_ptbr}. " + f"O valor informado (R$ {_money_ptbr(requested_value)}) não corresponde a essa cobrança. " + "Informe o valor da cobrança que deseja contestar." + if billed_ptbr else + "O valor informado não corresponde à cobrança encontrada na fatura. Informe o valor correto da cobrança que deseja contestar." + ) + return { + "eligible": False, + "status": "NEEDS_PARAMETER", + "parameter": "valor", + "reason": "CVAL", + "recoverable_reason": "amount_not_supported_by_invoice", + "subject": canonical_subject, + "resolved_value": billed, + "parameter_message": message, + "validation_log": validation_log, + "items": validated, + "metadata": { + "side_effect_free": True, + "target_tool": args.get("target_tool") or "contestar_cobranca", + "guardrail_code": "CVAL", + "entity_resolution": "invoice_evidence", + }, + } return { "eligible": False, "status": "BLOCKED", @@ -1145,14 +1201,79 @@ def _vas_domain_policy_from_invoice_detail(canonical: str, args: dict[str, Any]) return "", item_type +def _resolve_multiple_vas_subjects(subject: str, catalog: list[dict[str, Any]], args: dict[str, Any]) -> list[dict[str, Any]]: + """Resolve explicit multi-entity cancellation references against authorized evidence. + + Named references are split into conversational segments and each segment must + resolve uniquely through the same catalog resolver used for single entities. + Explicit mass expansion is restricted to "todos os VAS/serviços avulsos"; + generic "todos" is deliberately not expanded. + """ + raw = str(subject or "").strip() + norm = _norm_entity_reference(raw) + if not norm: + return [] + explicit_all_avulso = any(token in norm for token in ( + "todos os vas avulsos", "todos vas avulsos", "todos os servicos avulsos", + "todos servicos avulsos", "todas as assinaturas avulsas", + )) + selected_names: list[str] = [] + if explicit_all_avulso: + selected_names = [str(row.get("name") or "").strip() for row in catalog if row.get("name")] + else: + cleaned = re.sub(r"(?i)\b(cancelar|cancela|cancele|retirar|retire|tirar|tire|desativar|desative)\b", " ", raw) + segments = [seg.strip(" .:-") for seg in re.split(r"\s*(?:,|;|/|\be\b|\bmais\b)\s*", cleaned, flags=re.I) if seg.strip(" .:-")] + if len(segments) < 2: + return [] + for segment in segments: + canonical, _ = _resolve_catalog_entity(segment, catalog) + if canonical: + selected_names.append(canonical) + selected: list[dict[str, Any]] = [] + for name in list(dict.fromkeys(selected_names)): + resolved_class, resolved_type = _vas_domain_policy_from_invoice_detail(name, args) + if explicit_all_avulso and resolved_class != "cancelar_vas_avulso": + continue + selected.append({ + "name": name, + "msisdn": _invoice_detail_msisdn(args.get("invoice_detail"), name) or str(args.get("msisdn") or ""), + "tool_category": resolved_class or None, + "item_type": resolved_type or None, + }) + return selected + + async def _validate_vas_subject(args: dict[str, Any]) -> dict[str, Any]: """Side-effect-free entity resolution used before confirmation/execution.""" await _enrich_invoice_context("validar_vas_subject", args) subject = str(args.get("subject") or "").strip() catalog = _vas_entity_catalog(args) canonical, matches = _resolve_catalog_entity(subject, catalog) + requested_tool = str(args.get("target_tool") or "").strip() + multi = _resolve_multiple_vas_subjects(subject, catalog, args) if requested_tool == "cancelar_vas_avulso" else [] + if len(multi) >= 2 or (multi and any(token in _norm_entity_reference(subject) for token in ("todos os vas avulsos", "todos os servicos avulsos"))): + names = [str(item.get("name") or "").strip() for item in multi if item.get("name")] + return { + "eligible": True, + "status": "ELIGIBLE", + "subject": subject, + "resolved_subject": names[0] if len(names) == 1 else ", ".join(names), + "resolved_subjects": names, + "entity_resolution": "vas_evidence_multiple", + "transaction_decision": { + "resolved_arguments": {"subject": ", ".join(names), "items": multi}, + "target_tool": requested_tool, + "action_changed": False, + "requires_reconfirmation": False, + "confirmation_message": ( + f"Você confirma o cancelamento dos serviços {', '.join(names)}? " + "Responda 'sim' para executar ou 'não' para cancelar." + ), + "domain_policy": {"class": "cancelar_vas_avulso", "item_type": "multiple"}, + }, + "metadata": {"side_effect_free": True, "target_tool": requested_tool, "catalog_size": len(catalog), "resolved_count": len(names)}, + } if canonical: - requested_tool = str(args.get("target_tool") or "").strip() effective_tool = requested_tool resolved_class = "" resolved_type = "" diff --git a/docs/FIX_CONTAS_REPORT_20260831_SECOND_PASS.md b/docs/FIX_CONTAS_REPORT_20260831_SECOND_PASS.md new file mode 100644 index 0000000..58be391 --- /dev/null +++ b/docs/FIX_CONTAS_REPORT_20260831_SECOND_PASS.md @@ -0,0 +1,27 @@ +# Correções após regressão de 31/08/2026 + +Esta rodada corrige quatro comportamentos observados no relatório de regressão sem reintroduzir o runtime conversacional legado. + +## 1. Cancelamento múltiplo após confirmação + +O snapshot transacional do framework já preservava corretamente os argumentos confirmados. O defeito estava no preflight de execução do MCP do Contas, que tentava resolver novamente o `subject` de apresentação (por exemplo, `Tamboro Mensal, Paramount+`) mesmo quando `items[]` já continha múltiplas entidades canônicas pré-validadas. Agora `items[]` é a fonte de verdade nessa condição e não há segunda resolução textual. + +## 2. Contestação com valor incompatível + +Uma divergência de valor comprovada pelo CVAL passa a ser recuperável: o item permanece preservado, apenas `valor` volta para coleta e a resposta informa o valor autoritativo encontrado na fatura. O contrato de auditoria mantém `reason=CVAL` e acrescenta `recoverable_reason=amount_not_supported_by_invoice`. + +O runtime genérico ganhou suporte opcional a `parameter_message` emitido por um pre-validator de domínio. O framework apenas apresenta essa mensagem enquanto permanece em `COLLECTING_PARAMETERS`; ele não interpreta a regra de negócio. + +## 3. Encerramento explícito + +Expressões inequívocas como `entendi, obrigado, era só isso` encerram a sessão como `resolvido`, desde que não exista transação ou workflow ativo. Isso evita que uma despedida caia em fallback/guardrail sem consumir confirmações pendentes. + +## 4. Continuação plural após explicação de fatura + +Frases como `as duas mesmo, pode seguir` imediatamente após `contas_invoice_explanation` permanecem no contexto de explicação e não são confundidas com finalização genérica. + +## Regressão + +- Testes novos e direcionados: 58/58 no Contas e 30/30 no runtime transacional do framework. +- `tests/migration`: 818 PASS / 2 FAIL. +- Os 2 FAIL restantes são preexistentes nesta base: caso histórico de `validar_contestacao` com TIM Fashion/R$50 e fraseologia de `termino_desconto`. diff --git a/docs/FIX_CONTAS_RESIDUAL_CONVERSATION_PARITY_20260831.md b/docs/FIX_CONTAS_RESIDUAL_CONVERSATION_PARITY_20260831.md new file mode 100644 index 0000000..c86dc15 --- /dev/null +++ b/docs/FIX_CONTAS_RESIDUAL_CONVERSATION_PARITY_20260831.md @@ -0,0 +1,63 @@ +# Correção de paridade conversacional residual do Contas — 2026-08-31 + +## Escopo + +Correções pontuais extraídas do comportamento útil do Contas anterior sem restaurar o runtime legado: + +- retenção antes de handoff humano; +- jurídico/Anatel/Procon com dependência de entidade previamente em foco; +- três falas consecutivas realmente incompreensíveis; +- preservação do pós-finalização no framework, com status terminal de erro respeitado; +- cancelamento múltiplo por entidades nomeadas/contextuais e por "todos os VAS avulsos". + +## Arquitetura + +Foi adicionada `app/domain/contas/conversation_policy.py`, executada depois do `EnterpriseRouter` e antes do agente de domínio. A policy não executa side effects: apenas reprompta, enriquece contexto ou altera o roteamento. Toda operação transacional continua passando pelo `AgentRuntimeMixin`, pré-validação MCP, confirmação explícita e workflow do Contas. + +## Retenção + +Quando o router solicita handoff humano, a policy procura evidência autoritativa de VAS avulso que participou da variação da conta usando `varied_avulso_items`. Sem evidência, o handoff segue normalmente. Com evidência, a policy oferece dois degraus: explicação da variação e, em seguida, tratamento dos VAS identificados. Recusa do segundo degrau leva ao handoff humano normal. + +O aceite do tratamento cria reentrada contextual com os nomes já comprovados; o cliente não precisa repeti-los e a transação continua sujeita à pré-validação e confirmação. + +## Jurídico / Anatel / Procon + +A mera ameaça regulatória não cria uma transação. Sem entidade concreta em foco, a fala é encaminhada como reclamação ampla ao suporte, sem MCP transacional. Quando já existe `subject/items` em estado transacional, o foco é preservado e a fala pode continuar no fluxo correspondente. A fatura inteira nunca é usada para inventar o alvo. + +## Três falas incompreensíveis + +Foi criada a intent semântica `contas_no_match`, exclusiva para fala sem conteúdo recuperável. `fallback` genérico não conta como incompreensão. O contador é consecutivo e reinicia em qualquer turno compreendido. + +- 1ª: pede reformulação; +- 2ª: pede reformulação; +- 3ª: encerra pelo nó global `end_session`, chamando `finalizar_atendimento` com `status=erro_no_match`. + +O limite pode ser configurado por `CONTAS_NO_MATCH_MAX_CONSECUTIVE`, default 3. + +## Cancelamento múltiplo + +`validar_vas_subject` agora resolve múltiplas entidades exclusivamente contra catálogo autorizado VAS/fatura. Exemplos suportados após extração semântica contextual: + +- `cancela Netflix e HBO`; +- `os dois` / `ambos`, quando o extrator LLM consegue resolver os nomes pelo contexto imediato; +- `todos os VAS avulsos`. + +`todos` genérico não expande em massa. Para "todos os VAS avulsos", itens estratégicos/bundle são filtrados pela política de domínio. Os itens resolvidos são enviados como `items[]` para o workflow batch existente, com uma única confirmação explícita antes da execução. + +## Pós-finalização + +Não foi criado runtime duplicado. O lifecycle continua no framework. O nó `end_session` passou apenas a respeitar um `terminal_status` já definido pela policy (por exemplo `erro_no_match`) e uma mensagem terminal específica, mantendo o mecanismo atual de replay/soft reset. + +## Testes + +Novos testes: + +- `tests/migration/test_contas_conversation_policy_residuals.py`; +- `tests/migration/test_multiple_vas_subject_resolution.py`. + +Regressão direcionada: **61 PASS**. + +Regressão completa `tests/migration`: **810 PASS / 2 FAIL**. Os mesmos dois FAIL foram reproduzidos no ZIP original sem estas alterações, portanto são falhas preexistentes e fora do escopo desta correção: + +1. `test_validar_contestacao_aprova_quando_item_e_valor_sao_comprovados`; +2. `test_termino_desconto_e_valor_divergente_preservam_semantica_do_original`. diff --git a/tests/migration/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc index 18dcdb9..6307e16 100644 Binary files a/tests/migration/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_adversarial_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_adversarial_parity.cpython-313-pytest-9.0.2.pyc index 06a02ac..df8ed54 100644 Binary files a/tests/migration/__pycache__/test_adversarial_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_adversarial_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_backend_wrapper_contract_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_backend_wrapper_contract_parity.cpython-313-pytest-9.0.2.pyc index 54e1635..9ba7c9a 100644 Binary files a/tests/migration/__pycache__/test_backend_wrapper_contract_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_backend_wrapper_contract_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_business_events_and_guardrails.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_business_events_and_guardrails.cpython-313-pytest-9.0.2.pyc index aa5cfaa..f100c2e 100644 Binary files a/tests/migration/__pycache__/test_business_events_and_guardrails.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_business_events_and_guardrails.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_cancel_backend_wrapper_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_cancel_backend_wrapper_parity.cpython-313-pytest-9.0.2.pyc index bfb982c..2fe5e19 100644 Binary files a/tests/migration/__pycache__/test_cancel_backend_wrapper_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_cancel_backend_wrapper_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_cancel_composite_workflow.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_cancel_composite_workflow.cpython-313-pytest-9.0.2.pyc index 935d8b3..7251d60 100644 Binary files a/tests/migration/__pycache__/test_cancel_composite_workflow.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_cancel_composite_workflow.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_cancel_wrapper_remaining_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_cancel_wrapper_remaining_parity.cpython-313-pytest-9.0.2.pyc index 95c5d4a..e506ba1 100644 Binary files a/tests/migration/__pycache__/test_cancel_wrapper_remaining_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_cancel_wrapper_remaining_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contas_conversation_policy_residuals.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contas_conversation_policy_residuals.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..c18630d Binary files /dev/null and b/tests/migration/__pycache__/test_contas_conversation_policy_residuals.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contas_domain_mock.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contas_domain_mock.cpython-313-pytest-9.0.2.pyc index 33878ef..de79f93 100644 Binary files a/tests/migration/__pycache__/test_contas_domain_mock.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_contas_domain_mock.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contas_invoice_explanation_routing.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contas_invoice_explanation_routing.cpython-313-pytest-9.0.2.pyc index 666add5..8972762 100644 Binary files a/tests/migration/__pycache__/test_contas_invoice_explanation_routing.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_contas_invoice_explanation_routing.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contestation_business_rules_full.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contestation_business_rules_full.cpython-313-pytest-9.0.2.pyc index ed16df8..987391d 100644 Binary files a/tests/migration/__pycache__/test_contestation_business_rules_full.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_contestation_business_rules_full.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contestation_failure_mapping.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contestation_failure_mapping.cpython-313-pytest-9.0.2.pyc index 31fdbe2..53a7814 100644 Binary files a/tests/migration/__pycache__/test_contestation_failure_mapping.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_contestation_failure_mapping.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contestation_prompt_terminal_out_of_scope.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contestation_prompt_terminal_out_of_scope.cpython-313-pytest-9.0.2.pyc index dfed706..72822fb 100644 Binary files a/tests/migration/__pycache__/test_contestation_prompt_terminal_out_of_scope.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_contestation_prompt_terminal_out_of_scope.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contestation_single_item_mock_regression.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contestation_single_item_mock_regression.cpython-313-pytest-9.0.2.pyc index 02a9dc7..928aa92 100644 Binary files a/tests/migration/__pycache__/test_contestation_single_item_mock_regression.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_contestation_single_item_mock_regression.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contestation_subject_entity_resolution.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contestation_subject_entity_resolution.cpython-313-pytest-9.0.2.pyc index 3f25021..cc9af69 100644 Binary files a/tests/migration/__pycache__/test_contestation_subject_entity_resolution.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_contestation_subject_entity_resolution.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_discount_history_mcp_service.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_discount_history_mcp_service.cpython-313-pytest-9.0.2.pyc index 0622a87..5e5dee2 100644 Binary files a/tests/migration/__pycache__/test_discount_history_mcp_service.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_discount_history_mcp_service.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_dispute_actions_remaining_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_dispute_actions_remaining_parity.cpython-313-pytest-9.0.2.pyc index 1372177..542dcc5 100644 Binary files a/tests/migration/__pycache__/test_dispute_actions_remaining_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_dispute_actions_remaining_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_external_guardrails_judges_spi.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_external_guardrails_judges_spi.cpython-313-pytest-9.0.2.pyc index 1dd140d..de91aa0 100644 Binary files a/tests/migration/__pycache__/test_external_guardrails_judges_spi.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_external_guardrails_judges_spi.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_finalization_parity_extended.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_finalization_parity_extended.cpython-313-pytest-9.0.2.pyc index d5bd9c7..581b193 100644 Binary files a/tests/migration/__pycache__/test_finalization_parity_extended.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_finalization_parity_extended.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_framework_agent_gap_fixes.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_framework_agent_gap_fixes.cpython-313-pytest-9.0.2.pyc index 956fe74..8a8dd2d 100644 Binary files a/tests/migration/__pycache__/test_framework_agent_gap_fixes.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_framework_agent_gap_fixes.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_framework_llm_composition_directive.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_framework_llm_composition_directive.cpython-313-pytest-9.0.2.pyc index 6512c75..5f8eb5d 100644 Binary files a/tests/migration/__pycache__/test_framework_llm_composition_directive.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_framework_llm_composition_directive.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_framework_native_structure.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_framework_native_structure.cpython-313-pytest-9.0.2.pyc index ccecc89..19b341b 100644 Binary files a/tests/migration/__pycache__/test_framework_native_structure.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_framework_native_structure.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_framework_rag_directive.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_framework_rag_directive.cpython-313-pytest-9.0.2.pyc index e6a2eac..d4fd0b4 100644 Binary files a/tests/migration/__pycache__/test_framework_rag_directive.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_framework_rag_directive.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_framework_zero_legacy.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_framework_zero_legacy.cpython-313-pytest-9.0.2.pyc index b5e5f7b..b324c3d 100644 Binary files a/tests/migration/__pycache__/test_framework_zero_legacy.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_framework_zero_legacy.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_guardrail_binary_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_guardrail_binary_parity.cpython-313-pytest-9.0.2.pyc index ac2e807..6453b1d 100644 Binary files a/tests/migration/__pycache__/test_guardrail_binary_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_guardrail_binary_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_guardrail_context_dict_history.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_guardrail_context_dict_history.cpython-313-pytest-9.0.2.pyc index 6b333bb..1cc5a37 100644 Binary files a/tests/migration/__pycache__/test_guardrail_context_dict_history.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_guardrail_context_dict_history.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_guardrail_original_defaults_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_guardrail_original_defaults_parity.cpython-313-pytest-9.0.2.pyc index 4d47049..5bacca2 100644 Binary files a/tests/migration/__pycache__/test_guardrail_original_defaults_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_guardrail_original_defaults_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_human_handoff_guardrail_context.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_human_handoff_guardrail_context.cpython-313-pytest-9.0.2.pyc index 61c1fef..c2cf288 100644 Binary files a/tests/migration/__pycache__/test_human_handoff_guardrail_context.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_human_handoff_guardrail_context.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_input_guardrail_user_feedback.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_input_guardrail_user_feedback.cpython-313-pytest-9.0.2.pyc index a72d4ae..a5533e9 100644 Binary files a/tests/migration/__pycache__/test_input_guardrail_user_feedback.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_input_guardrail_user_feedback.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_invoice_context_framework_native.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_invoice_context_framework_native.cpython-313-pytest-9.0.2.pyc index c8d9f21..d1277bc 100644 Binary files a/tests/migration/__pycache__/test_invoice_context_framework_native.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_invoice_context_framework_native.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_invoice_explanation_final_response.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_invoice_explanation_final_response.cpython-313-pytest-9.0.2.pyc index 30ec2dc..661455f 100644 Binary files a/tests/migration/__pycache__/test_invoice_explanation_final_response.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_invoice_explanation_final_response.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_invoice_explanation_handoff_policy.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_invoice_explanation_handoff_policy.cpython-313-pytest-9.0.2.pyc index 9c0fec1..b3784d6 100644 Binary files a/tests/migration/__pycache__/test_invoice_explanation_handoff_policy.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_invoice_explanation_handoff_policy.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_invoice_explanation_unmatched_meaningful.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_invoice_explanation_unmatched_meaningful.cpython-313-pytest-9.0.2.pyc index 556bf1b..8560694 100644 Binary files a/tests/migration/__pycache__/test_invoice_explanation_unmatched_meaningful.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_invoice_explanation_unmatched_meaningful.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_line_policy_alternatives.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_line_policy_alternatives.cpython-313-pytest-9.0.2.pyc index 36bda08..5d75edf 100644 Binary files a/tests/migration/__pycache__/test_line_policy_alternatives.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_line_policy_alternatives.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_mcp_contestation_subject_guard.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_mcp_contestation_subject_guard.cpython-313-pytest-9.0.2.pyc index 43095bd..80e4bae 100644 Binary files a/tests/migration/__pycache__/test_mcp_contestation_subject_guard.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_mcp_contestation_subject_guard.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_multiple_vas_subject_resolution.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_multiple_vas_subject_resolution.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..3b1c5c1 Binary files /dev/null and b/tests/migration/__pycache__/test_multiple_vas_subject_resolution.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_no_legacy_dependency.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_no_legacy_dependency.cpython-313-pytest-9.0.2.pyc index cf4def8..a04404d 100644 Binary files a/tests/migration/__pycache__/test_no_legacy_dependency.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_no_legacy_dependency.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_observability_code_mapping.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_observability_code_mapping.cpython-313-pytest-9.0.2.pyc index cbec713..3d217c5 100644 Binary files a/tests/migration/__pycache__/test_observability_code_mapping.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_observability_code_mapping.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_observability_default_overlay_compatibility.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_observability_default_overlay_compatibility.cpython-313-pytest-9.0.2.pyc index 4ccd187..aebe584 100644 Binary files a/tests/migration/__pycache__/test_observability_default_overlay_compatibility.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_observability_default_overlay_compatibility.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_observability_mapping_provider_boundary.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_observability_mapping_provider_boundary.cpython-313-pytest-9.0.2.pyc index ed4eb4b..0bed412 100644 Binary files a/tests/migration/__pycache__/test_observability_mapping_provider_boundary.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_observability_mapping_provider_boundary.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_observability_mapping_registry_actions.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_observability_mapping_registry_actions.cpython-313-pytest-9.0.2.pyc index 6173e6c..9f882ac 100644 Binary files a/tests/migration/__pycache__/test_observability_mapping_registry_actions.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_observability_mapping_registry_actions.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_original_compliance_anatel.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_original_compliance_anatel.cpython-313-pytest-9.0.2.pyc index e08f119..ae7a9a3 100644 Binary files a/tests/migration/__pycache__/test_original_compliance_anatel.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_original_compliance_anatel.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_original_contestation_validation.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_original_contestation_validation.cpython-313-pytest-9.0.2.pyc index 8a37eda..f3310dc 100644 Binary files a/tests/migration/__pycache__/test_original_contestation_validation.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_original_contestation_validation.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_original_item_matcher_transcription.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_original_item_matcher_transcription.cpython-313-pytest-9.0.2.pyc index 07b724c..5bda56c 100644 Binary files a/tests/migration/__pycache__/test_original_item_matcher_transcription.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_original_item_matcher_transcription.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_original_vas_cancellation_message.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_original_vas_cancellation_message.cpython-313-pytest-9.0.2.pyc index 7bbfe54..498c10f 100644 Binary files a/tests/migration/__pycache__/test_original_vas_cancellation_message.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_original_vas_cancellation_message.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_original_vas_variation.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_original_vas_variation.cpython-313-pytest-9.0.2.pyc index f2a8291..e4098cb 100644 Binary files a/tests/migration/__pycache__/test_original_vas_variation.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_original_vas_variation.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_original_vas_variation_adversarial.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_original_vas_variation_adversarial.cpython-313-pytest-9.0.2.pyc index 16d771b..c6a6bcc 100644 Binary files a/tests/migration/__pycache__/test_original_vas_variation_adversarial.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_original_vas_variation_adversarial.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_original_workflow_cases.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_original_workflow_cases.cpython-313-pytest-9.0.2.pyc index fd43505..1290e62 100644 Binary files a/tests/migration/__pycache__/test_original_workflow_cases.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_original_workflow_cases.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_output_supervisor_no_contract_hardcodes.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_output_supervisor_no_contract_hardcodes.cpython-313-pytest-9.0.2.pyc index 3506cd2..dae0dab 100644 Binary files a/tests/migration/__pycache__/test_output_supervisor_no_contract_hardcodes.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_output_supervisor_no_contract_hardcodes.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_pro_rata_full_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_pro_rata_full_parity.cpython-313-pytest-9.0.2.pyc index f2257f6..c5a01b4 100644 Binary files a/tests/migration/__pycache__/test_pro_rata_full_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_pro_rata_full_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_rag_resilience_and_informational_context.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_rag_resilience_and_informational_context.cpython-313-pytest-9.0.2.pyc index ef9a524..545539d 100644 Binary files a/tests/migration/__pycache__/test_rag_resilience_and_informational_context.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_rag_resilience_and_informational_context.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_real_legacy_integration_compat.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_real_legacy_integration_compat.cpython-313-pytest-9.0.2.pyc index 49e042e..5566fb0 100644 Binary files a/tests/migration/__pycache__/test_real_legacy_integration_compat.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_real_legacy_integration_compat.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_remaining_command_contracts.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_remaining_command_contracts.cpython-313-pytest-9.0.2.pyc index 4910164..0e69a81 100644 Binary files a/tests/migration/__pycache__/test_remaining_command_contracts.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_remaining_command_contracts.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_requested_tools_parity_pente_fino.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_requested_tools_parity_pente_fino.cpython-313-pytest-9.0.2.pyc index 0d64632..59b4930 100644 Binary files a/tests/migration/__pycache__/test_requested_tools_parity_pente_fino.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_requested_tools_parity_pente_fino.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_revprec_epistemic_uncertainty.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_revprec_epistemic_uncertainty.cpython-313-pytest-9.0.2.pyc index a9c5d91..afaf712 100644 Binary files a/tests/migration/__pycache__/test_revprec_epistemic_uncertainty.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_revprec_epistemic_uncertainty.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_subject_entity_resolution_all_domains.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_subject_entity_resolution_all_domains.cpython-313-pytest-9.0.2.pyc index 495e054..52bc762 100644 Binary files a/tests/migration/__pycache__/test_subject_entity_resolution_all_domains.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_subject_entity_resolution_all_domains.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_termino_desconto_grounding.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_termino_desconto_grounding.cpython-313-pytest-9.0.2.pyc index 46263d8..7d7a305 100644 Binary files a/tests/migration/__pycache__/test_termino_desconto_grounding.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_termino_desconto_grounding.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_tim_aoferta_transaction_continuation.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_tim_aoferta_transaction_continuation.cpython-313-pytest-9.0.2.pyc index ffceec5..9d5b658 100644 Binary files a/tests/migration/__pycache__/test_tim_aoferta_transaction_continuation.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_tim_aoferta_transaction_continuation.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_tim_contract_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_tim_contract_parity.cpython-313-pytest-9.0.2.pyc index 11e034f..8850bd1 100644 Binary files a/tests/migration/__pycache__/test_tim_contract_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_tim_contract_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_tim_contracts_and_idempotency.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_tim_contracts_and_idempotency.cpython-313-pytest-9.0.2.pyc index cfddac4..63c35f1 100644 Binary files a/tests/migration/__pycache__/test_tim_contracts_and_idempotency.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_tim_contracts_and_idempotency.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_tim_event_metadata_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_tim_event_metadata_parity.cpython-313-pytest-9.0.2.pyc index bbef2c6..2fdc85b 100644 Binary files a/tests/migration/__pycache__/test_tim_event_metadata_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_tim_event_metadata_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_tim_oos_handoff_bypass.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_tim_oos_handoff_bypass.cpython-313-pytest-9.0.2.pyc index ec77c5d..1050d47 100644 Binary files a/tests/migration/__pycache__/test_tim_oos_handoff_bypass.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_tim_oos_handoff_bypass.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_tool_policy_operation_types.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_tool_policy_operation_types.cpython-313-pytest-9.0.2.pyc index e2fbd16..864f796 100644 Binary files a/tests/migration/__pycache__/test_tool_policy_operation_types.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_tool_policy_operation_types.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_vaa_dispute_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_vaa_dispute_parity.cpython-313-pytest-9.0.2.pyc index cf78d23..8b01f6e 100644 Binary files a/tests/migration/__pycache__/test_vaa_dispute_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_vaa_dispute_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_vas_response_renderer.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_vas_response_renderer.cpython-313-pytest-9.0.2.pyc index c6252be..8cf0c51 100644 Binary files a/tests/migration/__pycache__/test_vas_response_renderer.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_vas_response_renderer.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_workflow_execution_latch.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_workflow_execution_latch.cpython-313-pytest-9.0.2.pyc index 91ae166..a9f98e8 100644 Binary files a/tests/migration/__pycache__/test_workflow_execution_latch.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_workflow_execution_latch.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_wrapper_last_contracts.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_wrapper_last_contracts.cpython-313-pytest-9.0.2.pyc index 726269c..f43c5da 100644 Binary files a/tests/migration/__pycache__/test_wrapper_last_contracts.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_wrapper_last_contracts.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/test_contas_conversation_policy_residuals.py b/tests/migration/test_contas_conversation_policy_residuals.py new file mode 100644 index 0000000..aaa04e3 --- /dev/null +++ b/tests/migration/test_contas_conversation_policy_residuals.py @@ -0,0 +1,107 @@ +from app.domain.contas.conversation_policy import evaluate, is_regulatory_threat + + +def test_no_match_three_consecutive_then_terminal(monkeypatch): + monkeypatch.setenv("CONTAS_NO_MATCH_MAX_CONSECUTIVE", "3") + base = {"user_text": "???", "intent": "contas_no_match", "route": "suporte_contas_agent", "route_decision": {"method": "llm", "intent": "contas_no_match"}} + d1 = evaluate(base) + assert d1 and d1.patch["no_match_count"] == 1 and d1.route == "conversation_policy_response" + d2 = evaluate({**base, "no_match_count": 1}) + assert d2 and d2.patch["no_match_count"] == 2 and not d2.patch.get("session_ended") + d3 = evaluate({**base, "no_match_count": 2}) + assert d3 and d3.patch["no_match_count"] == 3 + assert d3.route == "end_session" + assert d3.patch["terminal_status"] == "erro_no_match" + + +def test_understood_turn_resets_no_match_counter(): + state = {"user_text": "quero minha fatura", "intent": "contas_invoice_query", "route": "faturas_agent", "route_decision": {"method": "keyword", "intent": "contas_invoice_query"}, "no_match_count": 2} + d = evaluate(state) + assert d and d.patch == {"no_match_count": 0} + + +def test_regulatory_threat_without_focus_does_not_invent_transaction(): + state = {"user_text": "vou procurar a Anatel e o Procon", "intent": "fallback", "route": "faturas_agent", "route_decision": {"method": "llm", "intent": "fallback"}} + d = evaluate(state) + assert d and d.route == "suporte_contas_agent" + assert d.intent == "contas_regulatory_complaint" + assert d.patch["mcp_tools"] == [] + + +def test_regulatory_threat_with_focused_subject_preserves_action_context(): + state = { + "user_text": "se não resolver vou para a Anatel", + "intent": "fallback", "route": "faturas_agent", "route_decision": {"method": "llm", "intent": "fallback"}, + "last_transaction": {"arguments": {"subject": "HBO Max Standard"}}, + } + d = evaluate(state) + assert d and d.route == "contestacao_agent" + assert d.intent == "contas_vas_cancel" + assert d.patch["regulatory_context"]["focused_items"] == ["HBO Max Standard"] + + +def test_regulatory_detector_terms(): + assert is_regulatory_threat("vou procurar meus direitos no Procon") + assert is_regulatory_threat("vou à justiça") + + +def test_retention_offer_and_two_step_acceptance(monkeypatch): + import app.domain.contas.conversation_policy as policy + items = [{"name": "VAS A", "msisdn": "11999999999"}, {"name": "VAS B", "msisdn": "11999999999"}] + monkeypatch.setattr(policy, "_retention_items", lambda state: items) + first = policy.evaluate({"user_text": "quero um atendente", "route": "human_handoff", "intent": "human_handoff", "route_decision": {"method": "continuity", "intent": "human_handoff"}}) + assert first and first.route == "conversation_policy_response" + assert first.patch["contas_retention"]["pending_stage"] == "explain" + + second = policy.evaluate({"user_text": "não, quero atendente", "route": "human_handoff", "intent": "human_handoff", "route_decision": {"method": "continuity", "intent": "human_handoff"}, "contas_retention": first.patch["contas_retention"]}) + assert second and second.patch["contas_retention"]["pending_stage"] == "continue" + + third = policy.evaluate({"user_text": "sim", "route": "human_handoff", "intent": "human_handoff", "route_decision": {"method": "continuity", "intent": "human_handoff"}, "contas_retention": second.patch["contas_retention"]}) + assert third and third.route == "contestacao_agent" + assert third.patch["context"]["focused_items"] == ["VAS A", "VAS B"] + assert third.patch["_route_metadata"]["original_input"] == "cancelar VAS A e VAS B" + +def test_retention_second_decline_handoffs(monkeypatch): + import app.domain.contas.conversation_policy as policy + state = {"user_text": "não, atendente", "route": "human_handoff", "intent": "human_handoff", "route_decision": {"method": "continuity", "intent": "human_handoff"}, "contas_retention": {"pending_stage": "continue", "stages_offered": ["explain", "continue"], "items": [{"name": "VAS A", "msisdn": "11999999999"}]}} + d = policy.evaluate(state) + assert d and d.route == "human_handoff" and d.reason == "human_retention_declined" + + +def test_generic_router_fallback_is_not_counted_as_incomprehensible(): + d = evaluate({"user_text": "quero falar sobre outra empresa", "intent": "fallback", "route": "faturas_agent", "route_decision": {"method": "fallback", "intent": "fallback"}, "no_match_count": 1}) + assert d and d.patch == {"no_match_count": 0} + + +def test_explicit_gratitude_closes_only_without_live_transaction(): + history = [{"role": "assistant", "content": "Com essa explicação, sanei sua dúvida?", "metadata": {"intent": "contas_invoice_explanation"}}] + d = evaluate({"user_text": "entendi, obrigado, era só isso", "intent": "fallback", "route": "suporte_contas_agent", "history": history}) + assert d and d.route == "end_session" + assert d.patch["terminal_status"] == "resolvido" + + active = evaluate({ + "user_text": "entendi, obrigado, era só isso", + "intent": "contas_vas_cancel", + "route": "contestacao_agent", + "history": history, + "transaction_status": "AWAITING_CONFIRMATION", + "active_transaction": {"tool_name": "cancelar_vas_avulso", "status": "AWAITING_CONFIRMATION"}, + }) + assert active is None or active.route != "end_session" + + +def test_plural_confirmation_after_invoice_explanation_stays_in_explanation(): + history = [{ + "role": "assistant", + "content": "Essas duas cobranças de VOD + Canais abertos são as que você não reconhece.", + "metadata": {"intent": "contas_invoice_explanation"}, + }] + d = evaluate({ + "user_text": "as duas mesmo, pode seguir", + "intent": "fallback", + "route": "suporte_contas_agent", + "history": history, + }) + assert d and d.route == "faturas_agent" + assert d.intent == "contas_invoice_explanation" + assert d.patch["mcp_tools"] == ["invoice_explanation"] diff --git a/tests/migration/test_multiple_vas_subject_resolution.py b/tests/migration/test_multiple_vas_subject_resolution.py new file mode 100644 index 0000000..351eb21 --- /dev/null +++ b/tests/migration/test_multiple_vas_subject_resolution.py @@ -0,0 +1,25 @@ +import importlib + +mcp = importlib.import_module("contas_mcp.servers.contas_mcp_server.main") + + +def test_multiple_named_subjects_use_unique_catalog_resolution(monkeypatch): + catalog = [{"name": "Netflix Mensal"}, {"name": "HBO Max Standard"}, {"name": "TIM Fashion Mensal"}] + monkeypatch.setattr(mcp, "_vas_domain_policy_from_invoice_detail", lambda name, args: ("cancelar_vas_avulso", "avulso")) + monkeypatch.setattr(mcp, "_invoice_detail_msisdn", lambda detail, name: "11999999999") + items = mcp._resolve_multiple_vas_subjects("cancela Netflix e HBO", catalog, {"msisdn": "11999999999"}) + assert [x["name"] for x in items] == ["Netflix Mensal", "HBO Max Standard"] + + +def test_generic_todos_does_not_expand_mass_cancellation(monkeypatch): + catalog = [{"name": "Netflix Mensal"}, {"name": "TIM Fashion Mensal"}] + monkeypatch.setattr(mcp, "_vas_domain_policy_from_invoice_detail", lambda name, args: ("cancelar_vas_avulso", "avulso")) + assert mcp._resolve_multiple_vas_subjects("cancela todos", catalog, {}) == [] + + +def test_explicit_all_avulsos_filters_non_avulso(monkeypatch): + catalog = [{"name": "VAS A"}, {"name": "Netflix"}] + monkeypatch.setattr(mcp, "_vas_domain_policy_from_invoice_detail", lambda name, args: ("cancelar_vas_avulso", "avulso") if name == "VAS A" else ("vas_estrategico", "estrategico")) + monkeypatch.setattr(mcp, "_invoice_detail_msisdn", lambda detail, name: "11999999999") + items = mcp._resolve_multiple_vas_subjects("cancele todos os VAS avulsos", catalog, {"msisdn": "11999999999"}) + assert [x["name"] for x in items] == ["VAS A"] diff --git a/tests/migration/test_subject_entity_resolution_all_domains.py b/tests/migration/test_subject_entity_resolution_all_domains.py index 598b1f1..4711601 100644 --- a/tests/migration/test_subject_entity_resolution_all_domains.py +++ b/tests/migration/test_subject_entity_resolution_all_domains.py @@ -234,3 +234,46 @@ async def test_vas_validator_real_invoice_detail_redirects_youtube_without_resol assert decision['target_tool'] == 'tratar_vas_estrategico' assert decision['action_changed'] is True assert decision['requires_reconfirmation'] is True + + +def test_multi_item_execution_preflight_does_not_reresolve_joined_subject(): + args = { + 'msisdn': '11999999999', + 'subject': 'Tamboro Mensal, Paramount+', + 'items': [ + {'name': 'Tamboro Mensal', 'msisdn': '11999999999'}, + {'name': 'Paramount+', 'msisdn': '11999999999'}, + ], + } + assert main._preflight_subject('cancelar_vas_avulso', args) is None + + +@pytest.mark.asyncio +async def test_contestation_wrong_amount_is_recoverable_with_invoice_amount(monkeypatch): + async def no_enrich(name, args): + return None + + monkeypatch.setattr(main, '_enrich_invoice_context', no_enrich) + monkeypatch.setattr(main, '_preflight_subject', lambda name, args: None) + monkeypatch.setattr(main, 'validate_contestation_items', lambda items, payload: ( + items, + [{ + 'erro': 'valor_ajuste_maior_que_item', + 'valor_item_fatura': '14.99', + 'item_fatura_resolvido': 'Tamboro Mensal', + }], + "Valor de ajuste excede o valor cobrado", + )) + result = await main._validate_contestation({ + 'msisdn': '11999999999', + 'subject': 'Tamboro Mensal', + 'valor': '29.98', + 'billing_analysis': {}, + 'target_tool': 'contestar_cobranca', + }) + assert result['eligible'] is False + assert result['status'] == 'NEEDS_PARAMETER' + assert result['parameter'] == 'valor' + assert result['resolved_value'] == '14.99' + assert 'R$ 14,99' in result['parameter_message'] + assert 'R$ 29,98' in result['parameter_message']