new feature: Multi-Intent
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.
@@ -0,0 +1,70 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _template_dirs():
|
||||
return sorted(
|
||||
p
|
||||
for p in ROOT.rglob("*")
|
||||
if p.is_dir() and p.name in {"agent_template_backend", "agent_template_backend_day_zero"}
|
||||
)
|
||||
|
||||
|
||||
def test_all_agent_template_backend_copies_have_safe_agent_prompts():
|
||||
targets = _template_dirs()
|
||||
assert targets, "Nenhum template backend encontrado"
|
||||
for base in targets:
|
||||
checks = {
|
||||
"billing_agent.py": (
|
||||
"Nunca exponha identificadores técnicos",
|
||||
"Não acrescente canais",
|
||||
"Se uma tool retornar BLOCKED",
|
||||
),
|
||||
"product_agent.py": (
|
||||
"Nunca exponha identificadores técnicos",
|
||||
"can.cancel",
|
||||
"Se uma tool retornar BLOCKED",
|
||||
),
|
||||
"orders_agent.py": (
|
||||
"Nunca exponha identificadores técnicos",
|
||||
"Não declare sucesso",
|
||||
"Se uma tool retornar BLOCKED",
|
||||
),
|
||||
"support_agent.py": (
|
||||
"Nunca exponha identificadores técnicos",
|
||||
"Não declare sucesso",
|
||||
"Se uma tool retornar BLOCKED",
|
||||
),
|
||||
}
|
||||
for filename, required in checks.items():
|
||||
text = (base / "app" / "agents" / filename).read_text(encoding="utf-8")
|
||||
for marker in required:
|
||||
assert marker in text, f"{base.relative_to(ROOT)}/{filename} sem {marker!r}"
|
||||
|
||||
|
||||
def test_all_agent_template_backend_copies_isolate_interrupted_transaction_context():
|
||||
for base in _template_dirs():
|
||||
graph = (base / "app" / "workflows" / "agent_graph.py").read_text(encoding="utf-8")
|
||||
assert 'route_metadata.get("transaction_interruption")' in graph
|
||||
assert '== "intent_shift"' in graph
|
||||
assert 'ctx["historical_transaction_ignored"] = True' in graph
|
||||
assert 'ctx["expected_protocols"] = protocols' in graph
|
||||
assert 'self._output_guardrail_context(state)' in graph
|
||||
|
||||
|
||||
def test_full_templates_do_not_dump_raw_invoice_payload():
|
||||
checked = 0
|
||||
for base in _template_dirs():
|
||||
renderer = base / "app" / "presentation" / "tool_renderers.py"
|
||||
if not renderer.exists():
|
||||
continue
|
||||
checked += 1
|
||||
text = renderer.read_text(encoding="utf-8")
|
||||
assert 'Fatura consultada: {result}' not in text
|
||||
assert 'result.get("valor_total")' in text
|
||||
assert 'result.get("vencimento")' in text
|
||||
assert 'result.get("status")' in text
|
||||
assert 'result.get("itens")' in text
|
||||
assert checked > 0
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from agent_framework.runtime.transaction_parameters import extract_transaction_parameters
|
||||
from agent_framework.runtime.transaction_parameters import extract_transaction_parameters, reconcile_transaction_parameters
|
||||
|
||||
|
||||
class _ContextAwareLLM:
|
||||
@@ -355,3 +355,57 @@ async def test_temporal_reconciler_single_scan_uses_all_known_fields_as_anchors_
|
||||
assert result["values"]["data"] == "01/11/2025"
|
||||
assert result["provenance"]["subject"] == "history:1"
|
||||
|
||||
|
||||
class _AssistantEvidenceSegmentationLLM:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
import json
|
||||
self.calls += 1
|
||||
prompt = messages[-1]["content"]
|
||||
evidence = json.loads(prompt.split("evidence_blocks: ", 1)[1].split("\n", 1)[0])
|
||||
anchored = [
|
||||
block for block in evidence
|
||||
if block.get("role") == "assistant"
|
||||
and any(match.get("field") == "valor" and float(match.get("value")) == 14.99 for match in block.get("anchor_matches", []))
|
||||
]
|
||||
assert len(anchored) == 1
|
||||
assert "Tamboro Mensal" in anchored[0]["text"]
|
||||
assert "TIM Fashion" not in anchored[0]["text"]
|
||||
assert "Neymar" not in anchored[0]["text"]
|
||||
return {
|
||||
"content": json.dumps({
|
||||
"fields": {
|
||||
"subject": {"decision": "resolved", "value": "Tamboro Mensal", "source": "history:1"},
|
||||
"valor": {"decision": "preserve", "value": 14.99, "source": "state"},
|
||||
}
|
||||
}, ensure_ascii=False)
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconciler_segments_assistant_list_so_known_anchor_resolves_missing_field():
|
||||
llm = _AssistantEvidenceSegmentationLLM()
|
||||
result = await reconcile_transaction_parameters(
|
||||
llm,
|
||||
text="é a de quatorze e noventa e nove",
|
||||
tool_name="contestar_cobranca",
|
||||
parameter_names=["subject", "valor"],
|
||||
known_arguments={"valor": 14.99},
|
||||
parameter_schema={
|
||||
"subject": {"type": "string", "description": "Nome do item ou cobrança a contestar."},
|
||||
"valor": {"type": "number", "description": "Valor monetário do item a contestar."},
|
||||
},
|
||||
conversational_context=(
|
||||
"priority_3_previous_assistant_tool_or_evidence_context:\n"
|
||||
"history:1: assistant: Houve as cobranças: * Cobrança Tamboro Mensal no valor de R$ 14,99 no dia 01/11/25. "
|
||||
"* Cobrança TIM Fashion Mensal no valor de R$ 10,00 no dia 01/11/25. "
|
||||
"* Cobrança Neymar Jr no valor de R$ 12,00 no dia 01/11/25.\n"
|
||||
"priority_4_previous_user_utterances:\n"
|
||||
"history:2: user: tem uma cobrança aqui que eu não reconheço"
|
||||
),
|
||||
)
|
||||
assert llm.calls == 1
|
||||
assert result["values"]["subject"] == "Tamboro Mensal"
|
||||
assert float(result["values"]["valor"]) == 14.99
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import json
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
||||
|
||||
|
||||
class _Router:
|
||||
def __init__(self):
|
||||
self.registry = SimpleNamespace(
|
||||
tools={"contestar_cobranca": object(), "validar_contestacao": object()},
|
||||
get_tool=lambda name: SimpleNamespace(
|
||||
description="Contesta uma cobrança após validação",
|
||||
args_schema={
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"description": "Referência a um item concreto e identificável da fatura",
|
||||
"user_prompt": "Qual cobrança ou item você deseja contestar?",
|
||||
},
|
||||
"valor": {
|
||||
"type": "number",
|
||||
"description": "Valor monetário explicitamente associado pelo cliente ao item",
|
||||
"user_prompt": "Qual é o valor da cobrança que você deseja contestar?",
|
||||
},
|
||||
},
|
||||
requires=["subject", "valor"],
|
||||
) if name == "contestar_cobranca" else None,
|
||||
)
|
||||
|
||||
def resolve_execution_policy(self, tool_name, arguments=None):
|
||||
if tool_name == "contestar_cobranca":
|
||||
return {
|
||||
"operation_type": "transactional",
|
||||
"require_confirmation": True,
|
||||
"requires": ["subject", "valor"],
|
||||
"policy_source": "test",
|
||||
"pre_validation": {"enabled": True, "tool": "validar_contestacao", "fail_open": False},
|
||||
}
|
||||
return {"operation_type": "read_only", "require_confirmation": False, "policy_source": "test"}
|
||||
|
||||
|
||||
class _LLM:
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
prompt = messages[-1]["content"]
|
||||
generation = kwargs.get("generation_name")
|
||||
if generation == "llm.transaction_parameter_current_only":
|
||||
low = prompt.lower()
|
||||
fields = json.loads(prompt.split("Formato: ", 1)[1])
|
||||
out = dict(fields)
|
||||
if "quatorze e noventa e nove" in low:
|
||||
if "valor" in out:
|
||||
out["valor"] = 14.99
|
||||
elif "vinte e cinco e cinquenta" in low:
|
||||
if "valor" in out:
|
||||
out["valor"] = 25.50
|
||||
elif "dezenove e noventa e nove" in low:
|
||||
if "valor" in out:
|
||||
out["valor"] = 19.99
|
||||
return {"content": json.dumps(out, ensure_ascii=False)}
|
||||
if generation == "llm.transaction_parameter_extraction":
|
||||
# Deliberately leave subject unresolved so scenario 8 exercises the
|
||||
# unique anchored structural candidate + authoritative validator path.
|
||||
return {"content": json.dumps({"fields": {
|
||||
"subject": {"decision": "unresolved", "value": None, "source": ""},
|
||||
"valor": {"decision": "preserve", "value": None, "source": "state"},
|
||||
}}, ensure_ascii=False)}
|
||||
return {"content": "{}"}
|
||||
|
||||
|
||||
class _Runtime(AgentRuntimeMixin):
|
||||
def __init__(self):
|
||||
self.tool_router = _Router()
|
||||
self.llm = _LLM()
|
||||
self.calls = []
|
||||
|
||||
async def _call_mcp_tool(self, tool_name, arguments, state):
|
||||
self.calls.append((tool_name, dict(arguments)))
|
||||
if tool_name != "validar_contestacao":
|
||||
return {"ok": True, "tool_name": tool_name, "result": {"status": "OK"}}
|
||||
|
||||
subject = str(arguments.get("subject") or "")
|
||||
valor = arguments.get("valor")
|
||||
# Canonicalize the raw anchored fragment from scenario 8.
|
||||
if "Tamboro Mensal" in subject:
|
||||
canonical = "Tamboro Mensal"
|
||||
else:
|
||||
return {"ok": True, "eligible": False, "status": "NEEDS_PARAMETER", "parameter": "subject"}
|
||||
|
||||
if float(valor) not in {14.99, 19.99}:
|
||||
return {
|
||||
"ok": True,
|
||||
"eligible": False,
|
||||
"status": "NEEDS_PARAMETER",
|
||||
"parameter": "valor",
|
||||
"clear_fields": ["resolved_value"],
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"eligible": True,
|
||||
"transaction_decision": {
|
||||
"resolved_arguments": {"subject": canonical, "valor": float(valor)}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _collecting_state(arguments, text, *, metadata=None, context=None):
|
||||
return {
|
||||
"user_text": text,
|
||||
"sanitized_input": text,
|
||||
"route": "contestacao_agent",
|
||||
"intent": "state:COLLECTING_CONTESTACAO_PARAMETERS",
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
"active_transaction": {
|
||||
"transaction_id": "tx",
|
||||
"tool_name": "contestar_cobranca",
|
||||
"arguments": dict(arguments),
|
||||
"status": "COLLECTING_PARAMETERS",
|
||||
"parameter_schema": {
|
||||
"subject": {"type": "string", "description": "Referência a um item concreto e identificável da fatura"},
|
||||
"valor": {"type": "number", "description": "Valor monetário explicitamente associado pelo cliente ao item"},
|
||||
},
|
||||
"parameter_conversational_context": context or "",
|
||||
},
|
||||
"selected_tool_call": {"tool_name": "contestar_cobranca", "arguments": dict(arguments)},
|
||||
"missing_parameters": [x for x in ["subject", "valor"] if arguments.get(x) in (None, "")],
|
||||
"route_decision": {"metadata": metadata or {}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario8_value_anchor_reconciles_subject_then_awaits_confirmation():
|
||||
runtime = _Runtime()
|
||||
context = (
|
||||
"user: tem uma cobrança aqui que eu não reconheço\n"
|
||||
"assistant: Analisando a fatura. * Cobrança Tamboro Mensal no valor de R$ 14.99 no dia 01/11/25. "
|
||||
"* Cobrança TIM Fashion Mensal no valor de R$ 10.00 no dia 01/11/25."
|
||||
)
|
||||
state = _collecting_state(
|
||||
{},
|
||||
"é a de quatorze e noventa e nove",
|
||||
metadata={
|
||||
"contextual_reentry": True,
|
||||
"original_input": "é a de quatorze e noventa e nove",
|
||||
"relevant_conversation_context": context,
|
||||
},
|
||||
context=context,
|
||||
)
|
||||
|
||||
result = await runtime.execute_tools_for_intent(state, tools=[])
|
||||
|
||||
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
|
||||
assert state["missing_parameters"] == []
|
||||
assert state["pending_tool_call"]["arguments"]["subject"] == "Tamboro Mensal"
|
||||
assert state["pending_tool_call"]["arguments"]["valor"] == 14.99
|
||||
assert state["transaction_pre_validation"]["eligible"] is True
|
||||
assert result[-1]["awaiting_confirmation"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario16_invalid_value_clears_only_value_and_preserves_subject():
|
||||
runtime = _Runtime()
|
||||
state = _collecting_state({"subject": "Tamboro Mensal"}, "é a de vinte e cinco e cinquenta")
|
||||
|
||||
result = await runtime.execute_tools_for_intent(state, tools=[])
|
||||
|
||||
assert state["transaction_status"] == "COLLECTING_PARAMETERS"
|
||||
assert state["active_transaction"]["arguments"]["subject"] == "Tamboro Mensal"
|
||||
assert "valor" not in state["active_transaction"]["arguments"]
|
||||
assert state["missing_parameters"] == ["valor"]
|
||||
assert result[-1]["collecting_parameters"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario16_current_turn_correction_updates_existing_value_then_awaits_confirmation():
|
||||
runtime = _Runtime()
|
||||
state = _collecting_state(
|
||||
{"subject": "Tamboro Mensal", "valor": 25.50},
|
||||
"desculpa é a de dezenove e noventa e nove",
|
||||
)
|
||||
# The old value exists, so missing_parameters alone must NOT restrict which
|
||||
# current-turn fields may be corrected.
|
||||
state["missing_parameters"] = ["subject"]
|
||||
|
||||
result = await runtime.execute_tools_for_intent(state, tools=[])
|
||||
|
||||
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
|
||||
assert state["missing_parameters"] == []
|
||||
assert state["pending_tool_call"]["arguments"]["subject"] == "Tamboro Mensal"
|
||||
assert state["pending_tool_call"]["arguments"]["valor"] == 19.99
|
||||
assert state["transaction_pre_validation"]["eligible"] is True
|
||||
assert result[-1]["awaiting_confirmation"] is True
|
||||
@@ -2,6 +2,20 @@ import pytest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agent_framework.routing.enterprise_router import EnterpriseRouter
|
||||
|
||||
|
||||
def test_single_word_keyword_requires_a_complete_token():
|
||||
from agent_framework.routing.models import IntentDefinition
|
||||
|
||||
router = object.__new__(EnterpriseRouter)
|
||||
router.intents = [
|
||||
IntentDefinition(
|
||||
name="cancel", agent="orders_agent", priority=10,
|
||||
keywords=["cancela"], examples=[], enabled=True,
|
||||
)
|
||||
]
|
||||
assert router._route_by_keyword("cancela o pedido") is not None
|
||||
assert router._route_by_keyword("sim, pode cancelar") is None
|
||||
from agent_framework.routing.models import RouteDecision
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.routing.enterprise_router import EnterpriseRouter
|
||||
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
||||
from agent_framework.runtime.transaction_parameters import reconcile_transaction_parameters
|
||||
|
||||
|
||||
class _RouterLLM:
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1])
|
||||
if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt:
|
||||
return json.dumps({"subject": None, "valor": None})
|
||||
if "esquece" in prompt.lower():
|
||||
return json.dumps({
|
||||
"decision": "ABANDON",
|
||||
"intent": "billing_invoice_explanation",
|
||||
"agent": "billing_agent",
|
||||
"confidence": 0.99,
|
||||
"reason": "abandono explícito da transação atual",
|
||||
})
|
||||
return json.dumps({
|
||||
"decision": "SHIFT",
|
||||
"intent": "contas_vas_cancel",
|
||||
"agent": "vas_agent",
|
||||
"confidence": 0.99,
|
||||
"reason": "classificação genérica motivada por cancelar",
|
||||
})
|
||||
|
||||
|
||||
def _router(tmp_path):
|
||||
routing = tmp_path / "routing.yaml"
|
||||
routing.write_text(
|
||||
"""
|
||||
router:
|
||||
fallback_agent: contestacao_agent
|
||||
confidence_threshold: 0.70
|
||||
state_policies:
|
||||
- state: COLLECTING_PARAMETERS
|
||||
agent: contestacao_agent
|
||||
intents:
|
||||
- name: contas_contestation
|
||||
agent: contestacao_agent
|
||||
priority: 10
|
||||
keywords: [contestar]
|
||||
- name: contas_vas_cancel
|
||||
agent: vas_agent
|
||||
priority: 20
|
||||
keywords: [cancelar]
|
||||
- name: billing_invoice_explanation
|
||||
agent: billing_agent
|
||||
priority: 30
|
||||
keywords: [fatura]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return EnterpriseRouter(
|
||||
SimpleNamespace(
|
||||
ROUTING_CONFIG_PATH=str(routing),
|
||||
ENABLE_LLM_ROUTER=True,
|
||||
ENABLE_ROUTE_STICKINESS=False,
|
||||
),
|
||||
llm=_RouterLLM(),
|
||||
)
|
||||
|
||||
|
||||
def _active_state(message):
|
||||
return {
|
||||
"user_text": message,
|
||||
"sanitized_input": message,
|
||||
"next_state": "COLLECTING_PARAMETERS",
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
"missing_parameters": ["valor"],
|
||||
"intent": "state:COLLECTING_PARAMETERS",
|
||||
"active_agent": "contestacao_agent",
|
||||
"active_transaction": {
|
||||
"tool_name": "contestar_cobranca",
|
||||
"arguments": {"subject": "Tamboro Mensal"},
|
||||
"status": "COLLECTING_PARAMETERS",
|
||||
"started_from_intent": "contas_contestation",
|
||||
"parameter_schema": {"valor": {"type": "number"}},
|
||||
},
|
||||
"history": [
|
||||
{"role": "user", "content": "não reconheço o Tamboro Mensal, quero tirar da fatura"},
|
||||
{"role": "assistant", "content": "Qual é o valor da cobrança que você deseja contestar?"},
|
||||
{"role": "user", "content": message},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_shift_does_not_interrupt_active_transaction(tmp_path):
|
||||
decision = await _router(tmp_path).route(_active_state("isso mesmo, pode cancelar"))
|
||||
assert decision.intent == "state:COLLECTING_PARAMETERS"
|
||||
assert not (decision.metadata or {}).get("transaction_interruption")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_abandonment_can_leave_active_transaction(tmp_path):
|
||||
decision = await _router(tmp_path).route(_active_state("esquece isso, quero ver minha fatura"))
|
||||
assert decision.intent == "billing_invoice_explanation"
|
||||
assert decision.metadata["transaction_interruption"] == "explicit_abandonment"
|
||||
|
||||
|
||||
class _PolicyRouter:
|
||||
def __init__(self):
|
||||
self.registry = SimpleNamespace(
|
||||
tools={"contestar_cobranca": object()},
|
||||
get_tool=lambda name: SimpleNamespace(
|
||||
selection_keywords=["contestar"],
|
||||
args_schema={
|
||||
"subject": {"type": "string", "description": "item concreto da fatura"},
|
||||
"valor": {"type": "number", "description": "valor associado"},
|
||||
},
|
||||
requires=["subject", "valor"],
|
||||
description="Contesta cobrança validada",
|
||||
),
|
||||
)
|
||||
|
||||
def resolve_execution_policy(self, tool_name, arguments=None):
|
||||
return {
|
||||
"operation_type": "transactional",
|
||||
"require_confirmation": True,
|
||||
"requires": ["subject", "valor"],
|
||||
"pre_validation": {"enabled": True, "tool": "validar_contestacao", "fail_open": False},
|
||||
}
|
||||
|
||||
|
||||
class _UnresolvedReconcileLLM:
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
prompt = messages[-1]["content"]
|
||||
if kwargs.get("profile_name") == "transaction_parameter_extraction":
|
||||
if "FOCO PRINCIPAL DA RECONCILIAÇÃO" in prompt:
|
||||
return {"content": json.dumps({
|
||||
"fields": {
|
||||
"subject": {"decision": "unresolved", "value": None, "source": ""},
|
||||
"valor": {"decision": "preserve", "value": 14.99, "source": "state"},
|
||||
}
|
||||
})}
|
||||
return {"content": json.dumps({"subject": None, "valor": 14.99})}
|
||||
return {"content": "{}"}
|
||||
|
||||
|
||||
class _CandidateRuntime(AgentRuntimeMixin):
|
||||
def __init__(self):
|
||||
self.tool_router = _PolicyRouter()
|
||||
self.llm = _UnresolvedReconcileLLM()
|
||||
self.calls = []
|
||||
|
||||
async def _call_mcp_tool(self, tool_name, arguments, state):
|
||||
self.calls.append((tool_name, dict(arguments)))
|
||||
if tool_name == "validar_contestacao":
|
||||
subject = str(arguments.get("subject") or "")
|
||||
if "Tamboro Mensal" in subject and float(arguments.get("valor")) == 14.99:
|
||||
return {
|
||||
"ok": True,
|
||||
"tool_name": tool_name,
|
||||
"result": {
|
||||
"eligible": True,
|
||||
"status": "ELIGIBLE",
|
||||
"transaction_decision": {
|
||||
"resolved_arguments": {"subject": "Tamboro Mensal", "valor": 14.99}
|
||||
},
|
||||
},
|
||||
}
|
||||
return {"ok": True, "tool_name": tool_name, "result": {"eligible": False, "status": "NEEDS_PARAMETER", "parameter": "subject"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unique_anchored_block_is_candidate_only_and_prevalidator_canonicalizes():
|
||||
runtime = _CandidateRuntime()
|
||||
state = {
|
||||
"user_text": "é a de quatorze e noventa e nove",
|
||||
"sanitized_input": "é a de quatorze e noventa e nove",
|
||||
"mcp_tools": ["contestar_cobranca"],
|
||||
"route": "contestacao_agent",
|
||||
"active_agent": "contestacao_agent",
|
||||
"intent": "contas_contestation",
|
||||
"route_decision": {"metadata": {}},
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
"missing_parameters": ["subject"],
|
||||
"active_transaction": {
|
||||
"tool_name": "contestar_cobranca",
|
||||
"arguments": {"valor": 14.99},
|
||||
"status": "COLLECTING_PARAMETERS",
|
||||
"requires": ["subject", "valor"],
|
||||
"parameter_schema": {
|
||||
"subject": {"type": "string", "description": "item concreto da fatura"},
|
||||
"valor": {"type": "number", "description": "valor associado"},
|
||||
},
|
||||
"parameter_conversational_context": (
|
||||
"history:1: assistant: Houve: * Cobrança Tamboro Mensal no valor de R$ 14,99 no dia 01/11/25. "
|
||||
"* Cobrança TIM Fashion Mensal no valor de R$ 10,00 no dia 01/11/25."
|
||||
),
|
||||
},
|
||||
"selected_tool_call": {"tool_name": "contestar_cobranca", "arguments": {"valor": 14.99}},
|
||||
}
|
||||
|
||||
result = await runtime.execute_tools_for_intent(state, tools=[])
|
||||
|
||||
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
|
||||
assert state["pending_tool_call"]["arguments"]["subject"] == "Tamboro Mensal"
|
||||
assert state["pending_tool_call"]["arguments"]["valor"] == 14.99
|
||||
assert state["transaction_parameter_reconciliation"]["applied_structural_candidates"] == {
|
||||
"subject": "unique_anchored_evidence_block"
|
||||
}
|
||||
validator_args = runtime.calls[0][1]
|
||||
assert validator_args["subject"].startswith("Cobrança Tamboro Mensal")
|
||||
assert result[-1]["awaiting_confirmation"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_turn_value_then_unique_anchor_reaches_confirmation_from_empty_transaction():
|
||||
"""Regression for Contas scenario 08: current amount first, temporal subject second."""
|
||||
runtime = _CandidateRuntime()
|
||||
state = {
|
||||
"user_text": "é a de quatorze e noventa e nove",
|
||||
"sanitized_input": "é a de quatorze e noventa e nove",
|
||||
"mcp_tools": ["contestar_cobranca"],
|
||||
"route": "contestacao_agent",
|
||||
"active_agent": "contestacao_agent",
|
||||
"intent": "contas_contestation",
|
||||
"route_decision": {"metadata": {}},
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
"missing_parameters": ["subject", "valor"],
|
||||
"active_transaction": {
|
||||
"tool_name": "contestar_cobranca",
|
||||
"arguments": {},
|
||||
"status": "COLLECTING_PARAMETERS",
|
||||
"requires": ["subject", "valor"],
|
||||
"parameter_schema": {
|
||||
"subject": {"type": "string", "description": "item concreto da fatura"},
|
||||
"valor": {"type": "number", "description": "valor monetário associado"},
|
||||
},
|
||||
"parameter_conversational_context": (
|
||||
"history:1: assistant: Houve: * Cobrança Tamboro Mensal no valor de R$ 14,99 no dia 01/11/25. "
|
||||
"* Cobrança TIM Fashion Mensal no valor de R$ 10,00 no dia 01/11/25."
|
||||
),
|
||||
},
|
||||
"selected_tool_call": {"tool_name": "contestar_cobranca", "arguments": {}},
|
||||
}
|
||||
|
||||
result = await runtime.execute_tools_for_intent(state, tools=[])
|
||||
|
||||
assert state["transaction_parameter_collection"]["resolved_fields"] == ["valor"]
|
||||
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
|
||||
assert state["pending_tool_call"]["arguments"]["subject"] == "Tamboro Mensal"
|
||||
assert state["pending_tool_call"]["arguments"]["valor"] == 14.99
|
||||
assert result[-1]["awaiting_confirmation"] is True
|
||||
|
||||
|
||||
class _ClearMissingSubjectReconcileLLM(_UnresolvedReconcileLLM):
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
prompt = messages[-1]["content"]
|
||||
if kwargs.get("profile_name") == "transaction_parameter_extraction":
|
||||
if "FOCO PRINCIPAL DA RECONCILIAÇÃO" in prompt:
|
||||
return {"content": json.dumps({
|
||||
"fields": {
|
||||
"subject": {"decision": "clear", "value": None, "source": "history:1"},
|
||||
"valor": {"decision": "preserve", "value": 14.99, "source": "state"},
|
||||
}
|
||||
})}
|
||||
return {"content": json.dumps({"subject": None, "valor": 14.99})}
|
||||
return {"content": "{}"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initially_missing_subject_clear_still_allows_unique_anchored_candidate():
|
||||
"""Scenario 08: clear on an already-missing field must not suppress candidate fallback."""
|
||||
rec = await reconcile_transaction_parameters(
|
||||
_ClearMissingSubjectReconcileLLM(),
|
||||
text="é a de quatorze e noventa e nove",
|
||||
tool_name="contestar_cobranca",
|
||||
parameter_names=["subject", "valor"],
|
||||
known_arguments={"valor": 14.99},
|
||||
parameter_schema={
|
||||
"subject": {"type": "string", "description": "item concreto da fatura"},
|
||||
"valor": {"type": "number", "description": "valor monetário associado"},
|
||||
},
|
||||
tool_description="Contesta uma cobrança identificada por item e valor",
|
||||
conversational_context=(
|
||||
"priority_3_previous_assistant_tool_or_evidence_context:\n"
|
||||
"history:1: assistant: Analisando a sua fatura atual. * Cobrança Tamboro Mensal no valor de R$ 14,99 no dia 01/11/25. "
|
||||
"* Cobrança TIM Fashion Mensal no valor de R$ 10,00 no dia 01/11/25.\n"
|
||||
"priority_4_previous_user_utterances:\n"
|
||||
"history:2: user: tem uma cobrança aqui que eu não reconheço"
|
||||
),
|
||||
)
|
||||
|
||||
assert rec["decisions"]["subject"] == "clear"
|
||||
assert rec["clear_fields"] == ["subject"]
|
||||
assert rec["candidates"] == {
|
||||
"subject": "Cobrança Tamboro Mensal no valor de R$ 14,99 no dia 01/11/25."
|
||||
}
|
||||
assert rec["candidate_provenance"] == {
|
||||
"subject": "unique_anchored_evidence_block"
|
||||
}
|
||||
@@ -738,3 +738,34 @@ async def test_correction_of_missing_numeric_value_does_not_reopen_resolved_text
|
||||
)
|
||||
assert runtime.pending_seen == ["valor"]
|
||||
assert out == {"valor": 19.99}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconciler_exposes_schema_driven_field_evidence_views_for_missing_fields():
|
||||
class _LLM:
|
||||
def __init__(self):
|
||||
self.prompt = ""
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
self.prompt = messages[0]["content"]
|
||||
return {"content": '{"fields":{"subject":{"decision":"resolved","value":"Produto A","source":"history:1"},"valor":{"decision":"preserve","value":14.99,"source":"state"}}}'}
|
||||
|
||||
from agent_framework.runtime.transaction_parameters import reconcile_transaction_parameters
|
||||
llm = _LLM()
|
||||
result = await reconcile_transaction_parameters(
|
||||
llm,
|
||||
text="é a de quatorze e noventa e nove",
|
||||
tool_name="acao_generica",
|
||||
parameter_names=["subject", "valor"],
|
||||
known_arguments={"valor": 14.99},
|
||||
parameter_schema={
|
||||
"subject": {"type": "string", "description": "referência concreta identificável"},
|
||||
"valor": {"type": "number", "description": "quantia associada"},
|
||||
},
|
||||
conversational_context=(
|
||||
"priority_3_previous_assistant_tool_or_evidence_context:\n"
|
||||
"history:1: assistant: * Produto A no atributo 14,99. * Produto B no atributo 10,00."
|
||||
),
|
||||
)
|
||||
assert result["values"]["subject"] == "Produto A"
|
||||
assert "field_evidence_views:" in llm.prompt
|
||||
assert '"subject"' in llm.prompt
|
||||
assert '"anchor_matches"' in llm.prompt
|
||||
|
||||
@@ -1058,11 +1058,12 @@ class _CorrectionDuringCollectingLLM:
|
||||
prompt = messages[-1]["content"]
|
||||
self.prompts.append(prompt)
|
||||
if kwargs.get("profile_name") == "transaction_parameter_extraction":
|
||||
# Current-only extraction now exposes the requested keys through the
|
||||
# declarative schema/format rather than the legacy pending_parameters label.
|
||||
if "current_user_message: desculpa, é a de quatorze e noventa e nove" in prompt:
|
||||
return {"content": json.dumps({"subject": None, "valor": 14.99}, ensure_ascii=False)}
|
||||
pending = json.loads(prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0])
|
||||
out = {name: None for name in pending}
|
||||
# O router já resolveu subject a partir do contexto. O runtime ainda
|
||||
# precisa permitir que a mensagem atual corrija um valor previamente
|
||||
# coletado, mesmo que valor não esteja em missing_parameters.
|
||||
if "valor" in out:
|
||||
out["valor"] = 14.99
|
||||
return {"content": json.dumps(out, ensure_ascii=False)}
|
||||
@@ -1374,3 +1375,61 @@ async def test_temporal_reconciler_is_fallback_after_traditional_candidate_fails
|
||||
assert state["pending_tool_call"]["arguments"]["subject"] == "Tamboro Mensal"
|
||||
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
|
||||
assert result[-1]["awaiting_confirmation"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collecting_parameters_materializes_router_cache_before_current_only_t6_regression():
|
||||
"""T6 regression: router-resolved subject must enter active arguments.
|
||||
|
||||
The router already consumed the pending subject in the same turn. A subsequent
|
||||
current-only extraction over the literal message "sim" must not be required to
|
||||
rediscover it, and temporal reconciliation must not leave it only as metadata.
|
||||
"""
|
||||
runtime = _ContestRuntime()
|
||||
state = {
|
||||
"user_text": "sim",
|
||||
"sanitized_input": "sim",
|
||||
"route": "contestacao_agent",
|
||||
"active_agent": "contestacao_agent",
|
||||
"intent": "state:COLLECTING_CONTESTACAO_PARAMETERS",
|
||||
"route_decision": {
|
||||
"route": "contestacao_agent",
|
||||
"agent": "contestacao_agent",
|
||||
"intent": "state:COLLECTING_CONTESTACAO_PARAMETERS",
|
||||
"metadata": {
|
||||
"transaction_turn_consumed": True,
|
||||
"transaction_parameter_values": {"subject": "Tamboro Mensal"},
|
||||
"transaction_parameter_source": "llm",
|
||||
"transaction_parameter_missing_before": ["subject"],
|
||||
},
|
||||
},
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
"missing_parameters": ["subject"],
|
||||
"active_transaction": {
|
||||
"transaction_id": "tx-t6-router-cache",
|
||||
"tool_name": "contestar_cobranca",
|
||||
"arguments": {"valor": 19.99},
|
||||
"status": "COLLECTING_PARAMETERS",
|
||||
"started_from_intent": "contas_contestation",
|
||||
"requires": ["subject", "valor"],
|
||||
"parameter_schema": {
|
||||
"subject": {"type": "string", "description": "item concreto da fatura"},
|
||||
"valor": {"type": "number", "description": "valor da cobrança"},
|
||||
},
|
||||
"tool_description": "Contesta cobrança validada",
|
||||
},
|
||||
"selected_tool_call": {
|
||||
"tool_name": "contestar_cobranca",
|
||||
"arguments": {"valor": 19.99},
|
||||
},
|
||||
}
|
||||
|
||||
results = await runtime.execute_tools_for_intent(state, tools=[])
|
||||
|
||||
assert results[-1]["transaction_status"] == "AWAITING_CONFIRMATION"
|
||||
assert state["pending_tool_call"]["arguments"]["subject"] == "Tamboro Mensal"
|
||||
assert state["pending_tool_call"]["arguments"]["valor"] == 19.99
|
||||
assert state["active_transaction"]["arguments"]["subject"] == "Tamboro Mensal"
|
||||
assert state["missing_parameters"] == []
|
||||
collection = state["transaction_parameter_collection"]
|
||||
assert collection["router_resolved_fields"] == ["subject"]
|
||||
assert runtime.calls == []
|
||||
|
||||
66
agent_framework_oci/tests/unit/test_multi_intent_planner.py
Normal file
66
agent_framework_oci/tests/unit/test_multi_intent_planner.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from agent_framework.routing.models import IntentDefinition
|
||||
from agent_framework.routing.multi_intent import MultiIntentPlanner
|
||||
|
||||
|
||||
def _planner():
|
||||
return MultiIntentPlanner([
|
||||
IntentDefinition(name="cancel", agent="orders", priority=10, keywords=["cancelar pedido"], mcp_tools=["cancelar_pedido"]),
|
||||
IntentDefinition(name="invoice", agent="billing", priority=20, keywords=["segunda via"], mcp_tools=["consultar_fatura"]),
|
||||
], {
|
||||
"enabled": True,
|
||||
}, transactional_tools={"cancelar_pedido"})
|
||||
|
||||
|
||||
def test_builds_validated_plan_and_keeps_primary_first():
|
||||
plan = _planner().plan("quero cancelar pedido e preciso da segunda via")
|
||||
assert plan is not None
|
||||
assert [item.intent for item in plan.operations] == ["cancel", "invoice"]
|
||||
assert plan.operations[1].disposition == "execute"
|
||||
assert plan.operations[1].tools == ["consultar_fatura"]
|
||||
|
||||
|
||||
def test_confirmation_with_secondary_does_not_lose_confirmation():
|
||||
plan = _planner().plan_confirmation_secondary(
|
||||
"sim e preciso da segunda via", primary_intent="cancel", primary_agent="orders"
|
||||
)
|
||||
assert plan is not None
|
||||
assert [item.intent for item in plan.operations] == ["cancel", "invoice"]
|
||||
assert MultiIntentPlanner.public_messages(plan) == []
|
||||
|
||||
|
||||
def test_transactional_intent_is_primary_even_when_mentioned_second():
|
||||
plan = _planner().plan("preciso da segunda via e quero cancelar pedido")
|
||||
assert plan is not None
|
||||
assert [item.intent for item in plan.operations] == ["cancel", "invoice"]
|
||||
|
||||
|
||||
def test_does_not_plan_single_intent_or_invent_unknown_intent():
|
||||
assert _planner().plan("quero cancelar pedido") is None
|
||||
assert _planner().plan("quero cancelar pedido e saber o clima") is None
|
||||
|
||||
|
||||
def test_known_primary_with_actionable_unknown_creates_off_context_topic():
|
||||
plan = _planner().plan("quero cancelar pedido e alterar meu CNPJ")
|
||||
assert plan is not None
|
||||
assert plan.operations[1].intent == "off_context"
|
||||
assert plan.operations[1].disposition == "unsupported"
|
||||
|
||||
|
||||
def test_confirmation_with_unknown_secondary_keeps_confirmation():
|
||||
plan = _planner().plan_confirmation_secondary(
|
||||
"sim e altere meu CNPJ", primary_intent="cancel", primary_agent="orders"
|
||||
)
|
||||
assert plan is not None
|
||||
assert plan.operations[0].intent == "cancel"
|
||||
assert plan.operations[1].intent == "off_context"
|
||||
|
||||
|
||||
def test_single_word_keyword_does_not_match_a_longer_word_prefix():
|
||||
planner = MultiIntentPlanner([
|
||||
IntentDefinition(name="cancel", agent="orders", priority=10, keywords=["cancela"]),
|
||||
IntentDefinition(name="invoice", agent="billing", priority=20, keywords=["segunda via"]),
|
||||
])
|
||||
plan = planner.plan("sim, pode cancelar e preciso da segunda via")
|
||||
assert plan is not None
|
||||
assert all(item.intent != "cancel" for item in plan.operations)
|
||||
assert [item.intent for item in plan.operations] == ["invoice", "off_context"]
|
||||
@@ -0,0 +1,53 @@
|
||||
import pytest
|
||||
|
||||
from agent_framework.routing.pending_topics import drain_pending_topics
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_secondary_topic_does_not_inherit_primary_transaction_state():
|
||||
captured = {}
|
||||
|
||||
async def billing_agent(state):
|
||||
captured.update(state)
|
||||
return {"answer": "Aqui está a sua segunda via."}
|
||||
|
||||
state = {
|
||||
"answer": "Você confirma o cancelamento do Tamboro?",
|
||||
"sanitized_input": "quero cancelar Tamboro e quero minha segunda via",
|
||||
"confirmation_required": True,
|
||||
"active_transaction": {"tool_name": "cancelar_vas_avulso"},
|
||||
"transaction_status": "AWAITING_CONFIRMATION",
|
||||
"selected_tool_call": {"tool_name": "cancelar_vas_avulso"},
|
||||
"pending_tool_call": {"tool_name": "cancelar_vas_avulso"},
|
||||
"pending_domain_workflow": {"workflow_name": "cancelamento_vas_avulso"},
|
||||
"missing_parameters": ["subject"],
|
||||
"next_state": "WAITING_CONTESTACAO_CONFIRMATION",
|
||||
"mcp_results": [{"tool_name": "consultar_vas"}],
|
||||
"pending_topics": [{
|
||||
"operation_id": "op-2",
|
||||
"intent": "contas_invoice_query",
|
||||
"agent": "faturas_agent",
|
||||
"domain": "telecom_contas",
|
||||
"tools": ["consultar_faturas"],
|
||||
"source_text": "quero minha segunda via",
|
||||
"disposition": "execute",
|
||||
"status": "pending",
|
||||
}],
|
||||
}
|
||||
|
||||
result = await drain_pending_topics(
|
||||
state, state["answer"], {"faturas_agent": billing_agent}
|
||||
)
|
||||
|
||||
assert captured["active_transaction"] is None
|
||||
assert captured["selected_tool_call"] == {}
|
||||
assert captured["pending_tool_call"] == {}
|
||||
assert captured["pending_domain_workflow"] is None
|
||||
assert captured["next_state"] is None
|
||||
assert captured["sanitized_input"] == "quero minha segunda via"
|
||||
assert captured["mcp_results"] == []
|
||||
assert captured["mcp_tools"] == ["consultar_faturas"]
|
||||
assert captured["available_mcp_tools"] == ["consultar_faturas"]
|
||||
assert captured["domain"] == "telecom_contas"
|
||||
assert result.answer.startswith("Aqui está a sua segunda via.")
|
||||
assert result.pending_topics == []
|
||||
@@ -0,0 +1,39 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _local_links(path: Path) -> list[str]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
return [
|
||||
target for target in re.findall(r"\[[^]]*\]\(([^)]+)\)", text)
|
||||
if not target.startswith(("http://", "https://", "#", "mailto:"))
|
||||
]
|
||||
|
||||
|
||||
def test_main_readmes_have_no_broken_local_links():
|
||||
for name in ("README.md", "README_en.md"):
|
||||
readme = ROOT / name
|
||||
for target in _local_links(readme):
|
||||
relative = unquote(target.split("#", 1)[0])
|
||||
assert not relative or (readme.parent / relative).exists(), (name, target)
|
||||
|
||||
|
||||
def test_every_feature_guide_is_listed_in_corresponding_main_readme():
|
||||
pairs = (("pt-BR", "README.md"), ("en", "README_en.md"))
|
||||
for language, readme_name in pairs:
|
||||
readme = (ROOT / readme_name).read_text(encoding="utf-8")
|
||||
guides = sorted((ROOT / "docs" / "features" / language).glob("[0-9][0-9]_*.md"))
|
||||
assert guides
|
||||
for guide in guides:
|
||||
expected = f"docs/features/{language}/{guide.name}"
|
||||
assert expected in readme, (readme_name, expected)
|
||||
|
||||
|
||||
def test_portuguese_and_english_feature_catalogs_are_symmetric():
|
||||
pt = {path.name for path in (ROOT / "docs" / "features" / "pt-BR").glob("*.md")}
|
||||
en = {path.name for path in (ROOT / "docs" / "features" / "en").glob("*.md")}
|
||||
assert pt == en
|
||||
@@ -0,0 +1,38 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_all_tuning_backend_graphs_support_pending_topics():
|
||||
graphs = sorted((ROOT / "Tuning-Performance").glob("**/app/workflows/agent_graph.py"))
|
||||
assert graphs
|
||||
for path in graphs:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
assert "from agent_framework.routing.pending_topics import drain_pending_topics" in source, path
|
||||
assert '"pending_topics": drained.pending_topics' in source, path
|
||||
assert '"handled_topics": drained.handled_topics' in source, path
|
||||
|
||||
|
||||
def test_all_tuning_backend_states_expose_pending_topics():
|
||||
states = sorted((ROOT / "Tuning-Performance").glob("**/app/state.py"))
|
||||
assert states
|
||||
for path in states:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
assert "pending_topics: list[dict[str, Any]]" in source, path
|
||||
assert "handled_topics: list[dict[str, Any]]" in source, path
|
||||
|
||||
|
||||
def test_canonical_templates_delegate_drain_to_framework_helper():
|
||||
for template in ("agent_template_backend", "agent_template_backend_day_zero"):
|
||||
graph = ROOT / "templates" / template / "app" / "workflows" / "agent_graph.py"
|
||||
state = ROOT / "templates" / template / "app" / "state.py"
|
||||
graph_source = graph.read_text(encoding="utf-8")
|
||||
state_source = state.read_text(encoding="utf-8")
|
||||
assert "from agent_framework.routing.pending_topics import drain_pending_topics" in graph_source
|
||||
assert "drained = await drain_pending_topics(" in graph_source
|
||||
assert '"pending_topics": drained.pending_topics' in graph_source
|
||||
assert '"handled_topics": drained.handled_topics' in graph_source
|
||||
assert "MultiIntentPlanner.public_messages" not in graph_source
|
||||
assert "pending_topics: list[dict[str, Any]]" in state_source
|
||||
assert "handled_topics: list[dict[str, Any]]" in state_source
|
||||
Reference in New Issue
Block a user