Ajustes no agente de contas

This commit is contained in:
2026-08-31 14:13:51 -03:00
parent 00ac7f0c83
commit 87a0b77dae
137 changed files with 715 additions and 8 deletions

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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']