bugfix: extractor cases 8 and 16

This commit is contained in:
2026-09-08 10:13:08 -03:00
parent b0f65c6844
commit 3c28c6a1e7
4 changed files with 109 additions and 105 deletions

View File

@@ -3008,48 +3008,38 @@ class AgentRuntimeMixin:
policy = self._resolve_tool_execution_policy(tool_name, previous_args)
missing_before = self._missing_required_arguments(policy, previous_args)
# Primeira tentativa: coleta tradicional usando SOMENTE a fala
# atual. O histórico não deve participar enquanto a mensagem corrente
# conseguir preencher o contrato normalmente.
# Uma única fonte de verdade para parâmetros: o extractor recebe
# todos os required fields, valores já conhecidos e o contexto
# 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)
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,
tool_name=tool_name,
missing_parameters=missing_before,
missing_parameters=collector_fields,
known_arguments=previous_args,
)
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"] = {
"tool_name": tool_name,
"mode": "current_turn",
"mode": "single_extractor",
"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
# forma genérica (não são parsing textual). Para required fields,
@@ -3096,65 +3086,11 @@ class AgentRuntimeMixin:
state, tool_name=tool_name, arguments=arguments, policy=policy, emit_events=emit_events
)
if pre_validation_result is not None:
# A pre-validation é a fronteira autoritativa da coleta
# tradicional. Se ela rejeitar um candidato de forma recuperável
# (NEEDS_PARAMETER), ainda não repromptamos o usuário: fazemos UMA
# tentativa de reconciliação temporal com o histórico bounded e
# 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]
# A pre-validation é a fronteira autoritativa. Se pedir um
# parâmetro novamente, o estado produzido por ela é preservado
# e o próximo turno volta ao mesmo extractor. Não existe uma
# segunda tentativa escondida de reconciliação no mesmo turno.
return [pre_validation_result]
tool_name, policy, force_confirmation = self._apply_prevalidated_transaction_decision(
state, tool_name=tool_name, arguments=arguments, policy=policy

View File

@@ -230,20 +230,88 @@ async def extract_transaction_parameters(
tool_description: str | None = None,
conversational_context: str | None = None,
) -> 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
needs preserve/clear/provenance decisions. Keeping this facade avoids
breaking routers and existing extensions that only need candidate extraction.
The current utterance is authoritative for new values/corrections. A bounded
conversation context may only be used to resolve references made by the
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(
llm,
text=text,
tool_name=tool_name,
parameter_names=missing_parameters,
known_arguments=known_arguments,
parameter_schema=parameter_schema,
tool_description=tool_description,
conversational_context=conversational_context,
names = [str(name) for name in (missing_parameters or []) if str(name).strip()]
message = str(text or "").strip()
if not names or not message or llm is None:
return {}
schema = dict(parameter_schema or {})
known = {
str(key): value
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