bugfix: ABANDON precedence
This commit is contained in:
Binary file not shown.
@@ -630,9 +630,24 @@ class EnterpriseRouter:
|
||||
if state_decision:
|
||||
tx_status = str(state.get("transaction_status") or "").strip().upper()
|
||||
|
||||
# Confirmation is the only transaction input with absolute precedence:
|
||||
# an explicit yes/no answers the confirmation contract itself.
|
||||
# Explicit abandonment must be evaluated before confirmation
|
||||
# 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 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(
|
||||
state, text=str(text), state_decision=state_decision
|
||||
)
|
||||
@@ -687,6 +702,21 @@ class EnterpriseRouter:
|
||||
next_state=tx_status,
|
||||
)
|
||||
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(
|
||||
state, text=str(text), state_decision=synthetic
|
||||
)
|
||||
@@ -846,11 +876,12 @@ class EnterpriseRouter:
|
||||
) -> RouteDecision | None:
|
||||
"""Try to consume the turn under the active transaction contract first.
|
||||
|
||||
AWAITING_CONFIRMATION consumes an explicit confirmation before any shift
|
||||
classification. COLLECTING_PARAMETERS also has precedence: if at least one
|
||||
pending parameter can be extracted, the active transaction keeps ownership
|
||||
of the turn. Semantic intent-shift is evaluated only when extraction returns
|
||||
no usable pending parameter.
|
||||
In AWAITING_CONFIRMATION, an explicit ABANDON candidate is classified
|
||||
before confirmation consumption; plain yes/no responses still keep
|
||||
deterministic confirmation precedence because they do not pass the
|
||||
abandonment gate. In COLLECTING_PARAMETERS, ABANDON is likewise checked
|
||||
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()
|
||||
if tx_status == "AWAITING_CONFIRMATION":
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -630,9 +630,24 @@ class EnterpriseRouter:
|
||||
if state_decision:
|
||||
tx_status = str(state.get("transaction_status") or "").strip().upper()
|
||||
|
||||
# Confirmation is the only transaction input with absolute precedence:
|
||||
# an explicit yes/no answers the confirmation contract itself.
|
||||
# Explicit abandonment must be evaluated before confirmation
|
||||
# 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 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(
|
||||
state, text=str(text), state_decision=state_decision
|
||||
)
|
||||
@@ -640,13 +655,24 @@ class EnterpriseRouter:
|
||||
await self._emit(consumed, state)
|
||||
return consumed
|
||||
|
||||
# Transaction parameter precedence is absolute while collecting:
|
||||
# first let the active transaction try to consume the current turn.
|
||||
# Only when NO pending parameter can be extracted do we ask the
|
||||
# semantic classifier whether the user changed goals. This prevents
|
||||
# value/name/reference answers (for example "a de 14,99") from being
|
||||
# stolen by a semantically plausible but incompatible intent.
|
||||
# During parameter collection, explicit abandonment has stronger
|
||||
# precedence than parameter extraction. Otherwise an LLM parameter
|
||||
# extractor can incorrectly "consume" phrases such as
|
||||
# "esquece, não quero mais cancelar" and the ABANDON classifier is
|
||||
# never reached. SHIFT remains after parameter extraction so a valid
|
||||
# value/name/reference answer is not stolen by a plausible new intent.
|
||||
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(
|
||||
state, text=str(text), state_decision=state_decision
|
||||
)
|
||||
@@ -687,6 +713,21 @@ class EnterpriseRouter:
|
||||
next_state=tx_status,
|
||||
)
|
||||
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(
|
||||
state, text=str(text), state_decision=synthetic
|
||||
)
|
||||
@@ -699,6 +740,21 @@ class EnterpriseRouter:
|
||||
return consumed
|
||||
|
||||
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(
|
||||
state, text=str(text), state_decision=synthetic
|
||||
)
|
||||
@@ -846,11 +902,12 @@ class EnterpriseRouter:
|
||||
) -> RouteDecision | None:
|
||||
"""Try to consume the turn under the active transaction contract first.
|
||||
|
||||
AWAITING_CONFIRMATION consumes an explicit confirmation before any shift
|
||||
classification. COLLECTING_PARAMETERS also has precedence: if at least one
|
||||
pending parameter can be extracted, the active transaction keeps ownership
|
||||
of the turn. Semantic intent-shift is evaluated only when extraction returns
|
||||
no usable pending parameter.
|
||||
In AWAITING_CONFIRMATION, an explicit ABANDON candidate is classified
|
||||
before confirmation consumption; plain yes/no responses still keep
|
||||
deterministic confirmation precedence because they do not pass the
|
||||
abandonment gate. In COLLECTING_PARAMETERS, ABANDON is likewise checked
|
||||
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()
|
||||
if tx_status == "AWAITING_CONFIRMATION":
|
||||
@@ -960,12 +1017,36 @@ class EnterpriseRouter:
|
||||
}
|
||||
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(
|
||||
self,
|
||||
state: dict[str, Any],
|
||||
*,
|
||||
text: str,
|
||||
state_decision: RouteDecision,
|
||||
abandon_only: bool = False,
|
||||
) -> RouteDecision | None:
|
||||
"""Detecta semanticamente mudança de intenção durante uma transação.
|
||||
|
||||
@@ -1038,18 +1119,30 @@ class EnterpriseRouter:
|
||||
else None
|
||||
),
|
||||
}
|
||||
system = (
|
||||
"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. "
|
||||
"A extração dos parâmetros pendentes já foi tentada antes desta etapa e não consumiu o turno. "
|
||||
"Se ainda assim a mensagem for apenas uma resposta referencial/valor/nome ao dado pendente, retorne CONTINUE. "
|
||||
"Se o usuário passou claramente a perseguir outro objetivo sem desistir explicitamente da ação atual, "
|
||||
"retorne SHIFT e a nova intent permitida. "
|
||||
"Se o usuário desistiu explicitamente da ação/transação atual, retorne ABANDON. "
|
||||
"Quando ABANDON 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. "
|
||||
"Retorne somente JSON válido com decision, intent, agent, confidence, reason."
|
||||
)
|
||||
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 = (
|
||||
"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. "
|
||||
"A extração dos parâmetros pendentes já foi tentada antes desta etapa e não consumiu o turno. "
|
||||
"Se ainda assim a mensagem for apenas uma resposta referencial/valor/nome ao dado pendente, retorne CONTINUE. "
|
||||
"Se o usuário passou claramente a perseguir outro objetivo sem desistir explicitamente da ação atual, "
|
||||
"retorne SHIFT e a nova intent permitida. "
|
||||
"Se o usuário desistiu explicitamente da ação/transação atual, retorne ABANDON. "
|
||||
"Quando ABANDON 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. "
|
||||
"Retorne somente JSON válido com decision, intent, agent, confidence, reason."
|
||||
)
|
||||
user = {
|
||||
"message": text,
|
||||
"transaction": transaction_context,
|
||||
@@ -1074,7 +1167,10 @@ class EnterpriseRouter:
|
||||
return None
|
||||
|
||||
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
|
||||
confidence = float(data.get("confidence") or 0.0)
|
||||
if confidence < self.intent_shift_threshold:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,6 +13,14 @@ class _RouterLLM:
|
||||
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:
|
||||
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():
|
||||
return json.dumps({
|
||||
"decision": "ABANDON",
|
||||
@@ -48,6 +56,8 @@ router:
|
||||
state_policies:
|
||||
- state: COLLECTING_PARAMETERS
|
||||
agent: contestacao_agent
|
||||
- state: WAITING_CONTESTACAO_CONFIRMATION
|
||||
agent: contestacao_agent
|
||||
intents:
|
||||
- name: contas_contestation
|
||||
agent: contestacao_agent
|
||||
@@ -61,6 +71,10 @@ intents:
|
||||
agent: billing_agent
|
||||
priority: 30
|
||||
keywords: [fatura]
|
||||
- name: product_services_information
|
||||
agent: product_agent
|
||||
priority: 40
|
||||
keywords: [servicos contratados, serviços contratados]
|
||||
""",
|
||||
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"
|
||||
|
||||
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self):
|
||||
self.registry = SimpleNamespace(
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user