bugfix: ABANDON precedence

This commit is contained in:
2026-09-17 10:23:02 -03:00
parent 3b03bdbdab
commit c318d9af27
60 changed files with 255 additions and 33 deletions

View File

@@ -630,9 +630,24 @@ class EnterpriseRouter:
if state_decision: if state_decision:
tx_status = str(state.get("transaction_status") or "").strip().upper() tx_status = str(state.get("transaction_status") or "").strip().upper()
# Confirmation is the only transaction input with absolute precedence: # Explicit abandonment must be evaluated before confirmation
# an explicit yes/no answers the confirmation contract itself. # consumption. Otherwise an utterance such as
# "não quero cancelar; quero apenas consultar meus serviços" can be
# trapped by the confirmation state and never reach ABANDON. A plain
# yes/no still keeps deterministic confirmation precedence because it
# does not pass the abandonment gate.
if tx_status == "AWAITING_CONFIRMATION": if tx_status == "AWAITING_CONFIRMATION":
if self._looks_like_explicit_abandonment(str(text)):
abandonment = await self._transaction_state_interruption_candidate(
state,
text=str(text),
state_decision=state_decision,
abandon_only=True,
)
if abandonment is not None:
await self._emit(abandonment, state)
return abandonment
consumed = await self._transaction_parameter_precedence( consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=state_decision state, text=str(text), state_decision=state_decision
) )
@@ -687,6 +702,21 @@ class EnterpriseRouter:
next_state=tx_status, next_state=tx_status,
) )
if tx_status == "AWAITING_CONFIRMATION": if tx_status == "AWAITING_CONFIRMATION":
if self._looks_like_explicit_abandonment(str(text)):
abandonment = await self._transaction_state_interruption_candidate(
state,
text=str(text),
state_decision=synthetic,
abandon_only=True,
)
if abandonment is not None:
abandonment.metadata = {
**(abandonment.metadata or {}),
"transaction_state_recovered": True,
}
await self._emit(abandonment, state)
return abandonment
consumed = await self._transaction_parameter_precedence( consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=synthetic state, text=str(text), state_decision=synthetic
) )
@@ -846,11 +876,12 @@ class EnterpriseRouter:
) -> RouteDecision | None: ) -> RouteDecision | None:
"""Try to consume the turn under the active transaction contract first. """Try to consume the turn under the active transaction contract first.
AWAITING_CONFIRMATION consumes an explicit confirmation before any shift In AWAITING_CONFIRMATION, an explicit ABANDON candidate is classified
classification. COLLECTING_PARAMETERS also has precedence: if at least one before confirmation consumption; plain yes/no responses still keep
pending parameter can be extracted, the active transaction keeps ownership deterministic confirmation precedence because they do not pass the
of the turn. Semantic intent-shift is evaluated only when extraction returns abandonment gate. In COLLECTING_PARAMETERS, ABANDON is likewise checked
no usable pending parameter. before extraction, while SHIFT remains after parameter extraction so valid
referential/value answers are not stolen from the active transaction.
""" """
tx_status = str(state.get("transaction_status") or "").strip().upper() tx_status = str(state.get("transaction_status") or "").strip().upper()
if tx_status == "AWAITING_CONFIRMATION": if tx_status == "AWAITING_CONFIRMATION":

View File

@@ -630,9 +630,24 @@ class EnterpriseRouter:
if state_decision: if state_decision:
tx_status = str(state.get("transaction_status") or "").strip().upper() tx_status = str(state.get("transaction_status") or "").strip().upper()
# Confirmation is the only transaction input with absolute precedence: # Explicit abandonment must be evaluated before confirmation
# an explicit yes/no answers the confirmation contract itself. # consumption. Otherwise an utterance such as
# "não quero cancelar; quero apenas consultar meus serviços" can be
# trapped by the confirmation state and never reach ABANDON. A plain
# yes/no still keeps deterministic confirmation precedence because it
# does not pass the abandonment gate.
if tx_status == "AWAITING_CONFIRMATION": if tx_status == "AWAITING_CONFIRMATION":
if self._looks_like_explicit_abandonment(str(text)):
abandonment = await self._transaction_state_interruption_candidate(
state,
text=str(text),
state_decision=state_decision,
abandon_only=True,
)
if abandonment is not None:
await self._emit(abandonment, state)
return abandonment
consumed = await self._transaction_parameter_precedence( consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=state_decision state, text=str(text), state_decision=state_decision
) )
@@ -640,13 +655,24 @@ class EnterpriseRouter:
await self._emit(consumed, state) await self._emit(consumed, state)
return consumed return consumed
# Transaction parameter precedence is absolute while collecting: # During parameter collection, explicit abandonment has stronger
# first let the active transaction try to consume the current turn. # precedence than parameter extraction. Otherwise an LLM parameter
# Only when NO pending parameter can be extracted do we ask the # extractor can incorrectly "consume" phrases such as
# semantic classifier whether the user changed goals. This prevents # "esquece, não quero mais cancelar" and the ABANDON classifier is
# value/name/reference answers (for example "a de 14,99") from being # never reached. SHIFT remains after parameter extraction so a valid
# stolen by a semantically plausible but incompatible intent. # value/name/reference answer is not stolen by a plausible new intent.
if tx_status == "COLLECTING_PARAMETERS": if tx_status == "COLLECTING_PARAMETERS":
if self._looks_like_explicit_abandonment(str(text)):
abandonment = await self._transaction_state_interruption_candidate(
state,
text=str(text),
state_decision=state_decision,
abandon_only=True,
)
if abandonment is not None:
await self._emit(abandonment, state)
return abandonment
consumed = await self._transaction_parameter_precedence( consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=state_decision state, text=str(text), state_decision=state_decision
) )
@@ -687,6 +713,21 @@ class EnterpriseRouter:
next_state=tx_status, next_state=tx_status,
) )
if tx_status == "AWAITING_CONFIRMATION": if tx_status == "AWAITING_CONFIRMATION":
if self._looks_like_explicit_abandonment(str(text)):
abandonment = await self._transaction_state_interruption_candidate(
state,
text=str(text),
state_decision=synthetic,
abandon_only=True,
)
if abandonment is not None:
abandonment.metadata = {
**(abandonment.metadata or {}),
"transaction_state_recovered": True,
}
await self._emit(abandonment, state)
return abandonment
consumed = await self._transaction_parameter_precedence( consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=synthetic state, text=str(text), state_decision=synthetic
) )
@@ -699,6 +740,21 @@ class EnterpriseRouter:
return consumed return consumed
if tx_status == "COLLECTING_PARAMETERS": if tx_status == "COLLECTING_PARAMETERS":
if self._looks_like_explicit_abandonment(str(text)):
abandonment = await self._transaction_state_interruption_candidate(
state,
text=str(text),
state_decision=synthetic,
abandon_only=True,
)
if abandonment is not None:
abandonment.metadata = {
**(abandonment.metadata or {}),
"transaction_state_recovered": True,
}
await self._emit(abandonment, state)
return abandonment
consumed = await self._transaction_parameter_precedence( consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=synthetic state, text=str(text), state_decision=synthetic
) )
@@ -846,11 +902,12 @@ class EnterpriseRouter:
) -> RouteDecision | None: ) -> RouteDecision | None:
"""Try to consume the turn under the active transaction contract first. """Try to consume the turn under the active transaction contract first.
AWAITING_CONFIRMATION consumes an explicit confirmation before any shift In AWAITING_CONFIRMATION, an explicit ABANDON candidate is classified
classification. COLLECTING_PARAMETERS also has precedence: if at least one before confirmation consumption; plain yes/no responses still keep
pending parameter can be extracted, the active transaction keeps ownership deterministic confirmation precedence because they do not pass the
of the turn. Semantic intent-shift is evaluated only when extraction returns abandonment gate. In COLLECTING_PARAMETERS, ABANDON is likewise checked
no usable pending parameter. before extraction, while SHIFT remains after parameter extraction so valid
referential/value answers are not stolen from the active transaction.
""" """
tx_status = str(state.get("transaction_status") or "").strip().upper() tx_status = str(state.get("transaction_status") or "").strip().upper()
if tx_status == "AWAITING_CONFIRMATION": if tx_status == "AWAITING_CONFIRMATION":
@@ -960,12 +1017,36 @@ class EnterpriseRouter:
} }
return state_decision return state_decision
@staticmethod
def _looks_like_explicit_abandonment(text: str) -> bool:
"""Cheap, domain-neutral gate for semantic ABANDON classification.
This is deliberately not the final decision. It only protects clear
abandonment utterances from being swallowed by parameter extraction; the
semantic classifier still decides whether the turn is actually ABANDON.
"""
import re
normalized = str(text or "").strip().lower()
if not normalized:
return False
cues = (
r"\besquece(?:\s+isso|\s+essa|\s+esse|\s+tudo)?\b",
r"\bn[aã]o\s+quero\s+mais\b",
r"\bdesisti(?:\s+disso|\s+dessa|\s+desse)?\b",
r"\bdeixa\s+(?:isso|essa|esse|pra\s+l[aá]|para\s+l[aá])\b",
r"\bpode\s+parar\b",
r"\bquero\s+parar\b",
r"\bcancela\s+essa\s+solicita[cç][aã]o\b",
)
return any(re.search(pattern, normalized, flags=re.IGNORECASE) for pattern in cues)
async def _transaction_state_interruption_candidate( async def _transaction_state_interruption_candidate(
self, self,
state: dict[str, Any], state: dict[str, Any],
*, *,
text: str, text: str,
state_decision: RouteDecision, state_decision: RouteDecision,
abandon_only: bool = False,
) -> RouteDecision | None: ) -> RouteDecision | None:
"""Detecta semanticamente mudança de intenção durante uma transação. """Detecta semanticamente mudança de intenção durante uma transação.
@@ -1038,6 +1119,18 @@ class EnterpriseRouter:
else None else None
), ),
} }
if abandon_only:
system = (
"Você decide somente se o usuário desistiu explicitamente da ação/transação ativa. "
"Use o significado completo da mensagem e o contexto transacional; não use palavras isoladas como regra. "
"Se houver desistência explícita da ação atual, retorne ABANDON. "
"Quando o abandono vier acompanhado de um novo objetivo, também informe intent e agent desse novo objetivo. "
"Quando for apenas abandono sem novo objetivo, intent e agent podem ser nulos. "
"Se a mensagem apenas fornece parâmetro, confirma contexto, ou somente muda/adiciona outro objetivo sem "
"desistir explicitamente da ação atual, retorne CONTINUE. "
"Retorne somente JSON válido com decision, intent, agent, confidence, reason."
)
else:
system = ( system = (
"Você decide apenas se o turno atual continua a transação ativa ou muda de intenção. " "Você decide apenas se o turno atual continua a transação ativa ou muda de intenção. "
"Use o significado da mensagem e o contexto transacional; não use palavras isoladas como regra. " "Use o significado da mensagem e o contexto transacional; não use palavras isoladas como regra. "
@@ -1074,7 +1167,10 @@ class EnterpriseRouter:
return None return None
decision_kind = str(data.get("decision") or "").strip().upper() decision_kind = str(data.get("decision") or "").strip().upper()
if decision_kind not in {"SHIFT", "ABANDON"}: if abandon_only:
if decision_kind != "ABANDON":
return None
elif decision_kind not in {"SHIFT", "ABANDON"}:
return None return None
confidence = float(data.get("confidence") or 0.0) confidence = float(data.get("confidence") or 0.0)
if confidence < self.intent_shift_threshold: if confidence < self.intent_shift_threshold:

View File

@@ -13,6 +13,14 @@ class _RouterLLM:
prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1]) prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1])
if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt: if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt:
return json.dumps({"subject": None, "valor": None}) return json.dumps({"subject": None, "valor": None})
if "nao quero cancelar. quero apenas verificar os meus servicos contratados" in prompt.lower():
return json.dumps({
"decision": "ABANDON",
"intent": "product_services_information",
"agent": "product_agent",
"confidence": 0.99,
"reason": "abandono explícito do cancelamento com novo objetivo",
})
if "nao quero mais cancelar" in prompt.lower() or "não quero mais cancelar" in prompt.lower(): if "nao quero mais cancelar" in prompt.lower() or "não quero mais cancelar" in prompt.lower():
return json.dumps({ return json.dumps({
"decision": "ABANDON", "decision": "ABANDON",
@@ -48,6 +56,8 @@ router:
state_policies: state_policies:
- state: COLLECTING_PARAMETERS - state: COLLECTING_PARAMETERS
agent: contestacao_agent agent: contestacao_agent
- state: WAITING_CONTESTACAO_CONFIRMATION
agent: contestacao_agent
intents: intents:
- name: contas_contestation - name: contas_contestation
agent: contestacao_agent agent: contestacao_agent
@@ -61,6 +71,10 @@ intents:
agent: billing_agent agent: billing_agent
priority: 30 priority: 30
keywords: [fatura] keywords: [fatura]
- name: product_services_information
agent: product_agent
priority: 40
keywords: [servicos contratados, serviços contratados]
""", """,
encoding="utf-8", encoding="utf-8",
) )
@@ -123,6 +137,87 @@ async def test_explicit_abandonment_without_new_goal_does_not_rearm_tool(tmp_pat
assert decision.metadata["interrupted_intent"] == "contas_contestation" assert decision.metadata["interrupted_intent"] == "contas_contestation"
def _awaiting_confirmation_state(message):
return {
"user_text": message,
"sanitized_input": message,
"next_state": "WAITING_CONTESTACAO_CONFIRMATION",
"transaction_status": "AWAITING_CONFIRMATION",
"intent": "state:WAITING_CONTESTACAO_CONFIRMATION",
"active_agent": "contestacao_agent",
"active_transaction": {
"tool_name": "cancelar_vas_avulso",
"arguments": {"subject": "Tamboro Mensal"},
"status": "AWAITING_CONFIRMATION",
"started_from_intent": "contas_vas_cancel",
},
"pending_tool_call": {
"tool_name": "cancelar_vas_avulso",
"arguments": {"subject": "Tamboro Mensal"},
},
"history": [
{"role": "user", "content": "quero cancelar tamboro"},
{"role": "assistant", "content": "Você confirma o cancelamento?"},
{"role": "user", "content": message},
],
}
@pytest.mark.asyncio
async def test_explicit_abandonment_precedes_awaiting_confirmation_and_routes_new_goal(tmp_path):
decision = await _router(tmp_path).route(
_awaiting_confirmation_state(
"nao quero cancelar. quero apenas verificar os meus servicos contratados"
)
)
assert decision.intent == "product_services_information"
assert decision.agent == "product_agent"
assert decision.metadata["transaction_interruption"] == "explicit_abandonment"
assert decision.metadata["interrupted_intent"] == "contas_vas_cancel"
assert not decision.metadata.get("transaction_turn_consumed")
@pytest.mark.asyncio
async def test_plain_negative_confirmation_remains_confirmation_not_abandon(tmp_path):
decision = await _router(tmp_path).route(_awaiting_confirmation_state("não"))
assert decision.intent == "state:WAITING_CONTESTACAO_CONFIRMATION"
assert decision.agent == "contestacao_agent"
assert decision.metadata["transaction_turn_consumed"] is True
assert decision.metadata["transaction_confirmation_decision"] == "reject"
assert "transaction_interruption" not in decision.metadata
class _MisleadingParameterRouterLLM(_RouterLLM):
async def ainvoke(self, messages, **kwargs):
prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1])
# Simulate the real failure mode: the parameter extractor hallucinates a
# usable value from an abandonment utterance. ABANDON must win before
# parameter extraction is allowed to consume the turn.
if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt:
if "nao quero mais cancelar" in prompt.lower() or "não quero mais cancelar" in prompt.lower():
return json.dumps({"valor": 14.99})
return await super().ainvoke(messages, **kwargs)
def _router_with_misleading_parameter_extractor(tmp_path):
router = _router(tmp_path)
router.llm = _MisleadingParameterRouterLLM()
return router
@pytest.mark.asyncio
async def test_explicit_abandonment_precedes_misleading_parameter_extraction(tmp_path):
decision = await _router_with_misleading_parameter_extractor(tmp_path).route(
_active_state("esquece, nao quero mais cancelar")
)
assert decision.intent == "state:TRANSACTION_ABANDONED"
assert decision.agent == "contestacao_agent"
assert decision.mcp_tools == []
assert decision.metadata["transaction_interruption"] == "explicit_abandonment"
assert not decision.metadata.get("transaction_turn_consumed")
class _PolicyRouter: class _PolicyRouter:
def __init__(self): def __init__(self):
self.registry = SimpleNamespace( self.registry = SimpleNamespace(