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.
|
||||
|
||||
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 logging
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
from .config_loader import load_intents, load_router_defaults, load_state_policies
|
||||
@@ -141,28 +143,79 @@ class EnterpriseRouter:
|
||||
)
|
||||
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:
|
||||
normalized = text.lower()
|
||||
matches: list[tuple[int, int, IntentDefinition, str]] = []
|
||||
normalized = text.casefold()
|
||||
matches: list[tuple[int, int, int, IntentDefinition, str, str]] = []
|
||||
for intent in self.intents:
|
||||
if not intent.enabled:
|
||||
continue
|
||||
for kw in intent.keywords:
|
||||
if kw.lower() in normalized:
|
||||
# menor priority vence; maior tamanho da keyword desempata
|
||||
matches.append((intent.priority, -len(kw), intent, kw))
|
||||
kw_normalized = kw.casefold()
|
||||
strategy = None
|
||||
# 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:
|
||||
return None
|
||||
matches.sort(key=lambda x: (x[0], x[1]))
|
||||
_, _, intent, kw = matches[0]
|
||||
matches.sort(key=lambda x: (x[0], x[1], x[2]))
|
||||
_, _, _, intent, kw, strategy = matches[0]
|
||||
return RouteDecision(
|
||||
route=intent.agent,
|
||||
agent=intent.agent,
|
||||
intent=intent.name,
|
||||
confidence=0.85,
|
||||
reason=f"Keyword '{kw}' correspondeu à intent '{intent.name}'.",
|
||||
confidence=0.85 if strategy == "exact" else 0.82,
|
||||
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",
|
||||
metadata={"matched_keyword": kw},
|
||||
metadata={"matched_keyword": kw, "keyword_match_strategy": strategy},
|
||||
domain=intent.domain,
|
||||
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["previous_agent"] == "orders_agent"
|
||||
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