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