mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-09-07 10:13:46 +00:00
adjustments: transaction parameter extraction
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user