mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-09-07 10:13:46 +00:00
adjustments: transaction parameter extraction
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,33 +288,6 @@ 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()
|
|
||||||
missing = list(state.get("missing_parameters") or [])
|
|
||||||
same_agent = candidate.agent == state_decision.agent
|
|
||||||
matched_keyword = str((candidate.metadata or {}).get("matched_keyword") or "").strip()
|
|
||||||
informative_tokens = [
|
|
||||||
token
|
|
||||||
for token in self._keyword_tokens(matched_keyword)
|
|
||||||
if len(token) > 1
|
|
||||||
]
|
|
||||||
|
|
||||||
# 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 = {
|
||||||
**(candidate.metadata or {}),
|
**(candidate.metadata or {}),
|
||||||
"transaction_interruption": "intent_shift",
|
"transaction_interruption": "intent_shift",
|
||||||
@@ -249,16 +297,6 @@ class EnterpriseRouter:
|
|||||||
"interruption_source": "configured_routing",
|
"interruption_source": "configured_routing",
|
||||||
}
|
}
|
||||||
return candidate
|
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
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
293
tests/test_transaction_parameter_llm_precedence.py
Normal file
293
tests/test_transaction_parameter_llm_precedence.py
Normal 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
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 "subject" in out:
|
||||||
|
out["subject"] = "TIM CTRL Redes Sociais 8.0"
|
||||||
|
return {"content": json.dumps(out, ensure_ascii=False)}
|
||||||
|
if kwargs.get("profile_name") == "mcp_parameter_extraction":
|
||||||
if "Campo: motivo" in prompt:
|
if "Campo: motivo" in prompt:
|
||||||
return {"content": '{"motivo": "não contratei"}'}
|
return {"content": '{"motivo": "não contratei"}'}
|
||||||
return {"content": '{}'}
|
if "Campo: valor" in prompt:
|
||||||
|
return {"content": '{"valor": null}'}
|
||||||
|
return {"content": "{}"}
|
||||||
|
|
||||||
|
|
||||||
class _InitialContestRouter(_ContestPolicyRouter):
|
class _InitialContestRouter(_ContestPolicyRouter):
|
||||||
|
|||||||
Reference in New Issue
Block a user