nova funcionalidade: reconciliacao temporal

This commit is contained in:
2026-09-02 13:07:21 -03:00
parent e6c254ff83
commit 76c8cdc738
190 changed files with 747 additions and 82 deletions

View File

@@ -323,10 +323,28 @@ class CoherenceRail(Guardrail):
"data": out, "data": out,
}, },
) )
try:
out = await classify_with_framework_llm( out = await classify_with_framework_llm(
_llm(ctx), "COER", {"text": text or "", "context": ctx}, _llm(ctx), "COER", {"text": text or "", "context": ctx},
profile_name="guardrail", component_name="guardrail.coer", generation_name="guardrail.coer", profile_name="guardrail", component_name="guardrail.coer", generation_name="guardrail.coer",
) )
except Exception as exc:
# COER is a conversational-coherence rail, not a security boundary.
# If its semantic classifier/provider is unavailable, the graph must
# continue and let the normal router/workflow clarification logic own
# the turn instead of surfacing an infrastructure exception.
return RailDecision(
code=self.code,
allowed=True,
reason="Classificador de coerência indisponível; continuidade delegada ao runtime",
sanitized_text=text,
metadata={
"mechanism": "infrastructure_fail_open",
"calibrated": True,
"error_type": type(exc).__name__,
"error": str(exc)[:500],
},
)
return RailDecision( return RailDecision(
code=self.code, allowed=bool(out.get("allowed", True)), code=self.code, allowed=bool(out.get("allowed", True)),
reason=str(out.get("reason") or out.get("label") or "COER avaliado"), reason=str(out.get("reason") or out.get("label") or "COER avaliado"),

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, parse_transaction_confirmation from agent_framework.runtime.transaction_parameters import 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
@@ -1466,6 +1466,153 @@ class AgentRuntimeMixin:
cfg = self._tool_config(tool_name) cfg = self._tool_config(tool_name)
return str(getattr(cfg, "description", "") or "") if cfg is not None else "" return str(getattr(cfg, "description", "") or "") if cfg is not None else ""
@staticmethod
def _transaction_context_newest_first(context: Any) -> str:
"""Normalize bounded conversation text into newest->oldest message blocks.
Role-labelled multi-line messages are kept intact. This avoids reversing
individual lines inside an assistant explanation while still honoring the
temporal search order required by parameter reconciliation.
"""
raw = str(context or "").strip()
if not raw:
return ""
blocks: list[str] = []
current: list[str] = []
for line in raw.splitlines():
stripped = line.strip()
if not stripped:
continue
low = stripped.lower()
starts_role = low.startswith(("user:", "assistant:", "system:", "cliente:", "agente:"))
if starts_role and current:
blocks.append(" ".join(current))
current = [stripped]
else:
current.append(stripped)
if current:
blocks.append(" ".join(current))
if not blocks:
blocks = [line.strip() for line in raw.splitlines() if line.strip()]
newest = list(reversed(blocks))
return "\n".join(f"history:{i+1}: {block}" for i, block in enumerate(newest))
async def _reconcile_transaction_parameters(
self,
state: dict[str, Any],
*,
tool_name: str,
parameter_names: list[str],
known_arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Reconcile a coherent required-parameter set from text and tool schema."""
# Compatibility/extensibility: if a domain/runtime subclass explicitly
# overrides the legacy extractor, honor that override and translate its
# candidates into reconciliation decisions. The base framework path below
# remains the coherent temporal reconciler.
override = getattr(type(self), "_extract_transaction_parameters", None)
if override is not None and override is not AgentRuntimeMixin._extract_transaction_parameters:
route_meta_compat = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {}
compat_fields = list(parameter_names) if route_meta_compat.get("transaction_parameter_values") else [
name for name in parameter_names if (known_arguments or {}).get(name) in _EMPTY_VALUES
]
extracted = await override(
self, state, tool_name=tool_name, missing_parameters=compat_fields,
known_arguments=dict(known_arguments or {}),
)
values = dict(extracted or {})
decisions = {name: ("resolved" if name in values else ("preserve" if (known_arguments or {}).get(name) not in _EMPTY_VALUES else "unresolved")) for name in parameter_names}
provenance = {name: ("current" if name in values else "state") for name in parameter_names if decisions[name] != "unresolved"}
return {"values": values, "decisions": decisions, "provenance": provenance, "clear_fields": []}
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 "")
route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {}
contextual_reentry = bool(route_meta.get("contextual_reentry"))
text = (route_meta.get("original_input") if contextual_reentry else None) or state.get("sanitized_input") or state.get("user_text") or ""
conversational_context = route_meta.get("relevant_conversation_context") if contextual_reentry else None
if not str(conversational_context or "").strip():
conversational_context = active.get("parameter_conversational_context")
if contextual_reentry and not str(conversational_context or "").strip():
effective = str(route_meta.get("contextual_reentry_input") or "")
prefix = "CONTEXTO DA SOLICITAÇÃO IMEDIATAMENTE ANTERIOR:\n"
suffix = "\n\nCONTINUAÇÃO ATUAL DO CLIENTE:\n"
if prefix in effective and suffix in effective:
conversational_context = effective.split(prefix, 1)[1].split(suffix, 1)[0].strip()
ordered_context = self._transaction_context_newest_first(conversational_context)
cached = route_meta.get("transaction_parameter_values")
effective_known = dict(known_arguments or {})
if isinstance(cached, dict):
for key, value in cached.items():
if str(key) in set(str(x) for x in parameter_names) and value not in _EMPTY_VALUES:
effective_known[str(key)] = value
return await reconcile_transaction_parameters(
getattr(self, "llm", None),
text=str(text),
tool_name=tool_name,
parameter_names=[str(x) for x in parameter_names],
known_arguments=effective_known,
parameter_schema=schema,
tool_description=description,
conversational_context=ordered_context,
)
async def _extract_transaction_parameters_current_only(
self,
state: dict[str, Any],
*,
tool_name: str,
missing_parameters: list[str],
known_arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Collect pending parameters from the current utterance only.
This is the normal COLLECTING_PARAMETERS path. Temporal conversation
history is deliberately excluded here; `_reconcile_transaction_parameters`
is the fallback when this direct collection cannot satisfy or validate the
declarative parameter contract.
"""
if not missing_parameters:
return {}
# Preserve the framework extension contract: domain/test runtimes that
# explicitly override the legacy collector remain the authoritative
# implementation of the *traditional* first pass. The temporal fallback
# is still owned by the base runtime and is invoked only after failure.
override = getattr(type(self), "_extract_transaction_parameters", None)
if override is not None and override is not AgentRuntimeMixin._extract_transaction_parameters:
return await override(
self,
state,
tool_name=tool_name,
missing_parameters=list(missing_parameters),
known_arguments=dict(known_arguments or {}),
)
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 "")
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 = text or 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=[str(x) for x in missing_parameters],
known_arguments=dict(known_arguments or {}),
parameter_schema=schema,
tool_description=description,
conversational_context="",
)
async def _extract_transaction_parameters( async def _extract_transaction_parameters(
self, self,
state: dict[str, Any], state: dict[str, Any],
@@ -1535,6 +1682,7 @@ class AgentRuntimeMixin:
suffix = "\n\nCONTINUAÇÃO ATUAL DO CLIENTE:\n" suffix = "\n\nCONTINUAÇÃO ATUAL DO CLIENTE:\n"
if prefix in effective and suffix in effective: if prefix in effective and suffix in effective:
conversational_context = effective.split(prefix, 1)[1].split(suffix, 1)[0].strip() conversational_context = effective.split(prefix, 1)[1].split(suffix, 1)[0].strip()
ordered_context = self._transaction_context_newest_first(conversational_context)
extracted = await extract_transaction_parameters( extracted = await extract_transaction_parameters(
getattr(self, "llm", None), getattr(self, "llm", None),
text=str(text), text=str(text),
@@ -1543,9 +1691,42 @@ class AgentRuntimeMixin:
known_arguments={**dict(known_arguments or {}), **reused}, known_arguments={**dict(known_arguments or {}), **reused},
parameter_schema=schema, parameter_schema=schema,
tool_description=description, tool_description=description,
conversational_context=str(conversational_context or ""), conversational_context=ordered_context,
) )
return {**reused, **extracted}
# Compatibility/resilience fallback: if a provider returns only a partial
# flat extraction, scan bounded prior text source-by-source from newest to
# oldest for still-unresolved fields. This preserves temporal order and
# remains schema-driven; the main COLLECTING_PARAMETERS path uses the
# coherent set reconciler above.
combined = {**reused, **extracted}
unresolved = [name for name in missing_parameters if name not in combined]
if unresolved and str(conversational_context or "").strip():
source_lines = self._transaction_context_newest_first(conversational_context).splitlines()
for source_line in source_lines:
if not unresolved:
break
source_text = source_line.split(": ", 1)[1] if ": " in source_line else source_line
low_source = source_text.lower()
for role_prefix in ("user: ", "assistant: ", "cliente: ", "agente: ", "system: "):
if low_source.startswith(role_prefix):
source_text = source_text[len(role_prefix):]
break
recovered = await extract_transaction_parameters(
getattr(self, "llm", None),
text=source_text,
tool_name=tool_name,
missing_parameters=list(unresolved),
known_arguments={**dict(known_arguments or {}), **combined},
parameter_schema=schema,
tool_description=description,
conversational_context="",
)
for key, value in recovered.items():
if key in unresolved and value not in _EMPTY_VALUES:
combined[key] = value
unresolved = [name for name in unresolved if name not in combined]
return combined
def _transactional_action_match(self, text: str, tools: list[str] | None = None) -> str | None: def _transactional_action_match(self, text: str, tools: list[str] | None = None) -> str | None:
"""Detecta solicitação transacional usando metadados de tools.yaml. """Detecta solicitação transacional usando metadados de tools.yaml.
@@ -2827,23 +3008,48 @@ 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)
# Parâmetros TRANSACIONAIS são interpretados exclusivamente pelo # Primeira tentativa: coleta tradicional usando SOMENTE a fala
# extrator LLM genérico. Durante COLLECTING_PARAMETERS, a fala atual # atual. O histórico não deve participar enquanto a mensagem corrente
# também pode CORRIGIR um required field já coletado em turno anterior # conseguir preencher o contrato normalmente.
# (ex.: valor=19,99 e o cliente diz "desculpa, é 14,99" enquanto arguments = dict(previous_args)
# subject ainda está pendente). Por isso o contrato editável do turno extracted_current = await self._extract_transaction_parameters_current_only(
# é o conjunto completo de ``requires``; somente as chaves realmente
# extraídas pela LLM sobrescrevem ``previous_args``. Campos não citados
# permanecem intactos. Isso preserva parameter-before-intent-shift sem
# tornar valores antigos imutáveis por acidente.
editable_required = [str(name) for name in (policy.get("requires") or [])]
extracted = await self._extract_transaction_parameters(
state, state,
tool_name=tool_name, tool_name=tool_name,
missing_parameters=editable_required, missing_parameters=missing_before,
known_arguments=previous_args, known_arguments=previous_args,
) )
arguments = {**previous_args, **extracted} arguments.update(dict(extracted_current or {}))
state["transaction_parameter_collection"] = {
"tool_name": tool_name,
"mode": "current_turn",
"resolved_fields": sorted(str(k) for k in (extracted_current or {}).keys()),
}
# 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)
# Argumentos estruturados já presentes no contexto são aceitos de # Argumentos estruturados já presentes no contexto são aceitos de
# forma genérica (não são parsing textual). Para required fields, # forma genérica (não são parsing textual). Para required fields,
@@ -2889,6 +3095,64 @@ class AgentRuntimeMixin:
pre_validation_result = await self._run_transaction_pre_validation( pre_validation_result = await self._run_transaction_pre_validation(
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:
# 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: if pre_validation_result is not None:
return [pre_validation_result] return [pre_validation_result]

View File

@@ -69,69 +69,77 @@ def parse_transaction_confirmation(text: str) -> str | None:
return None return None
async def extract_transaction_parameters( async def reconcile_transaction_parameters(
llm: Any, llm: Any,
*, *,
text: str, text: str,
tool_name: str, tool_name: str,
missing_parameters: list[str], parameter_names: list[str],
known_arguments: Mapping[str, Any] | None = None, known_arguments: Mapping[str, Any] | None = None,
parameter_schema: Mapping[str, Any] | None = None, parameter_schema: Mapping[str, Any] | None = None,
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]:
"""Extract values for pending transactional parameters using the LLM only. """Rebuild a coherent parameter set from text, newest to oldest.
This component intentionally contains no domain/entity regexes and no The framework is intentionally field/domain neutral. Meaning comes from the
knowledge of parameter names such as ``order_id`` or ``reason``. The declarative tool schema (normally tools.yaml) plus the conversation text.
transaction runtime supplies the pending parameter names and optional schema; The LLM may resolve a field, preserve a previously known value, explicitly
the LLM only interprets the current user turn. State/control-flow decisions clear a stale value whose context was superseded, or leave a field unresolved.
remain deterministic outside this function. Returned provenance is interpretive metadata only; authoritative business
validation still belongs to the configured pre-validation/tool layer.
""" """
pending = [str(name) for name in (missing_parameters or []) if str(name).strip()] names = [str(name) for name in (parameter_names or []) if str(name).strip()]
message = str(text or "").strip() message = str(text or "").strip()
if not pending or not message or llm is None: if not names or not message or llm is None:
return {} return {"values": {}, "decisions": {}, "provenance": {}, "clear_fields": []}
schema = dict(parameter_schema or {}) schema = dict(parameter_schema or {})
known = { known = {
str(key): value str(key): value
for key, value in dict(known_arguments or {}).items() for key, value in dict(known_arguments or {}).items()
if value not in _EMPTY_VALUES and str(key) not in pending if value not in _EMPTY_VALUES
} }
field_spec = { field_spec = {
name: { name: {
"type": schema.get(name, "string") if not isinstance(schema.get(name), dict) else schema.get(name, {}).get("type", "string"), "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"), "description": None if not isinstance(schema.get(name), dict) else schema.get(name, {}).get("description"),
} }
for name in pending for name in names
}
output_shape = {
"fields": {
name: {"decision": "resolved|preserve|clear|unresolved", "value": None, "source": "current|history:N|state"}
for name in names
}
} }
output_shape = {name: None for name in pending}
prompt = ( prompt = (
"Você extrai parâmetros PENDENTES de uma transação ativa. " "Você é o conciliador temporal de parâmetros de uma transação ativa. "
"Sua única tarefa é interpretar a mensagem atual e devolver valores para os parâmetros pendentes. " "Reconstrua UM CONJUNTO COERENTE de parâmetros; não extraia cada campo de forma isolada. "
"Não decida roteamento, intenção, confirmação ou execução da transação.\n\n" "Não decida roteamento, confirmação nem execução.\n\n"
"REGRAS OBRIGATÓRIAS:\n" "CONTRATO OBRIGATÓRIO:\n"
"1. Extraia SOMENTE parâmetros listados em pending_parameters.\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. O framework não conhece conceitos de domínio por nome de campo.\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" "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.\n"
"3. Se nenhum parâmetro pendente foi realmente informado, devolva null para todos.\n" "3. A busca textual é temporal, em ordem decrescente: user_message é a fonte mais nova; depois conversational_context já vem do texto mais novo para o mais antigo.\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" "3. Textos do usuário e textos explicativos do assistente podem ser usados para interpretar referências e relações entre campos. Não trate texto do contexto como uma nova afirmação do cliente; eles são contexto, não evidência autoritativa de negócio.\n"
"5. Se houver vários parâmetros pendentes, extraia todos os que estiverem presentes no mesmo turno.\n" "4. Para cada campo escolha: resolved = um texto determina novo valor; preserve = nenhum texto mais novo invalida o valor conhecido; clear = existe mudança textual mais nova que torna o valor conhecido incompatível/sem vínculo seguro com o novo contexto e ainda não há substituto; unresolved = não há valor conhecido nem candidato textual seguro.\n"
"6. O nome do parâmetro não precisa aparecer literalmente na fala. Associe semanticamente o valor usando o nome da transação e os metadados disponíveis para cada campo.\n" "5. Se uma fonte mais nova muda uma entidade, objeto, escopo ou outro contexto que dava sentido a campos extraídos anteriormente, REAVALIE os demais campos como conjunto. Não combine automaticamente um atributo antigo com um contexto novo só porque ambos existem.\n"
"7. Para cada parâmetro, considere o nome técnico, o tipo quando disponível e principalmente a descrição semântica quando disponível. A ausência de tipo ou descrição NÃO impede a extração.\n" "6. Um valor de texto anterior pode continuar válido após uma mudança somente quando os textos, considerados em conjunto, sustentarem de forma inequívoca que ele ainda pertence ao contexto mais recente. Caso contrário use clear para o campo dependente.\n"
"8. Se a mensagem deixar clara a correspondência entre um trecho e um parâmetro, preencha-o mesmo que o usuário não cite o nome técnico do campo.\n" "7. Se a nova menção é inválida no mundo real, NÃO volte silenciosamente ao valor antigo. Ainda assim devolva o candidato textual mais recente como resolved; a pre-validation autoritativa é responsável por rejeitá-lo.\n"
"9. Não use conhecimento externo para completar valores ausentes e não transforme aproximações ou suposições em fatos.\n" "8. known_arguments é fallback de estado. Use preserve somente quando nenhum texto mais novo o contradiz ou rompe sua associação contextual.\n"
"10. conversational_context, quando presente, serve SOMENTE para resolver referências da mensagem atual (por exemplo: 'a de 14,99' apontando para um item citado imediatamente antes). Não trate texto do contexto como uma nova afirmação do cliente nem como evidência de negócio.\n" "9. Uma expressão só pode preencher um campo se satisfizer semanticamente type/description desse campo. Não transfira um trecho para outro campo apenas por proximidade lexical.\n"
"11. Quando a mensagem atual identifica um valor OU nome e o contexto imediatamente anterior contém uma única entidade compatível, você pode preencher essa entidade e os atributos pendentes inequivocamente associados a ela como CANDIDATOS. Exemplo genérico: se a fala identifica uma entidade e o contexto associa unicamente essa entidade a um valor requerido, o valor pode ser retornado como candidato. A validação autoritativa ocorrerá depois; não invente se houver ambiguidade.\n" "10. Se um texto recente corrige explicitamente informação anterior, a correção prevalece para os campos que o schema permite inferir; os demais devem ser reavaliados quanto à coerência com a correção.\n"
"12. Em caso de dúvida razoável sobre a correspondência ou o valor, prefira null.\n" "11. Em ambiguidade razoável, prefira clear/unresolved a inventar uma associação. Em caso de dúvida razoável sobre a correspondência ou o valor, prefira null.\n"
"13. Responda SOMENTE JSON válido, sem markdown, sem explicação e sem chaves extras.\n\n" "12. Responda SOMENTE JSON válido no formato pedido, sem markdown e sem chaves extras.\n\n"
f"transaction_tool: {tool_name}\n" f"transaction_tool: {tool_name}\n"
f"transaction_description: {tool_description or ''}\n" f"transaction_description: {tool_description or ''}\n"
f"pending_parameters: {json.dumps(pending, ensure_ascii=False)}\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"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"known_arguments: {json.dumps(known, ensure_ascii=False, default=str)}\n"
f"conversational_context: {str(conversational_context or '').strip()}\n" "conversation_sources_newest_to_oldest:\n"
f"user_message: {message}\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)}" f"Formato obrigatório: {json.dumps(output_shape, ensure_ascii=False)}"
) )
@@ -144,44 +152,98 @@ async def extract_transaction_parameters(
temperature=0.0, temperature=0.0,
) )
except TypeError: except TypeError:
# Compatibilidade com doubles/testes e providers mínimos que aceitam
# apenas messages.
response = await llm.ainvoke([{"role": "user", "content": prompt}]) response = await llm.ainvoke([{"role": "user", "content": prompt}])
except Exception as exc: except Exception as exc:
logger.warning( logger.warning("transaction.parameter.reconcile_failed tool=%s fields=%s error=%s", tool_name, names, exc)
"transaction.parameter.llm_extract_failed tool=%s pending=%s error=%s", return {"values": {}, "decisions": {}, "provenance": {}, "clear_fields": []}
tool_name,
pending,
exc,
)
return {}
raw = _response_text(response).strip() raw = _response_text(response).strip()
try: try:
payload = parse_json_object(raw) payload = parse_json_object(raw)
except (TypeError, ValueError): except (TypeError, ValueError):
logger.warning( logger.warning("transaction.parameter.reconcile_invalid_output tool=%s raw=%r", tool_name, raw[:240])
"transaction.parameter.llm_invalid_structured_output tool=%s pending=%s raw=%r", return {"values": {}, "decisions": {}, "provenance": {}, "clear_fields": []}
tool_name,
pending,
raw[:240],
)
return {}
if not isinstance(payload, dict): if not isinstance(payload, dict):
return {} return {"values": {}, "decisions": {}, "provenance": {}, "clear_fields": []}
extracted: dict[str, Any] = {} # Backward compatibility with existing providers/test doubles that return a
for name in pending: # flat {field: value} object. Non-null flat values mean ``resolved``.
value = payload.get(name) fields = payload.get("fields") if isinstance(payload.get("fields"), dict) else None
if fields is None:
fields = {}
for name in names:
flat_value = payload.get(name)
if flat_value in _EMPTY_VALUES:
decision = "preserve" if name in known else "unresolved"
source = "state" if decision == "preserve" else ""
else:
decision = "resolved"
source = "current"
fields[name] = {"decision": decision, "value": flat_value, "source": source}
values: dict[str, Any] = {}
decisions: dict[str, str] = {}
provenance: dict[str, str] = {}
clear_fields: list[str] = []
for name in names:
item = fields.get(name) if isinstance(fields, dict) else None
if not isinstance(item, dict):
item = {"decision": "unresolved", "value": None, "source": ""}
decision = str(item.get("decision") or "unresolved").strip().lower()
if decision not in {"resolved", "preserve", "clear", "unresolved"}:
decision = "unresolved"
decisions[name] = decision
source = str(item.get("source") or "").strip()
if source:
provenance[name] = source
if decision == "clear":
clear_fields.append(name)
continue
if decision == "preserve":
if name in known:
values[name] = known[name]
continue
if decision != "resolved":
continue
declared = field_spec.get(name, {}).get("type", "string") declared = field_spec.get(name, {}).get("type", "string")
coerced = _coerce(value, declared) coerced = _coerce(item.get("value"), declared)
if coerced not in _EMPTY_VALUES: if coerced not in _EMPTY_VALUES:
extracted[name] = coerced values[name] = coerced
else:
decisions[name] = "unresolved"
logger.info( logger.info(
"transaction.parameter.llm_extracted tool=%s pending=%s consumed=%s", "transaction.parameter.reconciled tool=%s decisions=%s resolved=%s clear=%s provenance=%s",
tool_name, tool_name, decisions, sorted(values), clear_fields, provenance,
pending,
sorted(extracted),
) )
return extracted return {"values": values, "decisions": decisions, "provenance": provenance, "clear_fields": clear_fields}
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,
conversational_context: str | None = None,
) -> dict[str, Any]:
"""Compatibility facade returning only resolved candidates.
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.
"""
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,
)
return dict(result.get("values") or {})

Binary file not shown.

View File

@@ -49,6 +49,19 @@ class SuporteContasAgent(AgentRuntimeMixin):
) )
state["mcp_results"] = tool_context state["mcp_results"] = tool_context
# A no-match retry is already a resolved control-flow decision: the
# framework/router concluded that the utterance is not understood. Do
# not ask another generative model to reinterpret it into a concrete
# business action (which can fabricate intent from noisy ASR).
if str(state.get("intent") or "") == "contas_no_match_retry":
return {
"answer": "Desculpe, não entendi. Poderia repetir de outra forma?",
"next_state": state.get("next_state") or "SUPORTE_CONTAS_ACTIVE",
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
clarification_message = self.transaction_clarification_message(state) clarification_message = self.transaction_clarification_message(state)
if clarification_message: if clarification_message:
return { return {

View File

@@ -147,6 +147,43 @@ def _authorized_human_handoff(context: dict[str, Any]) -> bool:
return any(terminal_workflow_handoff(root) for root in roots) return any(terminal_workflow_handoff(root) for root in roots)
def _deterministic_tim_domain_response(text: str, context: dict[str, Any]) -> bool:
"""Conservative fast path for unmistakable TIM Contas domain output.
This extension may know the TIM Contas vocabulary; the generic framework does
not. Human-transfer language is intentionally excluded so handoff authorization
remains exclusively structural. Ambiguous domain prose still goes to TIM_OOS LLM.
"""
normalized = re.sub(r'\s+', ' ', str(text or '').strip().lower())
if not normalized:
return False
handoff_markers = (
'encaminhar', 'encaminhado', 'transferir', 'transferido',
'atendente', 'pessoa', 'humano', 'especialista',
)
if any(m in normalized for m in handoff_markers):
return False
domain_markers = (
'contestação', 'contestacao', 'fatura', 'cobrança', 'cobranca',
'plano', 'serviço', 'servico', 'vas', 'boleto', 'desconto',
)
completion_markers = (
'registrad', 'cancelad', 'concluíd', 'concluid', 'executad',
'processad', 'enviad', 'emitid', 'protocolo',
)
# Keep the bypass intentionally narrow: an unmistakable domain noun plus an
# operational/result marker. Ordinary explanations and ambiguous responses are
# still semantically audited by TIM_OOS.
return (
any(m in normalized for m in domain_markers)
and any(m in normalized for m in completion_markers)
)
def _parse_json(raw: Any) -> dict[str, Any]: def _parse_json(raw: Any) -> dict[str, Any]:
text = str(getattr(raw, 'content', raw) or '').strip() text = str(getattr(raw, 'content', raw) or '').strip()
m = re.search(r'\{[\s\S]*\}', text) m = re.search(r'\{[\s\S]*\}', text)
@@ -196,6 +233,22 @@ class TimOutOfScopeRail(_TimPromptRail):
}, },
}, },
) )
if _deterministic_tim_domain_response(text, context):
return RailDecision(
code=self.code,
allowed=True,
reason='resposta suportada pelo contexto do domínio TIM Contas',
sanitized_text=text,
metadata={
'external': True,
'domain': 'TIM_CONTAS',
'mechanism': 'deterministic_domain_bypass',
'data': {
'allowed': True,
'reason': 'resposta suportada pelo contexto do domínio TIM Contas',
},
},
)
return await super().evaluate(text, context) return await super().evaluate(text, context)
class TimProactiveOfferRail(_TimPromptRail): class TimProactiveOfferRail(_TimPromptRail):
@@ -347,6 +400,8 @@ class TimPrematureActionRail(_TimPromptRail):
'final_answer', 'customer_message', 'customer_response', 'answer', 'text', 'final_answer', 'customer_message', 'customer_response', 'answer', 'text',
'epistemic_status', 'output', 'result', 'items', 'results', 'errors', 'epistemic_status', 'output', 'result', 'items', 'results', 'errors',
'cancelados', 'nao_cancelados', 'nao_encontrados', 'terminal_status', 'cancelados', 'nao_cancelados', 'nao_encontrados', 'terminal_status',
'contestation_registered', 'contested_invoice_amount', 'contested_invoice_amount_open',
'sms_sent', 'barcode', 'itemName', 'itemsResponse', 'contested_items', 'sr',
} }
out = {} out = {}
for k, v in value.items(): for k, v in value.items():
@@ -414,6 +469,142 @@ class TimPrematureActionRail(_TimPromptRail):
return False return False
return candidate in cls._authoritative_output_messages(context) return candidate in cls._authoritative_output_messages(context)
@staticmethod
def _normalized_text(value: Any) -> str:
text = str(value or '').lower()
text = text.replace('r$', ' ')
text = re.sub(r'\s+', ' ', text)
return text.strip()
@classmethod
def _candidate_has_operational_completion_claim(cls, text: str) -> bool:
"""Detect completion/effect claims, not ordinary explanations.
REVPREC exists to stop claims that an operation already happened. A
billing explanation containing values/dates is not such a claim and must
not depend on a probabilistic binary LLM classification.
"""
normalized = cls._normalized_text(text)
patterns = (
r'\b(cancelad[oa]s?|cancelei|cancelamos|cancelou|cancelamento .*conclu)',
r'\b(contesta(?:ção|cao) .*?(?:registrad|abert|conclu|criad))',
r'\b(registrad[oa]s?|executei|executamos|executou|executad[oa]s?|conclu[ií]d[oa]s?|processad[oa]s?)\b',
r'\b(enviad[oa]s?|emitid[oa]s?|retirad[oa]s?|removid[oa]s?)\b',
r'\b(protocol(?:o)? .*?(?:abert|gerad|registrad))',
r'\b(?:foi|foram) (?:cancelad|contestad|registrad|enviad|emitid|retirad|removid)',
r'\b(?:was|were|has been) (?:cancelled|canceled|registered|sent|issued|removed)',
)
return any(re.search(pattern, normalized, re.I) for pattern in patterns)
@classmethod
def _current_execution_scalar_evidence(cls, context: dict[str, Any]) -> tuple[list[str], bool, bool]:
"""Flatten scalar facts from current successful result/output only.
Returns (facts, completed, positive_effect). Inputs, conversation state,
history and metadata are excluded so user claims can never prove an action.
"""
roots = (context or {}).get('mcp_results')
if roots is None:
roots = (context or {}).get('tool_result')
if isinstance(roots, dict):
roots = [roots]
if not isinstance(roots, (list, tuple)):
return [], False, False
facts: list[str] = []
completed = False
positive = False
positive_status = {'completed', 'opened', 'open', 'iniciada', 'iniciado', 'success', 'succeeded', 'closed', 'fechado'}
positive_keys = {
'success', 'executed', 'contestation_registered', 'sms_sent',
'cancelled', 'canceled', 'registered', 'created', 'updated',
}
skip = {'state', 'input', 'metadata', 'conversation_history', 'history', 'session', 'session_metadata'}
def walk(value: Any, path: tuple[str, ...] = ()) -> None:
nonlocal completed, positive
if isinstance(value, dict):
for k, v in value.items():
key = str(k).lower()
if key in skip:
continue
if key == 'status' and str(v).strip().lower() == 'completed':
completed = True
if key in positive_keys and v is True:
positive = True
if key == 'status' and str(v).strip().lower() in positive_status:
positive = True
# Keys such as sms_sent/barcode are themselves meaningful
# evidence and can support paraphrased customer-facing text.
if key not in {'output', 'result', 'results'}:
facts.append(key)
walk(v, path + (key,))
elif isinstance(value, (list, tuple)):
for item in value[:50]:
walk(item, path)
elif value not in (None, ''):
facts.append(str(value))
for item in roots:
if not isinstance(item, dict) or item.get('ok') is False:
continue
if str(((item.get('result') or {}) if isinstance(item.get('result'), dict) else {}).get('status') or '').upper() == 'COMPLETED':
completed = True
walk(item.get('result', item))
return facts, completed, positive
@classmethod
def _candidate_is_structurally_grounded_completion(cls, text: str, context: dict[str, Any]) -> bool:
"""Allow a paraphrased completion only when current execution proves it.
This deliberately does not know tool names. It requires a completed
current execution, a positive side-effect marker, grounding for every
material number mentioned by the candidate, and at least one meaningful
textual fact/entity shared with the execution result.
"""
if not cls._candidate_has_operational_completion_claim(text):
return False
facts, completed, positive = cls._current_execution_scalar_evidence(context)
if not (completed and positive and facts):
return False
candidate = cls._normalized_text(text)
evidence_text = cls._normalized_text(' '.join(facts))
def canon_number(raw: str) -> str:
raw = raw.strip().replace(' ', '')
if ',' in raw and '.' in raw:
if raw.rfind(',') > raw.rfind('.'):
raw = raw.replace('.', '').replace(',', '.')
else:
raw = raw.replace(',', '')
else:
raw = raw.replace(',', '.')
try:
num = float(raw)
return (f'{num:.6f}').rstrip('0').rstrip('.')
except ValueError:
return re.sub(r'\D', '', raw)
candidate_numbers = [canon_number(x) for x in re.findall(r'(?<!\w)\d[\d .,-]*\d|(?<!\w)\d', candidate)]
evidence_numbers = {canon_number(x) for x in re.findall(r'(?<!\w)\d[\d .,-]*\d|(?<!\w)\d', evidence_text)}
material_numbers = [n for n in candidate_numbers if len(re.sub(r'\D', '', n)) >= 2]
if not material_numbers:
return False
if any(n not in evidence_numbers for n in material_numbers):
return False
# Require an entity/message overlap beyond generic success vocabulary.
candidate_words = set(re.findall(r'[a-zà-ÿ0-9+_-]{4,}', candidate, re.I))
stop = {
'sucesso', 'registrada', 'registrado', 'concluido', 'concluida',
'cliente', 'valor', 'protocolo', 'cobranca', 'contestacao', 'servico',
'mensal', 'novo', 'boleto', 'recebeu', 'detalhes',
}
evidence_words = set(re.findall(r'[a-zà-ÿ0-9+_-]{4,}', evidence_text, re.I))
meaningful_overlap = (candidate_words - stop) & (evidence_words - stop)
return bool(meaningful_overlap)
async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision: async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision:
# Strongest possible proof: the exact customer-facing candidate was emitted by # Strongest possible proof: the exact customer-facing candidate was emitted by
# the successful current-turn tool/workflow itself. REVPREC is about premature # the successful current-turn tool/workflow itself. REVPREC is about premature
@@ -433,6 +624,41 @@ class TimPrematureActionRail(_TimPromptRail):
}, },
) )
# REVPREC is not a general factuality rail. Ordinary descriptions and
# explanations contain no claim that an operation has already completed,
# so they are deterministically outside this rail's blocking scope.
if not self._candidate_has_operational_completion_claim(text):
return RailDecision(
code=self.code,
allowed=True,
reason='no_operational_completion_claim',
sanitized_text=text,
metadata={
'external': True,
'domain': 'TIM_CONTAS',
'mechanism': 'deterministic_claim_scope',
'terminal_action': 'retry',
},
)
# A composed/paraphrased response can still be proven by structured
# current-turn execution evidence even when it is not byte-for-byte equal
# to a workflow message. This removes nondeterministic false positives
# while keeping unsupported or contradictory claims on the semantic path.
if self._candidate_is_structurally_grounded_completion(text, context):
return RailDecision(
code=self.code,
allowed=True,
reason='current_execution_structurally_grounded',
sanitized_text=text,
metadata={
'external': True,
'domain': 'TIM_CONTAS',
'mechanism': 'deterministic_structured_execution_evidence',
'terminal_action': 'retry',
},
)
evidence = self._current_execution_evidence(context) evidence = self._current_execution_evidence(context)
llm = _llm(context) llm = _llm(context)
if llm is None: if llm is None:

Some files were not shown because too many files have changed in this diff Show More