136 lines
7.0 KiB
Python
136 lines
7.0 KiB
Python
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"]
|
|
|
|
|
|
def test_explicit_closure_has_precedence_over_no_match():
|
|
d = evaluate({
|
|
"user_text": "entendi, era só isso mesmo, obrigado",
|
|
"intent": "contas_no_match",
|
|
"route": "suporte_contas_agent",
|
|
"route_decision": {"method": "llm", "intent": "contas_no_match"},
|
|
"no_match_count": 1,
|
|
})
|
|
assert d and d.route == "end_session"
|
|
assert d.intent == "contas_conversation_close"
|
|
assert d.reason == "explicit_customer_closure"
|
|
assert d.patch["terminal_status"] == "resolvido"
|
|
assert d.patch["no_match_count"] == 0
|
|
|
|
|
|
def test_explicit_closure_does_not_preempt_live_confirmation_even_when_router_says_no_match():
|
|
d = evaluate({
|
|
"user_text": "entendi, era só isso mesmo, obrigado",
|
|
"intent": "contas_no_match",
|
|
"route": "contestacao_agent",
|
|
"route_decision": {"method": "llm", "intent": "contas_no_match"},
|
|
"transaction_status": "AWAITING_CONFIRMATION",
|
|
"active_transaction": {"tool_name": "cancelar_vas_avulso", "status": "AWAITING_CONFIRMATION"},
|
|
})
|
|
assert d and d.route == "conversation_policy_response"
|
|
assert d.intent == "contas_no_match_retry"
|