Ajustes conforme relatorio de testes 2026-08-27
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -25,12 +25,16 @@
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"checar_vas_variacao": {
|
||||
"handoff_pos_explicacao_nao": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"tem_vas_variado": true,
|
||||
"qtd_vas_variados": 2,
|
||||
"decisao_vas_variacao": "tem_vas_variado"
|
||||
"mensagem": "Para continuar com a sua solicitação, aguarde um instante.",
|
||||
"session_control": "HUMAN_HANDOFF",
|
||||
"human_handoff_requested": true,
|
||||
"handoff": true,
|
||||
"session_ended": true,
|
||||
"terminal_status": "human_handoff",
|
||||
"handoff_reason": "invoice_explanation_not_resolved"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -38,21 +42,25 @@
|
||||
"status": "WAITING_INPUT",
|
||||
"paused_at": "formatar",
|
||||
"expected_input_key": "resposta_usuario",
|
||||
"allowed_values": ["SIM", "NAO"]
|
||||
"allowed_values": [
|
||||
"SIM",
|
||||
"NAO",
|
||||
"CONTINUAR"
|
||||
]
|
||||
},
|
||||
"resume": {
|
||||
"resposta_usuario": "NAO"
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "checar_vas_variacao",
|
||||
"last_node": "handoff_pos_explicacao_nao",
|
||||
"trace_nodes": [
|
||||
"preparar",
|
||||
"formatar",
|
||||
"formatar",
|
||||
"decisao",
|
||||
"registrar_nao",
|
||||
"checar_vas_variacao"
|
||||
"handoff_pos_explicacao_nao"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,16 @@
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"checar_vas_variacao": {
|
||||
"handoff_pos_explicacao_nao": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"tem_vas_variado": false,
|
||||
"qtd_vas_variados": 0,
|
||||
"decisao_vas_variacao": "finaliza_nao_resolvido"
|
||||
"mensagem": "Para continuar com a sua solicitação, aguarde um instante.",
|
||||
"session_control": "HUMAN_HANDOFF",
|
||||
"human_handoff_requested": true,
|
||||
"handoff": true,
|
||||
"session_ended": true,
|
||||
"terminal_status": "human_handoff",
|
||||
"handoff_reason": "invoice_explanation_not_resolved"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -38,22 +42,25 @@
|
||||
"status": "WAITING_INPUT",
|
||||
"paused_at": "formatar",
|
||||
"expected_input_key": "resposta_usuario",
|
||||
"allowed_values": ["SIM", "NAO"]
|
||||
"allowed_values": [
|
||||
"SIM",
|
||||
"NAO",
|
||||
"CONTINUAR"
|
||||
]
|
||||
},
|
||||
"resume": {
|
||||
"resposta_usuario": "NAO"
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "finalizar_nao_resolvido",
|
||||
"last_node": "handoff_pos_explicacao_nao",
|
||||
"trace_nodes": [
|
||||
"preparar",
|
||||
"formatar",
|
||||
"formatar",
|
||||
"decisao",
|
||||
"registrar_nao",
|
||||
"checar_vas_variacao",
|
||||
"finalizar_nao_resolvido"
|
||||
"handoff_pos_explicacao_nao"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,11 @@
|
||||
"status": "WAITING_INPUT",
|
||||
"paused_at": "formatar",
|
||||
"expected_input_key": "resposta_usuario",
|
||||
"allowed_values": ["SIM", "NAO"]
|
||||
"allowed_values": [
|
||||
"SIM",
|
||||
"NAO",
|
||||
"CONTINUAR"
|
||||
]
|
||||
},
|
||||
"resume": {
|
||||
"resposta_usuario": "SIM"
|
||||
|
||||
74
tests/migration/test_contas_invoice_explanation_routing.py
Normal file
74
tests/migration/test_contas_invoice_explanation_routing.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework.routing.config_loader import load_intents
|
||||
from agent_framework.routing.enterprise_router import EnterpriseRouter
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _router_for_config() -> EnterpriseRouter:
|
||||
router = EnterpriseRouter.__new__(EnterpriseRouter)
|
||||
router.intents = load_intents(ROOT / "config" / "routing.yaml")
|
||||
return router
|
||||
|
||||
|
||||
def test_non_recognition_without_transactional_action_routes_to_invoice_explanation():
|
||||
router = _router_for_config()
|
||||
decision = router._route_by_keyword(
|
||||
"contratamos o plano de duzentos e nove, mas eu não estou reconhecendo "
|
||||
"itens eventuais sessenta e seis e noventa e seis"
|
||||
)
|
||||
assert decision is not None
|
||||
assert decision.intent == "contas_invoice_explanation"
|
||||
assert decision.agent == "faturas_agent"
|
||||
assert decision.mcp_tools == ["invoice_explanation"]
|
||||
|
||||
|
||||
def test_explicit_contestation_wins_over_non_recognition_language():
|
||||
router = _router_for_config()
|
||||
decision = router._route_by_keyword(
|
||||
"não reconheço essa cobrança e quero contestar agora"
|
||||
)
|
||||
assert decision is not None
|
||||
assert decision.intent == "contas_contestation"
|
||||
assert decision.agent == "contestacao_agent"
|
||||
assert "contestar_cobranca" in decision.mcp_tools
|
||||
|
||||
|
||||
def test_contestation_intent_supports_explicit_action_and_contextual_reentry():
|
||||
intents = {item.name: item for item in load_intents(ROOT / "config" / "routing.yaml")}
|
||||
contest = intents["contas_contestation"]
|
||||
explanation = intents["contas_invoice_explanation"]
|
||||
|
||||
assert "reentrada contextual" in contest.description
|
||||
assert "não reconheço essa cobrança" not in contest.keywords
|
||||
assert "não estou reconhecendo" in explanation.keywords
|
||||
assert explanation.examples[0].startswith("Contratamos um plano")
|
||||
assert "Contexto anterior" in contest.examples[0]
|
||||
|
||||
|
||||
def test_mig0003_causal_bill_increase_routes_to_invoice_explanation():
|
||||
router = _router_for_config()
|
||||
decision = router._route_by_keyword("por que minha conta subiu esse mês?")
|
||||
assert decision is not None
|
||||
assert decision.intent == "contas_invoice_explanation"
|
||||
assert decision.agent == "faturas_agent"
|
||||
assert decision.mcp_tools == ["invoice_explanation"]
|
||||
|
||||
|
||||
def test_generic_invoice_lookup_still_routes_to_invoice_query():
|
||||
router = _router_for_config()
|
||||
decision = router._route_by_keyword("quero minha fatura")
|
||||
assert decision is not None
|
||||
assert decision.intent == "contas_invoice_query"
|
||||
assert decision.mcp_tools == ["consultar_faturas"]
|
||||
|
||||
|
||||
def test_invoice_query_does_not_keep_ambiguous_minha_conta_keyword():
|
||||
intents = {item.name: item for item in load_intents(ROOT / "config" / "routing.yaml")}
|
||||
invoice_query = intents["contas_invoice_query"]
|
||||
explanation = intents["contas_invoice_explanation"]
|
||||
assert "minha conta" not in invoice_query.keywords
|
||||
assert "minha conta subiu" in explanation.keywords
|
||||
assert explanation.priority > invoice_query.priority
|
||||
@@ -0,0 +1,30 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from app.domain.contas.client import TimApiClient
|
||||
from app.domain.contas.workflow_actions import _classify_contestation_items
|
||||
|
||||
|
||||
def test_mock_contestar_filters_provider_response_to_current_transaction(monkeypatch):
|
||||
monkeypatch.setenv("TIM_USE_MOCK_GATEWAY", "true")
|
||||
client = TimApiClient()
|
||||
result = client.contestar({
|
||||
"sr": "1234567890",
|
||||
"items": [{"itemName": "Tamboro Mensal", "claimedAmount": "14.99", "validatedAmount": "14.99"}],
|
||||
})
|
||||
assert result["sr"] == "1234567890"
|
||||
assert [x["itemName"] for x in result["itemsResponse"]] == ["Tamboro Mensal"]
|
||||
assert result["itemsResponse"][0]["message"] == "Contestação criada/atualizada com sucesso"
|
||||
assert "items_response" not in result
|
||||
assert "validated_items" not in result
|
||||
assert "protocol_number" not in result
|
||||
|
||||
|
||||
def test_contestation_amount_does_not_convert_decimal_dot_to_cents():
|
||||
requested = [{"item_name": "Tamboro Mensal", "claimed_amount": "14.99", "validated_amount": "14.99"}]
|
||||
response = [{"itemName": "Tamboro Mensal", "status": "INICIADA", "correctAccountStatus": "CRIAR"}]
|
||||
contested, not_contested, already, open_amount, total_amount = _classify_contestation_items(requested, response)
|
||||
assert len(contested) == 1
|
||||
assert not not_contested
|
||||
assert not already
|
||||
assert Decimal(open_amount) == Decimal("14.99")
|
||||
assert Decimal(total_amount) == Decimal("14.99")
|
||||
@@ -0,0 +1,55 @@
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import yaml
|
||||
|
||||
|
||||
def test_contestation_subject_schema_requires_concrete_invoice_entity():
|
||||
cfg = yaml.safe_load(Path("config/tools.yaml").read_text(encoding="utf-8"))
|
||||
tools = cfg["tools"] if "tools" in cfg else cfg
|
||||
for tool_name in ("validar_contestacao", "contestar_cobranca"):
|
||||
subject = tools[tool_name]["args_schema"]["subject"]
|
||||
desc = subject["description"].lower()
|
||||
assert "item concreto" in desc
|
||||
assert "evidência da fatura" in desc or "evidencia da fatura" in desc
|
||||
assert subject["user_prompt"]
|
||||
|
||||
|
||||
def test_contestation_prompt_forbids_internal_validation_language():
|
||||
prompt = Path("config/prompts/contestation.yaml").read_text(encoding="utf-8")
|
||||
assert "nunca exponha códigos" in prompt.lower()
|
||||
assert "CVAL" in prompt
|
||||
assert "JSON" in prompt
|
||||
assert "guardrail_code" in prompt
|
||||
|
||||
|
||||
def test_validator_turns_unresolved_subject_into_recoverable_parameter_request(monkeypatch):
|
||||
from contas_mcp.servers.contas_mcp_server import main
|
||||
|
||||
async def no_enrich(_name, _args):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(main, "_enrich_invoice_context", no_enrich)
|
||||
monkeypatch.setattr(main, "_preflight_subject", lambda *_args, **_kwargs: None)
|
||||
args = {
|
||||
"subject": "fatura",
|
||||
"valor": 10.0,
|
||||
"target_tool": "contestar_cobranca",
|
||||
"billing_analysis": {
|
||||
"currentInvoice": [
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"items": [
|
||||
{"desc": "TIM Fashion Mensal", "value": "10.0", "contestable": True}
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
result = asyncio.run(main._validate_contestation(args))
|
||||
assert result["eligible"] is False
|
||||
assert result["status"] == "NEEDS_PARAMETER"
|
||||
assert result["parameter"] == "subject"
|
||||
assert result["reason"] == "subject_not_resolved"
|
||||
assert result["resolved_value"] == 10.0
|
||||
assert result["metadata"]["entity_resolution"] == "invoice_evidence"
|
||||
assert "error" not in result
|
||||
90
tests/migration/test_discount_history_mcp_service.py
Normal file
90
tests/migration/test_discount_history_mcp_service.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from pathlib import Path
|
||||
|
||||
from contas_mcp.servers.contas_mcp_server.discount_history_service import DiscountHistoryMockService
|
||||
from app.domain.contas import ContasDomainService
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
|
||||
FIXTURE = Path(__file__).resolve().parents[2] / "app" / "domain" / "contas" / "fixtures" / "discount_history.json"
|
||||
|
||||
|
||||
def test_discount_history_mock_returns_structured_authoritative_reason():
|
||||
service = DiscountHistoryMockService(FIXTURE)
|
||||
result = service.consultar_historico_descontos(msisdn="11999999999")
|
||||
assert result["success"] is True
|
||||
expired = next(row for row in result["discounts"] if row["discount_status"] == "EXPIRED")
|
||||
assert expired["plan_name"] == "TIM Black A 8.0"
|
||||
assert expired["previous_value"] == 80.0
|
||||
assert expired["current_value"] == 0.0
|
||||
assert expired["termination_reason"] == "FIM_PERIODO_FIDELIDADE"
|
||||
assert expired["termination_reason_description"] == "O período de fidelidade contratado foi encerrado."
|
||||
|
||||
|
||||
def test_discount_history_mock_can_filter_plan_without_inventing_other_reason():
|
||||
service = DiscountHistoryMockService(FIXTURE)
|
||||
result = service.consultar_historico_descontos(msisdn="11999999999", nome_plano="CTRL")
|
||||
assert result["success"] is True
|
||||
assert len(result["discounts"]) == 1
|
||||
assert result["discounts"][0]["discount_status"] == "ACTIVE"
|
||||
assert result["discounts"][0]["termination_reason"] is None
|
||||
|
||||
|
||||
def test_termino_desconto_formats_only_structured_discount_history_facts():
|
||||
service = DiscountHistoryMockService(FIXTURE)
|
||||
evidence = service.consultar_historico_descontos(msisdn="11999999999", nome_plano="TIM Black")
|
||||
action = build_contas_workflow_actions(ContasDomainService()).get("formatar_capability_resposta")
|
||||
result = action({"tipo": "termino_desconto", "discount_evidence": evidence}, {"input": {}})
|
||||
text = result["mensagem"]
|
||||
assert result["discount_reason_grounded"] is True
|
||||
assert result["epistemic_status"] == "grounded_fact"
|
||||
assert "TIM Black A 8.0" in text
|
||||
assert "R$ 80,00" in text
|
||||
assert "20/11/2025" in text
|
||||
assert "O período de fidelidade contratado foi encerrado." in text
|
||||
assert "FIM_PERIODO_FIDELIDADE" not in text
|
||||
|
||||
|
||||
def test_termino_desconto_active_discount_without_reason_remains_non_conclusive():
|
||||
service = DiscountHistoryMockService(FIXTURE)
|
||||
evidence = service.consultar_historico_descontos(msisdn="11999999999", nome_plano="CTRL")
|
||||
action = build_contas_workflow_actions(ContasDomainService()).get("formatar_capability_resposta")
|
||||
result = action({"tipo": "termino_desconto", "discount_evidence": evidence}, {"input": {}})
|
||||
assert result["discount_reason_grounded"] is False
|
||||
assert result["epistemic_status"] == "insufficient_evidence"
|
||||
assert "não informam o motivo" in result["mensagem"]
|
||||
|
||||
|
||||
def test_discount_history_temporal_reference_matches_last_invoice_fixture():
|
||||
import json
|
||||
|
||||
service = DiscountHistoryMockService(FIXTURE)
|
||||
result = service.consultar_historico_descontos(msisdn="11999999999", nome_plano="TIM Black")
|
||||
expired = result["discounts"][0]
|
||||
|
||||
invoice_fixture = FIXTURE.parent / "invoice_pdf_include_danfe_true.json"
|
||||
invoice = json.loads(invoice_fixture.read_text(encoding="utf-8"))
|
||||
summary = {row.get("desc"): row for row in invoice.get("Fatura Resumo", []) if isinstance(row, dict)}
|
||||
line = invoice["1199999999"]["Planos"]["TIM Black A 8.0"]
|
||||
billed_discount = next(row for row in line["descontos"] if row["desc"].startswith("Desc Fidel 80 TIM Black"))
|
||||
|
||||
assert result["as_of_date"] == "2025-11-20"
|
||||
assert result["last_billed_period"] == summary["PERÍODO"]["period"] == "14/10 a 13/11"
|
||||
assert result["last_invoice_issue_date"] == "2025-11-20"
|
||||
assert summary["EMISSÃO"]["emissao"] == "20/11/2025"
|
||||
assert expired["current_value"] == 0.0
|
||||
assert expired["current_value_reference"] == "contract_as_of_date"
|
||||
assert expired["last_billed_discount_value"] == abs(float(billed_discount["value"])) == 80.0
|
||||
assert expired["last_billed_status"] == "APPLIED"
|
||||
|
||||
|
||||
def test_termino_desconto_explains_contract_vs_last_billed_period_without_contradiction():
|
||||
service = DiscountHistoryMockService(FIXTURE)
|
||||
evidence = service.consultar_historico_descontos(msisdn="11999999999", nome_plano="TIM Black")
|
||||
action = build_contas_workflow_actions(ContasDomainService()).get("formatar_capability_resposta")
|
||||
result = action({"tipo": "termino_desconto", "discount_evidence": evidence}, {"input": {}})
|
||||
text = result["mensagem"]
|
||||
|
||||
assert "O último período faturado com esse desconto foi 14/10 a 13/11" in text
|
||||
assert "com R$ 80,00 de desconto" in text
|
||||
assert "na fatura emitida em 20/11/2025" in text
|
||||
assert "a situação contratual em 20/11/2025 já consta como encerrada" in text
|
||||
35
tests/migration/test_framework_agent_gap_fixes.py
Normal file
35
tests/migration/test_framework_agent_gap_fixes.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_contas_transactional_parameters_have_customer_facing_prompts():
|
||||
data = yaml.safe_load((ROOT / "config" / "tools.yaml").read_text(encoding="utf-8"))
|
||||
tools = data["tools"]
|
||||
expected = {
|
||||
("cancelar_vas_avulso", "subject"): "Qual serviço você deseja cancelar?",
|
||||
("tratar_vas_estrategico", "subject"): "Qual serviço ou benefício você deseja tratar?",
|
||||
("validar_contestacao", "subject"): "Qual cobrança ou item você não reconhece?",
|
||||
("validar_contestacao", "valor"): "Qual é o valor da cobrança?",
|
||||
("contestar_cobranca", "subject"): "Qual cobrança ou item você deseja contestar?",
|
||||
("contestar_cobranca", "valor"): "Qual é o valor da cobrança que você deseja contestar?",
|
||||
}
|
||||
for (tool, field), prompt in expected.items():
|
||||
assert tools[tool]["args_schema"][field]["user_prompt"] == prompt
|
||||
assert field not in prompt.lower().replace("subject", "") if field == "subject" else True
|
||||
|
||||
|
||||
def test_billing_prompt_forbids_unsupported_causal_hypotheses():
|
||||
prompt = yaml.safe_load((ROOT / "config" / "prompts" / "billing.yaml").read_text(encoding="utf-8"))["system"]
|
||||
assert "fim de promoção" in prompt
|
||||
assert "perda de elegibilidade" in prompt
|
||||
assert "sem evidência explícita" in prompt
|
||||
assert "Não prometa nem estime valores de faturas futuras" in prompt
|
||||
|
||||
|
||||
def test_support_prompt_does_not_simulate_handoff_or_terminal_success():
|
||||
prompt = yaml.safe_load((ROOT / "config" / "prompts" / "support.yaml").read_text(encoding="utf-8"))["system"]
|
||||
assert "Nunca anuncie transferência" in prompt
|
||||
assert "não simule handoff em texto livre" in prompt
|
||||
assert "OUT_OF_SCOPE" in prompt
|
||||
68
tests/migration/test_human_handoff_guardrail_context.py
Normal file
68
tests/migration/test_human_handoff_guardrail_context.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.workflows.agent_graph import AgentWorkflow
|
||||
|
||||
|
||||
def test_output_guardrail_context_exposes_structural_handoff_evidence():
|
||||
state = {
|
||||
'context': {},
|
||||
'user_text': 'quero falar com um atendente',
|
||||
'route': 'human_handoff',
|
||||
'intent': 'human_handoff',
|
||||
'session_control': 'HUMAN_HANDOFF',
|
||||
'human_handoff_requested': True,
|
||||
'route_decision': {
|
||||
'route': 'human_handoff',
|
||||
'agent': 'human_handoff',
|
||||
'intent': 'human_handoff',
|
||||
'handoff': True,
|
||||
'metadata': {'session_control': 'HUMAN_HANDOFF'},
|
||||
},
|
||||
'mcp_results': [{'tool_name': 'invoice_explanation', 'ok': True, 'result': {'status': 'PAUSED'}}],
|
||||
}
|
||||
|
||||
ctx = AgentWorkflow._output_guardrail_context(state)
|
||||
|
||||
assert ctx['current_route'] == 'human_handoff'
|
||||
assert ctx['current_intent'] == 'human_handoff'
|
||||
assert ctx['session_control'] == 'HUMAN_HANDOFF'
|
||||
assert ctx['human_handoff_requested'] is True
|
||||
assert ctx['handoff'] is True
|
||||
assert ctx['route_decision']['handoff'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_human_handoff_clears_live_paused_workflow_and_transaction_state():
|
||||
graph = object.__new__(AgentWorkflow)
|
||||
|
||||
class _Span:
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *args): return False
|
||||
|
||||
class _Telemetry:
|
||||
def span(self, *args, **kwargs): return _Span()
|
||||
async def event(self, *args, **kwargs): return None
|
||||
|
||||
class _Settings:
|
||||
HUMAN_HANDOFF_MESSAGE = 'Vou encaminhar seu atendimento para uma pessoa.'
|
||||
|
||||
graph.telemetry = _Telemetry()
|
||||
graph.settings = _Settings()
|
||||
graph.tool_router = None
|
||||
|
||||
result = await graph.human_handoff({
|
||||
'session_id': 's1',
|
||||
'pending_domain_workflow': {'execution_id': 'wf1', 'status': 'PAUSED'},
|
||||
'active_transaction': {'tool_name': 'invoice_explanation', 'status': 'WORKFLOW_PAUSED'},
|
||||
'mcp_results': [{'tool_name': 'invoice_explanation', 'ok': True}],
|
||||
'route_decision': {'reason': 'pedido explicito'},
|
||||
})
|
||||
|
||||
assert result['session_control'] == 'HUMAN_HANDOFF'
|
||||
assert result['pending_domain_workflow'] is None
|
||||
assert result['active_transaction'] is None
|
||||
assert result['mcp_results'] == []
|
||||
assert result['transaction_status'] == 'CANCELLED'
|
||||
assert result['confirmation_required'] is False
|
||||
41
tests/migration/test_input_guardrail_user_feedback.py
Normal file
41
tests/migration/test_input_guardrail_user_feedback.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.workflows.agent_graph import AgentWorkflow
|
||||
|
||||
|
||||
def decision(code, allowed=False, reason=""):
|
||||
return SimpleNamespace(code=code, allowed=allowed, reason=reason)
|
||||
|
||||
|
||||
def test_coer_block_is_clarification_not_fake_security_message():
|
||||
msg = AgentWorkflow._input_guardrail_user_message(
|
||||
[decision("COER", reason="fala incompreensível ou negação ambígua na transcrição")],
|
||||
{},
|
||||
"está cobrando um",
|
||||
)
|
||||
assert "incompleta ou ambígua" in msg
|
||||
assert "regra de segurança" not in msg.lower()
|
||||
assert "COER" not in msg
|
||||
|
||||
|
||||
def test_other_input_block_has_safe_non_internal_fallback():
|
||||
msg = AgentWorkflow._input_guardrail_user_message(
|
||||
[decision("UNKNOWN_RAIL", reason="internal implementation detail")],
|
||||
{},
|
||||
"texto",
|
||||
)
|
||||
assert "reformular" in msg.lower()
|
||||
assert "internal implementation detail" not in msg
|
||||
|
||||
|
||||
def test_blocked_input_is_routed_through_output_guardrails():
|
||||
src = open("app/workflows/agent_graph.py", encoding="utf-8").read()
|
||||
assert '{"blocked": "output_guardrails", "continue": "load_long_term_memory"}' in src
|
||||
assert '{"blocked": "persist", "continue": "judge"}' in src
|
||||
|
||||
|
||||
def test_blocked_input_clears_stale_tool_state():
|
||||
src = open("app/workflows/agent_graph.py", encoding="utf-8").read()
|
||||
assert '"mcp_tools": []' in src
|
||||
assert '"mcp_results": []' in src
|
||||
assert '"intent": "input_guardrail_blocked"' in src
|
||||
39
tests/migration/test_invoice_explanation_final_response.py
Normal file
39
tests/migration/test_invoice_explanation_final_response.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
|
||||
def test_invoice_explanation_sim_final_node_declares_final_response():
|
||||
spec = yaml.safe_load(Path("workflows/invoice_explanation.v2.yaml").read_text(encoding="utf-8"))
|
||||
nodes = {node["id"]: node for node in spec["nodes"]}
|
||||
cfg = nodes["registrar_protocolo_aceite"]["input"]
|
||||
assert cfg["workflow_response_final"] is True
|
||||
assert "{protocol}" in cfg["mensagem_final"]
|
||||
|
||||
|
||||
def test_protocol_action_can_materialize_workflow_final_response(monkeypatch):
|
||||
class Client:
|
||||
def abrir_protocolo(self, payload):
|
||||
return {"interactionProtocol": "1234567890", "status": "OPENED"}
|
||||
|
||||
class Service:
|
||||
client = Client()
|
||||
|
||||
registry = build_contas_workflow_actions(Service())
|
||||
action = registry.get("registrar_protocolo_inicio")
|
||||
result = action(
|
||||
{
|
||||
"msisdn": "11999999999",
|
||||
"scenario": "invoice_explanation_aceite_fechado",
|
||||
"request_status": "Fechado",
|
||||
"status": "CLOSED",
|
||||
"workflow_response_final": True,
|
||||
"mensagem_final": "Seu número de protocolo é {protocol}.",
|
||||
},
|
||||
{"input": {}},
|
||||
)
|
||||
assert result["workflow_response_final"] is True
|
||||
assert result["protocol_number"]
|
||||
assert result["mensagem"] == f"Seu número de protocolo é {result['protocol_number']}."
|
||||
130
tests/migration/test_invoice_explanation_handoff_policy.py
Normal file
130
tests/migration/test_invoice_explanation_handoff_policy.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.agents.faturas_agent import FaturasAgent
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
from contas_mcp.servers.contas_mcp_server import main as mcp_main
|
||||
|
||||
|
||||
def test_invoice_explanation_handoff_action_is_structural_and_uses_configured_message():
|
||||
class Service:
|
||||
pass
|
||||
|
||||
action = build_contas_workflow_actions(Service()).get("preparar_handoff_invoice_explanation")
|
||||
result = action(
|
||||
{
|
||||
"mensagem": "Para continuar com a sua solicitação, aguarde um instante.",
|
||||
"reason": "invoice_explanation_not_resolved",
|
||||
},
|
||||
{"input": {}},
|
||||
)
|
||||
assert result["mensagem"] == "Para continuar com a sua solicitação, aguarde um instante."
|
||||
assert result["session_control"] == "HUMAN_HANDOFF"
|
||||
assert result["human_handoff_requested"] is True
|
||||
assert result["handoff"] is True
|
||||
assert result["session_ended"] is True
|
||||
assert result["terminal_status"] == "human_handoff"
|
||||
|
||||
|
||||
def test_result_payload_promotes_invoice_explanation_handoff_control():
|
||||
result = {
|
||||
"execution_id": "exec-1",
|
||||
"status": "COMPLETED",
|
||||
"output": {
|
||||
"handoff_pos_explicacao_nao": {
|
||||
"success": True,
|
||||
"mensagem": "Para continuar com a sua solicitação, aguarde um instante.",
|
||||
"session_control": "HUMAN_HANDOFF",
|
||||
"handoff_reason": "invoice_explanation_not_resolved",
|
||||
}
|
||||
},
|
||||
"state": {"current_node": "handoff_pos_explicacao_nao"},
|
||||
"trace": [{"node": "handoff_pos_explicacao_nao", "status": "COMPLETED"}],
|
||||
}
|
||||
payload = mcp_main._result_payload(result, workflow_name="invoice_explanation")
|
||||
assert payload["mensagem"] == "Para continuar com a sua solicitação, aguarde um instante."
|
||||
assert payload["session_control"] == "HUMAN_HANDOFF"
|
||||
assert payload["human_handoff_requested"] is True
|
||||
assert payload["handoff"] is True
|
||||
assert payload["session_ended"] is True
|
||||
|
||||
|
||||
def test_faturas_agent_promotes_handoff_from_tool_context_to_graph_state():
|
||||
context = [{
|
||||
"tool_name": "retomar_workflow",
|
||||
"ok": True,
|
||||
"result": {
|
||||
"workflow_name": "invoice_explanation",
|
||||
"status": "COMPLETED",
|
||||
"mensagem": "Para continuar com a sua solicitação, aguarde um instante.",
|
||||
"session_control": "HUMAN_HANDOFF",
|
||||
"human_handoff_requested": True,
|
||||
"session_ended": True,
|
||||
"terminal_status": "human_handoff",
|
||||
"handoff_reason": "invoice_explanation_not_resolved",
|
||||
},
|
||||
}]
|
||||
patch = FaturasAgent._handoff_patch_from_tool_context(context)
|
||||
assert patch == {
|
||||
"session_control": "HUMAN_HANDOFF",
|
||||
"human_handoff_requested": True,
|
||||
"session_ended": True,
|
||||
"terminal_status": "human_handoff",
|
||||
"handoff_reason": "invoice_explanation_not_resolved",
|
||||
}
|
||||
|
||||
|
||||
def test_completed_terminal_workflow_short_circuits_llm_composition_directive():
|
||||
agent = FaturasAgent.__new__(FaturasAgent)
|
||||
context = [{
|
||||
"tool_name": "retomar_workflow",
|
||||
"ok": True,
|
||||
"result": {
|
||||
"status": "COMPLETED",
|
||||
"workflow_name": "generic_workflow",
|
||||
# Earlier node still carries a composition directive. It must not win
|
||||
# over the terminal last node.
|
||||
"requires_llm_composition": True,
|
||||
"response_instruction": "Componha uma nova resposta.",
|
||||
"output": {
|
||||
"formatar": {
|
||||
"requires_llm_composition": True,
|
||||
"mensagem": "mensagem anterior",
|
||||
},
|
||||
"final": {
|
||||
"mensagem": "Encaminhando para atendimento humano.",
|
||||
"session_control": "HUMAN_HANDOFF",
|
||||
"handoff": True,
|
||||
"session_ended": True,
|
||||
"terminal_status": "human_handoff",
|
||||
},
|
||||
},
|
||||
"state": {"current_node": "final"},
|
||||
},
|
||||
}]
|
||||
answer = agent.build_direct_mcp_answer({}, context, agent_label="AnyAgent")
|
||||
assert answer == "Encaminhando para atendimento humano."
|
||||
|
||||
|
||||
def test_result_payload_promotes_generic_terminal_last_node_contract():
|
||||
result = {
|
||||
"execution_id": "exec-generic",
|
||||
"status": "COMPLETED",
|
||||
"output": {
|
||||
"final": {
|
||||
"mensagem": "Sessão encerrada.",
|
||||
"session_control": "END_SESSION",
|
||||
"session_ended": True,
|
||||
"terminal_status": "done",
|
||||
}
|
||||
},
|
||||
"state": {"current_node": "final"},
|
||||
"trace": [{"node": "final", "status": "COMPLETED"}],
|
||||
}
|
||||
payload = mcp_main._result_payload(result, workflow_name="generic_example")
|
||||
assert payload["terminal"] is True
|
||||
assert payload["terminal_action"] == "end_session"
|
||||
assert payload["mensagem"] == "Sessão encerrada."
|
||||
assert payload["session_ended"] is True
|
||||
@@ -0,0 +1,18 @@
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
|
||||
def test_invoice_explanation_declares_configurable_semantic_classifier():
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
data = yaml.safe_load((root / "workflows" / "invoice_explanation.v2.yaml").read_text(encoding="utf-8"))
|
||||
formatar = next(node for node in data["nodes"] if node["id"] == "formatar")
|
||||
expected = formatar["pause"]["expected_input"]
|
||||
assert expected["allowed_values"] == ["SIM", "NAO", "CONTINUAR"]
|
||||
classifier = expected["semantic_classifier"]
|
||||
assert classifier["enabled"] is True
|
||||
assert classifier["include_relevant_context"] is True
|
||||
assert "{{ relevant_conversation_context }}" in classifier["prompt"]
|
||||
assert "{{ allowed_values }}" in classifier["prompt"]
|
||||
assert "R$ 275,00" in classifier["prompt"]
|
||||
assert "=> CONTINUAR" in classifier["prompt"]
|
||||
assert classifier["option_actions"]["CONTINUAR"]["action"] == "contextual_reentry"
|
||||
157
tests/migration/test_line_policy_alternatives.py
Normal file
157
tests/migration/test_line_policy_alternatives.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app.domain.contas.line_reference import extract_requested_line_reference
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
POLICY_DIR = ROOT / "contas_mcp" / "servers" / "contas_mcp_server"
|
||||
|
||||
|
||||
def _load(name: str):
|
||||
path = POLICY_DIR / name
|
||||
spec = importlib.util.spec_from_file_location(name.replace(".py", ""), path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_extrai_final_falado_sem_transformar_em_msisdn():
|
||||
ref = extract_requested_line_reference("quero cancelar o streaming do número da minha esposa, final quatro três dois um")
|
||||
assert ref == {"kind": "suffix", "value": "4321", "raw": "quatro tres dois um"}
|
||||
|
||||
|
||||
def test_alt1_bloqueia_outra_linha_e_mantem_linha_autenticada():
|
||||
alt1 = _load("line_policy_alt1.py")
|
||||
decision = alt1.apply_line_policy("cancelar_vas_avulso", {
|
||||
"msisdn": "11999999999",
|
||||
"requested_line_reference": {"kind": "suffix", "value": "4321"},
|
||||
})
|
||||
assert decision.allowed is False
|
||||
assert decision.effective_msisdn == "11999999999"
|
||||
assert decision.reason == "other_line_not_allowed"
|
||||
|
||||
|
||||
def test_alt1_permite_referencia_a_mesma_linha():
|
||||
alt1 = _load("line_policy_alt1.py")
|
||||
decision = alt1.apply_line_policy("consultar_faturas", {
|
||||
"msisdn": "11999994321",
|
||||
"requested_line_reference": {"kind": "suffix", "value": "4321"},
|
||||
})
|
||||
assert decision.allowed is True
|
||||
assert decision.effective_msisdn == "11999994321"
|
||||
|
||||
|
||||
def test_alt2_resolve_dependente_unico_por_sufixo_retornado_pelo_servico_autorizador():
|
||||
alt2 = _load("line_policy_alt2.py")
|
||||
decision = alt2.apply_line_policy("cancelar_vas_avulso", {
|
||||
"msisdn": "11999999999",
|
||||
"requested_line_reference": {"kind": "suffix", "value": "4321"},
|
||||
"authorized_lines_evidence": {
|
||||
"success": True,
|
||||
"authorized_lines": [
|
||||
{"msisdn": "11999999999", "relationship": "titular", "status": "ACTIVE", "authorized": True},
|
||||
{"msisdn": "11988884321", "relationship": "dependente", "status": "ACTIVE", "authorized": True},
|
||||
],
|
||||
"authorized_msisdns": ["11999999999", "11988884321"],
|
||||
},
|
||||
})
|
||||
assert decision.allowed is True
|
||||
assert decision.effective_msisdn == "11988884321"
|
||||
|
||||
|
||||
def test_alt2_nao_trata_invoice_detail_como_fonte_de_autorizacao():
|
||||
alt2 = _load("line_policy_alt2.py")
|
||||
decision = alt2.apply_line_policy("cancelar_vas_avulso", {
|
||||
"msisdn": "11999999999",
|
||||
"requested_line_reference": {"kind": "suffix", "value": "4321"},
|
||||
"invoice_detail": {
|
||||
"parsed_content": {
|
||||
"11999999999": {},
|
||||
"11988884321": {"SVA Detalhe Total": []},
|
||||
}
|
||||
},
|
||||
})
|
||||
assert decision.allowed is False
|
||||
assert decision.reason == "other_line_not_authorized_or_not_resolved"
|
||||
|
||||
|
||||
def test_alt2_nao_permite_linha_arbitraria_fora_da_resposta_do_servico():
|
||||
alt2 = _load("line_policy_alt2.py")
|
||||
decision = alt2.apply_line_policy("cancelar_vas_avulso", {
|
||||
"msisdn": "11999999999",
|
||||
"requested_line_reference": {"kind": "full", "value": "11977774321"},
|
||||
"authorized_lines_evidence": {
|
||||
"success": True,
|
||||
"authorized_msisdns": ["11999999999", "11988884321"],
|
||||
},
|
||||
})
|
||||
assert decision.allowed is False
|
||||
assert decision.reason == "other_line_not_authorized_or_not_resolved"
|
||||
|
||||
|
||||
def test_alt2_falha_fechada_se_servico_autorizador_falhar():
|
||||
alt2 = _load("line_policy_alt2.py")
|
||||
decision = alt2.apply_line_policy("consultar_vas", {
|
||||
"msisdn": "11999999999",
|
||||
"requested_line_reference": {"kind": "suffix", "value": "4321"},
|
||||
"authorized_lines_evidence": {"success": False, "status": "TIMEOUT"},
|
||||
})
|
||||
assert decision.allowed is False
|
||||
assert decision.authorized_lines == ("11999999999",)
|
||||
|
||||
|
||||
def test_alt2_falha_fechada_se_sufixo_for_ambiguo():
|
||||
alt2 = _load("line_policy_alt2.py")
|
||||
decision = alt2.apply_line_policy("consultar_vas", {
|
||||
"msisdn": "11999999999",
|
||||
"requested_line_reference": {"kind": "suffix", "value": "4321"},
|
||||
"authorized_lines_evidence": {
|
||||
"success": True,
|
||||
"authorized_msisdns": ["11911114321", "11922224321"],
|
||||
},
|
||||
})
|
||||
assert decision.allowed is False
|
||||
assert decision.reason == "other_line_ambiguous"
|
||||
|
||||
|
||||
def test_mock_consultar_linhas_autorizadas_expoe_dependente_4321():
|
||||
from contas_mcp.servers.contas_mcp_server.authorized_lines_service import AuthorizedLinesMockService
|
||||
|
||||
service = AuthorizedLinesMockService(ROOT / "app" / "domain" / "contas" / "fixtures" / "authorized_lines.json")
|
||||
result = service.consultar_linhas_autorizadas(
|
||||
authenticated_msisdn="11999999999",
|
||||
customer_key="11999999999",
|
||||
contract_key="3000131180",
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert "11988884321" in result["authorized_msisdns"]
|
||||
dependent = next(row for row in result["authorized_lines"] if row["msisdn"] == "11988884321")
|
||||
assert dependent["relationship"] == "dependente"
|
||||
|
||||
|
||||
def test_mcp_registra_consultar_linhas_autorizadas_e_alt2_consume_evidencia_explicita():
|
||||
source = (POLICY_DIR / "main.py").read_text(encoding="utf-8")
|
||||
assert '"consultar_linhas_autorizadas"' in source
|
||||
assert 'args["authorized_lines_evidence"] = authorized_lines_service.consultar_linhas_autorizadas' in source
|
||||
alt2_source = (POLICY_DIR / "line_policy_alt2.py").read_text(encoding="utf-8")
|
||||
assert 'args.get("authorized_lines_evidence")' in alt2_source
|
||||
assert 'args.get("invoice_detail")' not in alt2_source
|
||||
|
||||
|
||||
def test_politica_ativa_entregue_eh_alt1():
|
||||
active = (POLICY_DIR / "line_policy.py").read_text(encoding="utf-8")
|
||||
alt1 = (POLICY_DIR / "line_policy_alt1.py").read_text(encoding="utf-8")
|
||||
assert active == alt1
|
||||
|
||||
|
||||
def test_line_policy_block_result_declares_generic_terminal_contract():
|
||||
source = (POLICY_DIR / "main.py").read_text(encoding="utf-8")
|
||||
block = source[source.index('"status": "LINE_POLICY_BLOCKED"'):source.index('"metadata": {', source.index('"status": "LINE_POLICY_BLOCKED"'))]
|
||||
assert '"terminal": True' in block
|
||||
assert '"terminal_action": "block"' in block
|
||||
assert '"user_message": decision.user_message' in block
|
||||
@@ -522,3 +522,38 @@ def test_validate_contestation_items_prefere_evidencia_vas_quando_item_aparece_e
|
||||
assert validation_log[-1]["status"] == "aprovado"
|
||||
assert validation_log[-1]["secao_vas"] is True
|
||||
assert validation_log[-1]["tipo_fatura"] == "servicos_contratados_de_parceiros"
|
||||
|
||||
|
||||
def test_parse_amount_preserva_decimal_json_e_ptbr() -> None:
|
||||
assert contestation_validation._parse_amount("19.99") == contestation_validation.Decimal("19.99")
|
||||
assert contestation_validation._parse_amount("R$ 19,99") == contestation_validation.Decimal("19.99")
|
||||
assert contestation_validation._parse_amount("1.999,99") == contestation_validation.Decimal("1999.99")
|
||||
assert contestation_validation._parse_amount("1,999.99") == contestation_validation.Decimal("1999.99")
|
||||
|
||||
|
||||
def test_validate_contestation_items_resolve_homonimo_por_valor_exato() -> None:
|
||||
items = [{"item_name": "Tamboro Mensal", "claimed_amount": "19.99", "validated_amount": "19.99"}]
|
||||
_, validation_log, error = validate_contestation_items(
|
||||
items,
|
||||
_vas_invoice(
|
||||
{"desc": "Tamboro Mensal", "valor": "14.99"},
|
||||
{"desc": "Tamboro Mensal", "valor": "19.99"},
|
||||
),
|
||||
)
|
||||
assert error is None
|
||||
assert validation_log[-1]["status"] == "aprovado"
|
||||
assert validation_log[-1]["valor_item_fatura"] == "19.99"
|
||||
|
||||
|
||||
def test_validate_contestation_items_bloqueia_valor_acima_de_todos_homonimos() -> None:
|
||||
items = [{"item_name": "Tamboro Mensal", "claimed_amount": "29.98", "validated_amount": "29.98"}]
|
||||
_, validation_log, error = validate_contestation_items(
|
||||
items,
|
||||
_vas_invoice(
|
||||
{"desc": "Tamboro Mensal", "valor": "14.99"},
|
||||
{"desc": "Tamboro Mensal", "valor": "19.99"},
|
||||
),
|
||||
)
|
||||
assert error == "Valor de ajuste do item 'Tamboro Mensal' excede o valor cobrado na fatura."
|
||||
assert validation_log[-1]["erro"] == "valor_ajuste_maior_que_item"
|
||||
assert validation_log[-1]["valor_item_fatura"] == "19.99"
|
||||
|
||||
80
tests/migration/test_revprec_epistemic_uncertainty.py
Normal file
80
tests/migration/test_revprec_epistemic_uncertainty.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.extensions.tim_guardrails import TimPrematureActionRail
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def __init__(self, content: str):
|
||||
self.content = content
|
||||
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
return self.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revprec_allows_structured_insufficient_evidence_without_calling_llm():
|
||||
text = "Identifiquei dados de desconto, mas os dados disponíveis não informam o motivo da retirada ou do término do desconto."
|
||||
|
||||
class MustNotRunLLM:
|
||||
async def ainvoke(self, *args, **kwargs):
|
||||
raise AssertionError("LLM não deveria ser chamada no bypass estrutural")
|
||||
|
||||
ctx = {
|
||||
"guardrail_llm": MustNotRunLLM(),
|
||||
"mcp_results": [
|
||||
{
|
||||
"tool_name": "termino_desconto",
|
||||
"ok": True,
|
||||
"result": {
|
||||
"output": {
|
||||
"formatar": {
|
||||
"mensagem": text,
|
||||
"epistemic_status": "insufficient_evidence",
|
||||
"discount_reason_grounded": False,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
out = await TimPrematureActionRail().evaluate(text, ctx)
|
||||
assert out.allowed is True
|
||||
assert out.reason == "insufficient_evidence_non_assertive"
|
||||
assert out.metadata["mechanism"] == "deterministic_epistemic_bypass"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revprec_does_not_bypass_if_message_does_not_match_structured_result():
|
||||
structured = "Os dados disponíveis não informam o motivo."
|
||||
text = "Cancelei o serviço com sucesso."
|
||||
ctx = {
|
||||
"guardrail_llm": FakeLLM("1"),
|
||||
"mcp_results": [{"result": {"output": {"mensagem": structured, "epistemic_status": "insufficient_evidence"}}}],
|
||||
}
|
||||
out = await TimPrematureActionRail().evaluate(text, ctx)
|
||||
assert out.allowed is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revprec_still_blocks_real_premature_action_without_structured_marker():
|
||||
out = await TimPrematureActionRail().evaluate(
|
||||
"Cancelei o serviço com sucesso.",
|
||||
{"guardrail_llm": FakeLLM("1"), "mcp_results": []},
|
||||
)
|
||||
assert out.allowed is False
|
||||
|
||||
|
||||
def test_termino_desconto_declares_epistemic_status():
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
action = build_contas_workflow_actions(ContasDomainService()).get("formatar_capability_resposta")
|
||||
no_reason = action({"tipo": "termino_desconto"}, {"input": {}})
|
||||
with_reason = action(
|
||||
{"tipo": "termino_desconto", "discount_evidence": {"discount_reason": "fim da campanha"}},
|
||||
{"input": {}},
|
||||
)
|
||||
assert no_reason["epistemic_status"] == "insufficient_evidence"
|
||||
assert with_reason["epistemic_status"] == "grounded_fact"
|
||||
236
tests/migration/test_subject_entity_resolution_all_domains.py
Normal file
236
tests/migration/test_subject_entity_resolution_all_domains.py
Normal file
@@ -0,0 +1,236 @@
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
import pytest
|
||||
|
||||
from contas_mcp.servers.contas_mcp_server import main
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_all_concrete_subject_domains_have_domain_prevalidation():
|
||||
policies = yaml.safe_load((ROOT / 'config' / 'tool_policies.yaml').read_text(encoding='utf-8'))['tool_policies']
|
||||
assert policies['contestar_cobranca']['pre_validation']['tool'] == 'validar_contestacao'
|
||||
for tool in ('cancelar_vas_avulso', 'tratar_vas_estrategico'):
|
||||
assert policies[tool]['pre_validation'] == {
|
||||
'enabled': True,
|
||||
'tool': 'validar_vas_subject',
|
||||
'fail_open': False,
|
||||
}
|
||||
|
||||
|
||||
def test_vas_subject_schema_requires_concrete_entity_semantically():
|
||||
tools = yaml.safe_load((ROOT / 'config' / 'tools.yaml').read_text(encoding='utf-8'))['tools']
|
||||
for tool in ('cancelar_vas_avulso', 'tratar_vas_estrategico'):
|
||||
description = tools[tool]['args_schema']['subject']['description'].lower()
|
||||
assert 'concreto' in description
|
||||
assert 'item real' in description
|
||||
assert 'ausente/null' in description
|
||||
|
||||
|
||||
def test_generic_vas_category_does_not_resolve_to_arbitrary_product():
|
||||
catalog = [
|
||||
{'name': 'TIM Fashion Mensal'},
|
||||
{'name': 'Aya Audiobooks Premium'},
|
||||
{'name': 'Neymar Jr'},
|
||||
{'name': 'Tamboro Mensal'},
|
||||
{'name': 'Youtube Premium'},
|
||||
]
|
||||
canonical, matches = main._resolve_catalog_entity('streaming', catalog)
|
||||
assert canonical is None
|
||||
assert matches == []
|
||||
|
||||
|
||||
def test_partial_unique_concrete_vas_name_resolves():
|
||||
catalog = [
|
||||
{'name': 'TIM Fashion Mensal'},
|
||||
{'name': 'Aya Audiobooks Premium'},
|
||||
{'name': 'Neymar Jr'},
|
||||
]
|
||||
canonical, matches = main._resolve_catalog_entity('TIM Fashion', catalog)
|
||||
assert canonical == 'TIM Fashion Mensal'
|
||||
assert len(matches) == 1
|
||||
|
||||
|
||||
def test_ambiguous_reference_is_not_silently_resolved():
|
||||
catalog = [
|
||||
{'name': 'Apple Music SVA Mes'},
|
||||
{'name': 'Apple Music Dados Mes'},
|
||||
]
|
||||
canonical, matches = main._resolve_catalog_entity('Apple Music', catalog)
|
||||
assert canonical is None
|
||||
assert {item['name'] for item in matches} == {'Apple Music SVA Mes', 'Apple Music Dados Mes'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vas_validator_returns_needs_parameter_for_generic_category(monkeypatch):
|
||||
async def no_enrich(name, args):
|
||||
return None
|
||||
monkeypatch.setattr(main, '_enrich_invoice_context', no_enrich)
|
||||
monkeypatch.setattr(main, '_vas_entity_catalog', lambda args: [
|
||||
{'name': 'TIM Fashion Mensal'},
|
||||
{'name': 'Aya Audiobooks Premium'},
|
||||
{'name': 'Neymar Jr'},
|
||||
{'name': 'Tamboro Mensal'},
|
||||
])
|
||||
result = await main._validate_vas_subject({
|
||||
'msisdn': '11999999999',
|
||||
'subject': 'streaming',
|
||||
'target_tool': 'cancelar_vas_avulso',
|
||||
})
|
||||
assert result['eligible'] is False
|
||||
assert result['status'] == 'NEEDS_PARAMETER'
|
||||
assert result['parameter'] == 'subject'
|
||||
assert result['reason'] == 'subject_not_resolved'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vas_validator_accepts_unique_concrete_entity(monkeypatch):
|
||||
async def no_enrich(name, args):
|
||||
return None
|
||||
monkeypatch.setattr(main, '_enrich_invoice_context', no_enrich)
|
||||
monkeypatch.setattr(main, '_vas_entity_catalog', lambda args: [
|
||||
{'name': 'TIM Fashion Mensal'},
|
||||
{'name': 'Tamboro Mensal'},
|
||||
])
|
||||
result = await main._validate_vas_subject({
|
||||
'msisdn': '11999999999',
|
||||
'subject': 'TIM Fashion',
|
||||
'target_tool': 'cancelar_vas_avulso',
|
||||
})
|
||||
assert result['eligible'] is True
|
||||
assert result['status'] == 'ELIGIBLE'
|
||||
assert result['resolved_subject'] == 'TIM Fashion Mensal'
|
||||
|
||||
|
||||
def test_vas_preflight_never_falls_through_when_subject_is_unresolved():
|
||||
# Existing execution-time preflight is a second defensive layer. It must not
|
||||
# let an unresolved generic entity proceed even if the pre-validation stage
|
||||
# was bypassed by a caller outside the framework runtime.
|
||||
args = {
|
||||
'msisdn': '11999999999',
|
||||
'subject': 'streaming',
|
||||
'billing_analysis': {'currentInvoice': []},
|
||||
}
|
||||
result = main._preflight_subject('cancelar_vas_avulso', args)
|
||||
assert result is not None
|
||||
assert result['status'] == 'NEEDS_PARAMETER'
|
||||
assert result['parameter'] == 'subject'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vas_validator_redirects_canonical_strategic_entity_before_execution(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
async def no_enrich(name, args):
|
||||
args['billing_analysis'] = {'currentInvoice': []}
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(main, '_enrich_invoice_context', no_enrich)
|
||||
monkeypatch.setattr(main, '_vas_entity_catalog', lambda args: [
|
||||
{'name': 'Tamboro Mensal'},
|
||||
{'name': 'Youtube Premium'},
|
||||
])
|
||||
monkeypatch.setattr(
|
||||
main.invoice_resolver,
|
||||
'resolve',
|
||||
lambda subjects, billing: SimpleNamespace(
|
||||
resolved=[SimpleNamespace(
|
||||
canonical_name='Youtube Premium',
|
||||
tool_category='vas_estrategico',
|
||||
item_type='streaming',
|
||||
)],
|
||||
ambiguous=[],
|
||||
out_of_scope=[],
|
||||
),
|
||||
)
|
||||
|
||||
result = await main._validate_vas_subject({
|
||||
'msisdn': '11999999999',
|
||||
'subject': 'youtube',
|
||||
'target_tool': 'cancelar_vas_avulso',
|
||||
})
|
||||
|
||||
assert result['eligible'] is True
|
||||
assert result['resolved_subject'] == 'Youtube Premium'
|
||||
decision = result['transaction_decision']
|
||||
assert decision['resolved_arguments']['subject'] == 'Youtube Premium'
|
||||
assert decision['target_tool'] == 'tratar_vas_estrategico'
|
||||
assert decision['action_changed'] is True
|
||||
assert decision['requires_reconfirmation'] is True
|
||||
assert 'Youtube Premium' in decision['confirmation_message']
|
||||
|
||||
|
||||
def test_vas_domain_policy_uses_invoice_detail_classe_for_strategic_item():
|
||||
args = {
|
||||
'invoice_detail': {
|
||||
'parsed_content': {
|
||||
'1199999999': {
|
||||
'SVA Detalhe Total': [
|
||||
{
|
||||
'desc': 'Youtube Premium',
|
||||
'value': 10,
|
||||
'classe': 'estrategico',
|
||||
'estrategico': True,
|
||||
'verb': 'falar sobre',
|
||||
},
|
||||
{
|
||||
'desc': 'Tamboro Mensal',
|
||||
'value': 14.99,
|
||||
'classe': 'avulso',
|
||||
'verb': 'cancelar',
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert main._vas_domain_policy_from_invoice_detail('Youtube Premium', args) == ('vas_estrategico', 'estrategico')
|
||||
assert main._vas_domain_policy_from_invoice_detail('Tamboro Mensal', args) == ('cancelar_vas_avulso', 'avulso')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vas_validator_real_invoice_detail_redirects_youtube_without_resolver_guess(monkeypatch):
|
||||
async def enrich(name, args):
|
||||
args['billing_analysis'] = {
|
||||
'currentInvoice': [
|
||||
{
|
||||
'type': 'streaming',
|
||||
'desc': 'Streamings',
|
||||
'items': [{'desc': 'Youtube Premium', 'value': '10.0'}],
|
||||
}
|
||||
]
|
||||
}
|
||||
args['invoice_detail'] = {
|
||||
'parsed_content': {
|
||||
'1199999999': {
|
||||
'SVA Detalhe Total': [
|
||||
{
|
||||
'desc': 'Youtube Premium',
|
||||
'value': 10,
|
||||
'classe': 'estrategico',
|
||||
'estrategico': True,
|
||||
'verb': 'falar sobre',
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, '_enrich_invoice_context', enrich)
|
||||
monkeypatch.setattr(main, '_vas_entity_catalog', lambda args: [{'name': 'Youtube Premium'}])
|
||||
# If invoice-detail classification works, the generic resolver fallback is unnecessary.
|
||||
monkeypatch.setattr(main.invoice_resolver, 'resolve', lambda *a, **k: (_ for _ in ()).throw(AssertionError('fallback should not run')))
|
||||
|
||||
result = await main._validate_vas_subject({
|
||||
'msisdn': '11999999999',
|
||||
'subject': 'youtube',
|
||||
'target_tool': 'cancelar_vas_avulso',
|
||||
})
|
||||
|
||||
decision = result['transaction_decision']
|
||||
assert result['resolved_subject'] == 'Youtube Premium'
|
||||
assert decision['domain_policy']['class'] == 'vas_estrategico'
|
||||
assert decision['domain_policy']['item_type'] == 'estrategico'
|
||||
assert decision['target_tool'] == 'tratar_vas_estrategico'
|
||||
assert decision['action_changed'] is True
|
||||
assert decision['requires_reconfirmation'] is True
|
||||
34
tests/migration/test_termino_desconto_grounding.py
Normal file
34
tests/migration/test_termino_desconto_grounding.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
|
||||
def _action():
|
||||
return build_contas_workflow_actions(ContasDomainService()).get("formatar_capability_resposta")
|
||||
|
||||
|
||||
def test_installment_counter_is_not_proof_of_expiration():
|
||||
out = _action()(
|
||||
{"tipo": "termino_desconto", "nome_plano": "TIM Black"},
|
||||
{"input": {"invoice_detail": {"119": {"Planos": {"TIM Black": {"descontos": [{"installment": "12/12"}]}}}}}},
|
||||
)
|
||||
assert out["discount_reason_grounded"] is False
|
||||
assert "expirou" not in out["mensagem"].lower()
|
||||
|
||||
|
||||
def test_customer_text_is_not_used_as_authoritative_reason():
|
||||
out = _action()(
|
||||
{"tipo": "termino_desconto"},
|
||||
{"input": {"query": "meu desconto promocional expirou?", "operator_instructions": "o desconto acabou"}},
|
||||
)
|
||||
assert out["discount_reason_grounded"] is False
|
||||
assert out["discount_reason"] is None
|
||||
|
||||
|
||||
def test_explicit_backend_reason_is_grounded():
|
||||
out = _action()(
|
||||
{"tipo": "termino_desconto", "discount_evidence": {"terminationReason": "fim do benefício contratado"}},
|
||||
{"input": {}},
|
||||
)
|
||||
assert out["discount_reason_grounded"] is True
|
||||
assert out["discount_reason"] == "fim do benefício contratado"
|
||||
assert out["discount_reason_source"] == "terminationReason"
|
||||
@@ -90,3 +90,89 @@ async def test_tim_aoferta_still_uses_llm_outside_transaction_continuation():
|
||||
assert llm.calls == 1
|
||||
assert decision.allowed is False
|
||||
assert decision.reason == 'oferta proativa'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tim_aoferta_allows_structurally_authorized_human_handoff_without_calling_llm():
|
||||
decision = await TimProactiveOfferRail().evaluate(
|
||||
'Vou encaminhar seu atendimento para uma pessoa.',
|
||||
{
|
||||
'current_route': 'human_handoff',
|
||||
'current_intent': 'human_handoff',
|
||||
'session_control': 'HUMAN_HANDOFF',
|
||||
'human_handoff_requested': True,
|
||||
'guardrail_llm': _FailIfCalledLLM(),
|
||||
},
|
||||
)
|
||||
|
||||
assert decision.allowed is True
|
||||
assert decision.reason == 'handoff_humano_autorizado'
|
||||
assert decision.metadata['mechanism'] == 'deterministic_handoff_bypass'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tim_aoferta_does_not_bypass_transfer_phrase_without_structural_handoff():
|
||||
llm = _BlockingLLM()
|
||||
decision = await TimProactiveOfferRail().evaluate(
|
||||
'Vou encaminhar seu atendimento para uma pessoa.',
|
||||
{
|
||||
'current_route': 'faturas_agent',
|
||||
'current_intent': 'contas_invoice_query',
|
||||
'guardrail_llm': llm,
|
||||
},
|
||||
)
|
||||
|
||||
assert llm.calls == 1
|
||||
assert decision.allowed is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tim_aoferta_allows_terminal_handoff_from_resumed_workflow_without_router_handoff():
|
||||
decision = await TimProactiveOfferRail().evaluate(
|
||||
'Para continuar com a sua solicitação, aguarde um instante.',
|
||||
{
|
||||
'current_route': 'faturas_agent',
|
||||
'current_intent': 'contas_invoice_explanation',
|
||||
'mcp_results': [{
|
||||
'tool_name': 'retomar_workflow',
|
||||
'result': {
|
||||
'status': 'COMPLETED',
|
||||
'output': {
|
||||
'mensagem': 'Para continuar com a sua solicitação, aguarde um instante.',
|
||||
'session_control': 'HUMAN_HANDOFF',
|
||||
'human_handoff_requested': True,
|
||||
'handoff': True,
|
||||
'session_ended': True,
|
||||
'terminal_status': 'human_handoff',
|
||||
},
|
||||
},
|
||||
}],
|
||||
'guardrail_llm': _FailIfCalledLLM(),
|
||||
},
|
||||
)
|
||||
|
||||
assert decision.allowed is True
|
||||
assert decision.reason == 'handoff_humano_autorizado'
|
||||
assert decision.metadata['mechanism'] == 'deterministic_handoff_bypass'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tim_aoferta_does_not_trust_non_terminal_workflow_handoff_flag():
|
||||
llm = _BlockingLLM()
|
||||
decision = await TimProactiveOfferRail().evaluate(
|
||||
'Vou encaminhar seu atendimento para uma pessoa.',
|
||||
{
|
||||
'current_route': 'faturas_agent',
|
||||
'current_intent': 'contas_invoice_explanation',
|
||||
'mcp_results': [{
|
||||
'result': {
|
||||
'output': {
|
||||
'handoff': True,
|
||||
},
|
||||
},
|
||||
}],
|
||||
'guardrail_llm': llm,
|
||||
},
|
||||
)
|
||||
|
||||
assert llm.calls == 1
|
||||
assert decision.allowed is False
|
||||
|
||||
136
tests/migration/test_tim_oos_handoff_bypass.py
Normal file
136
tests/migration/test_tim_oos_handoff_bypass.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.extensions.tim_guardrails import TimOutOfScopeRail
|
||||
|
||||
|
||||
class _FailIfCalledLLM:
|
||||
async def ainvoke(self, *args, **kwargs):
|
||||
raise AssertionError('TIM_OOS LLM must not run for structurally authorized human handoff')
|
||||
|
||||
|
||||
class _BlockingLLM:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
async def ainvoke(self, *args, **kwargs):
|
||||
self.calls += 1
|
||||
return '{"allowed": false, "reason": "mensagem fora do escopo de contas/faturas TIM"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tim_oos_allows_structurally_authorized_human_handoff_without_calling_llm():
|
||||
decision = await TimOutOfScopeRail().evaluate(
|
||||
'Vou encaminhar seu atendimento para uma pessoa.',
|
||||
{
|
||||
'current_route': 'human_handoff',
|
||||
'current_intent': 'human_handoff',
|
||||
'session_control': 'HUMAN_HANDOFF',
|
||||
'human_handoff_requested': True,
|
||||
'guardrail_llm': _FailIfCalledLLM(),
|
||||
},
|
||||
)
|
||||
|
||||
assert decision.allowed is True
|
||||
assert decision.code == 'TIM_OOS'
|
||||
assert decision.reason == 'handoff_humano_autorizado'
|
||||
assert decision.metadata['mechanism'] == 'deterministic_handoff_bypass'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tim_oos_allows_route_decision_structural_handoff_without_calling_llm():
|
||||
decision = await TimOutOfScopeRail().evaluate(
|
||||
'Vou encaminhar seu atendimento para uma pessoa.',
|
||||
{
|
||||
'route_decision': {
|
||||
'route': 'human_handoff',
|
||||
'intent': 'human_handoff',
|
||||
'handoff': True,
|
||||
'metadata': {'session_control': 'HUMAN_HANDOFF'},
|
||||
},
|
||||
'guardrail_llm': _FailIfCalledLLM(),
|
||||
},
|
||||
)
|
||||
|
||||
assert decision.allowed is True
|
||||
assert decision.metadata['mechanism'] == 'deterministic_handoff_bypass'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tim_oos_does_not_bypass_transfer_phrase_without_structural_handoff():
|
||||
llm = _BlockingLLM()
|
||||
decision = await TimOutOfScopeRail().evaluate(
|
||||
'Vou encaminhar seu atendimento para uma pessoa.',
|
||||
{
|
||||
'current_route': 'faturas_agent',
|
||||
'current_intent': 'contas_invoice_query',
|
||||
'guardrail_llm': llm,
|
||||
},
|
||||
)
|
||||
|
||||
assert llm.calls == 1
|
||||
assert decision.allowed is False
|
||||
assert decision.reason == 'mensagem fora do escopo de contas/faturas TIM'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tim_oos_requires_route_and_control_evidence_not_only_handoff_boolean():
|
||||
llm = _BlockingLLM()
|
||||
decision = await TimOutOfScopeRail().evaluate(
|
||||
'Vou encaminhar seu atendimento para uma pessoa.',
|
||||
{
|
||||
'handoff': True,
|
||||
'current_route': 'faturas_agent',
|
||||
'current_intent': 'contas_invoice_query',
|
||||
'guardrail_llm': llm,
|
||||
},
|
||||
)
|
||||
|
||||
assert llm.calls == 1
|
||||
assert decision.allowed is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tim_oos_allows_terminal_handoff_from_resumed_workflow_without_router_handoff():
|
||||
decision = await TimOutOfScopeRail().evaluate(
|
||||
'Para continuar com a sua solicitação, aguarde um instante.',
|
||||
{
|
||||
'current_route': 'faturas_agent',
|
||||
'current_intent': 'contas_invoice_explanation',
|
||||
'mcp_results': [{
|
||||
'tool_name': 'retomar_workflow',
|
||||
'result': {
|
||||
'status': 'COMPLETED',
|
||||
'output': {
|
||||
'session_control': 'HUMAN_HANDOFF',
|
||||
'human_handoff_requested': True,
|
||||
'handoff': True,
|
||||
'session_ended': True,
|
||||
'terminal_status': 'human_handoff',
|
||||
},
|
||||
},
|
||||
}],
|
||||
'guardrail_llm': _FailIfCalledLLM(),
|
||||
},
|
||||
)
|
||||
|
||||
assert decision.allowed is True
|
||||
assert decision.reason == 'handoff_humano_autorizado'
|
||||
assert decision.metadata['mechanism'] == 'deterministic_handoff_bypass'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tim_oos_does_not_trust_non_terminal_workflow_handoff_flag():
|
||||
llm = _BlockingLLM()
|
||||
decision = await TimOutOfScopeRail().evaluate(
|
||||
'Vou encaminhar seu atendimento para uma pessoa.',
|
||||
{
|
||||
'current_route': 'faturas_agent',
|
||||
'current_intent': 'contas_invoice_explanation',
|
||||
'mcp_results': [{'result': {'output': {'handoff': True}}}],
|
||||
'guardrail_llm': llm,
|
||||
},
|
||||
)
|
||||
|
||||
assert llm.calls == 1
|
||||
assert decision.allowed is False
|
||||
@@ -50,3 +50,46 @@ def test_transaction_state_patch_preserva_latch():
|
||||
runtime = Runtime()
|
||||
patch = runtime.transaction_state_patch({"business_workflows_executed": ["invoice_explanation"]})
|
||||
assert patch["business_workflows_executed"] == ["invoice_explanation"]
|
||||
|
||||
|
||||
def test_transaction_state_patch_preserva_boundary_de_soft_reset():
|
||||
runtime = Runtime()
|
||||
patch = runtime.transaction_state_patch({
|
||||
"transaction_status": "COMPLETED",
|
||||
"operational_context_boundary_pending": True,
|
||||
})
|
||||
assert patch["transaction_status"] == "COMPLETED"
|
||||
assert patch["operational_context_boundary_pending"] is True
|
||||
|
||||
|
||||
def test_workflow_response_final_fecha_latch_mesmo_se_adapter_retornar_paused():
|
||||
runtime = Runtime()
|
||||
state = {
|
||||
"pending_domain_workflow": {
|
||||
"execution_id": "exec-1",
|
||||
"workflow_name": "invoice_explanation",
|
||||
},
|
||||
"transaction_status": "WORKFLOW_PAUSED",
|
||||
}
|
||||
envelope = {
|
||||
"result": {
|
||||
"result": {
|
||||
"status": "PAUSED",
|
||||
"execution_id": "exec-1",
|
||||
"metadata": {
|
||||
"workflow_name": "invoice_explanation",
|
||||
"workflow_execution_id": "exec-1",
|
||||
"resume_tool": "retomar_workflow",
|
||||
},
|
||||
"output": {
|
||||
"workflow_response_final": True,
|
||||
"mensagem": "Seu número de protocolo é 1234567890.",
|
||||
},
|
||||
"state": {"current_node": "registrar_protocolo_aceite"},
|
||||
}
|
||||
}
|
||||
}
|
||||
runtime._capture_pending_domain_workflow(state, envelope)
|
||||
assert state["pending_domain_workflow"] is None
|
||||
assert state["transaction_status"] == "COMPLETED"
|
||||
assert state["operational_context_boundary_pending"] is True
|
||||
|
||||
Reference in New Issue
Block a user