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:
|
||||
|
||||
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:
|
||||
Enquanto existir uma transação ativa, o framework trata o turno nesta ordem:
|
||||
|
||||
```text
|
||||
não fiz essa contratação TIM CTRL Redes Sociais 8.0
|
||||
-> informe valor
|
||||
R$ 71,99
|
||||
-> valor=71.99; continua contestar_cobranca; executa pré-validação
|
||||
ACTIVE_TRANSACTION
|
||||
|
|
||||
+-- COLLECTING_PARAMETERS
|
||||
| |
|
||||
| +-- 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;
|
||||
- entidade curta como resposta de parâmetro;
|
||||
- pergunta clara durante coleta ainda interrompendo a transação;
|
||||
- regressões existentes de intent-shift e transactional tool flow.
|
||||
A extração textual dos parâmetros de negócio é feita exclusivamente por LLM. O componente recebe:
|
||||
|
||||
- nome da tool/transação ativa;
|
||||
- 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
|
||||
max_tokens: 80
|
||||
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
|
||||
max_tokens: 80
|
||||
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
|
||||
max_tokens: 80
|
||||
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
|
||||
max_tokens: 80
|
||||
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
|
||||
max_tokens: 80
|
||||
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
|
||||
max_tokens: 80
|
||||
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
|
||||
max_tokens: 80
|
||||
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
|
||||
max_tokens: 80
|
||||
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 .continuity import SemanticRouteContinuity
|
||||
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")
|
||||
|
||||
@@ -78,6 +79,12 @@ class EnterpriseRouter:
|
||||
# pendente antes de executar a nova intent.
|
||||
state_decision = self._route_by_state(current_state)
|
||||
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(
|
||||
state, text=str(text), state_decision=state_decision
|
||||
)
|
||||
@@ -109,6 +116,17 @@ class EnterpriseRouter:
|
||||
method="state",
|
||||
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(
|
||||
state, text=str(text), state_decision=synthetic
|
||||
)
|
||||
@@ -186,6 +204,63 @@ class EnterpriseRouter:
|
||||
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(
|
||||
self,
|
||||
state: dict[str, Any],
|
||||
@@ -213,52 +288,15 @@ class EnterpriseRouter:
|
||||
or (previous_intent and not previous_intent.startswith("state:") and candidate.intent != previous_intent)
|
||||
)
|
||||
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 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.
|
||||
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
|
||||
else:
|
||||
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.runtime.transaction_parameters import extract_transaction_parameters, parse_transaction_confirmation
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -571,6 +572,7 @@ class AgentRuntimeMixin:
|
||||
state: dict[str, Any],
|
||||
*,
|
||||
overwrite_from_message: bool = False,
|
||||
exclude_fields: Iterable[str] = (),
|
||||
) -> dict[str, Any]:
|
||||
"""Executa regras ``extract`` declaradas para a tool escolhida.
|
||||
|
||||
@@ -586,11 +588,14 @@ class AgentRuntimeMixin:
|
||||
return dict(arguments or {})
|
||||
|
||||
resolved = dict(arguments or {})
|
||||
excluded = {str(name) for name in (exclude_fields or ())}
|
||||
runtime = self.get_runtime_context(state)
|
||||
message = runtime.sanitized_input or runtime.original_text or runtime.user_text
|
||||
llm = getattr(self, "llm", None)
|
||||
|
||||
for field_name, rule in rules.items():
|
||||
if str(field_name) in excluded:
|
||||
continue
|
||||
from_message = str(rule.get("from") or "message").lower() == "message"
|
||||
if not from_message:
|
||||
continue
|
||||
@@ -1233,46 +1238,59 @@ class AgentRuntimeMixin:
|
||||
|
||||
@staticmethod
|
||||
def _confirmation_decision(text: str) -> str | None:
|
||||
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
|
||||
return parse_transaction_confirmation(text)
|
||||
|
||||
@staticmethod
|
||||
def _extract_action_arguments(text: str) -> dict[str, Any]:
|
||||
"""Extrai apenas entidades explicitamente informadas na mensagem.
|
||||
def _transaction_parameter_schema(self, tool_name: str, policy: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Return generic schema metadata for transactional required parameters."""
|
||||
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
|
||||
uma compra" expressam a ação, mas não necessariamente o motivo. Defaults
|
||||
declarados no mapper continuam sendo aplicados por ``build_tool_arguments``.
|
||||
def _transaction_tool_description(self, tool_name: str) -> str:
|
||||
cfg = self._tool_config(tool_name)
|
||||
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 ""
|
||||
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)
|
||||
route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {}
|
||||
cached = route_meta.get("transaction_parameter_values")
|
||||
if isinstance(cached, dict):
|
||||
allowed = set(str(x) for x in missing_parameters)
|
||||
reused = {str(k): v for k, v in cached.items() if str(k) in allowed and v not in _EMPTY_VALUES}
|
||||
if reused:
|
||||
return reused
|
||||
|
||||
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,
|
||||
active = self._active_transaction(state) or {}
|
||||
schema = active.get("parameter_schema") if isinstance(active.get("parameter_schema"), dict) else None
|
||||
if not schema:
|
||||
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:
|
||||
"""Detecta solicitação transacional usando metadados de tools.yaml.
|
||||
@@ -1515,12 +1533,19 @@ class AgentRuntimeMixin:
|
||||
) -> dict[str, Any]:
|
||||
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())
|
||||
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 = {
|
||||
"transaction_id": txid,
|
||||
"tool_name": tool_name,
|
||||
"arguments": dict(arguments or {}),
|
||||
"status": status,
|
||||
"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
|
||||
return tx
|
||||
@@ -2057,6 +2082,7 @@ class AgentRuntimeMixin:
|
||||
if active_before_interruption and interruption == "intent_shift":
|
||||
interrupted_tool = active_before_interruption.get("tool_name")
|
||||
self._finish_active_transaction(state, "CANCELLED")
|
||||
state["transaction_pre_validation"] = None
|
||||
state["tool_policy_result"] = {
|
||||
"action": "cancelled_by_intent_shift",
|
||||
"tool_name": interrupted_tool,
|
||||
@@ -2080,28 +2106,40 @@ class AgentRuntimeMixin:
|
||||
tool_name = selected.get("tool_name")
|
||||
if tool_name:
|
||||
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,
|
||||
tool_name=tool_name,
|
||||
intent=state.get("intent"),
|
||||
aliases=aliases,
|
||||
extra_args=self._extract_action_arguments(text),
|
||||
missing_parameters=missing_before,
|
||||
known_arguments=previous_args,
|
||||
)
|
||||
# Durante coleta incremental, valores de contexto podem ainda conter
|
||||
# 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}
|
||||
arguments = {**previous_args, **extracted}
|
||||
|
||||
# Campos de envelope pertencem ao turno corrente e devem permanecer
|
||||
# atualizados, mesmo quando os parâmetros de negócio ficam congelados.
|
||||
for per_turn_key in ("query", "operator_instructions", "interaction_key"):
|
||||
if non_empty_new.get(per_turn_key) not in (None, "", [], {}):
|
||||
arguments[per_turn_key] = non_empty_new[per_turn_key]
|
||||
|
||||
# Reutiliza o contrato declarativo para preencher somente os campos
|
||||
# ainda faltantes; campos previamente coletados não são sobrescritos.
|
||||
arguments = await self._extract_mcp_parameters(tool_name, arguments, state)
|
||||
# Argumentos estruturados já presentes no contexto são aceitos de
|
||||
# forma genérica (não são parsing textual). Para required fields,
|
||||
# só completam lacunas que a fala atual/LLM não preencheu; valores
|
||||
# previamente coletados nunca são sobrescritos.
|
||||
contextual = self.build_tool_arguments(
|
||||
state, tool_name=tool_name, intent=state.get("intent"), aliases=aliases
|
||||
)
|
||||
required_set = set(str(name) for name in (policy.get("requires") or []))
|
||||
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)
|
||||
missing = self._missing_required_arguments(policy, arguments)
|
||||
if missing:
|
||||
@@ -2250,23 +2288,46 @@ class AgentRuntimeMixin:
|
||||
if not selected_action:
|
||||
return results
|
||||
|
||||
explicit_action_args = self._extract_action_arguments(text)
|
||||
action_args = self.build_tool_arguments(
|
||||
state,
|
||||
tool_name=selected_action,
|
||||
intent=state.get("intent"),
|
||||
aliases=aliases,
|
||||
extra_args=explicit_action_args,
|
||||
)
|
||||
# Nova transação: parâmetros declarados ``from: message`` não podem ser
|
||||
# herdados de context.tool_arguments de uma operação anterior.
|
||||
# Campos que o contrato MCP declara como vindos da mensagem corrente não
|
||||
# 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(
|
||||
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
|
||||
# turno transacional.
|
||||
policy = self._resolve_tool_execution_policy(selected_action, action_args)
|
||||
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(
|
||||
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)
|
||||
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
|
||||
max_tokens: 80
|
||||
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
|
||||
max_tokens: 80
|
||||
timeout_seconds: 5
|
||||
|
||||
transaction_parameter_extraction:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1-mini
|
||||
temperature: 0
|
||||
max_tokens: 500
|
||||
timeout_seconds: 8
|
||||
|
||||
@@ -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):
|
||||
routing = tmp_path / "routing.yaml"
|
||||
routing.write_text(ROUTING_YAML, encoding="utf-8")
|
||||
@@ -42,7 +56,7 @@ def _router(tmp_path, *, stickiness=True):
|
||||
ENABLE_LLM_ROUTER=False,
|
||||
ENABLE_ROUTE_STICKINESS=stickiness,
|
||||
)
|
||||
return EnterpriseRouter(settings)
|
||||
return EnterpriseRouter(settings, llm=_ParameterLLM())
|
||||
|
||||
|
||||
def _active_tx(status="COLLECTING_PARAMETERS", arguments=None):
|
||||
|
||||
@@ -30,20 +30,45 @@ import pytest
|
||||
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:
|
||||
def __init__(self):
|
||||
from types import SimpleNamespace
|
||||
self.registry = SimpleNamespace(
|
||||
tools={"consultar_pedido": object(), "solicitar_devolucao": object()},
|
||||
get_tool=lambda name: {
|
||||
"consultar_pedido": SimpleNamespace(selection_keywords=["consultar pedido", "pedido"]),
|
||||
"solicitar_devolucao": SimpleNamespace(selection_keywords=["devolver pedido", "devolver", "devolução", "arrependimento"]),
|
||||
"consultar_pedido": SimpleNamespace(selection_keywords=["consultar pedido", "pedido"], args_schema={}, requires=[]),
|
||||
"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),
|
||||
)
|
||||
|
||||
def resolve_execution_policy(self, tool_name, arguments=None):
|
||||
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"}
|
||||
|
||||
def validate_execution_policy(self, tool_name, arguments=None):
|
||||
@@ -56,6 +81,7 @@ class _PolicyRouter:
|
||||
class _Runtime(AgentRuntimeMixin):
|
||||
def __init__(self):
|
||||
self.tool_router = _PolicyRouter()
|
||||
self.llm = _TransactionTestLLM()
|
||||
self.calls = []
|
||||
|
||||
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:
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
prompt = messages[0]["content"]
|
||||
if "Campo: subject" in prompt:
|
||||
return {"content": '{"subject": "TIM CTRL Redes Sociais 8.0"}'}
|
||||
if "Campo: valor" in prompt:
|
||||
return {"content": '{"valor": null}'}
|
||||
if "Campo: motivo" in prompt:
|
||||
return {"content": '{"motivo": "não contratei"}'}
|
||||
return {"content": '{}'}
|
||||
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])
|
||||
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:
|
||||
return {"content": '{"motivo": "não contratei"}'}
|
||||
if "Campo: valor" in prompt:
|
||||
return {"content": '{"valor": null}'}
|
||||
return {"content": "{}"}
|
||||
|
||||
|
||||
class _InitialContestRouter(_ContestPolicyRouter):
|
||||
|
||||
Reference in New Issue
Block a user