490 lines
17 KiB
Python
490 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
from agent_framework.channels.interruption import evaluate_interruption
|
|
from agent_framework.guardrails.calibrated.contracts import GuardRailContext
|
|
from agent_framework.guardrails.calibrated.rails.anatel import AnatelRail
|
|
|
|
from app.domain.contas.vas_variation import VariedCharge, varied_current_charges
|
|
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
|
|
|
|
|
PAST = "2026-03-10T00:00:00.000Z"
|
|
CURRENT = "2026-04-07T00:00:00.000Z"
|
|
|
|
|
|
def _group(*items: dict, type_: str = "servicos_contratados_de_parceiros") -> dict:
|
|
return {"type": type_, "desc": "Serviços de valor adicionado", "items": list(items)}
|
|
|
|
|
|
def test_vas_variation_preserva_multiset_e_direcao_atual():
|
|
payload = {"invoiceVariation": [
|
|
_group(
|
|
{"desc": "Qualifica Mensal", "value": "19.99", "invoice": PAST},
|
|
{"desc": "Qualifica Mensal", "value": "19.99", "invoice": CURRENT},
|
|
{"desc": "Qualifica Mensal", "value": "19.99", "invoice": CURRENT},
|
|
)
|
|
]}
|
|
result = varied_current_charges(payload)
|
|
assert result == (VariedCharge(desc="Qualifica Mensal", value=result[0].value),)
|
|
assert str(result[0].value) == "19.99"
|
|
|
|
|
|
def test_vas_variation_streaming_nao_entra_no_recorte_avulso_padrao():
|
|
payload = {"invoiceVariation": [
|
|
_group(
|
|
{"desc": "Netflix Padrão", "value": "20.90", "invoice": CURRENT},
|
|
type_="streaming",
|
|
)
|
|
]}
|
|
assert varied_current_charges(payload) == ()
|
|
|
|
|
|
def test_post_finalize_replay_e_short_circuit_framework():
|
|
decision = evaluate_interruption(
|
|
payload={},
|
|
message_text="alô, ainda está aí?",
|
|
session_metadata={
|
|
"conversation_closed": True,
|
|
"terminal_status": "nao_resolvido",
|
|
"last_assistant_text": "Aguarde um instante na linha. Protocolo: 2026492272073",
|
|
"last_assistant_is_interruptible": True,
|
|
},
|
|
)
|
|
assert decision.action == "replay"
|
|
assert decision.reason == "post_finalize"
|
|
assert "2026492272073" in decision.replay_text
|
|
assert decision.is_interruptible is False
|
|
|
|
|
|
def test_anatel_rail_e_framework_native_sem_dependencia_legado():
|
|
rail = AnatelRail()
|
|
blocked = rail.evaluate(
|
|
GuardRailContext(
|
|
session_id="s1",
|
|
user_text="O ajuste foi concluído.",
|
|
agent_metadata={
|
|
"tipo_fluxo": "ajuste",
|
|
"requer_protocolo": True,
|
|
"expected_protocols": ["2026492272073"],
|
|
},
|
|
)
|
|
)
|
|
assert blocked.allowed is False
|
|
assert blocked.sanitized_text
|
|
assert "protocolo" in blocked.sanitized_text.lower()
|
|
|
|
approved = rail.evaluate(
|
|
GuardRailContext(
|
|
session_id="s1",
|
|
user_text="Ajuste concluído. Protocolo: 2026492272073.",
|
|
agent_metadata={"tipo_fluxo": "ajuste"},
|
|
)
|
|
)
|
|
assert approved.allowed is True
|
|
|
|
|
|
class _FakeClient:
|
|
def __init__(self):
|
|
self.contestar_calls = 0
|
|
|
|
def billing_analysis(self, msisdn: str):
|
|
return {
|
|
"Servicos de Valor Adicionado": [
|
|
{"desc": "Pacote Musica", "valor": "10,00"}
|
|
]
|
|
}
|
|
|
|
def contestar(self, payload):
|
|
self.contestar_calls += 1
|
|
return {"ok": True}
|
|
|
|
|
|
class _FakeService:
|
|
def __init__(self):
|
|
self.client = _FakeClient()
|
|
|
|
|
|
def test_cval_bloqueia_contestacao_antes_do_side_effect():
|
|
service = _FakeService()
|
|
reg = build_contas_workflow_actions(service) # type: ignore[arg-type]
|
|
action = reg.get("abrir_contestacao_cliente")
|
|
result = action(
|
|
{
|
|
"msisdn": "11999999999",
|
|
"items": [
|
|
{
|
|
"itemName": "Pacote nao cobrado",
|
|
"claimedAmount": "10",
|
|
"validatedAmount": "10",
|
|
}
|
|
],
|
|
},
|
|
{"input": {}},
|
|
)
|
|
assert result["success"] is False
|
|
assert result["blocked"] is True
|
|
assert result["guardrail_code"] == "CVAL"
|
|
assert service.client.contestar_calls == 0
|
|
|
|
from app.domain.contas.item_matcher import SimilarityItemMatcher
|
|
|
|
|
|
def test_similarity_matcher_recupera_erros_de_transcricao_comuns():
|
|
matcher = SimilarityItemMatcher()
|
|
candidates = [
|
|
"Tamboro Mensal",
|
|
"Netflix Padrão",
|
|
"Truecaller Semanal",
|
|
"MasterChef Mensal",
|
|
"Paramount+ Mensal",
|
|
]
|
|
assert matcher.match("mestershef", candidates)[0] == "MasterChef Mensal"
|
|
assert matcher.match("trucaller", candidates)[0] == "Truecaller Semanal"
|
|
assert matcher.match("paramaunt", candidates)[0] == "Paramount+ Mensal"
|
|
|
|
|
|
def test_tool_clarification_aceita_opcao_ordinal():
|
|
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
|
|
|
options = [
|
|
{"label": "Tamboro Mensal — linha final 1111", "value": "Tamboro Mensal"},
|
|
{"label": "Tamboro Mensal — linha final 2222", "value": "Tamboro Mensal"},
|
|
]
|
|
selected = AgentRuntimeMixin._choose_tool_clarification_option("2", options)
|
|
assert selected == options[1]
|
|
selected_by_name = AgentRuntimeMixin._choose_tool_clarification_option(
|
|
"Tamboro Mensal — linha final 1111", options
|
|
)
|
|
assert selected_by_name == options[0]
|
|
|
|
|
|
def test_output_guardrail_context_extrai_protocolos_das_tools():
|
|
from app.workflows.agent_graph import AgentWorkflow
|
|
|
|
ctx = AgentWorkflow._output_guardrail_context(
|
|
{
|
|
"context": {},
|
|
"mcp_results": [
|
|
{
|
|
"ok": True,
|
|
"result": {
|
|
"result": {
|
|
"protocolo_id": "2026492272073",
|
|
"nested": {"protocolNumber": "2026492272074"},
|
|
}
|
|
},
|
|
}
|
|
],
|
|
}
|
|
)
|
|
assert ctx["requer_protocolo"] is True
|
|
assert ctx["tipo_fluxo"] == "ajuste"
|
|
assert ctx["expected_protocols"] == ["2026492272073", "2026492272074"]
|
|
assert ctx["tool_executed"] is True
|
|
|
|
|
|
def test_fix_whole_utterance_transcription_preserva_fronteira_de_frase():
|
|
from agent_framework.channels.transcription import fix_whole_utterance_transcription
|
|
|
|
assert fix_whole_utterance_transcription("Fim") == "Sim"
|
|
assert fix_whole_utterance_transcription("Mim?") == "Sim"
|
|
assert fix_whole_utterance_transcription("chegou ao fim") == "chegou ao fim"
|
|
assert fix_whole_utterance_transcription("isso é pra mim") == "isso é pra mim"
|
|
|
|
|
|
def test_post_finalize_replay_preserva_terminal_status_e_fallback_sem_historico():
|
|
decision = evaluate_interruption(
|
|
payload={},
|
|
message_text="oi",
|
|
session_metadata={
|
|
"conversation_closed": True,
|
|
"terminal_status": "nao_resolvido",
|
|
"last_assistant_text": "",
|
|
"terminal_replay_text": "",
|
|
},
|
|
terminal_fallback_text=(
|
|
"Por aqui finalizamos o tratamento da sua solicitação. "
|
|
"Aguarde um instante na linha."
|
|
),
|
|
)
|
|
assert decision.action == "replay"
|
|
assert decision.reason == "post_finalize"
|
|
assert decision.terminal_status == "nao_resolvido"
|
|
assert decision.replay_text.startswith("Por aqui finalizamos")
|
|
assert decision.is_interruptible is False
|
|
|
|
|
|
def test_post_finalize_replay_status_invalido_usa_fallback_seguro():
|
|
decision = evaluate_interruption(
|
|
payload={},
|
|
message_text="oi",
|
|
session_metadata={"conversation_closed": True},
|
|
terminal_fallback_text="Atendimento já encerrado.",
|
|
)
|
|
assert decision.action == "replay"
|
|
assert decision.terminal_status == "erro_falha_sistema"
|
|
assert decision.replay_text == "Atendimento já encerrado."
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("entrada", "esperado"),
|
|
[
|
|
("Fim", "Sim"),
|
|
("fim", "Sim"),
|
|
("FIM", "Sim"),
|
|
("Fim.", "Sim"),
|
|
(" fim ", "Sim"),
|
|
("fim!", "Sim"),
|
|
("Mim", "Sim"),
|
|
("mim", "Sim"),
|
|
("Mim?", "Sim"),
|
|
("chegou ao fim", "chegou ao fim"),
|
|
("isso é pra mim", "isso é pra mim"),
|
|
("fim de semana", "fim de semana"),
|
|
("Fim, pode encerrar", "Fim, pode encerrar"),
|
|
("mim mesmo", "mim mesmo"),
|
|
("Sim", "Sim"),
|
|
("não", "não"),
|
|
("", ""),
|
|
(" ", " "),
|
|
("cancela o Tamboro", "cancela o Tamboro"),
|
|
],
|
|
)
|
|
def test_transcription_fix_paridade_original(entrada: str, esperado: str):
|
|
from agent_framework.channels.transcription import fix_whole_utterance_transcription
|
|
|
|
assert fix_whole_utterance_transcription(entrada) == esperado
|
|
|
|
|
|
class _ClassifierResponse:
|
|
def __init__(self, content: str):
|
|
self.content = content
|
|
|
|
|
|
class _ClassifierLLM:
|
|
def __init__(self, content: str = "0", *, fail: bool = False):
|
|
self.content = content
|
|
self.fail = fail
|
|
self.calls = []
|
|
|
|
async def ainvoke(self, messages, **kwargs):
|
|
self.calls.append((messages, kwargs))
|
|
if self.fail:
|
|
raise RuntimeError("classifier unavailable")
|
|
return _ClassifierResponse(self.content)
|
|
|
|
|
|
def test_processing_interruption_interrompivel_pede_classificacao():
|
|
decision = evaluate_interruption(
|
|
payload={"processing_interruption": {"heard_text": "cancelar também"}},
|
|
message_text="cancelar também",
|
|
session_metadata={
|
|
"last_assistant_text": "Vou verificar sua fatura.",
|
|
"last_assistant_is_interruptible": True,
|
|
},
|
|
)
|
|
assert decision.action == "classify"
|
|
assert decision.replay_text == "Vou verificar sua fatura."
|
|
assert decision.heard_text == "cancelar também"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_processing_interruption_classifier_resultado_1_reprocessa():
|
|
from agent_framework.channels.interruption import classify_processing_interruption
|
|
|
|
llm = _ClassifierLLM("1")
|
|
result = await classify_processing_interruption(
|
|
llm,
|
|
original_agent="Vou orientar sobre o cancelamento.",
|
|
original_client="Quero cancelar VOD e o",
|
|
supplement_client="Aluguel de Filme 1.",
|
|
)
|
|
assert result is True
|
|
assert llm.calls
|
|
_, kwargs = llm.calls[0]
|
|
assert kwargs["profile_name"] == "processing_interruption_classifier"
|
|
assert kwargs["component_name"] == "processing_interruption_classifier"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_processing_interruption_classifier_falha_faz_replay_seguro():
|
|
from agent_framework.channels.interruption import classify_processing_interruption
|
|
|
|
llm = _ClassifierLLM(fail=True)
|
|
result = await classify_processing_interruption(
|
|
llm,
|
|
original_agent="Aqui vai a resposta.",
|
|
original_client="quanto custa?",
|
|
supplement_client="oi",
|
|
)
|
|
assert result is False
|
|
|
|
|
|
|
|
def test_finalizacao_informacional_infer_tipo_pela_fatura():
|
|
from app.domain.contas.workflow_actions import _infer_informational_vas_types
|
|
|
|
invoice_detail = {
|
|
"11999999999": {
|
|
"Serviços Bundle Inclusos": [{"desc": "EXA Cloud", "value": 0}],
|
|
"Serviços Contratados de Terceiros": [{"desc": "Netflix Premium", "value": 59.9}],
|
|
"SVA Detalhe Total": [{"desc": "Aluguel de Filme 2", "value": 19.9}],
|
|
}
|
|
}
|
|
assert _infer_informational_vas_types(
|
|
["EXA Cloud", "Netflix Premium", "Aluguel de Filme 2"], invoice_detail
|
|
) == {"bundle", "estrategico", "avulso"}
|
|
assert _infer_informational_vas_types(["exa clod"], invoice_detail) == {"bundle"}
|
|
|
|
|
|
def test_finalizacao_informacional_infer_tipo_de_billing_analysis_json():
|
|
import json
|
|
from app.domain.contas.workflow_actions import _infer_informational_vas_types
|
|
payload = {
|
|
"currentInvoice": [
|
|
{"desc": "Serviços de valor adicionado", "items": [
|
|
{"contestable": True, "desc": "Aluguel de Filme 3", "type": "servicos_contratados_de_parceiros"},
|
|
{"contestable": False, "desc": "VOD + Canais Abertos", "type": "servicos_contratados_de_parceiros"},
|
|
]},
|
|
{"desc": "Streamings", "items": [
|
|
{"contestable": False, "desc": "YouTube Premium Mensal", "type": "streaming"}
|
|
]},
|
|
]
|
|
}
|
|
assert _infer_informational_vas_types(
|
|
["YouTube Premium Mensal", "Aluguel de Filme 3"], json.dumps(payload, ensure_ascii=False)
|
|
) == {"estrategico", "avulso"}
|
|
|
|
|
|
def test_output_guardrail_context_inclui_pedido_atual_para_aoferta():
|
|
from app.workflows.agent_graph import AgentWorkflow
|
|
|
|
ctx = AgentWorkflow._output_guardrail_context(
|
|
{
|
|
"context": {},
|
|
"history": [
|
|
{"role": "assistant", "content": "O TIM Music custa R$ 12,90."},
|
|
],
|
|
"user_text": "Quero cancelar o TIM Music",
|
|
"mcp_results": [],
|
|
}
|
|
)
|
|
assert ctx["conversation_history"][-1] == {
|
|
"role": "user",
|
|
"content": "Quero cancelar o TIM Music",
|
|
}
|
|
assert ctx["history_texts"][-1] == "Quero cancelar o TIM Music"
|
|
|
|
|
|
def test_output_guardrail_context_nao_duplica_user_text_ja_no_historico():
|
|
from app.workflows.agent_graph import AgentWorkflow
|
|
|
|
ctx = AgentWorkflow._output_guardrail_context(
|
|
{
|
|
"context": {},
|
|
"history": [{"role": "user", "content": "Quero cancelar o TIM Music"}],
|
|
"user_text": "Quero cancelar o TIM Music",
|
|
"mcp_results": [],
|
|
}
|
|
)
|
|
assert len(ctx["conversation_history"]) == 1
|
|
|
|
|
|
def test_output_guardrail_context_isola_transacao_terminal_apos_intent_shift():
|
|
from app.workflows.agent_graph import AgentWorkflow
|
|
|
|
ctx = AgentWorkflow._output_guardrail_context(
|
|
{
|
|
"context": {
|
|
"transaction_pre_validation": {
|
|
"status": "OUT_OF_SCOPE",
|
|
"terminal": True,
|
|
"error": "TIM CTRL Redes Sociais 8.0",
|
|
},
|
|
},
|
|
"history": [
|
|
{"role": "user", "content": "nao contratei TIM CTRL Redes Sociais 8.0"},
|
|
{"role": "assistant", "content": "Esse item nao e contestavel."},
|
|
],
|
|
"user_text": "quais sao meus servicos",
|
|
"route": "vas_agent",
|
|
"intent": "contas_vas_information",
|
|
"route_decision": {
|
|
"route": "vas_agent",
|
|
"intent": "contas_vas_information",
|
|
"metadata": {
|
|
"route_stickiness_preempted": True,
|
|
"previous_agent": "contestacao_agent",
|
|
"previous_intent": "contas_contestation",
|
|
},
|
|
},
|
|
"transaction_status": "OUT_OF_SCOPE",
|
|
"transaction_pre_validation": {
|
|
"status": "OUT_OF_SCOPE",
|
|
"terminal": True,
|
|
"error": "TIM CTRL Redes Sociais 8.0",
|
|
},
|
|
"mcp_results": [],
|
|
}
|
|
)
|
|
|
|
assert ctx["conversation_history"] == [
|
|
{"role": "user", "content": "quais sao meus servicos"}
|
|
]
|
|
assert ctx["history_texts"] == ["quais sao meus servicos"]
|
|
assert ctx["current_user_message"] == "quais sao meus servicos"
|
|
assert ctx["current_route"] == "vas_agent"
|
|
assert ctx["current_intent"] == "contas_vas_information"
|
|
assert ctx["historical_transaction_ignored"] is True
|
|
assert ctx["historical_transaction_status"] == "OUT_OF_SCOPE"
|
|
assert "transaction_pre_validation" not in ctx
|
|
assert "TIM CTRL Redes Sociais" not in str(ctx)
|
|
|
|
|
|
def test_output_guardrail_context_preserva_historico_no_turno_terminal_sem_intent_shift():
|
|
from app.workflows.agent_graph import AgentWorkflow
|
|
|
|
ctx = AgentWorkflow._output_guardrail_context(
|
|
{
|
|
"context": {},
|
|
"history": [
|
|
{"role": "user", "content": "nao contratei TIM CTRL Redes Sociais 8.0"},
|
|
],
|
|
"user_text": "o valor e 71,99",
|
|
"route": "contestacao_agent",
|
|
"intent": "state:COLLECTING_CONTESTACAO_PARAMETERS",
|
|
"route_decision": {
|
|
"route": "contestacao_agent",
|
|
"intent": "state:COLLECTING_CONTESTACAO_PARAMETERS",
|
|
"metadata": {},
|
|
},
|
|
"transaction_status": "OUT_OF_SCOPE",
|
|
"transaction_pre_validation": {
|
|
"status": "OUT_OF_SCOPE",
|
|
"terminal": True,
|
|
},
|
|
"mcp_results": [],
|
|
}
|
|
)
|
|
|
|
assert ctx["history_texts"] == [
|
|
"nao contratei TIM CTRL Redes Sociais 8.0",
|
|
"o valor e 71,99",
|
|
]
|
|
assert "historical_transaction_ignored" not in ctx
|
|
|
|
|
|
def test_vas_prompt_nao_expoe_capacidade_cancelamento_em_consulta_informativa():
|
|
from pathlib import Path
|
|
|
|
prompt = Path("config/prompts/vas.yaml").read_text(encoding="utf-8")
|
|
assert "não exponha capacidades transacionais" in prompt
|
|
assert "can.cancel" in prompt
|
|
assert '"Cancelamento disponível"' in prompt
|
|
assert "a menos que o cliente tenha perguntado explicitamente" in prompt
|