adjustments: transaction parameter extraction

This commit is contained in:
T3782834
2026-08-21 22:44:36 -03:00
parent 727997aa41
commit d93efd8972
19 changed files with 963 additions and 141 deletions

View File

@@ -9,6 +9,7 @@ from typing import Any
from .config_loader import load_intents, load_router_defaults, load_state_policies
from .continuity import SemanticRouteContinuity
from .models import IntentDefinition, RouteDecision, RouterStatePolicy
from agent_framework.runtime.transaction_parameters import extract_transaction_parameters, parse_transaction_confirmation
logger = logging.getLogger("agent_framework.routing")
@@ -78,6 +79,12 @@ class EnterpriseRouter:
# pendente antes de executar a nova intent.
state_decision = self._route_by_state(current_state)
if state_decision:
consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=state_decision
)
if consumed is not None:
await self._emit(consumed, state)
return consumed
interruption = await self._transaction_state_interruption_candidate(
state, text=str(text), state_decision=state_decision
)
@@ -109,6 +116,17 @@ class EnterpriseRouter:
method="state",
next_state=tx_status,
)
consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=synthetic
)
if consumed is not None:
consumed.metadata = {
**(consumed.metadata or {}),
"transaction_state_recovered": True,
}
await self._emit(consumed, state)
return consumed
interruption = await self._transaction_state_interruption_candidate(
state, text=str(text), state_decision=synthetic
)
@@ -186,6 +204,63 @@ class EnterpriseRouter:
return decision
async def _transaction_parameter_precedence(
self,
state: dict[str, Any],
*,
text: str,
state_decision: RouteDecision,
) -> RouteDecision | None:
"""Consume a turn as transaction parameters before evaluating intent shift.
Only COLLECTING_PARAMETERS participates. The LLM extracts values for the
currently missing parameters; if at least one value is found, the state
route wins deterministically and intent-shift classification is skipped.
"""
tx_status = str(state.get("transaction_status") or "").strip().upper()
if tx_status == "AWAITING_CONFIRMATION":
confirmation = parse_transaction_confirmation(text)
if confirmation is None:
return None
state_decision.metadata = {
**(state_decision.metadata or {}),
"transaction_turn_consumed": True,
"transaction_confirmation_decision": confirmation,
"transaction_confirmation_source": "deterministic",
}
return state_decision
if tx_status != "COLLECTING_PARAMETERS":
return None
missing = [str(name) for name in (state.get("missing_parameters") or []) if str(name).strip()]
if not missing:
return None
active = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {}
tool_name = str(active.get("tool_name") or ((state.get("selected_tool_call") or {}).get("tool_name") if isinstance(state.get("selected_tool_call"), dict) else "") or "").strip()
if not tool_name:
return None
known = dict(active.get("arguments") or {})
schema = active.get("parameter_schema") if isinstance(active.get("parameter_schema"), dict) else {}
description = str(active.get("tool_description") or "")
values = await extract_transaction_parameters(
self.llm,
text=text,
tool_name=tool_name,
missing_parameters=missing,
known_arguments=known,
parameter_schema=schema,
tool_description=description,
)
if not values:
return None
state_decision.metadata = {
**(state_decision.metadata or {}),
"transaction_turn_consumed": True,
"transaction_parameter_values": values,
"transaction_parameter_source": "llm",
"transaction_parameter_missing_before": missing,
}
return state_decision
async def _transaction_state_interruption_candidate(
self,
state: dict[str, Any],
@@ -213,52 +288,15 @@ class EnterpriseRouter:
or (previous_intent and not previous_intent.startswith("state:") and candidate.intent != previous_intent)
)
if different:
tx_status = str(state.get("transaction_status") or "").strip().upper()
missing = list(state.get("missing_parameters") or [])
same_agent = candidate.agent == state_decision.agent
matched_keyword = str((candidate.metadata or {}).get("matched_keyword") or "").strip()
informative_tokens = [
token
for token in self._keyword_tokens(matched_keyword)
if len(token) > 1
]
# Durante coleta de parâmetros, uma keyword genérica de uma única
# palavra do MESMO agente não pode preemptar a transação. Ex.:
# ``o pedido é o PED-1001`` enquanto ``order_id`` está pendente.
# Nesse caso ``pedido`` pode casar com ``retail_order_tracking``,
# mas a mensagem é perfeitamente compatível com a resposta ao
# parâmetro solicitado. Keywords mais específicas (duas ou mais
# palavras informativas) continuam aptas a representar mudança
# explícita de intenção. Se o roteador LLM estiver habilitado,
# deixamos a decisão semântica abaixo desempatar o caso fraco.
weak_same_agent_keyword_during_collection = (
tx_status == "COLLECTING_PARAMETERS"
and bool(missing)
and same_agent
and len(informative_tokens) <= 1
)
if not weak_same_agent_keyword_during_collection:
candidate.metadata = {
**(candidate.metadata or {}),
"transaction_interruption": "intent_shift",
"interrupted_state": state_decision.next_state,
"interrupted_agent": state_decision.agent,
"interrupted_intent": started_intent or previous_intent,
"interruption_source": "configured_routing",
}
return candidate
logger.debug(
"Keyword transacional fraca não preemptou coleta de parâmetro: "
"keyword=%r intent=%s missing=%s",
matched_keyword,
candidate.intent,
missing,
)
# Não retorne aqui: se houver LLM router, ele pode confirmar uma
# mudança semântica real; sem LLM, a transação permanece ativa.
candidate.metadata = {
**(candidate.metadata or {}),
"transaction_interruption": "intent_shift",
"interrupted_state": state_decision.next_state,
"interrupted_agent": state_decision.agent,
"interrupted_intent": started_intent or previous_intent,
"interruption_source": "configured_routing",
}
return candidate
else:
return None

View File

@@ -10,6 +10,7 @@ from typing import Any, Iterable, Mapping
from agent_framework.memory.summary_memory import MemoryContext, render_recent_messages
from agent_framework.runtime.transaction_parameters import extract_transaction_parameters, parse_transaction_confirmation
logger = logging.getLogger(__name__)
@@ -571,6 +572,7 @@ class AgentRuntimeMixin:
state: dict[str, Any],
*,
overwrite_from_message: bool = False,
exclude_fields: Iterable[str] = (),
) -> dict[str, Any]:
"""Executa regras ``extract`` declaradas para a tool escolhida.
@@ -586,11 +588,14 @@ class AgentRuntimeMixin:
return dict(arguments or {})
resolved = dict(arguments or {})
excluded = {str(name) for name in (exclude_fields or ())}
runtime = self.get_runtime_context(state)
message = runtime.sanitized_input or runtime.original_text or runtime.user_text
llm = getattr(self, "llm", None)
for field_name, rule in rules.items():
if str(field_name) in excluded:
continue
from_message = str(rule.get("from") or "message").lower() == "message"
if not from_message:
continue
@@ -1233,46 +1238,59 @@ class AgentRuntimeMixin:
@staticmethod
def _confirmation_decision(text: str) -> str | None:
normalized = " ".join((text or "").strip().lower().split())
normalized = re.sub(r"[.!?]+$", "", normalized).strip()
if normalized in {"sim", "confirmo", "sim, confirmo", "pode fazer", "pode prosseguir", "sim, desejo", "sim, desejo trocar", "sim, confirmo a devolução", "sim, confirmo a troca"}:
return "confirm"
if normalized in {"não", "nao", "cancelar", "cancele", "não confirmo", "nao confirmo"}:
return "reject"
return None
return parse_transaction_confirmation(text)
@staticmethod
def _extract_action_arguments(text: str) -> dict[str, Any]:
"""Extrai apenas entidades explicitamente informadas na mensagem.
def _transaction_parameter_schema(self, tool_name: str, policy: dict[str, Any] | None = None) -> dict[str, Any]:
"""Return generic schema metadata for transactional required parameters."""
cfg = self._tool_config(tool_name)
raw_schema = dict(getattr(cfg, "args_schema", {}) or {}) if cfg is not None else {}
required = [str(name) for name in ((policy or {}).get("requires") or getattr(cfg, "requires", []) or [])]
if not required:
return raw_schema
return {name: raw_schema.get(name, "string") for name in required}
Não usa a mensagem inteira como ``reason``: frases como "quero devolver
uma compra" expressam a ação, mas não necessariamente o motivo. Defaults
declarados no mapper continuam sendo aplicados por ``build_tool_arguments``.
def _transaction_tool_description(self, tool_name: str) -> str:
cfg = self._tool_config(tool_name)
return str(getattr(cfg, "description", "") or "") if cfg is not None else ""
async def _extract_transaction_parameters(
self,
state: dict[str, Any],
*,
tool_name: str,
missing_parameters: list[str],
known_arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Use the dedicated LLM extractor for pending transaction parameters.
A route decision may already contain the extraction performed by the
router solely to enforce parameter-before-intent-shift precedence. Reuse
it to avoid a second LLM call in the same turn.
"""
raw = text or ""
args: dict[str, Any] = {}
match = re.search(
r"(?:pedido|ordem)\s*(?:n[ºo°.]?\s*)?(?:é\s*(?:o\s*)?|[:#=-]\s*)?([A-Za-z0-9_-]+)",
raw,
flags=re.IGNORECASE,
)
if match:
args["order_id"] = match.group(1)
route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {}
cached = route_meta.get("transaction_parameter_values")
if isinstance(cached, dict):
allowed = set(str(x) for x in missing_parameters)
reused = {str(k): v for k, v in cached.items() if str(k) in allowed and v not in _EMPTY_VALUES}
if reused:
return reused
reason_match = re.search(
r"(?:porque|pois|motivo\s*[:=-]?|por\s+(?:arrependimento|defeito|erro|atraso)|me\s+arrependi(?:\s+da\s+compra)?|arrependimento)\s*(.*)",
raw,
flags=re.IGNORECASE,
active = self._active_transaction(state) or {}
schema = active.get("parameter_schema") if isinstance(active.get("parameter_schema"), dict) else None
if not schema:
policy = self._resolve_tool_execution_policy(tool_name, known_arguments or {})
schema = self._transaction_parameter_schema(tool_name, policy)
description = str(active.get("tool_description") or self._transaction_tool_description(tool_name) or "")
text = state.get("sanitized_input") or state.get("user_text") or ""
return await extract_transaction_parameters(
getattr(self, "llm", None),
text=str(text),
tool_name=tool_name,
missing_parameters=list(missing_parameters or []),
known_arguments=known_arguments or {},
parameter_schema=schema,
tool_description=description,
)
if reason_match:
reason = reason_match.group(1).strip(" .,:;-")
if not reason:
matched_phrase = reason_match.group(0).strip(" .,:;-")
if re.search(r"me\s+arrependi|arrependimento", matched_phrase, flags=re.IGNORECASE):
reason = "Arrependimento da compra"
if reason:
args["reason"] = reason
return args
def _transactional_action_match(self, text: str, tools: list[str] | None = None) -> str | None:
"""Detecta solicitação transacional usando metadados de tools.yaml.
@@ -1515,12 +1533,19 @@ class AgentRuntimeMixin:
) -> dict[str, Any]:
current = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {}
txid = transaction_id or current.get("transaction_id") or str(uuid.uuid4())
if str(current.get("tool_name") or "") != str(tool_name):
state["transaction_pre_validation"] = None
cfg = self._tool_config(tool_name)
policy = self._resolve_tool_execution_policy(tool_name, arguments or {})
tx = {
"transaction_id": txid,
"tool_name": tool_name,
"arguments": dict(arguments or {}),
"status": status,
"started_from_intent": current.get("started_from_intent") or state.get("intent"),
"requires": list(policy.get("requires") or getattr(cfg, "requires", []) or []),
"parameter_schema": self._transaction_parameter_schema(tool_name, policy),
"tool_description": self._transaction_tool_description(tool_name),
}
state["active_transaction"] = tx
return tx
@@ -2057,6 +2082,7 @@ class AgentRuntimeMixin:
if active_before_interruption and interruption == "intent_shift":
interrupted_tool = active_before_interruption.get("tool_name")
self._finish_active_transaction(state, "CANCELLED")
state["transaction_pre_validation"] = None
state["tool_policy_result"] = {
"action": "cancelled_by_intent_shift",
"tool_name": interrupted_tool,
@@ -2080,28 +2106,40 @@ class AgentRuntimeMixin:
tool_name = selected.get("tool_name")
if tool_name:
previous_args = dict(selected.get("arguments") or {})
new_args = self.build_tool_arguments(
policy = self._resolve_tool_execution_policy(tool_name, previous_args)
missing_before = self._missing_required_arguments(policy, previous_args)
# Parâmetros TRANSACIONAIS são interpretados exclusivamente pelo
# extrator LLM genérico. Não existem regexes/nome de entidade
# hardcoded no framework. O extrator recebe apenas os parâmetros
# ainda pendentes da policy e pode consumir um ou vários no turno.
extracted = await self._extract_transaction_parameters(
state,
tool_name=tool_name,
intent=state.get("intent"),
aliases=aliases,
extra_args=self._extract_action_arguments(text),
missing_parameters=missing_before,
known_arguments=previous_args,
)
# Durante coleta incremental, valores de contexto podem ainda conter
# parâmetros de uma operação anterior. O que já foi coletado para a
# transação pendente prevalece; o turno atual só preenche lacunas.
non_empty_new = {k: v for k, v in new_args.items() if v not in (None, "", [], {})}
arguments = {**non_empty_new, **previous_args}
arguments = {**previous_args, **extracted}
# Campos de envelope pertencem ao turno corrente e devem permanecer
# atualizados, mesmo quando os parâmetros de negócio ficam congelados.
for per_turn_key in ("query", "operator_instructions", "interaction_key"):
if non_empty_new.get(per_turn_key) not in (None, "", [], {}):
arguments[per_turn_key] = non_empty_new[per_turn_key]
# Reutiliza o contrato declarativo para preencher somente os campos
# ainda faltantes; campos previamente coletados não são sobrescritos.
arguments = await self._extract_mcp_parameters(tool_name, arguments, state)
# Argumentos estruturados já presentes no contexto são aceitos de
# forma genérica (não são parsing textual). Para required fields,
# só completam lacunas que a fala atual/LLM não preencheu; valores
# previamente coletados nunca são sobrescritos.
contextual = self.build_tool_arguments(
state, tool_name=tool_name, intent=state.get("intent"), aliases=aliases
)
required_set = set(str(name) for name in (policy.get("requires") or []))
for key, value in contextual.items():
if value in _EMPTY_VALUES:
continue
if key in required_set:
if arguments.get(key) in _EMPTY_VALUES:
arguments[key] = value
else:
arguments[key] = value
arguments = await self._extract_mcp_parameters(
tool_name, arguments, state, exclude_fields=policy.get("requires") or []
)
policy = self._resolve_tool_execution_policy(tool_name, arguments)
missing = self._missing_required_arguments(policy, arguments)
if missing:
@@ -2250,23 +2288,46 @@ class AgentRuntimeMixin:
if not selected_action:
return results
explicit_action_args = self._extract_action_arguments(text)
action_args = self.build_tool_arguments(
state,
tool_name=selected_action,
intent=state.get("intent"),
aliases=aliases,
extra_args=explicit_action_args,
)
# Nova transação: parâmetros declarados ``from: message`` não podem ser
# herdados de context.tool_arguments de uma operação anterior.
# Campos que o contrato MCP declara como vindos da mensagem corrente não
# podem herdar valores textuais de uma transação anterior. Isto é apenas
# uma regra de freshness do envelope MCP; a extração de policy.requires
# continua exclusivamente no TransactionParameterExtractor LLM abaixo.
action_args = self._drop_stale_message_extracted_arguments(
selected_action, action_args, explicit_fields=explicit_action_args.keys()
selected_action, action_args, explicit_fields=()
)
# A mensagem atual é a fonte de verdade para esses campos no primeiro
# turno transacional.
policy = self._resolve_tool_execution_policy(selected_action, action_args)
required = [str(name) for name in (policy.get("requires") or [])]
# Valores já estruturados no contexto podem satisfazer requirements sem
# parsing textual. Para qualquer required field ainda ausente, a fala do
# usuário é interpretada exclusivamente pelo extrator LLM transacional.
missing_initial = self._missing_required_arguments(policy, action_args)
# No primeiro turno, a fala atual pode fornecer/corrigir qualquer required
# field, inclusive um valor que exista no contexto estruturado mas pertença
# a uma transação anterior. O extrator continua restrito ao contrato
# ``requires`` e só sobrescreve quando a LLM realmente extrai um valor.
extracted_initial = await self._extract_transaction_parameters(
state,
tool_name=selected_action,
missing_parameters=required,
known_arguments={k: v for k, v in action_args.items() if k not in set(required)},
)
action_args.update(extracted_initial)
# O mapper MCP continua responsável somente por parâmetros auxiliares que
# não pertencem ao contrato transacional.
action_args = await self._extract_mcp_parameters(
selected_action, action_args, state, overwrite_from_message=True
selected_action,
action_args,
state,
overwrite_from_message=True,
exclude_fields=required,
)
policy = self._resolve_tool_execution_policy(selected_action, action_args)
selected = {"tool_name": selected_action, "arguments": action_args}

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import re
from typing import Any
def confirmation_decision(text: str) -> str | None:
"""Classifica respostas explícitas ao estado AWAITING_CONFIRMATION.
Esta função é compartilhada pelo router (precedência antes de intent_shift)
e pelo runtime (execução/cancelamento efetivo), garantindo que ambos
reconheçam exatamente o mesmo conjunto de respostas.
"""
normalized = " ".join((text or "").strip().lower().split())
normalized = re.sub(r"[.!?]+$", "", normalized).strip()
if normalized in {
"sim",
"confirmo",
"sim, confirmo",
"pode fazer",
"pode prosseguir",
"sim, desejo",
"sim, desejo trocar",
"sim, confirmo a devolução",
"sim, confirmo a troca",
}:
return "confirm"
if normalized in {"não", "nao", "cancelar", "cancele", "não confirmo", "nao confirmo"}:
return "reject"
return None
def extract_action_arguments(text: str) -> dict[str, Any]:
"""Extrai entidades explicitamente informadas em ações transacionais.
É usada tanto pelo runtime quanto pelo probe de precedência do router. Não
transforma a mensagem inteira em motivo: só captura valores explicitamente
identificáveis no turno atual.
"""
raw = text or ""
args: dict[str, Any] = {}
match = re.search(
r"(?:pedido|ordem)\s*(?:n[ºo°.]?\s*)?(?:é\s*(?:o\s*)?|[:#=-]\s*)?([A-Za-z0-9_-]+)",
raw,
flags=re.IGNORECASE,
)
if match:
args["order_id"] = match.group(1)
reason_match = re.search(
r"(?:porque|pois|motivo\s*[:=-]?|por\s+(?:arrependimento|defeito|erro|atraso)|me\s+arrependi(?:\s+da\s+compra)?|arrependimento)\s*(.*)",
raw,
flags=re.IGNORECASE,
)
if reason_match:
reason = reason_match.group(1).strip(" .,:;-")
if not reason:
matched_phrase = reason_match.group(0).strip(" .,:;-")
if re.search(r"me\s+arrependi|arrependimento", matched_phrase, flags=re.IGNORECASE):
reason = "Arrependimento da compra"
if reason:
args["reason"] = reason
return args

View File

@@ -0,0 +1,181 @@
from __future__ import annotations
import json
import logging
import re
from typing import Any, Mapping
logger = logging.getLogger(__name__)
_EMPTY_VALUES = (None, "", {}, [])
def _response_text(response: Any) -> str:
if response is None:
return ""
if isinstance(response, str):
return response
if isinstance(response, dict):
return str(response.get("content") or response.get("text") or response.get("answer") or "")
return str(getattr(response, "content", None) or getattr(response, "text", None) or response)
def _coerce(value: Any, declared_type: Any) -> Any:
if value in _EMPTY_VALUES:
return None
type_name = str(declared_type or "string").strip().lower()
try:
if type_name in {"integer", "int"}:
return int(value)
if type_name in {"number", "float", "double"}:
return float(value)
if type_name in {"boolean", "bool"}:
if isinstance(value, bool):
return value
normalized = str(value).strip().lower()
if normalized in {"true", "1", "yes", "sim"}:
return True
if normalized in {"false", "0", "no", "não", "nao"}:
return False
return None
if type_name in {"array", "list"}:
return value if isinstance(value, list) else [value]
if type_name in {"object", "dict", "map"}:
return value if isinstance(value, dict) else None
return str(value).strip()
except (TypeError, ValueError):
return None
def parse_transaction_confirmation(text: str) -> str | None:
"""Recognize an explicit confirmation/rejection before intent-shift routing.
This is intentionally small and domain-neutral. Parameter interpretation is
LLM-only; confirmation remains a deterministic control token so an explicit
yes/no cannot be reclassified as a new intent.
"""
normalized = " ".join(str(text or "").strip().lower().split())
normalized = re.sub(r"[.!?]+$", "", normalized).strip()
if normalized in {
"sim", "confirmo", "sim, confirmo", "pode fazer", "pode prosseguir",
"sim, desejo", "sim, desejo trocar", "sim, confirmo a devolução",
"sim, confirmo a troca",
}:
return "confirm"
if normalized in {"não", "nao", "cancelar", "cancele", "não confirmo", "nao confirmo"}:
return "reject"
return None
async def extract_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 values for pending transactional parameters using the LLM only.
This component intentionally contains no domain/entity regexes and no
knowledge of parameter names such as ``order_id`` or ``reason``. The
transaction runtime supplies the pending parameter names and optional schema;
the LLM only interprets the current user turn. State/control-flow decisions
remain deterministic outside this function.
"""
pending = [str(name) for name in (missing_parameters or []) if str(name).strip()]
message = str(text or "").strip()
if not pending 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 and str(key) not in pending
}
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 pending
}
output_shape = {name: None for name in pending}
prompt = (
"Você extrai parâmetros PENDENTES de uma transação ativa. "
"Sua única tarefa é interpretar a mensagem atual e devolver valores para os parâmetros pendentes. "
"Não decida roteamento, intenção, confirmação ou execução da transação.\n\n"
"REGRAS OBRIGATÓRIAS:\n"
"1. Extraia SOMENTE parâmetros listados em pending_parameters.\n"
"2. Não invente valores e não transforme uma nova solicitação/intenção do usuário em valor de parâmetro.\n"
"3. Se nenhum parâmetro pendente foi realmente informado, devolva null para todos.\n"
"4. Se houver apenas um parâmetro pendente, uma resposta contendo apenas um valor pode ser associada a ele quando isso for semanticamente inequívoco.\n"
"5. Se houver vários parâmetros pendentes, extraia todos os que estiverem presentes no mesmo turno.\n"
"6. O nome do parâmetro não precisa aparecer literalmente na fala; use a semântica, o nome da transação e o schema para associar valores.\n"
"7. Em caso de dúvida, prefira null.\n"
"8. Responda SOMENTE JSON válido, sem markdown, sem explicação e sem chaves extras.\n\n"
f"transaction_tool: {tool_name}\n"
f"transaction_description: {tool_description or ''}\n"
f"pending_parameters: {json.dumps(pending, 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"Formato obrigatório: {json.dumps(output_shape, 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_extraction",
temperature=0.0,
max_tokens=max(120, min(500, 80 + 60 * len(pending))),
)
except TypeError:
# Compatibilidade com doubles/testes e providers mínimos que aceitam
# apenas messages.
response = await llm.ainvoke([{"role": "user", "content": prompt}])
except Exception as exc:
logger.warning(
"transaction.parameter.llm_extract_failed tool=%s pending=%s error=%s",
tool_name,
pending,
exc,
)
return {}
raw = _response_text(response).strip()
if raw.startswith("```"):
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.IGNORECASE | re.DOTALL).strip()
try:
payload = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
logger.warning(
"transaction.parameter.llm_invalid_json tool=%s pending=%s raw=%r",
tool_name,
pending,
raw[:240],
)
return {}
if not isinstance(payload, dict):
return {}
extracted: dict[str, Any] = {}
for name in pending:
value = payload.get(name)
declared = field_spec.get(name, {}).get("type", "string")
coerced = _coerce(value, declared)
if coerced not in _EMPTY_VALUES:
extracted[name] = coerced
logger.info(
"transaction.parameter.llm_extracted tool=%s pending=%s consumed=%s",
tool_name,
pending,
sorted(extracted),
)
return extracted