bugfix: extractor cases 8 and 16

This commit is contained in:
2026-09-08 10:13:03 -03:00
parent 1711575abe
commit 82636ec15b
4 changed files with 109 additions and 105 deletions

View File

@@ -3008,47 +3008,37 @@ 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 # Uma única fonte de verdade para parâmetros: o extractor recebe
# atual. O histórico não deve participar enquanto a mensagem corrente # todos os required fields, valores já conhecidos e o contexto
# conseguir preencher o contrato normalmente. # bounded da própria transação. Assim ele pode resolver referências
# e correções explícitas sem uma segunda máquina de reconciliação.
arguments = dict(previous_args) arguments = dict(previous_args)
extracted_current = await self._extract_transaction_parameters_current_only( required_fields = [str(name) for name in (policy.get("requires") or [])]
collector_override = getattr(type(self), "_extract_transaction_parameters", None)
collector_fields = (
list(missing_before)
if collector_override is not None and collector_override is not AgentRuntimeMixin._extract_transaction_parameters
else required_fields
)
extracted_current = await self._extract_transaction_parameters(
state, state,
tool_name=tool_name, tool_name=tool_name,
missing_parameters=missing_before, missing_parameters=collector_fields,
known_arguments=previous_args, known_arguments=previous_args,
) )
arguments.update(dict(extracted_current or {})) arguments.update(dict(extracted_current or {}))
route_meta_collection = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {}
router_values = route_meta_collection.get("transaction_parameter_values")
router_fields = sorted(
str(k) for k, v in (router_values.items() if isinstance(router_values, dict) else [])
if v not in _EMPTY_VALUES
)
state["transaction_parameter_collection"] = { state["transaction_parameter_collection"] = {
"tool_name": tool_name, "tool_name": tool_name,
"mode": "current_turn", "mode": "single_extractor",
"resolved_fields": sorted(str(k) for k in (extracted_current or {}).keys()), "resolved_fields": sorted(str(k) for k in (extracted_current or {}).keys()),
"router_resolved_fields": router_fields,
} }
# Segunda opção: somente se a coleta tradicional ainda deixar
# required fields em aberto, reconcilie temporalmente usando o
# schema/descrições declarados em tools.yaml e o contexto bounded
# newest->oldest preservado na transação.
missing_after_current = self._missing_required_arguments(policy, arguments)
if missing_after_current:
required_fields = [str(name) for name in (policy.get("requires") or [])]
reconciliation = await self._reconcile_transaction_parameters(
state,
tool_name=tool_name,
parameter_names=required_fields,
known_arguments=arguments,
)
for field in reconciliation.get("clear_fields") or []:
arguments.pop(str(field), None)
arguments.update(dict(reconciliation.get("values") or {}))
state["transaction_parameter_reconciliation"] = {
"tool_name": tool_name,
"trigger": "traditional_collection_unresolved",
"decisions": dict(reconciliation.get("decisions") or {}),
"provenance": dict(reconciliation.get("provenance") or {}),
"clear_fields": list(reconciliation.get("clear_fields") or []),
}
else:
state.pop("transaction_parameter_reconciliation", None) state.pop("transaction_parameter_reconciliation", None)
# Argumentos estruturados já presentes no contexto são aceitos de # Argumentos estruturados já presentes no contexto são aceitos de
@@ -3096,64 +3086,10 @@ class AgentRuntimeMixin:
state, tool_name=tool_name, arguments=arguments, policy=policy, emit_events=emit_events state, tool_name=tool_name, arguments=arguments, policy=policy, emit_events=emit_events
) )
if pre_validation_result is not None: if pre_validation_result is not None:
# A pre-validation é a fronteira autoritativa da coleta # A pre-validation é a fronteira autoritativa. Se pedir um
# tradicional. Se ela rejeitar um candidato de forma recuperável # parâmetro novamente, o estado produzido por ela é preservado
# (NEEDS_PARAMETER), ainda não repromptamos o usuário: fazemos UMA # e o próximo turno volta ao mesmo extractor. Não existe uma
# tentativa de reconciliação temporal com o histórico bounded e # segunda tentativa escondida de reconciliação no mesmo turno.
# com a semântica declarada em tools.yaml. Isso cobre referências
# como "é a de R$ 19,99" sem tornar o reconciliador o caminho
# primário de COLLECTING_PARAMETERS.
pre_meta = state.get("transaction_pre_validation")
pre_meta = pre_meta if isinstance(pre_meta, dict) else {}
retry_parameter = str(pre_meta.get("parameter") or "").strip()
recoverable = (
str(pre_meta.get("status") or "").upper() == "NEEDS_PARAMETER"
and bool(retry_parameter)
and state.get("transaction_status") == "COLLECTING_PARAMETERS"
)
if recoverable:
recovered_selected = dict(self._active_transaction(state) or state.get("selected_tool_call") or {})
recovered_arguments = dict(recovered_selected.get("arguments") or {})
required_fields = [str(name) for name in (policy.get("requires") or [])]
reconciliation = await self._reconcile_transaction_parameters(
state,
tool_name=tool_name,
parameter_names=required_fields,
known_arguments=recovered_arguments,
)
for field in reconciliation.get("clear_fields") or []:
recovered_arguments.pop(str(field), None)
recovered_arguments.update(dict(reconciliation.get("values") or {}))
state["transaction_parameter_reconciliation"] = {
"tool_name": tool_name,
"trigger": "prevalidation_needs_parameter",
"rejected_parameter": retry_parameter,
"decisions": dict(reconciliation.get("decisions") or {}),
"provenance": dict(reconciliation.get("provenance") or {}),
"clear_fields": list(reconciliation.get("clear_fields") or []),
}
retry_policy = self._resolve_tool_execution_policy(tool_name, recovered_arguments)
retry_missing = self._missing_required_arguments(retry_policy, recovered_arguments)
if retry_parameter not in retry_missing:
state["selected_tool_call"] = {"tool_name": tool_name, "arguments": recovered_arguments}
self._set_active_transaction(
state, tool_name=tool_name, arguments=recovered_arguments, status="COLLECTING_PARAMETERS"
)
state["missing_parameters"] = retry_missing
pre_validation_retry = await self._run_transaction_pre_validation(
state,
tool_name=tool_name,
arguments=recovered_arguments,
policy=retry_policy,
emit_events=emit_events,
)
if pre_validation_retry is None:
arguments = recovered_arguments
policy = retry_policy
pre_validation_result = None
else:
return [pre_validation_retry]
if pre_validation_result is not None:
return [pre_validation_result] return [pre_validation_result]
tool_name, policy, force_confirmation = self._apply_prevalidated_transaction_decision( tool_name, policy, force_confirmation = self._apply_prevalidated_transaction_decision(

View File

@@ -230,20 +230,88 @@ async def extract_transaction_parameters(
tool_description: str | None = None, tool_description: str | None = None,
conversational_context: str | None = None, conversational_context: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Compatibility facade returning only resolved candidates. """Extract transaction parameter values with one simple source of truth.
New runtime code should use :func:`reconcile_transaction_parameters` when it The current utterance is authoritative for new values/corrections. A bounded
needs preserve/clear/provenance decisions. Keeping this facade avoids conversation context may only be used to resolve references made by the
breaking routers and existing extensions that only need candidate extraction. current utterance (for example, "the 14.99 one"). Known arguments are state
defaults: if a field is not returned it is preserved by the caller.
The extractor does not decide preserve/clear/unresolved, routing, confirmation
or execution. Business validity remains the responsibility of pre-validation.
""" """
result = await reconcile_transaction_parameters( names = [str(name) for name in (missing_parameters or []) if str(name).strip()]
llm, message = str(text or "").strip()
text=text, if not names or not message or llm is None:
tool_name=tool_name, return {}
parameter_names=missing_parameters,
known_arguments=known_arguments, schema = dict(parameter_schema or {})
parameter_schema=parameter_schema, known = {
tool_description=tool_description, str(key): value
conversational_context=conversational_context, for key, value in dict(known_arguments or {}).items()
if value not in _EMPTY_VALUES
}
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_shape = {name: None for name in names}
prompt = (
"Você extrai parâmetros de uma transação ativa. Não faça roteamento, confirmação, execução ou validação de negócio.\n\n"
"REGRAS:\n"
"1. O significado de cada campo vem exclusivamente de parameter_schema e transaction_description, considerando principalmente a descrição semântica quando disponível.\n"
"2. A ausência de tipo ou descrição NÃO impede a extração quando o restante do contrato e o texto forem semanticamente suficientes.\n3. A fala atual é a fonte principal. Retorne um campo quando a fala atual o informa, corrige ou referencia de forma suficientemente clara.\n"
"4. conversational_context é apenas contexto recente para resolver referências da fala atual, como 'esse', 'a de 14,99', 'o primeiro'. Não trate texto do contexto como uma nova afirmação do cliente; use-o somente para interpretar a referência atual.\n"
"5. known_arguments contém valores já conhecidos. Não os repita por obrigação. Se a fala atual não os altera, omita o campo ou retorne null; o runtime os preservará.\n"
"6. Se a fala atual corrigir explicitamente um valor conhecido, retorne o novo valor. A fala atual tem precedência temporal para esse campo.\n"
"7. Você pode retornar mais de um campo quando a combinação da fala atual com o contexto recente determinar a relação de forma clara.\n"
"8. Não valide se o valor existe no backend nem se a combinação é permitida. Retorne o candidato textual; a pre-validation fará a validação autoritativa.\n"
"9. Em caso de dúvida razoável sobre a correspondência ou o valor, prefira null. Não invente associações.\n"
"10. Responda somente JSON válido, sem markdown e sem chaves extras.\n\n"
f"transaction_tool: {tool_name}\n"
f"transaction_description: {tool_description or ''}\n"
f"parameter_names: {json.dumps(names, ensure_ascii=False)}\n"
f"pending_parameters: {json.dumps(names, ensure_ascii=False)}\n"
f"parameter_schema: {json.dumps(field_spec, ensure_ascii=False, default=str)}\n"
f"known_arguments: {json.dumps(known, ensure_ascii=False, default=str)}\n"
f"user_message: {message}\n"
f"conversational_context: {str(conversational_context or '').strip()}\n"
f"Formato obrigatório: {json.dumps(output_shape, ensure_ascii=False)}"
) )
return dict(result.get("values") or {})
try:
response = await llm.ainvoke(
[{"role": "user", "content": prompt}],
profile_name="transaction_parameter_extraction",
component_name="transaction_parameter_extraction",
generation_name="llm.transaction_parameter_extraction",
temperature=0.0,
)
except TypeError:
response = await llm.ainvoke([{"role": "user", "content": prompt}])
except Exception as exc:
logger.warning("transaction.parameter.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.extract_invalid_output 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)
if value in _EMPTY_VALUES:
continue
declared = field_spec.get(name, {}).get("type", "string")
coerced = _coerce(value, declared)
if coerced not in _EMPTY_VALUES:
values[name] = coerced
return values