adjustments: route stickness usefull tokens only

This commit is contained in:
2026-08-20 11:44:24 -03:00
parent 34d33350ae
commit d271031198
4 changed files with 173 additions and 10 deletions

View File

@@ -0,0 +1,41 @@
# Route Stickiness — deterministic intent shift (v14)
## Problema corrigido
Uma keyword multi-token como `cancelar pedido` não era reconhecida em frases como `quero cancelar meu pedido`. O match legado usava substring literal; assim, a keyword genérica `pedido` podia manter `retail_order_tracking` e a continuidade reutilizava a intent anterior.
## Correção
O `EnterpriseRouter` agora possui um segundo estágio determinístico para keywords multi-token: ordered-token matching com até três tokens intermediários. Não há chamada adicional de LLM.
Exemplos reconhecidos pela keyword configurada `cancelar pedido`:
- `quero cancelar meu pedido`
- `quero cancelar o meu pedido`
- `pode cancelar esse pedido`
- `gostaria de cancelar meu pedido`
Quando esse match identifica uma intent diferente da ativa, ele preempta a route stickiness antes do LLM de continuidade.
Metadados de auditoria esperados:
```json
{
"method": "keyword",
"intent": "retail_order_cancel",
"metadata": {
"matched_keyword": "cancelar pedido",
"keyword_match_strategy": "ordered_tokens",
"route_stickiness_preempted": true,
"previous_intent": "retail_order_tracking"
}
}
```
## Custo de LLM
Para mudança explícita reconhecida deterministicamente, o classificador LLM de continuity não é chamado. Para mensagens sem sinal explícito, a Route Stickiness continua com o comportamento configurado.
## Regressão
Testes cobrem mudança `retail_order_tracking -> retail_order_cancel` no mesmo `orders_agent`, inclusive com palavras intermediárias. A suíte relacionada passou com 18 testes.

View File

@@ -12,3 +12,8 @@
A route stickiness é preemptada quando uma keyword explícita configurada em `routing.yaml` identifica outra intent/agente. Assim, uma sessão em `retail_order_tracking` muda para `retail_support_exchange_return` ao receber pedidos como “devolver pedido”. Além disso, respostas diretas de tools read-only são bloqueadas quando a mensagem contém `selection_keywords` de qualquer tool transacional registrada. A route stickiness é preemptada quando uma keyword explícita configurada em `routing.yaml` identifica outra intent/agente. Assim, uma sessão em `retail_order_tracking` muda para `retail_support_exchange_return` ao receber pedidos como “devolver pedido”. Além disso, respostas diretas de tools read-only são bloqueadas quando a mensagem contém `selection_keywords` de qualquer tool transacional registrada.
As palavras de ação ficam em `config/tools.yaml`; o runtime não mantém aliases de domínio hardcoded. As palavras de ação ficam em `config/tools.yaml`; o runtime não mantém aliases de domínio hardcoded.
### Preempção determinística de mudança explícita de intent
A stickiness não chama um segundo LLM quando a mensagem contém uma mudança explícita que pode ser reconhecida deterministicamente. Keywords multi-token configuradas em `routing.yaml` aceitam até três tokens intermediários, preservando a ordem. Assim, `cancelar pedido` reconhece `quero cancelar meu pedido`, `cancelar o meu pedido` e `pode cancelar esse pedido`. Nesse caso a nova intent preempta a stickiness e o metadado `keyword_match_strategy=ordered_tokens` permite auditar a decisão. Mensagens sem sinal explícito continuam usando a route stickiness normalmente.

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
import json import json
import logging import logging
import re
import unicodedata
from typing import Any 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
@@ -141,28 +143,79 @@ class EnterpriseRouter:
) )
return None return None
@staticmethod
def _keyword_tokens(value: str) -> list[str]:
"""Tokeniza texto para matching determinístico tolerante a palavras de ligação.
A remoção de acentos evita duplicar regras apenas por variação ortográfica.
Não há chamada de LLM neste caminho.
"""
folded = unicodedata.normalize("NFKD", str(value or "").casefold())
folded = "".join(ch for ch in folded if not unicodedata.combining(ch))
return re.findall(r"[\w]+", folded, flags=re.UNICODE)
@classmethod
def _ordered_keyword_match(cls, keyword: str, text: str, *, max_gap: int = 3) -> bool:
"""Aceita uma keyword multi-token mesmo com poucos tokens inseridos.
Ex.: ``cancelar pedido`` casa com ``quero cancelar meu pedido`` e
``cancelar o meu pedido``. O limite de gap mantém a regra conservadora e
evita transformar o roteador determinístico em busca semântica ampla.
Keywords de um único token continuam usando apenas o match exato legado.
"""
wanted = cls._keyword_tokens(keyword)
actual = cls._keyword_tokens(text)
if len(wanted) < 2 or not actual:
return False
pos = -1
for token in wanted:
found = None
upper = min(len(actual), pos + max_gap + 2)
for idx in range(pos + 1, upper):
if actual[idx] == token:
found = idx
break
if found is None:
return False
pos = found
return True
def _route_by_keyword(self, text: str) -> RouteDecision | None: def _route_by_keyword(self, text: str) -> RouteDecision | None:
normalized = text.lower() normalized = text.casefold()
matches: list[tuple[int, int, IntentDefinition, str]] = [] matches: list[tuple[int, int, int, IntentDefinition, str, str]] = []
for intent in self.intents: for intent in self.intents:
if not intent.enabled: if not intent.enabled:
continue continue
for kw in intent.keywords: for kw in intent.keywords:
if kw.lower() in normalized: kw_normalized = kw.casefold()
# menor priority vence; maior tamanho da keyword desempata strategy = None
matches.append((intent.priority, -len(kw), intent, kw)) # Exato primeiro para preservar o comportamento existente.
if kw_normalized in normalized:
strategy = "exact"
elif self._ordered_keyword_match(kw, text):
strategy = "ordered_tokens"
if strategy:
# menor priority vence; exact vence fuzzy; keyword maior desempata
strategy_rank = 0 if strategy == "exact" else 1
matches.append((intent.priority, strategy_rank, -len(kw), intent, kw, strategy))
if not matches: if not matches:
return None return None
matches.sort(key=lambda x: (x[0], x[1])) matches.sort(key=lambda x: (x[0], x[1], x[2]))
_, _, intent, kw = matches[0] _, _, _, intent, kw, strategy = matches[0]
return RouteDecision( return RouteDecision(
route=intent.agent, route=intent.agent,
agent=intent.agent, agent=intent.agent,
intent=intent.name, intent=intent.name,
confidence=0.85, confidence=0.85 if strategy == "exact" else 0.82,
reason=f"Keyword '{kw}' correspondeu à intent '{intent.name}'.", reason=(
f"Keyword '{kw}' correspondeu à intent '{intent.name}'."
if strategy == "exact"
else f"Sequência de tokens da keyword '{kw}' correspondeu à intent '{intent.name}'."
),
method="keyword", method="keyword",
metadata={"matched_keyword": kw}, metadata={"matched_keyword": kw, "keyword_match_strategy": strategy},
domain=intent.domain, domain=intent.domain,
mcp_tools=intent.mcp_tools, mcp_tools=intent.mcp_tools,
) )

View File

@@ -94,3 +94,67 @@ async def test_same_agent_transaction_keyword_preempts_continuity(monkeypatch):
assert decision.metadata["route_stickiness_preempted"] is True assert decision.metadata["route_stickiness_preempted"] is True
assert decision.metadata["previous_agent"] == "orders_agent" assert decision.metadata["previous_agent"] == "orders_agent"
assert decision.metadata["previous_intent"] == "retail_order_tracking" assert decision.metadata["previous_intent"] == "retail_order_tracking"
@pytest.mark.parametrize(
"message",
[
"quero cancelar meu pedido",
"quero cancelar o meu pedido",
"pode cancelar esse pedido",
"gostaria de cancelar meu pedido",
],
)
@pytest.mark.asyncio
async def test_same_agent_transaction_shift_with_inserted_words_is_deterministic(message):
"""Inserted connector/pronoun words must not force an LLM continuity call."""
from agent_framework.routing.models import IntentDefinition
class _Continuity:
async def evaluate(self, state, *, intents):
raise AssertionError("continuity LLM must not run for explicit deterministic transaction shift")
router = object.__new__(EnterpriseRouter)
router.state_policies = []
router.intents = [
IntentDefinition(
name="retail_order_cancel", domain="retail", agent="orders_agent",
description="cancelamento", priority=20,
mcp_tools=["consultar_pedido", "cancelar_pedido"],
keywords=["cancelar pedido"], examples=[], enabled=True,
),
IntentDefinition(
name="retail_order_tracking", domain="retail", agent="orders_agent",
description="tracking", priority=30,
mcp_tools=["consultar_pedido", "consultar_entrega"],
keywords=["pedido"], examples=[], enabled=True,
),
]
router.continuity = _Continuity()
router.enable_llm_router = False
router.llm = None
router.telemetry = None
router.fallback_agent = "billing_agent"
decision = await router.route({
"user_text": message, "sanitized_input": message,
"active_agent": "orders_agent", "intent": "retail_order_tracking",
"route_decision": {
"route": "orders_agent", "agent": "orders_agent",
"intent": "retail_order_tracking", "domain": "retail",
"mcp_tools": ["consultar_pedido", "consultar_entrega"],
},
"context": {"session": {}},
})
assert decision.intent == "retail_order_cancel"
assert decision.agent == "orders_agent"
assert decision.mcp_tools == ["consultar_pedido", "cancelar_pedido"]
assert decision.metadata["route_stickiness_preempted"] is True
assert decision.metadata["keyword_match_strategy"] == "ordered_tokens"
def test_ordered_keyword_match_is_conservative():
assert EnterpriseRouter._ordered_keyword_match("cancelar pedido", "quero cancelar meu pedido") is True
assert EnterpriseRouter._ordered_keyword_match("cancelar pedido", "quero pedido e talvez cancelar depois") is False
assert EnterpriseRouter._ordered_keyword_match("pedido", "meu pedido") is False