mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-11-06 11:24:13 +00:00
bugfix: handoff
This commit is contained in:
@@ -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.
|
||||
@@ -256,6 +256,79 @@ class EnterpriseRouter:
|
||||
return matched, 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
|
||||
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)]
|
||||
@@ -524,6 +597,39 @@ class EnterpriseRouter:
|
||||
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 = 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":
|
||||
decision = await self._route_contextual_reentry(
|
||||
state=state,
|
||||
|
||||
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.
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.
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user