mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-09-07 10:13:46 +00:00
adjustments: route stickness usefull tokens only
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user