bugfix reconciliation extractor

This commit is contained in:
2026-09-07 22:38:12 -03:00
parent c90596436b
commit 168a27c0a1
3 changed files with 262 additions and 64 deletions

View File

@@ -880,12 +880,13 @@ class EnterpriseRouter:
text: str, text: str,
state_decision: RouteDecision, state_decision: RouteDecision,
) -> RouteDecision | None: ) -> RouteDecision | None:
"""Detecta semanticamente mudança de intenção durante uma transação. """Detecta abandono explícito da transação seguido (ou não) de nova intenção.
Não existe lista de palavras para desistência ou mudança de assunto. Uma Durante COLLECTING_PARAMETERS/AWAITING_CONFIRMATION, uma classificação
interrupção nasce de uma intent diferente resolvida por uma keyword genérica de outra intent nunca basta para interromper a transação. A saída
configurada no ``routing.yaml`` ou, na ausência dela, por uma decisão só é permitida quando o turno expressa semanticamente abandono explícito
semântica do LLM com o contexto da transação pendente. do objetivo atual (por exemplo, desistir/deixar para lá) ou handoff tratado
por sua regra própria. Não há blacklist/keyword hardcoded de abandono.
""" """
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()
@@ -910,15 +911,10 @@ class EnterpriseRouter:
# both failure modes: parameter extraction cannot hide a real new goal, # both failure modes: parameter extraction cannot hide a real new goal,
# and a broad keyword cannot steal a legitimate parameter turn. # and a broad keyword cannot steal a legitimate parameter turn.
if not (self.enable_llm_router and self.llm is not None): if not (self.enable_llm_router and self.llm is not None):
configured_candidate.metadata = { # Sem classificador semântico não há evidência suficiente para
**(configured_candidate.metadata or {}), # distinguir uma nova meta explícita de uma expressão pertencente
"transaction_interruption": "intent_shift", # à própria transação (ex.: "isso mesmo, pode cancelar").
"interrupted_state": state_decision.next_state, return None
"interrupted_agent": state_decision.agent,
"interrupted_intent": started_intent or previous_intent,
"interruption_source": "configured_routing",
}
return configured_candidate
if not (self.enable_llm_router and self.llm is not None): if not (self.enable_llm_router and self.llm is not None):
return None return None
@@ -951,11 +947,11 @@ class EnterpriseRouter:
), ),
} }
system = ( system = (
"Você decide apenas se o turno atual continua a transação ativa ou muda de intenção. " "Você decide somente se o usuário ABANDONA EXPLICITAMENTE a transação ativa. "
"Use o significado da mensagem e o contexto transacional; não use palavras isoladas como regra. " "Uma nova intent semanticamente plausível NÃO basta. Use o significado completo da mensagem e o contexto transacional; nunca use uma palavra isolada como regra. "
"A extração dos parâmetros pendentes já foi tentada antes desta etapa e não consumiu o turno. " "Retorne CONTINUE quando a fala puder ser confirmação, correção, parâmetro, referência ou instrução ligada à operação ativa, inclusive expressões como 'pode cancelar' quando cancelar pode ser a própria ação em andamento. "
"Se ainda assim a mensagem for apenas uma resposta referencial/valor/nome ao dado pendente, retorne CONTINUE. " "Retorne ABANDON somente quando houver evidência explícita de desistência/abandono da operação atual; se a mesma fala também trouxer um novo objetivo, informe a nova intent/agent permitidos. "
"Se o usuário passou claramente a perseguir outro objetivo, retorne SHIFT e a nova intent permitida. " "Exemplo semântico: 'esquece isso, quero ver minha fatura' = ABANDON + nova intent. "
"Retorne somente JSON válido com decision, intent, agent, confidence, reason." "Retorne somente JSON válido com decision, intent, agent, confidence, reason."
) )
user = { user = {
@@ -981,7 +977,7 @@ class EnterpriseRouter:
logger.warning("Falha ao avaliar mudança semântica de intent transacional via LLM: %s", exc) logger.warning("Falha ao avaliar mudança semântica de intent transacional via LLM: %s", exc)
return None return None
if str(data.get("decision") or "").strip().upper() != "SHIFT": if str(data.get("decision") or "").strip().upper() != "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:
@@ -1002,11 +998,11 @@ class EnterpriseRouter:
reason=str(data.get("reason") or "Mudança semântica de intenção durante transação."), reason=str(data.get("reason") or "Mudança semântica de intenção durante transação."),
method="llm", method="llm",
metadata={ metadata={
"transaction_interruption": "intent_shift", "transaction_interruption": "explicit_abandonment",
"interrupted_state": state_decision.next_state, "interrupted_state": state_decision.next_state,
"interrupted_agent": state_decision.agent, "interrupted_agent": state_decision.agent,
"interrupted_intent": started_intent or previous_intent, "interrupted_intent": started_intent or previous_intent,
"interruption_source": "semantic_classifier", "interruption_source": "semantic_abandonment_classifier",
"configured_routing_hint": ( "configured_routing_hint": (
configured_candidate.intent if configured_candidate is not None else None configured_candidate.intent if configured_candidate is not None else None
), ),

View File

@@ -12,7 +12,7 @@ from typing import Any, Iterable, Mapping
from agent_framework.memory.summary_memory import MemoryContext, render_recent_messages from agent_framework.memory.summary_memory import MemoryContext, render_recent_messages
from agent_framework.runtime.transaction_parameters import extract_transaction_parameters, reconcile_transaction_parameters, parse_transaction_confirmation from agent_framework.runtime.transaction_parameters import extract_current_transaction_parameters, extract_transaction_parameters, reconcile_transaction_parameters, parse_transaction_confirmation
from agent_framework.workflows.input_contract import match_expected_input from agent_framework.workflows.input_contract import match_expected_input
@@ -859,6 +859,17 @@ class AgentRuntimeMixin:
if parameter: if parameter:
recovered_arguments = dict(arguments or {}) recovered_arguments = dict(arguments or {})
recovered_arguments.pop(parameter, None) recovered_arguments.pop(parameter, None)
# Domain validators may declare derived fields that became invalid
# together with the rejected parameter. The framework does not
# interpret field names; it simply honors the generic clear_fields
# contract. This prevents stale derived values from contaminating
# later collection turns.
validator_clear_fields = [
str(name) for name in (payload.get("clear_fields") or [])
if str(name).strip()
]
for field in validator_clear_fields:
recovered_arguments.pop(field, None)
recovered_policy = self._resolve_tool_execution_policy(tool_name, recovered_arguments) recovered_policy = self._resolve_tool_execution_policy(tool_name, recovered_arguments)
missing = self._missing_required_arguments(recovered_policy, recovered_arguments) missing = self._missing_required_arguments(recovered_policy, recovered_arguments)
if parameter not in missing: if parameter not in missing:
@@ -877,6 +888,7 @@ class AgentRuntimeMixin:
"status": status, "status": status,
"parameter": parameter, "parameter": parameter,
"terminal": False, "terminal": False,
"validator_clear_fields": validator_clear_fields,
"result": result, "result": result,
} }
# A recoverable validator may provide a user-facing explanation # A recoverable validator may provide a user-facing explanation
@@ -1540,36 +1552,56 @@ class AgentRuntimeMixin:
arguments: dict[str, Any], arguments: dict[str, Any],
reconciliation: dict[str, Any] | None, reconciliation: dict[str, Any] | None,
*, *,
locked_current_fields: set[str] | None = None, protected_current_fields: set[str] | None = None,
) -> tuple[dict[str, Any], list[str], list[str]]: ) -> tuple[dict[str, Any], list[str]]:
"""Apply temporal reconciliation without mutating explicit current-turn fields. """Apply temporal reconciliation without erasing current-turn candidates.
Values extracted from the current user utterance have highest precedence for Current-turn extraction is the first source of truth for *what the user
that turn. The temporal reconciler may use them as anchors, but it cannot just said*. Reconciliation may canonicalize that candidate, and business
clear or overwrite them. A later authoritative pre-validation step may pre-validation may reject it, but temporal history must not make it
still reject a current value and trigger the dedicated recovery path. disappear before the validator sees it.
""" """
out = dict(arguments or {}) out = dict(arguments or {})
rec = dict(reconciliation or {}) rec = dict(reconciliation or {})
locked = {str(name) for name in (locked_current_fields or set())} protected = {str(name) for name in (protected_current_fields or set())}
ignored_clears: list[str] = [] ignored_clears: list[str] = []
ignored_values: list[str] = []
for field in rec.get("clear_fields") or []: for field in rec.get("clear_fields") or []:
name = str(field) name = str(field)
if name in locked: if name in protected:
ignored_clears.append(name) ignored_clears.append(name)
continue continue
out.pop(name, None) out.pop(name, None)
# Canonicalized values are still allowed; this is not a hard field lock.
out.update(dict(rec.get("values") or {}))
return out, ignored_clears
for field, value in dict(rec.get("values") or {}).items(): @staticmethod
def _apply_prevalidated_structural_candidates(
arguments: dict[str, Any],
reconciliation: dict[str, Any] | None,
policy: dict[str, Any] | None,
) -> tuple[dict[str, Any], dict[str, str]]:
"""Apply structural *candidates* only behind authoritative pre-validation.
Candidates are not resolved parameters. They may be raw local evidence
fragments and exist solely so a configured domain validator can
canonicalize/reject them. Without enabled pre-validation they are ignored.
"""
out = dict(arguments or {})
cfg = (policy or {}).get("pre_validation") if isinstance(policy, dict) else None
if not isinstance(cfg, dict) or not cfg.get("enabled") or not str(cfg.get("tool") or "").strip():
return out, {}
rec = dict(reconciliation or {})
candidates = dict(rec.get("candidates") or {})
provenance = dict(rec.get("candidate_provenance") or {})
applied: dict[str, str] = {}
for field, value in candidates.items():
name = str(field) name = str(field)
if name in locked: if out.get(name) not in _EMPTY_VALUES or value in _EMPTY_VALUES:
ignored_values.append(name)
continue continue
out[name] = value out[name] = value
applied[name] = str(provenance.get(name) or "structural_candidate")
return out, ignored_clears, ignored_values return out, applied
async def _reconcile_transaction_parameters( async def _reconcile_transaction_parameters(
self, self,
@@ -1676,7 +1708,7 @@ class AgentRuntimeMixin:
route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {} route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {}
text = route_meta.get("original_input") if route_meta.get("contextual_reentry") else None text = route_meta.get("original_input") if route_meta.get("contextual_reentry") else None
text = text or state.get("sanitized_input") or state.get("user_text") or "" text = text or state.get("sanitized_input") or state.get("user_text") or ""
return await extract_transaction_parameters( return await extract_current_transaction_parameters(
getattr(self, "llm", None), getattr(self, "llm", None),
text=str(text), text=str(text),
tool_name=tool_name, tool_name=tool_name,
@@ -1684,7 +1716,6 @@ class AgentRuntimeMixin:
known_arguments=dict(known_arguments or {}), known_arguments=dict(known_arguments or {}),
parameter_schema=schema, parameter_schema=schema,
tool_description=description, tool_description=description,
conversational_context="",
) )
async def _extract_transaction_parameters( async def _extract_transaction_parameters(
@@ -3043,12 +3074,12 @@ class AgentRuntimeMixin:
# intent, a transação anterior é encerrada e seus latches são limpos. # intent, a transação anterior é encerrada e seus latches são limpos.
active_before_interruption = self._active_transaction(state) active_before_interruption = self._active_transaction(state)
interruption = str(route_meta.get("transaction_interruption") or "").strip().lower() interruption = str(route_meta.get("transaction_interruption") or "").strip().lower()
if active_before_interruption and interruption == "intent_shift": if active_before_interruption and interruption == "explicit_abandonment":
interrupted_tool = active_before_interruption.get("tool_name") interrupted_tool = active_before_interruption.get("tool_name")
self._finish_active_transaction(state, "CANCELLED") self._finish_active_transaction(state, "CANCELLED")
state["transaction_pre_validation"] = None state["transaction_pre_validation"] = None
state["tool_policy_result"] = { state["tool_policy_result"] = {
"action": "cancelled_by_intent_shift", "action": "cancelled_by_explicit_abandonment",
"tool_name": interrupted_tool, "tool_name": interrupted_tool,
} }
@@ -3082,21 +3113,59 @@ class AgentRuntimeMixin:
policy = self._resolve_tool_execution_policy(tool_name, previous_args) policy = self._resolve_tool_execution_policy(tool_name, previous_args)
missing_before = self._missing_required_arguments(policy, previous_args) missing_before = self._missing_required_arguments(policy, previous_args)
# Primeira tentativa: coleta tradicional usando SOMENTE a fala # O EnterpriseRouter pode já ter consumido parâmetros pendentes
# atual. O histórico não deve participar enquanto a mensagem corrente # neste MESMO turno para garantir precedência da transação sobre
# conseguir preencher o contrato normalmente. # intent shift. Esses valores não são apenas telemetria: precisam
# ser materializados no estado transacional antes de qualquer nova
# extração. Eles continuam sendo candidatos e passam normalmente
# pela pre-validation autoritativa antes de confirmação/execução.
arguments = dict(previous_args) arguments = dict(previous_args)
cached_route_values: dict[str, Any] = {}
cached = route_meta.get("transaction_parameter_values")
missing_before_set = {str(name) for name in missing_before}
if isinstance(cached, dict):
cached_route_values = {
str(key): value
for key, value in cached.items()
if str(key) in missing_before_set and value not in _EMPTY_VALUES
}
arguments.update(cached_route_values)
# A fala atual pode tanto preencher um campo faltante quanto corrigir
# explicitamente um campo já coletado. Por isso o extrator current-only
# examina TODOS os parâmetros requeridos, nunca apenas missing_parameters.
# Ele continua estritamente local à mensagem atual e só devolve campos
# explicitamente presentes; portanto não compete com a reconciliação
# temporal e não apaga estado quando o usuário não mencionou o campo.
required_fields = [str(name) for name in (policy.get("requires") or [])]
# Legacy/domain overrides historically receive only pending fields.
# Keep that extension contract intact; the base framework extractor
# is the one allowed to inspect every required field so an explicit
# current-turn correction can replace an already collected value.
legacy_override = getattr(type(self), "_extract_transaction_parameters", None)
has_legacy_override = (
legacy_override is not None
and legacy_override is not AgentRuntimeMixin._extract_transaction_parameters
)
current_fields = (
self._missing_required_arguments(policy, arguments)
if has_legacy_override else required_fields
)
extracted_current = await self._extract_transaction_parameters_current_only( extracted_current = await self._extract_transaction_parameters_current_only(
state, state,
tool_name=tool_name, tool_name=tool_name,
missing_parameters=missing_before, missing_parameters=current_fields,
known_arguments=previous_args, known_arguments=arguments,
) )
arguments.update(dict(extracted_current or {})) arguments.update(dict(extracted_current or {}))
state["transaction_parameter_collection"] = { state["transaction_parameter_collection"] = {
"tool_name": tool_name, "tool_name": tool_name,
"mode": "current_turn", "mode": "current_turn",
"resolved_fields": sorted(str(k) for k in (extracted_current or {}).keys()), "router_resolved_fields": sorted(cached_route_values.keys()),
"current_turn_resolved_fields": sorted(str(k) for k in (extracted_current or {}).keys()),
"resolved_fields": sorted(
set(cached_route_values.keys()) | {str(k) for k in (extracted_current or {}).keys()}
),
} }
# Segunda opção: somente se a coleta tradicional ainda deixar # Segunda opção: somente se a coleta tradicional ainda deixar
@@ -3112,11 +3181,13 @@ class AgentRuntimeMixin:
parameter_names=required_fields, parameter_names=required_fields,
known_arguments=arguments, known_arguments=arguments,
) )
locked_current_fields = { protected_current_fields = {
str(k) for k, v in dict(extracted_current or {}).items() if v not in _EMPTY_VALUES str(k)
for k, v in dict(extracted_current or {}).items()
if v not in _EMPTY_VALUES
} }
arguments, ignored_clears, ignored_values = self._apply_transaction_reconciliation( arguments, ignored_clears = self._apply_transaction_reconciliation(
arguments, reconciliation, locked_current_fields=locked_current_fields arguments, reconciliation, protected_current_fields=protected_current_fields
) )
state["transaction_parameter_reconciliation"] = { state["transaction_parameter_reconciliation"] = {
"tool_name": tool_name, "tool_name": tool_name,
@@ -3125,9 +3196,8 @@ class AgentRuntimeMixin:
"provenance": dict(reconciliation.get("provenance") or {}), "provenance": dict(reconciliation.get("provenance") or {}),
"clear_fields": list(reconciliation.get("clear_fields") or []), "clear_fields": list(reconciliation.get("clear_fields") or []),
"values": dict(reconciliation.get("values") or {}), "values": dict(reconciliation.get("values") or {}),
"locked_current_fields": sorted(locked_current_fields), "protected_current_fields": sorted(protected_current_fields),
"ignored_clear_fields": sorted(ignored_clears), "ignored_clear_fields": sorted(ignored_clears),
"ignored_reconciled_values": sorted(ignored_values),
} }
else: else:
state.pop("transaction_parameter_reconciliation", None) state.pop("transaction_parameter_reconciliation", None)
@@ -3152,6 +3222,12 @@ class AgentRuntimeMixin:
tool_name, arguments, state, exclude_fields=policy.get("requires") or [] tool_name, arguments, state, exclude_fields=policy.get("requires") or []
) )
policy = self._resolve_tool_execution_policy(tool_name, arguments) policy = self._resolve_tool_execution_policy(tool_name, arguments)
arguments, applied_candidates = self._apply_prevalidated_structural_candidates(
arguments, locals().get("reconciliation"), policy
)
if applied_candidates:
state.setdefault("transaction_parameter_reconciliation", {})["applied_structural_candidates"] = applied_candidates
policy = self._resolve_tool_execution_policy(tool_name, arguments)
missing = self._missing_required_arguments(policy, arguments) missing = self._missing_required_arguments(policy, arguments)
if missing: if missing:
self._set_collecting_parameters( self._set_collecting_parameters(
@@ -3429,11 +3505,13 @@ class AgentRuntimeMixin:
parameter_names=required, parameter_names=required,
known_arguments=action_args, known_arguments=action_args,
) )
locked_current_fields = { protected_current_fields = {
str(k) for k, v in dict(extracted_initial or {}).items() if v not in _EMPTY_VALUES str(k)
for k, v in dict(extracted_initial or {}).items()
if str(k) in set(missing_initial) and v not in _EMPTY_VALUES
} }
action_args, ignored_clears, ignored_values = self._apply_transaction_reconciliation( action_args, ignored_clears = self._apply_transaction_reconciliation(
action_args, reconciliation, locked_current_fields=locked_current_fields action_args, reconciliation, protected_current_fields=protected_current_fields
) )
state["transaction_parameter_reconciliation"] = { state["transaction_parameter_reconciliation"] = {
"tool_name": selected_action, "tool_name": selected_action,
@@ -3442,9 +3520,8 @@ class AgentRuntimeMixin:
"provenance": dict(reconciliation.get("provenance") or {}), "provenance": dict(reconciliation.get("provenance") or {}),
"clear_fields": list(reconciliation.get("clear_fields") or []), "clear_fields": list(reconciliation.get("clear_fields") or []),
"values": dict(reconciliation.get("values") or {}), "values": dict(reconciliation.get("values") or {}),
"locked_current_fields": sorted(locked_current_fields), "protected_current_fields": sorted(protected_current_fields),
"ignored_clear_fields": sorted(ignored_clears), "ignored_clear_fields": sorted(ignored_clears),
"ignored_reconciled_values": sorted(ignored_values),
} }
else: else:
state.pop("transaction_parameter_reconciliation", None) state.pop("transaction_parameter_reconciliation", None)
@@ -3466,6 +3543,18 @@ class AgentRuntimeMixin:
) )
state["tool_policy_result"] = {**policy, "tool_name": selected_action} state["tool_policy_result"] = {**policy, "tool_name": selected_action}
action_args, applied_candidates = self._apply_prevalidated_structural_candidates(
action_args, locals().get("reconciliation"), policy
)
if applied_candidates:
state.setdefault("transaction_parameter_reconciliation", {})["applied_structural_candidates"] = applied_candidates
selected = {"tool_name": selected_action, "arguments": action_args}
state["selected_tool_call"] = selected
self._set_active_transaction(
state, tool_name=selected_action, arguments=action_args, status="COLLECTING_PARAMETERS"
)
policy = self._resolve_tool_execution_policy(selected_action, action_args)
missing = self._missing_required_arguments(policy, action_args) missing = self._missing_required_arguments(policy, action_args)
if missing: if missing:
self._set_collecting_parameters( self._set_collecting_parameters(

View File

@@ -69,6 +69,81 @@ def parse_transaction_confirmation(text: str) -> str | None:
return None return None
async def extract_current_transaction_parameters(
llm: Any,
*,
text: str,
tool_name: str,
missing_parameters: list[str],
known_arguments: Mapping[str, Any] | None = None,
parameter_schema: Mapping[str, Any] | None = None,
tool_description: str | None = None,
) -> dict[str, Any]:
"""Extract only values explicitly present in the current user utterance.
This is deliberately narrower than temporal reconciliation: no history, no
cross-turn inference and no routing. Spoken numeric expressions are normalized
according to the declared numeric type, while textual fields must satisfy the
schema description in the current message itself.
"""
names = [str(x) for x in (missing_parameters or []) if str(x).strip()]
message = str(text or "").strip()
if not names or not message or llm is None:
return {}
schema = dict(parameter_schema or {})
field_spec = {
name: {
"type": schema.get(name, "string") if not isinstance(schema.get(name), dict) else schema.get(name, {}).get("type", "string"),
"description": None if not isinstance(schema.get(name), dict) else schema.get(name, {}).get("description"),
}
for name in names
}
output = {name: None for name in names}
prompt = (
"Extraia SOMENTE parâmetros explicitamente expressos na mensagem atual do usuário. "
"Não use histórico, não complete referências elípticas com conhecimento anterior e não invente valores. "
"Use exclusivamente type/description do schema para decidir se um trecho preenche um campo. "
"Para campos numéricos, normalize também números falados por extenso para número (por exemplo uma expressão monetária falada deve virar number). "
"Para campos textuais que exigem uma entidade concreta, uma referência apenas por valor/posição não preenche o campo. "
"Retorne SOMENTE JSON válido com exatamente as chaves pedidas; use null quando ausente ou ambíguo.\n"
f"tool: {tool_name}\n"
f"tool_description: {tool_description or ''}\n"
f"known_arguments: {json.dumps(dict(known_arguments or {}), ensure_ascii=False, default=str)}\n"
f"parameter_schema: {json.dumps(field_spec, ensure_ascii=False, default=str)}\n"
f"current_user_message: {message}\n"
f"Formato: {json.dumps(output, ensure_ascii=False)}"
)
try:
response = await llm.ainvoke(
[{"role": "user", "content": prompt}],
profile_name="transaction_parameter_extraction",
component_name="transaction_parameter_extraction",
generation_name="llm.transaction_parameter_current_only",
temperature=0.0,
)
except TypeError:
response = await llm.ainvoke([{"role": "user", "content": prompt}])
except Exception as exc:
logger.warning("transaction.parameter.current_extract_failed tool=%s fields=%s error=%s", tool_name, names, exc)
return {}
raw = _response_text(response).strip()
try:
payload = parse_json_object(raw)
except (TypeError, ValueError):
logger.warning("transaction.parameter.current_extract_invalid tool=%s raw=%r", tool_name, raw[:240])
return {}
if not isinstance(payload, dict):
return {}
values: dict[str, Any] = {}
for name in names:
value = payload.get(name)
declared = field_spec.get(name, {}).get("type", "string")
coerced = _coerce(value, declared)
if coerced not in _EMPTY_VALUES:
values[name] = coerced
return values
async def reconcile_transaction_parameters( async def reconcile_transaction_parameters(
llm: Any, llm: Any,
*, *,
@@ -396,11 +471,49 @@ async def reconcile_transaction_parameters(
else: else:
decisions[name] = "unresolved" decisions[name] = "unresolved"
# Structural candidates are intentionally NOT resolved values. They are only
# fallback material for a configured authoritative pre-validator. This lets
# the runtime carry a unique local evidence fragment (or, when no anchor
# exists, a single prior user utterance) to domain validation without letting
# the framework invent/canonicalize business entities.
structural_candidates: dict[str, Any] = {}
candidate_provenance: dict[str, str] = {}
# For a field that was already missing before reconciliation, ``clear`` has
# no distinct state effect: there is no prior value to invalidate. Treat it
# as unresolved *only for structural-candidate fallback* so a unique anchored
# history fragment can still be offered to the configured authoritative
# pre-validator. This does not promote the fragment to a resolved value and
# does not change ``clear_fields`` semantics for fields that were previously
# populated.
unresolved_missing = [
name for name in missing_initial
if decisions.get(name) in {"unresolved", "clear"} and name not in values
]
anchored = [b for b in evidence_blocks if b.get("anchor_matches") and str(b.get("text") or "").strip()]
prior_user = [
b for b in evidence_blocks
if int(b.get("priority") or 99) == 4 and str(b.get("text") or "").strip()
]
for name in unresolved_missing:
declared = str(field_spec.get(name, {}).get("type") or "string").strip().lower()
if declared not in {"string", "str", "text"}:
continue
if len(anchored) == 1:
structural_candidates[name] = str(anchored[0].get("text") or "").strip()
candidate_provenance[name] = "unique_anchored_evidence_block"
logger.info( logger.info(
"transaction.parameter.reconciled tool=%s decisions=%s resolved=%s clear=%s provenance=%s", "transaction.parameter.reconciled tool=%s decisions=%s resolved=%s clear=%s provenance=%s candidates=%s",
tool_name, decisions, sorted(values), clear_fields, provenance, tool_name, decisions, sorted(values), clear_fields, provenance, sorted(structural_candidates),
) )
return {"values": values, "decisions": decisions, "provenance": provenance, "clear_fields": clear_fields} return {
"values": values,
"decisions": decisions,
"provenance": provenance,
"clear_fields": clear_fields,
"candidates": structural_candidates,
"candidate_provenance": candidate_provenance,
}
async def extract_transaction_parameters( async def extract_transaction_parameters(