bugfix: ABANDON precedence

This commit is contained in:
2026-09-17 10:23:35 -03:00
parent 50e7db4666
commit d986b9751f
61 changed files with 875 additions and 110 deletions

View File

@@ -630,32 +630,144 @@ 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: active_tx = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {}
# an explicit yes/no answers the confirmation contract itself. legacy_tx = state.get("pending_tool_call") or state.get("selected_tool_call") or {}
if tx_status == "AWAITING_CONFIRMATION": state_lock_name = str(
consumed = await self._transaction_parameter_precedence( state_decision.next_state
state, text=str(text), state_decision=state_decision or current_state
) or state_decision.intent.removeprefix("state:")
if consumed is not None: or ""
await self._emit(consumed, state) ).strip().upper()
return consumed transactional_state_lock = state_lock_name.startswith(
("WAITING_", "COLLECTING_", "AWAITING_")
)
# Transaction parameter precedence is absolute while collecting: # Rich transactional latches remain the primary source of truth.
# first let the active transaction try to consume the current turn. # The domain state lock is only a recovery fallback for checkpoints
# Only when NO pending parameter can be extracted do we ask the # where those latches were not restored. This prevents a generic
# semantic classifier whether the user changed goals. This prevents # COLLECTING_* state from stealing a legitimate parameter answer.
# value/name/reference answers (for example "a de 14,99") from being rich_transaction_context = bool(
# stolen by a semantically plausible but incompatible intent. active_tx.get("tool_name")
if tx_status == "COLLECTING_PARAMETERS": or (isinstance(legacy_tx, dict) and legacy_tx.get("tool_name"))
or tx_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}
)
fallback_state_lock_context = bool(
transactional_state_lock and not rich_transaction_context
)
has_transaction_context = bool(
rich_transaction_context or fallback_state_lock_context
)
# Some hosts restore the domain state but omit transaction_status.
# Infer only the generic phase needed by the precedence logic; do not
# replace richer transaction data when it exists.
effective_tx_status = tx_status
if not effective_tx_status:
if state_lock_name.startswith("COLLECTING_"):
effective_tx_status = "COLLECTING_PARAMETERS"
elif state_lock_name.startswith("AWAITING_") or "CONFIRMATION" in state_lock_name:
effective_tx_status = "AWAITING_CONFIRMATION"
precedence_state = state
if effective_tx_status and effective_tx_status != tx_status:
precedence_state = dict(state)
precedence_state["transaction_status"] = effective_tx_status
# Transactional interruption precedence is semantic, not lexical.
#
# COLLECTING_PARAMETERS is special: the pending parameter gets the
# first opportunity to consume the turn. If the input is pertinent
# to the parameter, extraction returns values and the transaction
# continues. Only when the input is not pertinent do we ask the
# semantic interruption classifier whether the user abandoned the
# active transaction. This preserves inputs such as
# ``motivo é que desisti`` as a legitimate ``reason`` value while
# still allowing ``não quero mais devolver; quero ver meus serviços``
# to abandon the current operation and start a new objective.
if effective_tx_status == "COLLECTING_PARAMETERS":
if rich_transaction_context:
relevance = await self._classify_transaction_parameter_relevance(
precedence_state, text=str(text)
)
if relevance is True:
consumed = await self._transaction_parameter_precedence(
precedence_state, text=str(text), state_decision=state_decision
)
if consumed is not None:
consumed.metadata = {
**(consumed.metadata or {}),
"transaction_parameter_relevant": True,
}
await self._emit(consumed, state)
return consumed
# Pertinent to the requested parameter, but extraction did
# not produce a structured value. Keep transaction
# ownership and let the agent reprompt/clarify; do not
# reinterpret the same input as ABANDON or SHIFT.
state_decision.metadata = {
**(state_decision.metadata or {}),
"transaction_parameter_relevant": True,
"transaction_parameter_extraction_empty": True,
}
await self._emit(state_decision, state)
return state_decision
if relevance is None:
# Compatibility/fail-safe when the relevance classifier is
# unavailable: retain the previous extractor-first behavior.
consumed = await self._transaction_parameter_precedence(
precedence_state, text=str(text), state_decision=state_decision
)
if consumed is not None:
consumed.metadata = {
**(consumed.metadata or {}),
"transaction_parameter_relevance": "unknown",
}
await self._emit(consumed, state)
return consumed
if has_transaction_context:
abandonment = await self._transaction_state_interruption_candidate(
precedence_state,
text=str(text),
state_decision=state_decision,
abandon_only=True,
)
if abandonment is not None:
abandonment.metadata = {
**(abandonment.metadata or {}),
"transaction_parameter_relevant": False if rich_transaction_context else None,
}
await self._emit(abandonment, state)
return abandonment
# In confirmation/waiting phases there is no pending free-form
# parameter whose value can be confused with abandonment. Evaluate
# ABANDON semantically before confirmation/state-lock consumption.
# Plain confirmations (e.g. yes/no) are classified as CONTINUE by
# this probe and then follow the normal confirmation path below.
elif has_transaction_context:
abandonment = await self._transaction_state_interruption_candidate(
precedence_state,
text=str(text),
state_decision=state_decision,
abandon_only=True,
)
if abandonment is not None:
await self._emit(abandonment, state)
return abandonment
if effective_tx_status == "AWAITING_CONFIRMATION":
consumed = await self._transaction_parameter_precedence( consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=state_decision precedence_state, text=str(text), state_decision=state_decision
) )
if consumed is not None: if consumed is not None:
await self._emit(consumed, state) await self._emit(consumed, state)
return consumed return consumed
interruption = await self._transaction_state_interruption_candidate( interruption = await self._transaction_state_interruption_candidate(
state, text=str(text), state_decision=state_decision precedence_state, text=str(text), state_decision=state_decision
) )
if interruption is not None: if interruption is not None:
await self._emit(interruption, state) await self._emit(interruption, state)
@@ -686,19 +798,61 @@ class EnterpriseRouter:
method="state", method="state",
next_state=tx_status, next_state=tx_status,
) )
if tx_status == "AWAITING_CONFIRMATION":
consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=synthetic
)
if consumed is not None:
consumed.metadata = {
**(consumed.metadata or {}),
"transaction_state_recovered": True,
}
await self._emit(consumed, state)
return consumed
if tx_status == "COLLECTING_PARAMETERS": if tx_status == "COLLECTING_PARAMETERS":
relevance = await self._classify_transaction_parameter_relevance(
state, text=str(text)
)
if relevance is True:
consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=synthetic
)
if consumed is not None:
consumed.metadata = {
**(consumed.metadata or {}),
"transaction_state_recovered": True,
"transaction_parameter_relevant": True,
}
await self._emit(consumed, state)
return consumed
synthetic.metadata = {
**(synthetic.metadata or {}),
"transaction_state_recovered": True,
"transaction_parameter_relevant": True,
"transaction_parameter_extraction_empty": True,
}
await self._emit(synthetic, state)
return synthetic
if relevance is None:
consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=synthetic
)
if consumed is not None:
consumed.metadata = {
**(consumed.metadata or {}),
"transaction_state_recovered": True,
"transaction_parameter_relevance": "unknown",
}
await self._emit(consumed, state)
return consumed
# No parameter was consumed (or this is confirmation): now decide
# semantically whether the user abandoned the active transaction.
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
if tx_status == "AWAITING_CONFIRMATION":
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
) )
@@ -837,6 +991,82 @@ class EnterpriseRouter:
return decision return decision
async def _classify_transaction_parameter_relevance(
self, state: dict[str, Any], *, text: str
) -> bool | None:
"""Semantically decide whether the turn answers a pending parameter.
This is intentionally separate from extraction. A parameter extractor
may be able to manufacture a syntactically valid value from unrelated
text; relevance answers the higher-level question first: does the user
appear to be responding to what the active transaction asked for?
Returns True/False when classification succeeds and None when the
classifier is unavailable or its output cannot be trusted.
"""
if not (self.enable_llm_router and self.llm is not None):
return None
missing = [str(name) for name in (state.get("missing_parameters") or []) if str(name).strip()]
if not missing:
return None
active = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {}
tool_name = str(active.get("tool_name") or "").strip()
if not tool_name:
return None
payload = {
"message": str(text or ""),
"transaction": {
"intent": active.get("started_from_intent") or state.get("intent"),
"tool_name": tool_name,
"tool_description": active.get("tool_description"),
"missing_parameters": missing,
"parameter_schema": active.get("parameter_schema") or {},
"known_arguments": active.get("arguments") or {},
"relevant_conversation_context": (
active.get("parameter_conversational_context")
or self._collect_transaction_parameter_context(state=state, current_text=str(text))
),
},
}
system = (
"Você decide somente se a mensagem do usuário é uma resposta pertinente ao(s) "
"parâmetro(s) que a transação ativa está pedindo. Avalie significado e contexto, "
"não palavras isoladas. Considere RELEVANT quando a mensagem fornece, esclarece, "
"corrige ou referencia plausivelmente o dado solicitado, mesmo em linguagem livre. "
"Considere NOT_RELEVANT quando a mensagem abandona a ação, inicia outro objetivo, "
"faz uma pergunta diferente ou não responde ao dado pedido. Não extraia valores e "
"não decida abandono/intent; classifique apenas pertinência. Retorne somente JSON "
"válido: {\"relevance\":\"RELEVANT|NOT_RELEVANT\",\"confidence\":0.0,\"reason\":\"...\"}."
)
try:
answer = await self.llm.ainvoke(
[
{"role": "system", "content": system},
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
],
temperature=0.0,
max_tokens=256,
profile_name="router",
component_name="router.transaction_parameter_relevance",
generation_name="llm.transaction_parameter_relevance",
)
data = self._parse_json(answer)
except Exception as exc:
logger.warning("Falha ao classificar pertinência do parâmetro transacional: %s", exc)
return None
relevance = str(data.get("relevance") or "").strip().upper()
try:
confidence = float(data.get("confidence") or 0.0)
except (TypeError, ValueError):
return None
if confidence < self.intent_shift_threshold:
return None
if relevance == "RELEVANT":
return True
if relevance == "NOT_RELEVANT":
return False
return None
async def _transaction_parameter_precedence( async def _transaction_parameter_precedence(
self, self,
state: dict[str, Any], state: dict[str, Any],
@@ -846,11 +1076,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":
@@ -966,13 +1197,14 @@ class EnterpriseRouter:
*, *,
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.
Não existe lista de palavras para desistência ou mudança de assunto. Uma Não existe lista de palavras para desistência. ABANDON é sempre uma
interrupção nasce de uma intent diferente resolvida por uma keyword decisão semântica do LLM baseada no contexto transacional. Uma possível
configurada no ``routing.yaml`` ou, na ausência dela, por uma decisão nova intenção pode aparecer como hint do ``routing.yaml``, mas esse hint
semântica do LLM com o contexto da transação pendente. não decide abandono e não substitui a classificação semântica.
""" """
active_tx = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {} active_tx = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {}
started_intent = str(active_tx.get("started_from_intent") or "").strip() started_intent = str(active_tx.get("started_from_intent") or "").strip()
@@ -1038,18 +1270,32 @@ class EnterpriseRouter:
else None else None
), ),
} }
system = ( if abandon_only:
"Você decide apenas se o turno atual continua a transação ativa ou muda de intenção. " system = (
"Use o significado da mensagem e o contexto transacional; não use palavras isoladas como regra. " "Você decide somente se o usuário desistiu explicitamente da ação/transação ativa. "
"A extração dos parâmetros pendentes já foi tentada antes desta etapa e não consumiu o turno. " "Quando a transação estava coletando parâmetros, a pertinência ao parâmetro já foi avaliada antes desta etapa; "
"Se ainda assim a mensagem for apenas uma resposta referencial/valor/nome ao dado pendente, retorne CONTINUE. " "portanto não transforme uma resposta pertinente ao parâmetro em abandono. "
"Se o usuário passou claramente a perseguir outro objetivo sem desistir explicitamente da ação atual, " "Use o significado completo da mensagem e o contexto transacional; não use palavras isoladas como regra. "
"retorne SHIFT e a nova intent permitida. " "Se houver desistência explícita da ação atual, retorne ABANDON. "
"Se o usuário desistiu explicitamente da ação/transação atual, retorne ABANDON. " "Quando o abandono vier acompanhado de um novo objetivo, também informe intent e agent desse novo objetivo. "
"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. "
"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 "
"Retorne somente JSON válido com decision, intent, agent, confidence, reason." "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 = { user = {
"message": text, "message": text,
"transaction": transaction_context, "transaction": transaction_context,
@@ -1074,7 +1320,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

@@ -630,32 +630,144 @@ 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: active_tx = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {}
# an explicit yes/no answers the confirmation contract itself. legacy_tx = state.get("pending_tool_call") or state.get("selected_tool_call") or {}
if tx_status == "AWAITING_CONFIRMATION": state_lock_name = str(
consumed = await self._transaction_parameter_precedence( state_decision.next_state
state, text=str(text), state_decision=state_decision or current_state
) or state_decision.intent.removeprefix("state:")
if consumed is not None: or ""
await self._emit(consumed, state) ).strip().upper()
return consumed transactional_state_lock = state_lock_name.startswith(
("WAITING_", "COLLECTING_", "AWAITING_")
)
# Transaction parameter precedence is absolute while collecting: # Rich transactional latches remain the primary source of truth.
# first let the active transaction try to consume the current turn. # The domain state lock is only a recovery fallback for checkpoints
# Only when NO pending parameter can be extracted do we ask the # where those latches were not restored. This prevents a generic
# semantic classifier whether the user changed goals. This prevents # COLLECTING_* state from stealing a legitimate parameter answer.
# value/name/reference answers (for example "a de 14,99") from being rich_transaction_context = bool(
# stolen by a semantically plausible but incompatible intent. active_tx.get("tool_name")
if tx_status == "COLLECTING_PARAMETERS": or (isinstance(legacy_tx, dict) and legacy_tx.get("tool_name"))
or tx_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}
)
fallback_state_lock_context = bool(
transactional_state_lock and not rich_transaction_context
)
has_transaction_context = bool(
rich_transaction_context or fallback_state_lock_context
)
# Some hosts restore the domain state but omit transaction_status.
# Infer only the generic phase needed by the precedence logic; do not
# replace richer transaction data when it exists.
effective_tx_status = tx_status
if not effective_tx_status:
if state_lock_name.startswith("COLLECTING_"):
effective_tx_status = "COLLECTING_PARAMETERS"
elif state_lock_name.startswith("AWAITING_") or "CONFIRMATION" in state_lock_name:
effective_tx_status = "AWAITING_CONFIRMATION"
precedence_state = state
if effective_tx_status and effective_tx_status != tx_status:
precedence_state = dict(state)
precedence_state["transaction_status"] = effective_tx_status
# Transactional interruption precedence is semantic, not lexical.
#
# COLLECTING_PARAMETERS is special: the pending parameter gets the
# first opportunity to consume the turn. If the input is pertinent
# to the parameter, extraction returns values and the transaction
# continues. Only when the input is not pertinent do we ask the
# semantic interruption classifier whether the user abandoned the
# active transaction. This preserves inputs such as
# ``motivo é que desisti`` as a legitimate ``reason`` value while
# still allowing ``não quero mais devolver; quero ver meus serviços``
# to abandon the current operation and start a new objective.
if effective_tx_status == "COLLECTING_PARAMETERS":
if rich_transaction_context:
relevance = await self._classify_transaction_parameter_relevance(
precedence_state, text=str(text)
)
if relevance is True:
consumed = await self._transaction_parameter_precedence(
precedence_state, text=str(text), state_decision=state_decision
)
if consumed is not None:
consumed.metadata = {
**(consumed.metadata or {}),
"transaction_parameter_relevant": True,
}
await self._emit(consumed, state)
return consumed
# Pertinent to the requested parameter, but extraction did
# not produce a structured value. Keep transaction
# ownership and let the agent reprompt/clarify; do not
# reinterpret the same input as ABANDON or SHIFT.
state_decision.metadata = {
**(state_decision.metadata or {}),
"transaction_parameter_relevant": True,
"transaction_parameter_extraction_empty": True,
}
await self._emit(state_decision, state)
return state_decision
if relevance is None:
# Compatibility/fail-safe when the relevance classifier is
# unavailable: retain the previous extractor-first behavior.
consumed = await self._transaction_parameter_precedence(
precedence_state, text=str(text), state_decision=state_decision
)
if consumed is not None:
consumed.metadata = {
**(consumed.metadata or {}),
"transaction_parameter_relevance": "unknown",
}
await self._emit(consumed, state)
return consumed
if has_transaction_context:
abandonment = await self._transaction_state_interruption_candidate(
precedence_state,
text=str(text),
state_decision=state_decision,
abandon_only=True,
)
if abandonment is not None:
abandonment.metadata = {
**(abandonment.metadata or {}),
"transaction_parameter_relevant": False if rich_transaction_context else None,
}
await self._emit(abandonment, state)
return abandonment
# In confirmation/waiting phases there is no pending free-form
# parameter whose value can be confused with abandonment. Evaluate
# ABANDON semantically before confirmation/state-lock consumption.
# Plain confirmations (e.g. yes/no) are classified as CONTINUE by
# this probe and then follow the normal confirmation path below.
elif has_transaction_context:
abandonment = await self._transaction_state_interruption_candidate(
precedence_state,
text=str(text),
state_decision=state_decision,
abandon_only=True,
)
if abandonment is not None:
await self._emit(abandonment, state)
return abandonment
if effective_tx_status == "AWAITING_CONFIRMATION":
consumed = await self._transaction_parameter_precedence( consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=state_decision precedence_state, text=str(text), state_decision=state_decision
) )
if consumed is not None: if consumed is not None:
await self._emit(consumed, state) await self._emit(consumed, state)
return consumed return consumed
interruption = await self._transaction_state_interruption_candidate( interruption = await self._transaction_state_interruption_candidate(
state, text=str(text), state_decision=state_decision precedence_state, text=str(text), state_decision=state_decision
) )
if interruption is not None: if interruption is not None:
await self._emit(interruption, state) await self._emit(interruption, state)
@@ -686,19 +798,61 @@ class EnterpriseRouter:
method="state", method="state",
next_state=tx_status, next_state=tx_status,
) )
if tx_status == "AWAITING_CONFIRMATION":
consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=synthetic
)
if consumed is not None:
consumed.metadata = {
**(consumed.metadata or {}),
"transaction_state_recovered": True,
}
await self._emit(consumed, state)
return consumed
if tx_status == "COLLECTING_PARAMETERS": if tx_status == "COLLECTING_PARAMETERS":
relevance = await self._classify_transaction_parameter_relevance(
state, text=str(text)
)
if relevance is True:
consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=synthetic
)
if consumed is not None:
consumed.metadata = {
**(consumed.metadata or {}),
"transaction_state_recovered": True,
"transaction_parameter_relevant": True,
}
await self._emit(consumed, state)
return consumed
synthetic.metadata = {
**(synthetic.metadata or {}),
"transaction_state_recovered": True,
"transaction_parameter_relevant": True,
"transaction_parameter_extraction_empty": True,
}
await self._emit(synthetic, state)
return synthetic
if relevance is None:
consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=synthetic
)
if consumed is not None:
consumed.metadata = {
**(consumed.metadata or {}),
"transaction_state_recovered": True,
"transaction_parameter_relevance": "unknown",
}
await self._emit(consumed, state)
return consumed
# No parameter was consumed (or this is confirmation): now decide
# semantically whether the user abandoned the active transaction.
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
if tx_status == "AWAITING_CONFIRMATION":
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
) )
@@ -837,6 +991,82 @@ class EnterpriseRouter:
return decision return decision
async def _classify_transaction_parameter_relevance(
self, state: dict[str, Any], *, text: str
) -> bool | None:
"""Semantically decide whether the turn answers a pending parameter.
This is intentionally separate from extraction. A parameter extractor
may be able to manufacture a syntactically valid value from unrelated
text; relevance answers the higher-level question first: does the user
appear to be responding to what the active transaction asked for?
Returns True/False when classification succeeds and None when the
classifier is unavailable or its output cannot be trusted.
"""
if not (self.enable_llm_router and self.llm is not None):
return None
missing = [str(name) for name in (state.get("missing_parameters") or []) if str(name).strip()]
if not missing:
return None
active = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {}
tool_name = str(active.get("tool_name") or "").strip()
if not tool_name:
return None
payload = {
"message": str(text or ""),
"transaction": {
"intent": active.get("started_from_intent") or state.get("intent"),
"tool_name": tool_name,
"tool_description": active.get("tool_description"),
"missing_parameters": missing,
"parameter_schema": active.get("parameter_schema") or {},
"known_arguments": active.get("arguments") or {},
"relevant_conversation_context": (
active.get("parameter_conversational_context")
or self._collect_transaction_parameter_context(state=state, current_text=str(text))
),
},
}
system = (
"Você decide somente se a mensagem do usuário é uma resposta pertinente ao(s) "
"parâmetro(s) que a transação ativa está pedindo. Avalie significado e contexto, "
"não palavras isoladas. Considere RELEVANT quando a mensagem fornece, esclarece, "
"corrige ou referencia plausivelmente o dado solicitado, mesmo em linguagem livre. "
"Considere NOT_RELEVANT quando a mensagem abandona a ação, inicia outro objetivo, "
"faz uma pergunta diferente ou não responde ao dado pedido. Não extraia valores e "
"não decida abandono/intent; classifique apenas pertinência. Retorne somente JSON "
"válido: {\"relevance\":\"RELEVANT|NOT_RELEVANT\",\"confidence\":0.0,\"reason\":\"...\"}."
)
try:
answer = await self.llm.ainvoke(
[
{"role": "system", "content": system},
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
],
temperature=0.0,
max_tokens=256,
profile_name="router",
component_name="router.transaction_parameter_relevance",
generation_name="llm.transaction_parameter_relevance",
)
data = self._parse_json(answer)
except Exception as exc:
logger.warning("Falha ao classificar pertinência do parâmetro transacional: %s", exc)
return None
relevance = str(data.get("relevance") or "").strip().upper()
try:
confidence = float(data.get("confidence") or 0.0)
except (TypeError, ValueError):
return None
if confidence < self.intent_shift_threshold:
return None
if relevance == "RELEVANT":
return True
if relevance == "NOT_RELEVANT":
return False
return None
async def _transaction_parameter_precedence( async def _transaction_parameter_precedence(
self, self,
state: dict[str, Any], state: dict[str, Any],
@@ -846,11 +1076,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":
@@ -966,13 +1197,14 @@ class EnterpriseRouter:
*, *,
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.
Não existe lista de palavras para desistência ou mudança de assunto. Uma Não existe lista de palavras para desistência. ABANDON é sempre uma
interrupção nasce de uma intent diferente resolvida por uma keyword decisão semântica do LLM baseada no contexto transacional. Uma possível
configurada no ``routing.yaml`` ou, na ausência dela, por uma decisão nova intenção pode aparecer como hint do ``routing.yaml``, mas esse hint
semântica do LLM com o contexto da transação pendente. não decide abandono e não substitui a classificação semântica.
""" """
active_tx = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {} active_tx = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {}
started_intent = str(active_tx.get("started_from_intent") or "").strip() started_intent = str(active_tx.get("started_from_intent") or "").strip()
@@ -1038,18 +1270,32 @@ class EnterpriseRouter:
else None else None
), ),
} }
system = ( if abandon_only:
"Você decide apenas se o turno atual continua a transação ativa ou muda de intenção. " system = (
"Use o significado da mensagem e o contexto transacional; não use palavras isoladas como regra. " "Você decide somente se o usuário desistiu explicitamente da ação/transação ativa. "
"A extração dos parâmetros pendentes já foi tentada antes desta etapa e não consumiu o turno. " "Quando a transação estava coletando parâmetros, a pertinência ao parâmetro já foi avaliada antes desta etapa; "
"Se ainda assim a mensagem for apenas uma resposta referencial/valor/nome ao dado pendente, retorne CONTINUE. " "portanto não transforme uma resposta pertinente ao parâmetro em abandono. "
"Se o usuário passou claramente a perseguir outro objetivo sem desistir explicitamente da ação atual, " "Use o significado completo da mensagem e o contexto transacional; não use palavras isoladas como regra. "
"retorne SHIFT e a nova intent permitida. " "Se houver desistência explícita da ação atual, retorne ABANDON. "
"Se o usuário desistiu explicitamente da ação/transação atual, retorne ABANDON. " "Quando o abandono vier acompanhado de um novo objetivo, também informe intent e agent desse novo objetivo. "
"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. "
"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 "
"Retorne somente JSON válido com decision, intent, agent, confidence, reason." "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 = { user = {
"message": text, "message": text,
"transaction": transaction_context, "transaction": transaction_context,
@@ -1074,7 +1320,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

@@ -11,8 +11,25 @@ from agent_framework.runtime.transaction_parameters import reconcile_transaction
class _RouterLLM: class _RouterLLM:
async def ainvoke(self, messages, **kwargs): async def ainvoke(self, messages, **kwargs):
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])
low = prompt.lower()
if kwargs.get("generation_name") == "llm.transaction_parameter_relevance":
if "isso mesmo, pode cancelar" in low or "motivo é que desisti" in low or "motivo e que desisti" in low:
return json.dumps({"relevance": "RELEVANT", "confidence": 0.99, "reason": "responde ao contexto solicitado"})
return json.dumps({"relevance": "NOT_RELEVANT", "confidence": 0.99, "reason": "não responde ao parâmetro pendente"})
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()
or "nao quero cancelar. quero apenas ver meus servicos contratados" in prompt.lower()
or "nao quero mais cancelar. quero apenas ver os servicos que eu contratei" 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 +65,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 +80,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 +146,168 @@ 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_explicit_abandonment_precedes_generic_state_lock_without_transaction_status(tmp_path):
state = _awaiting_confirmation_state(
"nao quero cancelar. quero apenas ver meus servicos contratados"
)
# Reproduce the host/checkpoint shape observed in production: the domain
# WAITING_* state is restored, but transaction_status is absent/stale.
state.pop("transaction_status", None)
decision = await _router(tmp_path).route(state)
assert decision.intent == "product_services_information"
assert decision.agent == "product_agent"
assert decision.metadata["transaction_interruption"] == "explicit_abandonment"
assert decision.metadata["interrupted_state"] == "WAITING_CONTESTACAO_CONFIRMATION"
assert not decision.metadata.get("transaction_turn_consumed")
@pytest.mark.asyncio
async def test_explicit_abandonment_precedes_state_lock_with_only_domain_state_preserved(tmp_path):
state = _awaiting_confirmation_state(
"nao quero mais cancelar. quero apenas ver os servicos que eu contratei"
)
# Production checkpoint shape: only the domain WAITING_* lock survives.
# No transaction_status, active_transaction or legacy pending tool latch.
state.pop("transaction_status", None)
state.pop("active_transaction", None)
state.pop("pending_tool_call", None)
state.pop("selected_tool_call", None)
decision = await _router(tmp_path).route(state)
assert decision.intent == "product_services_information"
assert decision.agent == "product_agent"
assert decision.metadata["transaction_interruption"] == "explicit_abandonment"
assert decision.metadata["interrupted_state"] == "WAITING_CONTESTACAO_CONFIRMATION"
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])
generation = kwargs.get("generation_name")
low = prompt.lower()
# Relevance is a semantic gate before extraction. The extractor below is
# intentionally misleading to prove that NOT_RELEVANT prevents a false
# parameter value from stealing an abandonment turn.
if generation == "llm.transaction_parameter_relevance":
if "motivo é que desisti" in low or "motivo e que desisti" in low:
return json.dumps({"relevance": "RELEVANT", "confidence": 0.99, "reason": "responde ao reason"})
if "nao quero mais devolver" in low or "não quero mais devolver" in low or "nao quero mais cancelar" in low:
return json.dumps({"relevance": "NOT_RELEVANT", "confidence": 0.99, "reason": "abandona a ação"})
return json.dumps({"relevance": "NOT_RELEVANT", "confidence": 0.95, "reason": "não responde ao parâmetro"})
if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt:
if "motivo é que desisti" in low or "motivo e que desisti" in low:
return json.dumps({"valor": "desisti"})
if "nao quero mais cancelar" in low or "não quero mais cancelar" in low:
return json.dumps({"valor": 14.99})
if "nao quero mais devolver" in low or "não quero mais devolver" in low:
return json.dumps({
"decision": "ABANDON",
"intent": "product_services_information",
"agent": "product_agent",
"confidence": 0.99,
"reason": "abandono explícito da devolução com novo objetivo",
})
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_parameter_relevance_precedes_abandonment(tmp_path):
state = _active_state("motivo é que desisti")
state["missing_parameters"] = ["valor"]
state["active_transaction"]["parameter_schema"] = {"valor": {"type": "string", "description": "motivo informado pelo cliente"}}
decision = await _router_with_misleading_parameter_extractor(tmp_path).route(state)
assert decision.intent == "state:COLLECTING_PARAMETERS"
assert decision.metadata["transaction_turn_consumed"] is True
assert decision.metadata["transaction_parameter_relevant"] is True
assert decision.metadata["transaction_parameter_values"] == {"valor": "desisti"}
assert "transaction_interruption" not in decision.metadata
@pytest.mark.asyncio
async def test_semantic_not_relevant_prevents_misleading_extractor_and_allows_abandon(tmp_path):
decision = await _router_with_misleading_parameter_extractor(tmp_path).route(
_active_state("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 decision.metadata["transaction_parameter_relevant"] is False
assert not decision.metadata.get("transaction_turn_consumed")
@pytest.mark.asyncio
async def test_abandon_plus_new_goal_after_parameter_relevance_fails(tmp_path):
decision = await _router_with_misleading_parameter_extractor(tmp_path).route(
_active_state("nao quero mais devolver. quero apenas ver 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["transaction_parameter_relevant"] is False
class _PolicyRouter: class _PolicyRouter:
def __init__(self): def __init__(self):
self.registry = SimpleNamespace( self.registry = SimpleNamespace(
@@ -354,3 +539,79 @@ async def test_runtime_abandon_cancels_only_active_transaction_without_rearming_
assert state["tool_policy_result"]["action"] == "cancelled_by_explicit_abandonment" assert state["tool_policy_result"]["action"] == "cancelled_by_explicit_abandonment"
# ABANDON encerra a ação ativa; não apaga outras ações pendentes por efeito colateral. # ABANDON encerra a ação ativa; não apaga outras ações pendentes por efeito colateral.
assert state["pending_topics"] == [{"operation_id": "op-x", "intent": "other_pending_intent"}] assert state["pending_topics"] == [{"operation_id": "op-x", "intent": "other_pending_intent"}]
class _ReasonParameterRouterLLM(_RouterLLM):
async def ainvoke(self, messages, **kwargs):
prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1])
lowered = prompt.lower()
if kwargs.get("generation_name") == "llm.transaction_parameter_relevance":
if "motivo é que desisti" in lowered or "motivo e que desisti" in lowered:
return json.dumps({"relevance": "RELEVANT", "confidence": 0.99, "reason": "responde ao motivo"})
if "nao quero mais devolver o pedido" in lowered or "não quero mais devolver o pedido" in lowered:
return json.dumps({"relevance": "NOT_RELEVANT", "confidence": 0.99, "reason": "abandona a devolução"})
if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt:
if "motivo é que desisti" in lowered or "motivo e que desisti" in lowered:
return json.dumps({"reason": "desisti"})
if "nao quero mais devolver o pedido" in lowered or "não quero mais devolver o pedido" in lowered:
return json.dumps({
"decision": "ABANDON",
"intent": None,
"agent": None,
"confidence": 0.99,
"reason": "abandono explícito da devolução",
})
return await super().ainvoke(messages, **kwargs)
def _reason_collecting_state(message):
return {
"user_text": message,
"sanitized_input": message,
"next_state": "COLLECTING_ORDERS_PARAMETERS",
"transaction_status": "COLLECTING_PARAMETERS",
"missing_parameters": ["reason"],
"intent": "state:COLLECTING_PARAMETERS",
"active_agent": "orders_agent",
"active_transaction": {
"tool_name": "solicitar_devolucao",
"arguments": {"order_id": "PED-1001"},
"status": "COLLECTING_PARAMETERS",
"started_from_intent": "retail_support_exchange_return",
"parameter_schema": {"reason": {"type": "string", "description": "motivo da devolução"}},
"tool_description": "Solicita devolução de pedido informando o motivo",
},
"history": [
{"role": "user", "content": "quero devolver o pedido PED-1001"},
{"role": "assistant", "content": "Qual o motivo da devolução?"},
{"role": "user", "content": message},
],
}
@pytest.mark.asyncio
async def test_parameter_value_desisti_is_not_abandon_when_reason_is_expected(tmp_path):
router = _router(tmp_path)
router.llm = _ReasonParameterRouterLLM()
decision = await router.route(_reason_collecting_state("motivo é que desisti"))
assert decision.intent == "state:COLLECTING_PARAMETERS"
assert decision.agent == "contestacao_agent" or decision.agent == "orders_agent"
assert decision.metadata["transaction_turn_consumed"] is True
assert decision.metadata["transaction_parameter_values"] == {"reason": "desisti"}
assert "transaction_interruption" not in decision.metadata
@pytest.mark.asyncio
async def test_action_scoped_abandon_still_preempts_reason_extraction(tmp_path):
router = _router(tmp_path)
router.llm = _ReasonParameterRouterLLM()
# The action is explicitly abandoned; this must not be consumed as reason.
decision = await router.route(_reason_collecting_state("nao quero mais devolver o pedido"))
assert decision.metadata["transaction_interruption"] == "explicit_abandonment"
def test_abandonment_has_no_lexical_gate_in_router_source():
from pathlib import Path
source = Path("libs/agent_framework/src/agent_framework/routing/enterprise_router.py").read_text(encoding="utf-8")
assert "_looks_like_explicit_abandonment" not in source
assert "_looks_like_action_abandonment" not in source
assert "cues = (" not in source

View File

@@ -301,7 +301,7 @@ intents:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_incompatible_intent_shift_runs_only_when_parameter_extractor_does_not_consume(tmp_path): async def test_incompatible_intent_shift_runs_only_when_parameter_extractor_does_not_consume(tmp_path):
"""A real new goal still shifts, but only after parameter extraction declines it.""" """A real new goal shifts after semantic parameter-relevance declines it."""
routing = tmp_path / "routing.yaml" routing = tmp_path / "routing.yaml"
routing.write_text( routing.write_text(
""" """
@@ -331,6 +331,8 @@ intents:
async def ainvoke(self, messages, **kwargs): async def ainvoke(self, messages, **kwargs):
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("generation_name") == "llm.transaction_parameter_relevance":
return json.dumps({"relevance": "NOT_RELEVANT", "confidence": 0.99, "reason": "test relevance"})
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:
self.extraction_calls += 1 self.extraction_calls += 1
# The extractor must not convert a clearly new request into the # The extractor must not convert a clearly new request into the
@@ -374,13 +376,13 @@ intents:
assert decision.intent == "retail_order_cancel" assert decision.intent == "retail_order_cancel"
assert decision.agent == "orders_agent" assert decision.agent == "orders_agent"
assert decision.metadata["transaction_interruption"] == "intent_shift" assert decision.metadata["transaction_interruption"] == "intent_shift"
assert llm.shift_calls == 1 assert llm.shift_calls == 2 # ABANDON probe + SHIFT classification
assert llm.extraction_calls == 1 assert llm.extraction_calls == 0 # NOT_RELEVANT skips parameter extraction
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_semantic_shift_without_keyword_runs_after_parameter_extractor_declines(tmp_path): async def test_semantic_shift_without_keyword_runs_after_parameter_extractor_declines(tmp_path):
"""Semantic SHIFT remains available when no pending parameter is consumed.""" """Semantic SHIFT remains available after parameter relevance is NOT_RELEVANT."""
routing = tmp_path / "routing.yaml" routing = tmp_path / "routing.yaml"
routing.write_text( routing.write_text(
""" """
@@ -410,6 +412,8 @@ intents:
async def ainvoke(self, messages, **kwargs): async def ainvoke(self, messages, **kwargs):
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("generation_name") == "llm.transaction_parameter_relevance":
return json.dumps({"relevance": "NOT_RELEVANT", "confidence": 0.99, "reason": "test relevance"})
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:
self.extraction_calls += 1 self.extraction_calls += 1
return json.dumps({"reason": None}) return json.dumps({"reason": None})
@@ -452,8 +456,8 @@ intents:
assert decision.agent == "orders_agent" assert decision.agent == "orders_agent"
assert decision.metadata["transaction_interruption"] == "intent_shift" assert decision.metadata["transaction_interruption"] == "intent_shift"
assert decision.metadata["interruption_source"] == "semantic_classifier" assert decision.metadata["interruption_source"] == "semantic_classifier"
assert llm.shift_calls == 1 assert llm.shift_calls == 2 # ABANDON probe + SHIFT classification
assert llm.extraction_calls == 1 assert llm.extraction_calls == 0 # NOT_RELEVANT skips parameter extraction
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -487,6 +491,8 @@ intents:
async def ainvoke(self, messages, **kwargs): async def ainvoke(self, messages, **kwargs):
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("generation_name") == "llm.transaction_parameter_relevance":
return json.dumps({"relevance": "RELEVANT", "confidence": 0.99, "reason": "test relevance"})
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:
self.extraction_calls += 1 self.extraction_calls += 1
assert "Tamboro Mensal" in prompt assert "Tamboro Mensal" in prompt