adjustments: transaction parameter extraction

This commit is contained in:
T3782834
2026-08-21 22:44:36 -03:00
parent 727997aa41
commit d93efd8972
19 changed files with 963 additions and 141 deletions

View File

@@ -1,32 +1,102 @@
# Transaction parameter precedence fix # Precedência transacional + extração LLM de parâmetros
Correção para a regressão em que uma resposta curta que preenchia um parâmetro pendente (ex.: `R$ 71,99` para `valor`) era classificada pelo LLM Router como uma nova intent e interrompia a transação. Esta correção remove a extração textual hardcoded de parâmetros transacionais e faz a coleta de `policy.requires` por um extrator LLM genérico.
## Regra aplicada ## Regra de precedência
Durante `COLLECTING_PARAMETERS` a ordem passa a ser: Enquanto existir uma transação ativa, o framework trata o turno nesta ordem:
1. resposta compatível com parâmetro pendente -> mantém a política de estado e continua a transação;
2. cancelamento explícito -> cancela a transação;
3. nova intenção clara/pergunta explícita -> interrompe a transação e volta ao roteamento normal;
4. caso ambíguo -> permanece em clarificação.
Exemplo corrigido:
```text ```text
não fiz essa contratação TIM CTRL Redes Sociais 8.0 ACTIVE_TRANSACTION
-> informe valor |
R$ 71,99 +-- COLLECTING_PARAMETERS
-> valor=71.99; continua contestar_cobranca; executa pré-validação | |
| +-- LLM tenta extrair SOMENTE os parâmetros ainda pendentes
| |
| +-- extraiu >= 1 ?
| |
| +-- SIM -> continua a transação; NÃO avalia intent_shift
| |
| +-- NÃO -> libera EnterpriseRouter para avaliar intent_shift
|
+-- AWAITING_CONFIRMATION
|
+-- reconhece confirmação/rejeição explícita
|
+-- reconheceu ?
|
+-- SIM -> continua/cancela a transação; NÃO avalia intent_shift
|
+-- NÃO -> libera EnterpriseRouter para avaliar intent_shift
``` ```
A mensagem `R$ 71,99` não pode mais virar `contas_invoice_explanation` enquanto `valor` estiver pendente. ## TransactionParameterExtractor
## Testes Novo componente:
Foram adicionados testes para: `libs/agent_framework/src/agent_framework/runtime/transaction_parameters.py`
- valor monetário com LLM sugerindo outra intent; A extração textual dos parâmetros de negócio é feita exclusivamente por LLM. O componente recebe:
- entidade curta como resposta de parâmetro;
- pergunta clara durante coleta ainda interrompendo a transação; - nome da tool/transação ativa;
- regressões existentes de intent-shift e transactional tool flow. - parâmetros atualmente pendentes;
- argumentos já conhecidos;
- schema/tipos declarados em `tools.yaml` quando disponíveis;
- descrição da tool;
- mensagem atual do usuário.
Ele não conhece nomes de domínio como `order_id`, `reason`, `subject`, `valor`, TIM ou retail. Não há regex de entidades de negócio.
A LLM pode interpretar, por exemplo:
- `PED-1001` quando só há um parâmetro compatível pendente;
- `o pedido é PED-1001`;
- `PED-1001, desisti da compra` preenchendo dois parâmetros no mesmo turno;
- respostas com o nome do parâmetro seguido do valor;
- respostas apenas com o valor, quando semanticamente inequívocas.
Em caso de dúvida, o prompt manda retornar `null`. Uma nova solicitação não deve ser transformada em valor de parâmetro.
## Separação de responsabilidades
`tool_policies.yaml` continua sendo a fonte de verdade para `requires`.
`tools.yaml` pode fornecer tipos via `args_schema` e descrição da tool para melhorar a interpretação sem introduzir código específico de domínio.
`mcp_parameter_mapping.yaml` continua responsável pelos parâmetros auxiliares/contrato MCP. As strategies do mapper são explicitamente excluídas dos campos presentes em `policy.requires`, para não misturar extração MCP com coleta transacional.
O `EnterpriseRouter` usa o mesmo extrator LLM apenas como *probe* de precedência. Se pelo menos um parâmetro pendente for encontrado, o turno permanece no estado transacional. Os valores extraídos são colocados no metadata da decisão e reutilizados pelo runtime, evitando uma segunda chamada LLM no mesmo turno.
## Profile LLM
Foi adicionado aos templates:
```yaml
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8
```
Generation/component:
- `llm.transaction_parameter_extraction`
- `transaction_parameter_extraction`
## Limpeza de estado
Em `intent_shift`, `transaction_pre_validation` da transação abandonada é removido para não contaminar a nova transação. O resultado de pre-validation continua preservado enquanto pertence à própria transação para auditoria.
## Testes adicionados
`tests/test_transaction_parameter_llm_precedence.py`
Cobertura:
1. dois parâmetros extraídos no mesmo turno;
2. um parâmetro preenchido ganha precedência sobre keyword que indicaria outra intent;
3. nenhum parâmetro encontrado libera `intent_shift`;
4. ausência do antigo `_extract_action_arguments()` hardcoded;
5. confirmação `sim` ganha precedência sobre intent shift.

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -72,3 +72,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -9,6 +9,7 @@ from typing import Any
from .config_loader import load_intents, load_router_defaults, load_state_policies from .config_loader import load_intents, load_router_defaults, load_state_policies
from .continuity import SemanticRouteContinuity from .continuity import SemanticRouteContinuity
from .models import IntentDefinition, RouteDecision, RouterStatePolicy from .models import IntentDefinition, RouteDecision, RouterStatePolicy
from agent_framework.runtime.transaction_parameters import extract_transaction_parameters, parse_transaction_confirmation
logger = logging.getLogger("agent_framework.routing") logger = logging.getLogger("agent_framework.routing")
@@ -78,6 +79,12 @@ class EnterpriseRouter:
# pendente antes de executar a nova intent. # pendente antes de executar a nova intent.
state_decision = self._route_by_state(current_state) state_decision = self._route_by_state(current_state)
if state_decision: if state_decision:
consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=state_decision
)
if consumed is not None:
await self._emit(consumed, state)
return consumed
interruption = await self._transaction_state_interruption_candidate( interruption = await self._transaction_state_interruption_candidate(
state, text=str(text), state_decision=state_decision state, text=str(text), state_decision=state_decision
) )
@@ -109,6 +116,17 @@ class EnterpriseRouter:
method="state", method="state",
next_state=tx_status, next_state=tx_status,
) )
consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=synthetic
)
if consumed is not None:
consumed.metadata = {
**(consumed.metadata or {}),
"transaction_state_recovered": True,
}
await self._emit(consumed, state)
return consumed
interruption = await self._transaction_state_interruption_candidate( interruption = await self._transaction_state_interruption_candidate(
state, text=str(text), state_decision=synthetic state, text=str(text), state_decision=synthetic
) )
@@ -186,6 +204,63 @@ class EnterpriseRouter:
return decision return decision
async def _transaction_parameter_precedence(
self,
state: dict[str, Any],
*,
text: str,
state_decision: RouteDecision,
) -> RouteDecision | None:
"""Consume a turn as transaction parameters before evaluating intent shift.
Only COLLECTING_PARAMETERS participates. The LLM extracts values for the
currently missing parameters; if at least one value is found, the state
route wins deterministically and intent-shift classification is skipped.
"""
tx_status = str(state.get("transaction_status") or "").strip().upper()
if tx_status == "AWAITING_CONFIRMATION":
confirmation = parse_transaction_confirmation(text)
if confirmation is None:
return None
state_decision.metadata = {
**(state_decision.metadata or {}),
"transaction_turn_consumed": True,
"transaction_confirmation_decision": confirmation,
"transaction_confirmation_source": "deterministic",
}
return state_decision
if tx_status != "COLLECTING_PARAMETERS":
return None
missing = [str(name) for name in (state.get("missing_parameters") or []) if str(name).strip()]
if not missing:
return None
active = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {}
tool_name = str(active.get("tool_name") or ((state.get("selected_tool_call") or {}).get("tool_name") if isinstance(state.get("selected_tool_call"), dict) else "") or "").strip()
if not tool_name:
return None
known = dict(active.get("arguments") or {})
schema = active.get("parameter_schema") if isinstance(active.get("parameter_schema"), dict) else {}
description = str(active.get("tool_description") or "")
values = await extract_transaction_parameters(
self.llm,
text=text,
tool_name=tool_name,
missing_parameters=missing,
known_arguments=known,
parameter_schema=schema,
tool_description=description,
)
if not values:
return None
state_decision.metadata = {
**(state_decision.metadata or {}),
"transaction_turn_consumed": True,
"transaction_parameter_values": values,
"transaction_parameter_source": "llm",
"transaction_parameter_missing_before": missing,
}
return state_decision
async def _transaction_state_interruption_candidate( async def _transaction_state_interruption_candidate(
self, self,
state: dict[str, Any], state: dict[str, Any],
@@ -213,52 +288,15 @@ class EnterpriseRouter:
or (previous_intent and not previous_intent.startswith("state:") and candidate.intent != previous_intent) or (previous_intent and not previous_intent.startswith("state:") and candidate.intent != previous_intent)
) )
if different: if different:
tx_status = str(state.get("transaction_status") or "").strip().upper() candidate.metadata = {
missing = list(state.get("missing_parameters") or []) **(candidate.metadata or {}),
same_agent = candidate.agent == state_decision.agent "transaction_interruption": "intent_shift",
matched_keyword = str((candidate.metadata or {}).get("matched_keyword") or "").strip() "interrupted_state": state_decision.next_state,
informative_tokens = [ "interrupted_agent": state_decision.agent,
token "interrupted_intent": started_intent or previous_intent,
for token in self._keyword_tokens(matched_keyword) "interruption_source": "configured_routing",
if len(token) > 1 }
] return candidate
# Durante coleta de parâmetros, uma keyword genérica de uma única
# palavra do MESMO agente não pode preemptar a transação. Ex.:
# ``o pedido é o PED-1001`` enquanto ``order_id`` está pendente.
# Nesse caso ``pedido`` pode casar com ``retail_order_tracking``,
# mas a mensagem é perfeitamente compatível com a resposta ao
# parâmetro solicitado. Keywords mais específicas (duas ou mais
# palavras informativas) continuam aptas a representar mudança
# explícita de intenção. Se o roteador LLM estiver habilitado,
# deixamos a decisão semântica abaixo desempatar o caso fraco.
weak_same_agent_keyword_during_collection = (
tx_status == "COLLECTING_PARAMETERS"
and bool(missing)
and same_agent
and len(informative_tokens) <= 1
)
if not weak_same_agent_keyword_during_collection:
candidate.metadata = {
**(candidate.metadata or {}),
"transaction_interruption": "intent_shift",
"interrupted_state": state_decision.next_state,
"interrupted_agent": state_decision.agent,
"interrupted_intent": started_intent or previous_intent,
"interruption_source": "configured_routing",
}
return candidate
logger.debug(
"Keyword transacional fraca não preemptou coleta de parâmetro: "
"keyword=%r intent=%s missing=%s",
matched_keyword,
candidate.intent,
missing,
)
# Não retorne aqui: se houver LLM router, ele pode confirmar uma
# mudança semântica real; sem LLM, a transação permanece ativa.
else: else:
return None return None

View File

@@ -10,6 +10,7 @@ from typing import Any, Iterable, Mapping
from agent_framework.memory.summary_memory import MemoryContext, render_recent_messages from agent_framework.memory.summary_memory import MemoryContext, render_recent_messages
from agent_framework.runtime.transaction_parameters import extract_transaction_parameters, parse_transaction_confirmation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -571,6 +572,7 @@ class AgentRuntimeMixin:
state: dict[str, Any], state: dict[str, Any],
*, *,
overwrite_from_message: bool = False, overwrite_from_message: bool = False,
exclude_fields: Iterable[str] = (),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Executa regras ``extract`` declaradas para a tool escolhida. """Executa regras ``extract`` declaradas para a tool escolhida.
@@ -586,11 +588,14 @@ class AgentRuntimeMixin:
return dict(arguments or {}) return dict(arguments or {})
resolved = dict(arguments or {}) resolved = dict(arguments or {})
excluded = {str(name) for name in (exclude_fields or ())}
runtime = self.get_runtime_context(state) runtime = self.get_runtime_context(state)
message = runtime.sanitized_input or runtime.original_text or runtime.user_text message = runtime.sanitized_input or runtime.original_text or runtime.user_text
llm = getattr(self, "llm", None) llm = getattr(self, "llm", None)
for field_name, rule in rules.items(): for field_name, rule in rules.items():
if str(field_name) in excluded:
continue
from_message = str(rule.get("from") or "message").lower() == "message" from_message = str(rule.get("from") or "message").lower() == "message"
if not from_message: if not from_message:
continue continue
@@ -1233,46 +1238,59 @@ class AgentRuntimeMixin:
@staticmethod @staticmethod
def _confirmation_decision(text: str) -> str | None: def _confirmation_decision(text: str) -> str | None:
normalized = " ".join((text or "").strip().lower().split()) return parse_transaction_confirmation(text)
normalized = re.sub(r"[.!?]+$", "", normalized).strip()
if normalized in {"sim", "confirmo", "sim, confirmo", "pode fazer", "pode prosseguir", "sim, desejo", "sim, desejo trocar", "sim, confirmo a devolução", "sim, confirmo a troca"}:
return "confirm"
if normalized in {"não", "nao", "cancelar", "cancele", "não confirmo", "nao confirmo"}:
return "reject"
return None
@staticmethod def _transaction_parameter_schema(self, tool_name: str, policy: dict[str, Any] | None = None) -> dict[str, Any]:
def _extract_action_arguments(text: str) -> dict[str, Any]: """Return generic schema metadata for transactional required parameters."""
"""Extrai apenas entidades explicitamente informadas na mensagem. cfg = self._tool_config(tool_name)
raw_schema = dict(getattr(cfg, "args_schema", {}) or {}) if cfg is not None else {}
required = [str(name) for name in ((policy or {}).get("requires") or getattr(cfg, "requires", []) or [])]
if not required:
return raw_schema
return {name: raw_schema.get(name, "string") for name in required}
Não usa a mensagem inteira como ``reason``: frases como "quero devolver def _transaction_tool_description(self, tool_name: str) -> str:
uma compra" expressam a ação, mas não necessariamente o motivo. Defaults cfg = self._tool_config(tool_name)
declarados no mapper continuam sendo aplicados por ``build_tool_arguments``. return str(getattr(cfg, "description", "") or "") if cfg is not None else ""
async def _extract_transaction_parameters(
self,
state: dict[str, Any],
*,
tool_name: str,
missing_parameters: list[str],
known_arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Use the dedicated LLM extractor for pending transaction parameters.
A route decision may already contain the extraction performed by the
router solely to enforce parameter-before-intent-shift precedence. Reuse
it to avoid a second LLM call in the same turn.
""" """
raw = text or "" route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {}
args: dict[str, Any] = {} cached = route_meta.get("transaction_parameter_values")
match = re.search( if isinstance(cached, dict):
r"(?:pedido|ordem)\s*(?:n[ºo°.]?\s*)?(?:é\s*(?:o\s*)?|[:#=-]\s*)?([A-Za-z0-9_-]+)", allowed = set(str(x) for x in missing_parameters)
raw, reused = {str(k): v for k, v in cached.items() if str(k) in allowed and v not in _EMPTY_VALUES}
flags=re.IGNORECASE, if reused:
) return reused
if match:
args["order_id"] = match.group(1)
reason_match = re.search( active = self._active_transaction(state) or {}
r"(?:porque|pois|motivo\s*[:=-]?|por\s+(?:arrependimento|defeito|erro|atraso)|me\s+arrependi(?:\s+da\s+compra)?|arrependimento)\s*(.*)", schema = active.get("parameter_schema") if isinstance(active.get("parameter_schema"), dict) else None
raw, if not schema:
flags=re.IGNORECASE, policy = self._resolve_tool_execution_policy(tool_name, known_arguments or {})
schema = self._transaction_parameter_schema(tool_name, policy)
description = str(active.get("tool_description") or self._transaction_tool_description(tool_name) or "")
text = state.get("sanitized_input") or state.get("user_text") or ""
return await extract_transaction_parameters(
getattr(self, "llm", None),
text=str(text),
tool_name=tool_name,
missing_parameters=list(missing_parameters or []),
known_arguments=known_arguments or {},
parameter_schema=schema,
tool_description=description,
) )
if reason_match:
reason = reason_match.group(1).strip(" .,:;-")
if not reason:
matched_phrase = reason_match.group(0).strip(" .,:;-")
if re.search(r"me\s+arrependi|arrependimento", matched_phrase, flags=re.IGNORECASE):
reason = "Arrependimento da compra"
if reason:
args["reason"] = reason
return args
def _transactional_action_match(self, text: str, tools: list[str] | None = None) -> str | None: def _transactional_action_match(self, text: str, tools: list[str] | None = None) -> str | None:
"""Detecta solicitação transacional usando metadados de tools.yaml. """Detecta solicitação transacional usando metadados de tools.yaml.
@@ -1515,12 +1533,19 @@ class AgentRuntimeMixin:
) -> dict[str, Any]: ) -> dict[str, Any]:
current = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {} current = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {}
txid = transaction_id or current.get("transaction_id") or str(uuid.uuid4()) txid = transaction_id or current.get("transaction_id") or str(uuid.uuid4())
if str(current.get("tool_name") or "") != str(tool_name):
state["transaction_pre_validation"] = None
cfg = self._tool_config(tool_name)
policy = self._resolve_tool_execution_policy(tool_name, arguments or {})
tx = { tx = {
"transaction_id": txid, "transaction_id": txid,
"tool_name": tool_name, "tool_name": tool_name,
"arguments": dict(arguments or {}), "arguments": dict(arguments or {}),
"status": status, "status": status,
"started_from_intent": current.get("started_from_intent") or state.get("intent"), "started_from_intent": current.get("started_from_intent") or state.get("intent"),
"requires": list(policy.get("requires") or getattr(cfg, "requires", []) or []),
"parameter_schema": self._transaction_parameter_schema(tool_name, policy),
"tool_description": self._transaction_tool_description(tool_name),
} }
state["active_transaction"] = tx state["active_transaction"] = tx
return tx return tx
@@ -2057,6 +2082,7 @@ class AgentRuntimeMixin:
if active_before_interruption and interruption == "intent_shift": if active_before_interruption and interruption == "intent_shift":
interrupted_tool = active_before_interruption.get("tool_name") interrupted_tool = active_before_interruption.get("tool_name")
self._finish_active_transaction(state, "CANCELLED") self._finish_active_transaction(state, "CANCELLED")
state["transaction_pre_validation"] = None
state["tool_policy_result"] = { state["tool_policy_result"] = {
"action": "cancelled_by_intent_shift", "action": "cancelled_by_intent_shift",
"tool_name": interrupted_tool, "tool_name": interrupted_tool,
@@ -2080,28 +2106,40 @@ class AgentRuntimeMixin:
tool_name = selected.get("tool_name") tool_name = selected.get("tool_name")
if tool_name: if tool_name:
previous_args = dict(selected.get("arguments") or {}) previous_args = dict(selected.get("arguments") or {})
new_args = self.build_tool_arguments( policy = self._resolve_tool_execution_policy(tool_name, previous_args)
missing_before = self._missing_required_arguments(policy, previous_args)
# Parâmetros TRANSACIONAIS são interpretados exclusivamente pelo
# extrator LLM genérico. Não existem regexes/nome de entidade
# hardcoded no framework. O extrator recebe apenas os parâmetros
# ainda pendentes da policy e pode consumir um ou vários no turno.
extracted = await self._extract_transaction_parameters(
state, state,
tool_name=tool_name, tool_name=tool_name,
intent=state.get("intent"), missing_parameters=missing_before,
aliases=aliases, known_arguments=previous_args,
extra_args=self._extract_action_arguments(text),
) )
# Durante coleta incremental, valores de contexto podem ainda conter arguments = {**previous_args, **extracted}
# parâmetros de uma operação anterior. O que já foi coletado para a
# transação pendente prevalece; o turno atual só preenche lacunas.
non_empty_new = {k: v for k, v in new_args.items() if v not in (None, "", [], {})}
arguments = {**non_empty_new, **previous_args}
# Campos de envelope pertencem ao turno corrente e devem permanecer # Argumentos estruturados já presentes no contexto são aceitos de
# atualizados, mesmo quando os parâmetros de negócio ficam congelados. # forma genérica (não são parsing textual). Para required fields,
for per_turn_key in ("query", "operator_instructions", "interaction_key"): # só completam lacunas que a fala atual/LLM não preencheu; valores
if non_empty_new.get(per_turn_key) not in (None, "", [], {}): # previamente coletados nunca são sobrescritos.
arguments[per_turn_key] = non_empty_new[per_turn_key] contextual = self.build_tool_arguments(
state, tool_name=tool_name, intent=state.get("intent"), aliases=aliases
# Reutiliza o contrato declarativo para preencher somente os campos )
# ainda faltantes; campos previamente coletados não são sobrescritos. required_set = set(str(name) for name in (policy.get("requires") or []))
arguments = await self._extract_mcp_parameters(tool_name, arguments, state) for key, value in contextual.items():
if value in _EMPTY_VALUES:
continue
if key in required_set:
if arguments.get(key) in _EMPTY_VALUES:
arguments[key] = value
else:
arguments[key] = value
arguments = await self._extract_mcp_parameters(
tool_name, arguments, state, exclude_fields=policy.get("requires") or []
)
policy = self._resolve_tool_execution_policy(tool_name, arguments) policy = self._resolve_tool_execution_policy(tool_name, arguments)
missing = self._missing_required_arguments(policy, arguments) missing = self._missing_required_arguments(policy, arguments)
if missing: if missing:
@@ -2250,23 +2288,46 @@ class AgentRuntimeMixin:
if not selected_action: if not selected_action:
return results return results
explicit_action_args = self._extract_action_arguments(text)
action_args = self.build_tool_arguments( action_args = self.build_tool_arguments(
state, state,
tool_name=selected_action, tool_name=selected_action,
intent=state.get("intent"), intent=state.get("intent"),
aliases=aliases, aliases=aliases,
extra_args=explicit_action_args,
) )
# Nova transação: parâmetros declarados ``from: message`` não podem ser # Campos que o contrato MCP declara como vindos da mensagem corrente não
# herdados de context.tool_arguments de uma operação anterior. # podem herdar valores textuais de uma transação anterior. Isto é apenas
# uma regra de freshness do envelope MCP; a extração de policy.requires
# continua exclusivamente no TransactionParameterExtractor LLM abaixo.
action_args = self._drop_stale_message_extracted_arguments( action_args = self._drop_stale_message_extracted_arguments(
selected_action, action_args, explicit_fields=explicit_action_args.keys() selected_action, action_args, explicit_fields=()
) )
# A mensagem atual é a fonte de verdade para esses campos no primeiro policy = self._resolve_tool_execution_policy(selected_action, action_args)
# turno transacional. required = [str(name) for name in (policy.get("requires") or [])]
# Valores já estruturados no contexto podem satisfazer requirements sem
# parsing textual. Para qualquer required field ainda ausente, a fala do
# usuário é interpretada exclusivamente pelo extrator LLM transacional.
missing_initial = self._missing_required_arguments(policy, action_args)
# No primeiro turno, a fala atual pode fornecer/corrigir qualquer required
# field, inclusive um valor que exista no contexto estruturado mas pertença
# a uma transação anterior. O extrator continua restrito ao contrato
# ``requires`` e só sobrescreve quando a LLM realmente extrai um valor.
extracted_initial = await self._extract_transaction_parameters(
state,
tool_name=selected_action,
missing_parameters=required,
known_arguments={k: v for k, v in action_args.items() if k not in set(required)},
)
action_args.update(extracted_initial)
# O mapper MCP continua responsável somente por parâmetros auxiliares que
# não pertencem ao contrato transacional.
action_args = await self._extract_mcp_parameters( action_args = await self._extract_mcp_parameters(
selected_action, action_args, state, overwrite_from_message=True selected_action,
action_args,
state,
overwrite_from_message=True,
exclude_fields=required,
) )
policy = self._resolve_tool_execution_policy(selected_action, action_args) policy = self._resolve_tool_execution_policy(selected_action, action_args)
selected = {"tool_name": selected_action, "arguments": action_args} selected = {"tool_name": selected_action, "arguments": action_args}

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import re
from typing import Any
def confirmation_decision(text: str) -> str | None:
"""Classifica respostas explícitas ao estado AWAITING_CONFIRMATION.
Esta função é compartilhada pelo router (precedência antes de intent_shift)
e pelo runtime (execução/cancelamento efetivo), garantindo que ambos
reconheçam exatamente o mesmo conjunto de respostas.
"""
normalized = " ".join((text or "").strip().lower().split())
normalized = re.sub(r"[.!?]+$", "", normalized).strip()
if normalized in {
"sim",
"confirmo",
"sim, confirmo",
"pode fazer",
"pode prosseguir",
"sim, desejo",
"sim, desejo trocar",
"sim, confirmo a devolução",
"sim, confirmo a troca",
}:
return "confirm"
if normalized in {"não", "nao", "cancelar", "cancele", "não confirmo", "nao confirmo"}:
return "reject"
return None
def extract_action_arguments(text: str) -> dict[str, Any]:
"""Extrai entidades explicitamente informadas em ações transacionais.
É usada tanto pelo runtime quanto pelo probe de precedência do router. Não
transforma a mensagem inteira em motivo: só captura valores explicitamente
identificáveis no turno atual.
"""
raw = text or ""
args: dict[str, Any] = {}
match = re.search(
r"(?:pedido|ordem)\s*(?:n[ºo°.]?\s*)?(?:é\s*(?:o\s*)?|[:#=-]\s*)?([A-Za-z0-9_-]+)",
raw,
flags=re.IGNORECASE,
)
if match:
args["order_id"] = match.group(1)
reason_match = re.search(
r"(?:porque|pois|motivo\s*[:=-]?|por\s+(?:arrependimento|defeito|erro|atraso)|me\s+arrependi(?:\s+da\s+compra)?|arrependimento)\s*(.*)",
raw,
flags=re.IGNORECASE,
)
if reason_match:
reason = reason_match.group(1).strip(" .,:;-")
if not reason:
matched_phrase = reason_match.group(0).strip(" .,:;-")
if re.search(r"me\s+arrependi|arrependimento", matched_phrase, flags=re.IGNORECASE):
reason = "Arrependimento da compra"
if reason:
args["reason"] = reason
return args

View File

@@ -0,0 +1,181 @@
from __future__ import annotations
import json
import logging
import re
from typing import Any, Mapping
logger = logging.getLogger(__name__)
_EMPTY_VALUES = (None, "", {}, [])
def _response_text(response: Any) -> str:
if response is None:
return ""
if isinstance(response, str):
return response
if isinstance(response, dict):
return str(response.get("content") or response.get("text") or response.get("answer") or "")
return str(getattr(response, "content", None) or getattr(response, "text", None) or response)
def _coerce(value: Any, declared_type: Any) -> Any:
if value in _EMPTY_VALUES:
return None
type_name = str(declared_type or "string").strip().lower()
try:
if type_name in {"integer", "int"}:
return int(value)
if type_name in {"number", "float", "double"}:
return float(value)
if type_name in {"boolean", "bool"}:
if isinstance(value, bool):
return value
normalized = str(value).strip().lower()
if normalized in {"true", "1", "yes", "sim"}:
return True
if normalized in {"false", "0", "no", "não", "nao"}:
return False
return None
if type_name in {"array", "list"}:
return value if isinstance(value, list) else [value]
if type_name in {"object", "dict", "map"}:
return value if isinstance(value, dict) else None
return str(value).strip()
except (TypeError, ValueError):
return None
def parse_transaction_confirmation(text: str) -> str | None:
"""Recognize an explicit confirmation/rejection before intent-shift routing.
This is intentionally small and domain-neutral. Parameter interpretation is
LLM-only; confirmation remains a deterministic control token so an explicit
yes/no cannot be reclassified as a new intent.
"""
normalized = " ".join(str(text or "").strip().lower().split())
normalized = re.sub(r"[.!?]+$", "", normalized).strip()
if normalized in {
"sim", "confirmo", "sim, confirmo", "pode fazer", "pode prosseguir",
"sim, desejo", "sim, desejo trocar", "sim, confirmo a devolução",
"sim, confirmo a troca",
}:
return "confirm"
if normalized in {"não", "nao", "cancelar", "cancele", "não confirmo", "nao confirmo"}:
return "reject"
return None
async def extract_transaction_parameters(
llm: Any,
*,
text: str,
tool_name: str,
missing_parameters: list[str],
known_arguments: Mapping[str, Any] | None = None,
parameter_schema: Mapping[str, Any] | None = None,
tool_description: str | None = None,
) -> dict[str, Any]:
"""Extract values for pending transactional parameters using the LLM only.
This component intentionally contains no domain/entity regexes and no
knowledge of parameter names such as ``order_id`` or ``reason``. The
transaction runtime supplies the pending parameter names and optional schema;
the LLM only interprets the current user turn. State/control-flow decisions
remain deterministic outside this function.
"""
pending = [str(name) for name in (missing_parameters or []) if str(name).strip()]
message = str(text or "").strip()
if not pending or not message or llm is None:
return {}
schema = dict(parameter_schema or {})
known = {
str(key): value
for key, value in dict(known_arguments or {}).items()
if value not in _EMPTY_VALUES and str(key) not in pending
}
field_spec = {
name: {
"type": schema.get(name, "string") if not isinstance(schema.get(name), dict) else schema.get(name, {}).get("type", "string"),
"description": None if not isinstance(schema.get(name), dict) else schema.get(name, {}).get("description"),
}
for name in pending
}
output_shape = {name: None for name in pending}
prompt = (
"Você extrai parâmetros PENDENTES de uma transação ativa. "
"Sua única tarefa é interpretar a mensagem atual e devolver valores para os parâmetros pendentes. "
"Não decida roteamento, intenção, confirmação ou execução da transação.\n\n"
"REGRAS OBRIGATÓRIAS:\n"
"1. Extraia SOMENTE parâmetros listados em pending_parameters.\n"
"2. Não invente valores e não transforme uma nova solicitação/intenção do usuário em valor de parâmetro.\n"
"3. Se nenhum parâmetro pendente foi realmente informado, devolva null para todos.\n"
"4. Se houver apenas um parâmetro pendente, uma resposta contendo apenas um valor pode ser associada a ele quando isso for semanticamente inequívoco.\n"
"5. Se houver vários parâmetros pendentes, extraia todos os que estiverem presentes no mesmo turno.\n"
"6. O nome do parâmetro não precisa aparecer literalmente na fala; use a semântica, o nome da transação e o schema para associar valores.\n"
"7. Em caso de dúvida, prefira null.\n"
"8. Responda SOMENTE JSON válido, sem markdown, sem explicação e sem chaves extras.\n\n"
f"transaction_tool: {tool_name}\n"
f"transaction_description: {tool_description or ''}\n"
f"pending_parameters: {json.dumps(pending, ensure_ascii=False)}\n"
f"parameter_schema: {json.dumps(field_spec, ensure_ascii=False, default=str)}\n"
f"known_arguments: {json.dumps(known, ensure_ascii=False, default=str)}\n"
f"user_message: {message}\n"
f"Formato obrigatório: {json.dumps(output_shape, ensure_ascii=False)}"
)
try:
response = await llm.ainvoke(
[{"role": "user", "content": prompt}],
profile_name="transaction_parameter_extraction",
component_name="transaction_parameter_extraction",
generation_name="llm.transaction_parameter_extraction",
temperature=0.0,
max_tokens=max(120, min(500, 80 + 60 * len(pending))),
)
except TypeError:
# Compatibilidade com doubles/testes e providers mínimos que aceitam
# apenas messages.
response = await llm.ainvoke([{"role": "user", "content": prompt}])
except Exception as exc:
logger.warning(
"transaction.parameter.llm_extract_failed tool=%s pending=%s error=%s",
tool_name,
pending,
exc,
)
return {}
raw = _response_text(response).strip()
if raw.startswith("```"):
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.IGNORECASE | re.DOTALL).strip()
try:
payload = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
logger.warning(
"transaction.parameter.llm_invalid_json tool=%s pending=%s raw=%r",
tool_name,
pending,
raw[:240],
)
return {}
if not isinstance(payload, dict):
return {}
extracted: dict[str, Any] = {}
for name in pending:
value = payload.get(name)
declared = field_spec.get(name, {}).get("type", "string")
coerced = _coerce(value, declared)
if coerced not in _EMPTY_VALUES:
extracted[name] = coerced
logger.info(
"transaction.parameter.llm_extracted tool=%s pending=%s consumed=%s",
tool_name,
pending,
sorted(extracted),
)
return extracted

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -0,0 +1,293 @@
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
from agent_framework.routing.enterprise_router import EnterpriseRouter
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
class _SemanticLLM:
"""Test double: parameter extraction + intent-shift classification."""
async def ainvoke(self, messages, **kwargs):
prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1])
profile = kwargs.get("profile_name")
if profile == "transaction_parameter_extraction" or "pending_parameters:" in prompt:
marker = "user_message: "
user = prompt.split(marker, 1)[1].split("\nFormato obrigatório:", 1)[0].strip() if marker in prompt else ""
pending_raw = prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0]
pending = json.loads(pending_raw)
values = {name: None for name in pending}
low = user.lower()
if "ped-1001" in low and "order_id" in values:
values["order_id"] = "PED-1001"
if "desisti" in low and "reason" in values:
values["reason"] = "desisti da compra"
if low.strip() == "71,99" and "valor" in values:
values["valor"] = 71.99
if low.strip() == "tim music" and "subject" in values:
values["subject"] = "TIM Music"
return json.dumps(values, ensure_ascii=False)
# Router LLM fallback: treat fatura as a real intent shift.
if "fatura" in prompt.lower():
return json.dumps({
"decision": "SHIFT",
"intent": "billing_invoice_explanation",
"agent": "billing_agent",
"confidence": 0.98,
"reason": "nova intenção de fatura",
})
return json.dumps({
"decision": "CONTINUE",
"intent": None,
"agent": None,
"confidence": 0.95,
"reason": "continua transação",
})
class _Router:
def __init__(self):
self.registry = SimpleNamespace(
tools={},
get_tool=self.get_tool,
)
def get_tool(self, name):
data = {
"solicitar_devolucao": SimpleNamespace(
name="solicitar_devolucao",
description="Abre uma solicitação de devolução de pedido.",
selection_keywords=["devolver pedido", "devolução", "devolver"],
args_schema={"order_id": "string", "reason": "string"},
requires=["order_id", "reason"],
confirmation_required=True,
tool_type="action",
),
"cancelar_pedido": SimpleNamespace(
name="cancelar_pedido",
description="Cancela um pedido.",
selection_keywords=["cancelar pedido", "cancelar compra"],
args_schema={"order_id": "string"},
requires=["order_id"],
confirmation_required=True,
tool_type="action",
),
}
return data.get(name)
def resolve_execution_policy(self, tool_name, arguments=None):
cfg = self.get_tool(tool_name)
if not cfg:
return {"operation_type": "read_only", "require_confirmation": False, "requires": []}
return {
"operation_type": "transactional",
"require_confirmation": True,
"requires": list(cfg.requires),
"policy_source": "test",
}
def parameter_extract_rules(self, tool_name):
# Deliberately has MCP mappings for the same fields: transactional fields
# must be excluded from this mechanism by the runtime.
return {
"order_id": {"from": "message", "strategy": "regex", "pattern": r"pedido\\s+(\\w+)"},
"reason": {"from": "message", "strategy": "regex", "pattern": r"motivo\\s+(.+)"},
}
def validate_execution_policy(self, tool_name, arguments=None):
return True, None, self.resolve_execution_policy(tool_name, arguments)
class _Runtime(AgentRuntimeMixin):
def __init__(self):
self.tool_router = _Router()
self.llm = _SemanticLLM()
self.calls = []
async def _call_mcp_tool(self, tool_name, arguments, state):
self.calls.append((tool_name, dict(arguments)))
return {"ok": True, "tool_name": tool_name, "result": {"status": "OK"}}
@pytest.mark.asyncio
async def test_transaction_extractor_handles_multiple_parameters_without_hardcoded_regex():
runtime = _Runtime()
state = {
"user_text": "quero devolver pedido PED-1001 porque desisti da compra",
"sanitized_input": "quero devolver pedido PED-1001 porque desisti da compra",
"mcp_tools": ["solicitar_devolucao"],
"route": "support_agent",
"intent": "retail_support_exchange_return",
}
result = await runtime.execute_tools_for_intent(state)
assert result[-1]["awaiting_confirmation"] is True
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
args = state["pending_tool_call"]["arguments"]
assert args["order_id"] == "PED-1001"
assert args["reason"] == "desisti da compra"
@pytest.mark.asyncio
async def test_collecting_one_parameter_consumes_turn_before_intent_shift(tmp_path):
routing = tmp_path / "routing.yaml"
routing.write_text(
"""
router:
fallback_agent: support_agent
confidence_threshold: 0.70
state_policies:
- state: COLLECTING_SUPPORT_PARAMETERS
agent: support_agent
intents:
- name: retail_order_tracking
agent: orders_agent
priority: 20
keywords: [pedido]
- name: retail_support_exchange_return
agent: support_agent
priority: 30
keywords: [devolver pedido]
- name: billing_invoice_explanation
agent: billing_agent
priority: 40
keywords: [fatura]
""",
encoding="utf-8",
)
settings = SimpleNamespace(
ROUTING_CONFIG_PATH=str(routing),
ENABLE_LLM_ROUTER=True,
ENABLE_ROUTE_STICKINESS=False,
)
router = EnterpriseRouter(settings, llm=_SemanticLLM())
state = {
"user_text": "o numero do pedido é PED-1001",
"sanitized_input": "o numero do pedido é PED-1001",
"next_state": "COLLECTING_SUPPORT_PARAMETERS",
"transaction_status": "COLLECTING_PARAMETERS",
"missing_parameters": ["order_id", "reason"],
"active_agent": "support_agent",
"intent": "state:COLLECTING_SUPPORT_PARAMETERS",
"active_transaction": {
"tool_name": "solicitar_devolucao",
"arguments": {},
"status": "COLLECTING_PARAMETERS",
"started_from_intent": "retail_support_exchange_return",
"parameter_schema": {"order_id": "string", "reason": "string"},
"tool_description": "Abre uma solicitação de devolução de pedido.",
},
}
decision = await router.route(state)
assert decision.agent == "support_agent"
assert decision.intent == "state:COLLECTING_SUPPORT_PARAMETERS"
assert decision.metadata["transaction_turn_consumed"] is True
assert decision.metadata["transaction_parameter_values"] == {"order_id": "PED-1001"}
assert "transaction_interruption" not in decision.metadata
@pytest.mark.asyncio
async def test_no_parameter_found_allows_intent_shift(tmp_path):
routing = tmp_path / "routing.yaml"
routing.write_text(
"""
router:
fallback_agent: support_agent
confidence_threshold: 0.70
state_policies:
- state: COLLECTING_SUPPORT_PARAMETERS
agent: support_agent
intents:
- name: retail_support_exchange_return
agent: support_agent
priority: 20
keywords: [devolver pedido]
- name: billing_invoice_explanation
agent: billing_agent
priority: 40
keywords: [fatura]
""",
encoding="utf-8",
)
settings = SimpleNamespace(
ROUTING_CONFIG_PATH=str(routing),
ENABLE_LLM_ROUTER=True,
ENABLE_ROUTE_STICKINESS=False,
)
router = EnterpriseRouter(settings, llm=_SemanticLLM())
state = {
"user_text": "esquece isso, quero ver minha fatura",
"sanitized_input": "esquece isso, quero ver minha fatura",
"next_state": "COLLECTING_SUPPORT_PARAMETERS",
"transaction_status": "COLLECTING_PARAMETERS",
"missing_parameters": ["order_id", "reason"],
"active_agent": "support_agent",
"intent": "state:COLLECTING_SUPPORT_PARAMETERS",
"active_transaction": {
"tool_name": "solicitar_devolucao",
"arguments": {},
"status": "COLLECTING_PARAMETERS",
"started_from_intent": "retail_support_exchange_return",
"parameter_schema": {"order_id": "string", "reason": "string"},
},
}
decision = await router.route(state)
assert decision.intent == "billing_invoice_explanation"
assert decision.agent == "billing_agent"
assert decision.metadata["transaction_interruption"] == "intent_shift"
def test_hardcoded_action_argument_extractor_removed():
from pathlib import Path
source = Path("libs/agent_framework/src/agent_framework/runtime/agent_runtime.py").read_text(encoding="utf-8")
assert "def _extract_action_arguments" not in source
assert "pedido|ordem" not in source
assert "reason_match" not in source
@pytest.mark.asyncio
async def test_confirmation_is_consumed_before_intent_shift(tmp_path):
routing = tmp_path / "routing.yaml"
routing.write_text(
"""
router:
fallback_agent: support_agent
confidence_threshold: 0.70
state_policies:
- state: WAITING_SUPPORT_CONFIRMATION
agent: support_agent
intents:
- name: generic_yes_intent
agent: other_agent
priority: 50
keywords: [sim]
""",
encoding="utf-8",
)
settings = SimpleNamespace(
ROUTING_CONFIG_PATH=str(routing),
ENABLE_LLM_ROUTER=True,
ENABLE_ROUTE_STICKINESS=False,
)
router = EnterpriseRouter(settings, llm=_SemanticLLM())
state = {
"user_text": "sim",
"sanitized_input": "sim",
"next_state": "WAITING_SUPPORT_CONFIRMATION",
"transaction_status": "AWAITING_CONFIRMATION",
"active_agent": "support_agent",
"active_transaction": {
"tool_name": "solicitar_devolucao",
"arguments": {"order_id": "PED-1001", "reason": "desisti"},
"status": "AWAITING_CONFIRMATION",
"started_from_intent": "retail_support_exchange_return",
},
}
decision = await router.route(state)
assert decision.agent == "support_agent"
assert decision.metadata["transaction_turn_consumed"] is True
assert decision.metadata["transaction_confirmation_decision"] == "confirm"
assert "transaction_interruption" not in decision.metadata

View File

@@ -34,6 +34,20 @@ intents:
""" """
class _ParameterLLM:
async def ainvoke(self, messages, **kwargs):
import json
prompt = messages[-1]["content"]
if kwargs.get("profile_name") == "transaction_parameter_extraction":
pending = json.loads(prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0])
user = prompt.split("user_message: ", 1)[1].split("\nFormato obrigatório:", 1)[0].strip()
out = {name: None for name in pending}
if len(pending) == 1 and user not in {"quero rastrear pedido", "quero ver minha fatura"}:
out[pending[0]] = user
return json.dumps(out, ensure_ascii=False)
return '{}'
def _router(tmp_path, *, stickiness=True): def _router(tmp_path, *, stickiness=True):
routing = tmp_path / "routing.yaml" routing = tmp_path / "routing.yaml"
routing.write_text(ROUTING_YAML, encoding="utf-8") routing.write_text(ROUTING_YAML, encoding="utf-8")
@@ -42,7 +56,7 @@ def _router(tmp_path, *, stickiness=True):
ENABLE_LLM_ROUTER=False, ENABLE_LLM_ROUTER=False,
ENABLE_ROUTE_STICKINESS=stickiness, ENABLE_ROUTE_STICKINESS=stickiness,
) )
return EnterpriseRouter(settings) return EnterpriseRouter(settings, llm=_ParameterLLM())
def _active_tx(status="COLLECTING_PARAMETERS", arguments=None): def _active_tx(status="COLLECTING_PARAMETERS", arguments=None):

View File

@@ -30,20 +30,45 @@ import pytest
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
class _TransactionTestLLM:
async def ainvoke(self, messages, **kwargs):
import json
prompt = messages[-1]["content"]
if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt:
pending = json.loads(prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0])
user = prompt.split("user_message: ", 1)[1].split("\nFormato obrigatório:", 1)[0].strip()
out = {name: None for name in pending}
low = user.lower()
if "order_id" in out:
import re
m = re.search(r"\b(?:ped[- ]?)?(\d+)\b", low, re.I)
if m:
out["order_id"] = ("PED-" + m.group(1)) if "ped" in m.group(0).lower() else m.group(1)
if "reason" in out and ("arrepend" in low or "desisti" in low):
out["reason"] = "Arrependimento da compra" if "arrepend" in low else "desisti da compra"
return {"content": json.dumps(out, ensure_ascii=False)}
return {"content": "{}"}
class _PolicyRouter: class _PolicyRouter:
def __init__(self): def __init__(self):
from types import SimpleNamespace from types import SimpleNamespace
self.registry = SimpleNamespace( self.registry = SimpleNamespace(
tools={"consultar_pedido": object(), "solicitar_devolucao": object()}, tools={"consultar_pedido": object(), "solicitar_devolucao": object()},
get_tool=lambda name: { get_tool=lambda name: {
"consultar_pedido": SimpleNamespace(selection_keywords=["consultar pedido", "pedido"]), "consultar_pedido": SimpleNamespace(selection_keywords=["consultar pedido", "pedido"], args_schema={}, requires=[]),
"solicitar_devolucao": SimpleNamespace(selection_keywords=["devolver pedido", "devolver", "devolução", "arrependimento"]), "solicitar_devolucao": SimpleNamespace(
selection_keywords=["devolver pedido", "devolver", "devolução", "arrependimento"],
args_schema={"order_id": "string", "reason": "string"},
requires=["order_id", "reason"],
description="Solicita devolução de pedido",
),
}.get(name), }.get(name),
) )
def resolve_execution_policy(self, tool_name, arguments=None): def resolve_execution_policy(self, tool_name, arguments=None):
if tool_name == "solicitar_devolucao": if tool_name == "solicitar_devolucao":
return {"operation_type": "transactional", "require_confirmation": True, "policy_source": "test"} return {"operation_type": "transactional", "require_confirmation": True, "requires": ["order_id", "reason"], "policy_source": "test"}
return {"operation_type": "read_only", "require_confirmation": False, "policy_source": "test"} return {"operation_type": "read_only", "require_confirmation": False, "policy_source": "test"}
def validate_execution_policy(self, tool_name, arguments=None): def validate_execution_policy(self, tool_name, arguments=None):
@@ -56,6 +81,7 @@ class _PolicyRouter:
class _Runtime(AgentRuntimeMixin): class _Runtime(AgentRuntimeMixin):
def __init__(self): def __init__(self):
self.tool_router = _PolicyRouter() self.tool_router = _PolicyRouter()
self.llm = _TransactionTestLLM()
self.calls = [] self.calls = []
async def _call_mcp_tool(self, tool_name, arguments, state): async def _call_mcp_tool(self, tool_name, arguments, state):
@@ -163,14 +189,20 @@ async def test_collecting_parameters_does_not_replace_collected_subject_with_sta
class _InitialContestLLM: class _InitialContestLLM:
async def ainvoke(self, messages, **kwargs): async def ainvoke(self, messages, **kwargs):
prompt = messages[0]["content"] import json
if "Campo: subject" in prompt: prompt = messages[-1]["content"]
return {"content": '{"subject": "TIM CTRL Redes Sociais 8.0"}'} if kwargs.get("profile_name") == "transaction_parameter_extraction":
if "Campo: valor" in prompt: pending = json.loads(prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0])
return {"content": '{"valor": null}'} out = {name: None for name in pending}
if "Campo: motivo" in prompt: if "subject" in out:
return {"content": '{"motivo": "não contratei"}'} out["subject"] = "TIM CTRL Redes Sociais 8.0"
return {"content": '{}'} return {"content": json.dumps(out, ensure_ascii=False)}
if kwargs.get("profile_name") == "mcp_parameter_extraction":
if "Campo: motivo" in prompt:
return {"content": '{"motivo": "não contratei"}'}
if "Campo: valor" in prompt:
return {"content": '{"valor": null}'}
return {"content": "{}"}
class _InitialContestRouter(_ContestPolicyRouter): class _InitialContestRouter(_ContestPolicyRouter):