diff --git a/agent_framework_oci/Documentacao/RELEASE_NOTES_ROUTE_STICKINESS_DETERMINISTIC_INTENT_SHIFT_V14.md b/agent_framework_oci/Documentacao/RELEASE_NOTES_ROUTE_STICKINESS_DETERMINISTIC_INTENT_SHIFT_V14.md new file mode 100644 index 0000000..e267937 --- /dev/null +++ b/agent_framework_oci/Documentacao/RELEASE_NOTES_ROUTE_STICKINESS_DETERMINISTIC_INTENT_SHIFT_V14.md @@ -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. diff --git a/agent_framework_oci/docs/PERFORMANCE_OPTIMIZATIONS_MCP_JUDGES_RAG.md b/agent_framework_oci/docs/PERFORMANCE_OPTIMIZATIONS_MCP_JUDGES_RAG.md index aac2987..5806879 100644 --- a/agent_framework_oci/docs/PERFORMANCE_OPTIMIZATIONS_MCP_JUDGES_RAG.md +++ b/agent_framework_oci/docs/PERFORMANCE_OPTIMIZATIONS_MCP_JUDGES_RAG.md @@ -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. diff --git a/agent_framework_oci/libs/agent_framework/src/agent_framework/routing/enterprise_router.py b/agent_framework_oci/libs/agent_framework/src/agent_framework/routing/enterprise_router.py index f4d228a..0b57ae7 100644 --- a/agent_framework_oci/libs/agent_framework/src/agent_framework/routing/enterprise_router.py +++ b/agent_framework_oci/libs/agent_framework/src/agent_framework/routing/enterprise_router.py @@ -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, ) diff --git a/agent_framework_oci/tests/test_route_stickiness_transaction_shift.py b/agent_framework_oci/tests/test_route_stickiness_transaction_shift.py index fee3400..febad44 100644 --- a/agent_framework_oci/tests/test_route_stickiness_transaction_shift.py +++ b/agent_framework_oci/tests/test_route_stickiness_transaction_shift.py @@ -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