mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-11-06 03:14:12 +00:00
bugfix reconciliation extractor
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.
@@ -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:
|
||||
@@ -42,3 +42,370 @@ async def test_contextual_reentry_separates_current_claim_from_prior_context_for
|
||||
assert "Cobrança Tamboro Mensal" in llm.prompt
|
||||
assert "user_message: é a de quatorze e noventa e nove" in llm.prompt
|
||||
assert "Não trate texto do contexto como uma nova afirmação do cliente" in llm.prompt
|
||||
|
||||
class _TwoPassContextLLM:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
prompt = messages[-1]["content"]
|
||||
self.calls.append(prompt)
|
||||
# First pass: current utterance provides only the amount.
|
||||
if "user_message: é a de vinte e cinco e cinquenta" in prompt:
|
||||
return json.dumps({"subject": None, "valor": 25.50}, ensure_ascii=False)
|
||||
# Second bounded pass: previous USER utterance provides the missing entity.
|
||||
if "user_message: não reconheço esse Tamboro Mensal na minha fatura" in prompt:
|
||||
return json.dumps({"subject": "Tamboro Mensal"}, ensure_ascii=False)
|
||||
return "{}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contextual_reentry_second_pass_recovers_only_missing_candidate_from_prior_user_turn():
|
||||
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
||||
|
||||
class Runtime(AgentRuntimeMixin):
|
||||
pass
|
||||
|
||||
runtime = Runtime()
|
||||
runtime.llm = _TwoPassContextLLM()
|
||||
state = {
|
||||
"route_decision": {
|
||||
"metadata": {
|
||||
"contextual_reentry": True,
|
||||
"original_input": "é a de vinte e cinco e cinquenta",
|
||||
"relevant_conversation_context": (
|
||||
"user: não reconheço esse Tamboro Mensal na minha fatura\n"
|
||||
"assistant: identifiquei duas cobranças de Tamboro Mensal"
|
||||
),
|
||||
}
|
||||
},
|
||||
"sanitized_input": "é a de vinte e cinco e cinquenta",
|
||||
}
|
||||
out = await runtime._extract_transaction_parameters(
|
||||
state=state,
|
||||
tool_name="contestar_cobranca",
|
||||
missing_parameters=["subject", "valor"],
|
||||
known_arguments={},
|
||||
)
|
||||
assert float(out["valor"]) == 25.5
|
||||
assert out["subject"] == "Tamboro Mensal"
|
||||
assert len(runtime.llm.calls) >= 2
|
||||
|
||||
class _CoherentTemporalLLM:
|
||||
"""Simulates the structured decisions expected from temporal reconciliation."""
|
||||
def __init__(self):
|
||||
self.prompts = []
|
||||
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
prompt = messages[-1]["content"]
|
||||
self.prompts.append(prompt)
|
||||
low = prompt.lower()
|
||||
# Product A + value X established.
|
||||
if "user_message: na verdade é tim fashion mensal" in low:
|
||||
return json.dumps({"fields": {
|
||||
"subject": {"decision": "resolved", "value": "TIM Fashion Mensal", "source": "current"},
|
||||
# Previous 14.99 was tied to the former product and must not be transplanted.
|
||||
"valor": {"decision": "clear", "value": None, "source": "history:1"},
|
||||
}}, ensure_ascii=False)
|
||||
if "user_message: na verdade é produto inexistente" in low:
|
||||
return json.dumps({"fields": {
|
||||
# Keep the newest candidate so authoritative validation can reject it.
|
||||
"subject": {"decision": "resolved", "value": "Produto Inexistente", "source": "current"},
|
||||
"valor": {"decision": "clear", "value": None, "source": "history:1"},
|
||||
}}, ensure_ascii=False)
|
||||
if "user_message: desculpa, era tamboro mensal mesmo" in low:
|
||||
return json.dumps({"fields": {
|
||||
"subject": {"decision": "resolved", "value": "Tamboro Mensal", "source": "current"},
|
||||
# Full temporal text makes the old amount coherent again with the restored product.
|
||||
"valor": {"decision": "resolved", "value": 14.99, "source": "history:2"},
|
||||
}}, ensure_ascii=False)
|
||||
return json.dumps({"fields": {
|
||||
"subject": {"decision": "preserve", "value": None, "source": "state"},
|
||||
"valor": {"decision": "preserve", "value": None, "source": "state"},
|
||||
}}, ensure_ascii=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporal_reconciliation_product_change_rechecks_old_value_as_part_of_coherent_set():
|
||||
from agent_framework.runtime.transaction_parameters import reconcile_transaction_parameters
|
||||
|
||||
llm = _CoherentTemporalLLM()
|
||||
out = await reconcile_transaction_parameters(
|
||||
llm,
|
||||
text="na verdade é TIM Fashion Mensal",
|
||||
conversational_context=(
|
||||
"history:1: user: é R$ 14,99\n"
|
||||
"history:2: user: quero contestar Tamboro Mensal"
|
||||
),
|
||||
tool_name="contestar_cobranca",
|
||||
parameter_names=["subject", "valor"],
|
||||
known_arguments={"subject": "Tamboro Mensal", "valor": 14.99},
|
||||
parameter_schema={
|
||||
"subject": {"type": "string", "description": "Nome do serviço, produto, item ou cobrança objeto da contestação."},
|
||||
"valor": {"type": "number", "description": "Valor monetário associado ao item objeto da contestação."},
|
||||
},
|
||||
tool_description="Contesta uma cobrança após validação.",
|
||||
)
|
||||
assert out["values"] == {"subject": "TIM Fashion Mensal"}
|
||||
assert out["clear_fields"] == ["valor"]
|
||||
assert out["provenance"]["subject"] == "current"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporal_reconciliation_invalid_new_product_does_not_erase_history_or_fall_back_silently():
|
||||
from agent_framework.runtime.transaction_parameters import reconcile_transaction_parameters
|
||||
|
||||
llm = _CoherentTemporalLLM()
|
||||
history = (
|
||||
"history:1: user: é R$ 14,99\n"
|
||||
"history:2: user: quero contestar Tamboro Mensal"
|
||||
)
|
||||
out = await reconcile_transaction_parameters(
|
||||
llm,
|
||||
text="na verdade é Produto Inexistente",
|
||||
conversational_context=history,
|
||||
tool_name="contestar_cobranca",
|
||||
parameter_names=["subject", "valor"],
|
||||
known_arguments={"subject": "Tamboro Mensal", "valor": 14.99},
|
||||
parameter_schema={
|
||||
"subject": {"type": "string", "description": "Nome do serviço, produto, item ou cobrança objeto da contestação."},
|
||||
"valor": {"type": "number", "description": "Valor monetário associado ao item objeto da contestação."},
|
||||
},
|
||||
tool_description="Contesta uma cobrança após validação.",
|
||||
)
|
||||
# The newest candidate stays visible for pre-validation; the old product is not silently restored.
|
||||
assert out["values"] == {"subject": "Produto Inexistente"}
|
||||
assert "valor" in out["clear_fields"]
|
||||
assert "Tamboro Mensal" in llm.prompts[-1] # history remains available to future reconciliation.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporal_reconciliation_can_restore_coherent_old_value_when_product_is_explicitly_restored():
|
||||
from agent_framework.runtime.transaction_parameters import reconcile_transaction_parameters
|
||||
|
||||
llm = _CoherentTemporalLLM()
|
||||
out = await reconcile_transaction_parameters(
|
||||
llm,
|
||||
text="desculpa, era Tamboro Mensal mesmo",
|
||||
conversational_context=(
|
||||
"history:1: user: na verdade é Produto Inexistente\n"
|
||||
"history:2: user: é R$ 14,99\n"
|
||||
"history:3: user: quero contestar Tamboro Mensal"
|
||||
),
|
||||
tool_name="contestar_cobranca",
|
||||
parameter_names=["subject", "valor"],
|
||||
known_arguments={},
|
||||
parameter_schema={
|
||||
"subject": {"type": "string", "description": "Nome do serviço, produto, item ou cobrança objeto da contestação."},
|
||||
"valor": {"type": "number", "description": "Valor monetário associado ao item objeto da contestação."},
|
||||
},
|
||||
tool_description="Contesta uma cobrança após validação.",
|
||||
)
|
||||
assert out["values"] == {"subject": "Tamboro Mensal", "valor": 14.99}
|
||||
assert out["provenance"] == {"subject": "current", "valor": "history:2"}
|
||||
|
||||
|
||||
def test_temporal_context_priority_places_assistant_before_older_user_history():
|
||||
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
||||
|
||||
context = (
|
||||
"user: tem uma cobrança aqui que eu não reconheço\n"
|
||||
"assistant: Cobrança Tamboro Mensal no valor de R$ 14,99; TIM Fashion Mensal no valor de R$ 10,00."
|
||||
)
|
||||
prioritized = AgentRuntimeMixin._transaction_context_priority_view(context)
|
||||
|
||||
assert "priority_3_previous_assistant_tool_or_evidence_context:" in prioritized
|
||||
assert "priority_4_previous_user_utterances:" in prioritized
|
||||
assert prioritized.index("assistant: Cobrança Tamboro Mensal") < prioritized.index("user: tem uma cobrança")
|
||||
|
||||
|
||||
class _AssistantRelationshipReconcilerLLM:
|
||||
def __init__(self):
|
||||
self.prompt = ""
|
||||
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
self.prompt = messages[-1]["content"]
|
||||
# Simula o comportamento desejado: a fala atual dá o valor, enquanto a
|
||||
# relação item->valor está numa resposta anterior do assistant.
|
||||
assert "user_message: é a de quatorze e noventa e nove" in self.prompt
|
||||
assert "priority_3_previous_assistant_tool_or_evidence_context:" in self.prompt
|
||||
assert "assistant: Cobrança Tamboro Mensal no valor de R$ 14,99" in self.prompt
|
||||
assert "anchor_relation_candidates:" in self.prompt
|
||||
assert "anchor[valor=14.99]" in self.prompt
|
||||
return json.dumps({"fields": {
|
||||
"subject": {"decision": "resolved", "value": "Tamboro Mensal", "source": "history:1"},
|
||||
"valor": {"decision": "resolved", "value": 14.99, "source": "current"},
|
||||
}}, ensure_ascii=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporal_reconciler_can_use_grounded_assistant_relationship_after_current_only_extraction_is_incomplete():
|
||||
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
||||
|
||||
class Runtime(AgentRuntimeMixin):
|
||||
pass
|
||||
|
||||
runtime = Runtime()
|
||||
runtime.llm = _AssistantRelationshipReconcilerLLM()
|
||||
runtime.tool_router = type("TR", (), {
|
||||
"registry": type("REG", (), {
|
||||
"get_tool": staticmethod(lambda name: type("CFG", (), {
|
||||
"requires": ["subject", "valor"],
|
||||
"args_schema": {
|
||||
"subject": {"type": "string", "description": "Nome do item ou cobrança a contestar."},
|
||||
"valor": {"type": "number", "description": "Valor monetário do item a contestar."},
|
||||
},
|
||||
"description": "Contesta uma cobrança após validação.",
|
||||
})())
|
||||
})(),
|
||||
"resolve_execution_policy": staticmethod(lambda name, arguments=None: {
|
||||
"operation_type": "transactional",
|
||||
"requires": ["subject", "valor"],
|
||||
}),
|
||||
})()
|
||||
|
||||
state = {
|
||||
"sanitized_input": "é a de quatorze e noventa e nove",
|
||||
"user_text": "é a de quatorze e noventa e nove",
|
||||
"route_decision": {"metadata": {
|
||||
"contextual_reentry": True,
|
||||
"original_input": "é a de quatorze e noventa e nove",
|
||||
"relevant_conversation_context": (
|
||||
"user: tem uma cobrança aqui que eu não reconheço\n"
|
||||
"assistant: Cobrança Tamboro Mensal no valor de R$ 14,99; TIM Fashion Mensal no valor de R$ 10,00."
|
||||
),
|
||||
}},
|
||||
}
|
||||
|
||||
# O happy path corrente só consegue o valor; o subject fica faltando.
|
||||
class _CurrentOnlyLLM:
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
return json.dumps({"valor": 14.99}, ensure_ascii=False)
|
||||
|
||||
original_llm = runtime.llm
|
||||
runtime.llm = _CurrentOnlyLLM()
|
||||
current = await runtime._extract_transaction_parameters_current_only(
|
||||
state,
|
||||
tool_name="contestar_cobranca",
|
||||
missing_parameters=["subject", "valor"],
|
||||
known_arguments={},
|
||||
)
|
||||
assert current == {"valor": 14.99}
|
||||
|
||||
runtime.llm = original_llm
|
||||
reconciled = await runtime._reconcile_transaction_parameters(
|
||||
state,
|
||||
tool_name="contestar_cobranca",
|
||||
parameter_names=["subject", "valor"],
|
||||
known_arguments=current,
|
||||
)
|
||||
assert reconciled["values"]["subject"] == "Tamboro Mensal"
|
||||
assert float(reconciled["values"]["valor"]) == 14.99
|
||||
assert "(3) respostas anteriores do assistente" in runtime.llm.prompt
|
||||
|
||||
class _UnifiedAnchorScanLLM:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
prompt = messages[-1]["content"]
|
||||
self.calls.append(prompt)
|
||||
assert "UMA ÚNICA VARREDURA TEMPORAL" in prompt
|
||||
assert "known_parameters: {\"valor\": 14.99}" in prompt
|
||||
assert "missing_parameters: [\"subject\", \"data\"]" in prompt
|
||||
assert "anchor_relation_candidates:" in prompt
|
||||
assert "anchor[valor=14.99]" in prompt
|
||||
assert "Tamboro Mensal no valor de R$ 14,99" in prompt
|
||||
return json.dumps({"fields": {
|
||||
"subject": {"decision": "resolved", "value": "Tamboro Mensal", "source": "history:1"},
|
||||
"valor": {"decision": "preserve", "value": None, "source": "state"},
|
||||
"data": {"decision": "resolved", "value": "01/11/2025", "source": "history:1"},
|
||||
}}, ensure_ascii=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporal_reconciler_single_scan_uses_all_known_fields_as_anchors_and_resolves_all_missing_fields():
|
||||
from agent_framework.runtime.transaction_parameters import reconcile_transaction_parameters
|
||||
|
||||
llm = _UnifiedAnchorScanLLM()
|
||||
result = await reconcile_transaction_parameters(
|
||||
llm,
|
||||
text="é a de quatorze e noventa e nove",
|
||||
tool_name="contestar_cobranca",
|
||||
parameter_names=["subject", "valor", "data"],
|
||||
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."},
|
||||
"data": {"type": "string", "description": "Data da cobrança selecionada."},
|
||||
},
|
||||
tool_description="Contesta uma cobrança após validação.",
|
||||
conversational_context=(
|
||||
"priority_3_previous_assistant_tool_or_evidence_context:\n"
|
||||
"history:1: assistant: Cobrança Tamboro Mensal no valor de R$ 14,99 no dia 01/11/2025 * "
|
||||
"TIM Fashion Mensal no valor de R$ 10,00 no dia 01/11/2025.\n"
|
||||
"priority_4_previous_user_utterances:\n"
|
||||
"history:2: user: tem uma cobrança aqui que eu não reconheço"
|
||||
),
|
||||
)
|
||||
|
||||
assert len(llm.calls) == 1
|
||||
assert result["values"]["subject"] == "Tamboro Mensal"
|
||||
assert float(result["values"]["valor"]) == 14.99
|
||||
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
|
||||
|
||||
191
tests/test_reconciliation_state_rules_8_16.py
Normal file
191
tests/test_reconciliation_state_rules_8_16.py
Normal file
@@ -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
|
||||
@@ -0,0 +1,293 @@
|
||||
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"}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@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"
|
||||
}
|
||||
@@ -643,3 +643,129 @@ intents: []
|
||||
decision = await router.route(state)
|
||||
assert decision.metadata["transaction_confirmation_decision"] == "confirm"
|
||||
assert decision.metadata["transaction_confirmation_source"] == "deterministic"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collecting_preserves_resolved_required_fields_and_only_extracts_currently_missing():
|
||||
class Runtime(_Runtime):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.pending_seen = None
|
||||
|
||||
async def _extract_transaction_parameters(
|
||||
self, state, *, tool_name, missing_parameters, known_arguments=None
|
||||
):
|
||||
self.pending_seen = list(missing_parameters)
|
||||
# Simulate the value extracted for the only missing field. A resolved
|
||||
# required field must not be offered back to the semantic extractor.
|
||||
return {"reason": "desisti da compra"}
|
||||
|
||||
runtime = Runtime()
|
||||
state = {
|
||||
"user_text": "desisti da compra",
|
||||
"sanitized_input": "desisti da compra",
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
"missing_parameters": ["reason"],
|
||||
"mcp_tools": ["solicitar_devolucao"],
|
||||
"active_transaction": {
|
||||
"tool_name": "solicitar_devolucao",
|
||||
"arguments": {"order_id": "PED-1001"},
|
||||
"status": "COLLECTING_PARAMETERS",
|
||||
"parameter_schema": {"order_id": "string", "reason": "string"},
|
||||
"tool_description": "Abre uma solicitação de devolução de pedido.",
|
||||
},
|
||||
"selected_tool_call": {
|
||||
"tool_name": "solicitar_devolucao",
|
||||
"arguments": {"order_id": "PED-1001"},
|
||||
},
|
||||
"route_decision": {"metadata": {}},
|
||||
"route": "support_agent",
|
||||
"intent": "state:COLLECTING_SUPPORT_PARAMETERS",
|
||||
}
|
||||
|
||||
result = await runtime.execute_tools_for_intent(state)
|
||||
assert runtime.pending_seen == ["reason"]
|
||||
assert state["pending_tool_call"]["arguments"]["order_id"] == "PED-1001"
|
||||
assert state["pending_tool_call"]["arguments"]["reason"] == "desisti da compra"
|
||||
assert result[-1]["awaiting_confirmation"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_correction_of_missing_numeric_value_does_not_reopen_resolved_text_entity():
|
||||
class Runtime(_Runtime):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.pending_seen = None
|
||||
|
||||
async def _extract_transaction_parameters(
|
||||
self, state, *, tool_name, missing_parameters, known_arguments=None
|
||||
):
|
||||
self.pending_seen = list(missing_parameters)
|
||||
# If subject were incorrectly offered again, a semantic extractor
|
||||
# could reinterpret the monetary phrase as an entity. The runtime
|
||||
# contract must expose only the missing numeric field here.
|
||||
out = {"valor": 19.99}
|
||||
if "subject" in missing_parameters:
|
||||
out["subject"] = "cobrança de R$ 19,99"
|
||||
return out
|
||||
|
||||
runtime = Runtime()
|
||||
state = {
|
||||
"user_text": "desculpa é a de dezenove e noventa e nove",
|
||||
"sanitized_input": "desculpa é a de dezenove e noventa e nove",
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
"missing_parameters": ["valor"],
|
||||
"mcp_tools": ["solicitar_devolucao"],
|
||||
"active_transaction": {
|
||||
"tool_name": "solicitar_devolucao",
|
||||
"arguments": {"order_id": "PED-1001"},
|
||||
"status": "COLLECTING_PARAMETERS",
|
||||
"parameter_schema": {"order_id": "string", "reason": "string", "valor": "number"},
|
||||
},
|
||||
"selected_tool_call": {
|
||||
"tool_name": "solicitar_devolucao",
|
||||
"arguments": {"order_id": "PED-1001"},
|
||||
},
|
||||
"route_decision": {"metadata": {}},
|
||||
"route": "support_agent",
|
||||
"intent": "state:COLLECTING_SUPPORT_PARAMETERS",
|
||||
}
|
||||
# This synthetic tool doesn't require valor, so exercise the extraction
|
||||
# contract directly with the same resolved-text/missing-number shape.
|
||||
out = await runtime._extract_transaction_parameters(
|
||||
state,
|
||||
tool_name="solicitar_devolucao",
|
||||
missing_parameters=["valor"],
|
||||
known_arguments={"subject": "Tamboro Mensal"},
|
||||
)
|
||||
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)}
|
||||
@@ -1283,3 +1284,152 @@ async def test_prevalidation_parameter_message_is_used_once_without_changing_tra
|
||||
# One-shot: subsequent clarification falls back to the normal prompt.
|
||||
assert runtime.transaction_clarification_message(state) != expected
|
||||
assert "transaction_parameter_message_override" not in state
|
||||
|
||||
class _TemporalFallbackRuntime(AgentRuntimeMixin):
|
||||
def __init__(self):
|
||||
from types import SimpleNamespace
|
||||
self.calls = []
|
||||
self.llm = None
|
||||
self.tool_router = SimpleNamespace(
|
||||
registry=SimpleNamespace(
|
||||
get_tool=lambda name: SimpleNamespace(
|
||||
requires=["subject"],
|
||||
args_schema={"subject": {"type": "string", "description": "Nome do serviço ou VAS alvo do cancelamento."}},
|
||||
description="Cancela o serviço VAS selecionado pelo cliente.",
|
||||
confirmation_required=True,
|
||||
tool_type="transactional",
|
||||
)
|
||||
),
|
||||
resolve_execution_policy=lambda name, arguments=None: {
|
||||
"operation_type": "transactional",
|
||||
"require_confirmation": True,
|
||||
"requires": ["subject"],
|
||||
"pre_validation": {"enabled": True, "tool": "validar_vas_subject", "fail_open": False},
|
||||
},
|
||||
)
|
||||
|
||||
async def _extract_transaction_parameters_current_only(self, state, *, tool_name, missing_parameters, known_arguments=None):
|
||||
# Simula o collector tradicional interpretando literalmente a fala atual.
|
||||
return {"subject": "dezenove e noventa e nove"}
|
||||
|
||||
async def _reconcile_transaction_parameters(self, state, *, tool_name, parameter_names, known_arguments=None):
|
||||
# Simula o reconciliador temporal usando tools.yaml + contexto newest->oldest.
|
||||
return {
|
||||
"values": {"subject": "Tamboro Mensal"},
|
||||
"decisions": {"subject": "resolved"},
|
||||
"provenance": {"subject": "history:2"},
|
||||
"clear_fields": [],
|
||||
}
|
||||
|
||||
async def _call_mcp_tool(self, tool_name, arguments, state):
|
||||
self.calls.append((tool_name, dict(arguments)))
|
||||
if tool_name == "validar_vas_subject":
|
||||
subject = arguments.get("subject")
|
||||
if subject == "Tamboro Mensal":
|
||||
return {"ok": True, "tool_name": tool_name, "result": {"eligible": True, "status": "ELIGIBLE"}}
|
||||
return {"ok": True, "tool_name": tool_name, "result": {
|
||||
"eligible": False,
|
||||
"status": "NEEDS_PARAMETER",
|
||||
"parameter": "subject",
|
||||
"reason": "subject_not_resolved",
|
||||
}}
|
||||
return {"ok": True, "tool_name": tool_name, "result": {"status": "OK"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporal_reconciler_is_fallback_after_traditional_candidate_fails_prevalidation():
|
||||
runtime = _TemporalFallbackRuntime()
|
||||
state = {
|
||||
"user_text": "é a de dezenove e noventa e nove",
|
||||
"sanitized_input": "é a de dezenove e noventa e nove",
|
||||
"route": "contestacao_agent",
|
||||
"intent": "state:COLLECTING_CONTESTACAO_PARAMETERS",
|
||||
"transaction_status": "COLLECTING_PARAMETERS",
|
||||
"selected_tool_call": {"tool_name": "cancelar_vas_avulso", "arguments": {}},
|
||||
"active_transaction": {
|
||||
"transaction_id": "tx-1",
|
||||
"tool_name": "cancelar_vas_avulso",
|
||||
"arguments": {},
|
||||
"status": "COLLECTING_PARAMETERS",
|
||||
"started_from_intent": "contas_vas_cancel",
|
||||
"requires": ["subject"],
|
||||
"parameter_schema": {"subject": {"type": "string", "description": "Nome do serviço ou VAS alvo do cancelamento."}},
|
||||
"tool_description": "Cancela o serviço VAS selecionado pelo cliente.",
|
||||
"parameter_conversational_context": (
|
||||
"user: não reconheço esse Tamboro Mensal na minha fatura\n"
|
||||
"assistant: identifiquei Tamboro Mensal por R$ 14,99 e R$ 19,99"
|
||||
),
|
||||
},
|
||||
"context": {},
|
||||
}
|
||||
|
||||
result = await runtime.execute_tools_for_intent(state, tools=[])
|
||||
|
||||
validations = [(name, args.get("subject")) for name, args in runtime.calls if name == "validar_vas_subject"]
|
||||
assert validations == [
|
||||
("validar_vas_subject", "dezenove e noventa e nove"),
|
||||
("validar_vas_subject", "Tamboro Mensal"),
|
||||
]
|
||||
assert state["transaction_parameter_collection"]["mode"] == "current_turn"
|
||||
assert state["transaction_parameter_reconciliation"]["trigger"] == "prevalidation_needs_parameter"
|
||||
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 == []
|
||||
|
||||
Reference in New Issue
Block a user