bugfix: handoff

This commit is contained in:
T3782834
2026-09-22 21:27:41 -03:00
parent e7b0ce4912
commit 8102a88537
123 changed files with 355 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
# Fix: contextual reentry before terminal workflow resume
## Problem
A paused workflow with a declarative `expected_input.semantic_classifier` could receive a
compound reply such as:
```text
ainda nao. Eu nao contratei esse neymar jr
```
If the classifier returned only `NAO`, the framework immediately resumed the paused workflow.
A backend workflow was then free to enter a terminal branch (for example human handoff) before
the substantive remainder of the same user turn was reinterpreted by normal routing.
This is an orchestration problem in the framework, not a domain rule for billing and not a
frontend problem.
## Corrected precedence
Literal `expected_input` matches keep absolute precedence and are unchanged. For non-literal
semantic matches, when the workflow itself declares an option with
`option_actions.<option>.action: contextual_reentry`, the framework now performs one generic
precedence validation before committing to a normal workflow resume.
The validation asks only whether the current utterance is completely consumed by the selected
resume option or whether it also contains substantive information that still needs interpretation.
It contains no domain vocabulary and no hardcoded `SIM`/`NAO` business meaning.
The effective order is now:
1. deterministic `expected_input` exact match;
2. explicit global human-handoff control;
3. workflow `semantic_classifier`;
4. if the semantic result would resume and the contract declares `contextual_reentry`, validate
whether substantive remainder exists;
5. substantive remainder -> `contextual_reentry` and normal routing;
6. pure reply -> resume paused workflow normally;
7. only after a genuine resume can the backend workflow reach its own terminal/handoff branch.
A workflow can explicitly disable this safeguard with:
```yaml
semantic_classifier:
contextual_reentry_precedence: false
```
The default is enabled whenever a `contextual_reentry` option is declared.
## Observability
When reentry preempts a semantic resume, route metadata now includes:
- `contextual_reentry_preempted_resume: true`
- `initial_classifier_output`
- `contextual_reentry_precedence_raw_output`
- the existing contextual-reentry metadata (`original_input`, `classifier_output`, bounded context)
## Regression tests
Tests cover both directions:
- a compound negative reply whose first classifier result is `NAO` is protected from premature
resume and is rerouted through contextual reentry;
- a pure semantic negative reply remains a normal workflow resume.

View File

@@ -256,6 +256,79 @@ class EnterpriseRouter:
return matched, raw return matched, raw
return None, raw return None, raw
async def _prefer_contextual_reentry_over_semantic_resume(
self,
*,
text: str,
classified: str,
expected_input: dict[str, Any],
pause_prompt: str,
relevant_conversation_context: str,
) -> tuple[bool, str | None, str | None]:
"""Protect contextual reentry from a premature workflow resume.
``semantic_classifier`` is agent-declared and normally authoritative. A
special ambiguity exists when the same contract declares an option whose
action is ``contextual_reentry``: an LLM can correctly notice a leading
answer to the pause (for example a negative acknowledgement) but miss the
substantive remainder of the same utterance. Resuming the workflow in
that situation can immediately reach a terminal branch such as handoff,
before normal routing sees the new information.
The framework therefore performs a generic *precedence validation* only
for non-literal semantic matches and only when the contract explicitly
declares contextual reentry. No domain vocabulary or SIM/NAO semantics
are embedded here.
"""
if self.llm is None or not isinstance(expected_input, dict):
return False, None, None
classifier = expected_input.get("semantic_classifier")
if not isinstance(classifier, dict):
return False, None, None
option_actions = classifier.get("option_actions")
if not isinstance(option_actions, dict):
return False, None, None
reentry_option = None
for option, cfg in option_actions.items():
if isinstance(cfg, dict) and str(cfg.get("action") or "").strip().lower() == "contextual_reentry":
reentry_option = str(option)
break
if not reentry_option or str(classified).upper() == reentry_option.upper():
return False, reentry_option, None
if classifier.get("contextual_reentry_precedence") is False:
return False, reentry_option, None
prompt = (
"Você valida precedência entre retomar um workflow pausado e fazer reentrada contextual. "
"Não decida a intenção de negócio. Analise apenas se a mensagem atual é totalmente "
"consumida pela opção já classificada ou se contém informação substantiva adicional que "
"precisa ser interpretada pelo pipeline normal.\n\n"
f"Pergunta pendente: {str(pause_prompt or '').strip()}\n"
f"Opção inicialmente classificada: {str(classified)}\n"
f"Contexto relevante: {str(relevant_conversation_context or '').strip()}\n"
f"Mensagem atual: {str(text or '').strip()}\n\n"
"Responda somente REENTER se houver pedido, objeção, correção, explicação, motivo, entidade, "
"valor, referência ou outro fato novo que ainda precise de interpretação além da resposta "
"à pergunta pendente. Responda somente RESUME se a mensagem inteira for apenas a resposta "
"à pergunta pendente, admitindo somente cortesia sem nova demanda."
)
try:
answer = await self.llm.ainvoke(
[
{"role": "system", "content": prompt},
{"role": "user", "content": str(text or "")},
],
profile_name="router",
component_name="workflow.expected_input.precedence",
generation_name="workflow.expected_input.contextual_reentry_precedence",
)
except Exception as exc:
logger.warning("Falha ao validar precedência de contextual_reentry: %s", exc)
return False, reentry_option, None
raw = str(answer or "").strip()
return raw.upper() == "REENTER", reentry_option, raw
@staticmethod @staticmethod
def _last_assistant_prompt(state: dict[str, Any], current_text: str) -> str: def _last_assistant_prompt(state: dict[str, Any], current_text: str) -> str:
history = [item for item in (state.get("history") or []) if isinstance(item, dict)] history = [item for item in (state.get("history") or []) if isinstance(item, dict)]
@@ -524,6 +597,39 @@ class EnterpriseRouter:
option_actions = option_actions if isinstance(option_actions, dict) else {} option_actions = option_actions if isinstance(option_actions, dict) else {}
action_cfg = option_actions.get(str(classified)) or option_actions.get(str(classified).upper()) action_cfg = option_actions.get(str(classified)) or option_actions.get(str(classified).upper())
action_cfg = action_cfg if isinstance(action_cfg, dict) else {} action_cfg = action_cfg if isinstance(action_cfg, dict) else {}
# A semantic match that would resume the paused workflow
# must not prematurely win over an explicitly declared
# contextual_reentry option when the same utterance also
# carries substantive new information. This validation is
# intentionally generic and runs only after literal
# expected_input matching has already failed.
if str(action_cfg.get("action") or "").strip().lower() != "contextual_reentry":
prefer_reentry, reentry_option, precedence_raw = await self._prefer_contextual_reentry_over_semantic_resume(
text=str(text),
classified=str(classified),
expected_input=expected_input,
pause_prompt=str(pause.get("prompt") or ""),
relevant_conversation_context=relevant_context,
)
if prefer_reentry and reentry_option:
decision = await self._route_contextual_reentry(
state=state,
original_input=str(text),
relevant_context=relevant_context,
classifier_output=str(reentry_option),
raw_classifier=raw_classifier,
allowed_values=list(expected_input.get("allowed_values") or []),
)
decision.metadata = {
**dict(decision.metadata or {}),
"contextual_reentry_preempted_resume": True,
"initial_classifier_output": str(classified),
"contextual_reentry_precedence_raw_output": precedence_raw,
}
await self._emit(decision, state)
return decision
if str(action_cfg.get("action") or "").strip().lower() == "contextual_reentry": if str(action_cfg.get("action") or "").strip().lower() == "contextual_reentry":
decision = await self._route_contextual_reentry( decision = await self._route_contextual_reentry(
state=state, state=state,

Some files were not shown because too many files have changed in this diff Show More