ajuste no contas

This commit is contained in:
T3782834
2026-09-01 11:24:03 -03:00
parent 9ed4782f9d
commit 397b831fd3
428 changed files with 2294 additions and 4648 deletions

Binary file not shown.

View File

@@ -147,10 +147,36 @@ def evaluate(state: dict[str, Any]) -> PolicyDecision | None:
method = str(route_decision.get("method") or "")
intent = str(state.get("intent") or route_decision.get("intent") or "")
previous_count = int(state.get("no_match_count") or 0)
reset_patch: dict[str, Any] = {"no_match_count": 0} if previous_count else {}
# Explicit conversational closure is a lifecycle signal, not a new business
# intent. It must take precedence over router no-match so deterministic closure
# phrases are not turned into generic fallback responses. Never consume it while
# a transaction/workflow is still live.
if _CLOSE_RE.search(text) and not _has_live_transaction(state):
return PolicyDecision(
route="end_session", intent="contas_conversation_close",
reason="explicit_customer_closure",
patch={**reset_patch, "terminal_status": "resolvido",
"conversation_terminal_message": "Atendimento encerrado. Obrigado pelo contato."},
)
# The first utterance after a completed operational boundary is a fresh
# interaction, never a resume of the workflow that just ended. A short
# re-engagement such as "ah espera" may legitimately have no business intent
# yet; answer with a neutral invitation instead of treating it as an error.
if intent == "contas_no_match" and bool(state.get("operational_context_reset")):
return PolicyDecision(
route="conversation_policy_response", intent="contas_post_terminal_reentry",
answer="Claro. Pode falar. Em que posso ajudar agora?",
reason="fresh_interaction_after_operational_boundary",
patch={**reset_patch, "no_match_count": 0},
)
# 1) Consecutive incomprehensible utterances. A router fallback is the generic,
# architecture-native signal that no intent was understood. Any understood turn
# resets the counter. Default requirement is three consecutive failures.
previous_count = int(state.get("no_match_count") or 0)
if intent == "contas_no_match":
count = previous_count + 1
limit = max(1, int(os.getenv("CONTAS_NO_MATCH_MAX_CONSECUTIVE", "3")))
@@ -167,18 +193,6 @@ def evaluate(state: dict[str, Any]) -> PolicyDecision | None:
reason="no_match_retry", patch={"no_match_count": count},
)
reset_patch: dict[str, Any] = {"no_match_count": 0} if previous_count else {}
# Explicit conversational closure is a lifecycle signal, not a new business
# intent. Never consume it while a transaction/workflow is still live.
if _CLOSE_RE.search(text) and not _has_live_transaction(state):
return PolicyDecision(
route="end_session", intent="contas_conversation_close",
reason="explicit_customer_closure",
patch={**reset_patch, "terminal_status": "resolvido",
"conversation_terminal_message": "Atendimento encerrado. Obrigado pelo contato."},
)
# A short plural continuation after an invoice explanation belongs to that
# explanation; it must not be mistaken for generic finalization merely because
# it contains an affirmative such as "pode seguir". The invoice agent keeps

View File

@@ -244,6 +244,96 @@ def _money(value: Any) -> str:
return f"{d:.2f}".replace(".", ",")
def _invoice_focus_normalize(value: Any) -> str:
"""Normalize customer/item wording only for conservative invoice focus matching.
This does not infer a charge. It is used only to narrow an already fetched
current-invoice payload to an item whose meaningful name tokens are all present
in the customer's utterance. The backend data remains the authority.
"""
import unicodedata
raw = unicodedata.normalize("NFKD", str(value or "").lower().replace("+", " mais "))
raw = "".join(ch for ch in raw if not unicodedata.combining(ch))
return " ".join(re.sub(r"[^a-z0-9]+", " ", raw).split())
def _focused_invoice_explanation(user_text: str, billing_analysis: Any) -> str:
"""Return a factual explanation focused on invoice items named by the user.
The legacy Billing Analysis ``invoiceExplanation`` is a global variation summary
and can omit a specifically questioned item even when that item exists in
``currentInvoice``. In that case the generic summary is the wrong customer
answer and can trigger AOFERTA for mentioning unrelated services.
We narrow only when the user's utterance contains every meaningful token of an
authoritative current-invoice item name (e.g. ``VOD + Canais abertos``).
Otherwise the existing global explanation remains untouched.
"""
if not isinstance(billing_analysis, dict):
return ""
user_norm = _invoice_focus_normalize(user_text)
if not user_norm:
return ""
user_tokens = set(user_norm.split())
stop = {"de", "do", "da", "dos", "das", "e", "mais", "a", "o", "um", "uma"}
matches: list[dict[str, Any]] = []
for section in billing_analysis.get("currentInvoice") or []:
if not isinstance(section, dict):
continue
for item in section.get("items") or []:
if not isinstance(item, dict):
continue
desc = str(item.get("desc") or "").strip()
if not desc:
continue
desc_tokens = [t for t in _invoice_focus_normalize(desc).split() if t not in stop and len(t) > 1]
# Require at least two meaningful tokens. This avoids narrowing on
# generic one-word coincidences such as "plano" or "servico".
if len(desc_tokens) < 2 or not all(t in user_tokens for t in desc_tokens):
continue
matches.append(item)
if not matches:
return ""
# Keep a single canonical item family. If the utterance happens to fully match
# more than one distinct name, do not guess which one the customer meant.
names = {}
for item in matches:
key = _invoice_focus_normalize(item.get("desc"))
names.setdefault(key, []).append(item)
if len(names) != 1:
return ""
items = next(iter(names.values()))
canonical = str(items[0].get("desc") or "cobrança").strip()
def fmt_date(value: Any) -> str:
raw = str(value or "").strip()
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})", raw)
if m:
return f"{m.group(3)}/{m.group(2)}/{m.group(1)}"
return raw
details = []
for item in items:
value = _money(item.get("value"))
date = fmt_date(item.get("date"))
details.append(f"R$ {value}" + (f" em {date}" if date else ""))
count = len(details)
if count == 1:
prefix = f"Na fatura atual, identifiquei uma cobrança de {canonical}: "
else:
prefix = f"Na fatura atual, identifiquei {count} cobranças de {canonical}: "
if count == 1:
detail_text = details[0]
else:
detail_text = ", ".join(details[:-1]) + " e " + details[-1]
return (
prefix + detail_text + ". "
"Os dados consultados comprovam esses lançamentos na fatura, mas não informam "
"a origem ou contratação além do item faturado."
)
def _normalize_contestation_name(value: Any) -> str:
import unicodedata
text = unicodedata.normalize("NFKD", str(value or "").strip())
@@ -641,7 +731,9 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
call_args = dict(params)
call_args.pop("msisdn", None)
evidence = service.invoice_explanation(msisdn=msisdn, **call_args)
text = _extract_invoice_text(evidence.get("billing_analysis"))
billing_analysis = evidence.get("billing_analysis") if isinstance(evidence, dict) else None
user_text = str(_first(params, state, "message", "text", "customer_message", "user_text") or "").strip()
text = _focused_invoice_explanation(user_text, billing_analysis) or _extract_invoice_text(billing_analysis)
except TimApiError as exc:
transport = {"_transport": {"rct_operation": "base_conhecimento", "attempts": list(exc.attempts or [])}}
rct_events = _transport_rct_events(transport)
@@ -1104,7 +1196,7 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
concurrency = max(1, int(os.getenv("TIM_CANCELAMENTO_BATCH_CONCURRENCY", "5")))
semaphore = asyncio.Semaphore(concurrency)
async def _process_item(item: dict[str, Any]) -> dict[str, Any]:
async def _process_item_impl(item: dict[str, Any]) -> dict[str, Any]:
async with semaphore:
msisdn = str(item.get("msisdn") or _first(params, state, "msisdn"))
subject = str(item.get("name") or item.get("desc") or item.get("subject") or "")
@@ -1165,7 +1257,37 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
await idempotency_store.set(key, item_result)
return item_result
except Exception as exc:
return {"success": False, "msisdn": msisdn, "subject": subject, "error": str(exc), "protocol": protocol}
return {
"success": False,
"msisdn": msisdn,
"subject": subject,
"reason": "cancel_vas_failed",
"error": str(exc),
"protocol": protocol,
"recoverable": True,
}
async def _process_item(item: dict[str, Any]) -> dict[str, Any]:
"""Boundary de robustez: nenhuma falha técnica de um item derruba o batch/request."""
try:
return await _process_item_impl(item)
except Exception as exc:
msisdn = str(item.get("msisdn") or _first(params, state, "msisdn"))
subject = str(item.get("name") or item.get("desc") or item.get("subject") or "")
logger.exception(
"Falha não tratada no cancelamento VAS; convertendo em resultado recuperável msisdn=%s subject=%s",
msisdn,
subject,
)
return {
"success": False,
"msisdn": msisdn,
"subject": subject,
"reason": "internal_processing_failed",
"error": str(exc),
"recoverable": True,
"fallback_applied": True,
}
valid_items = [dict(x) for x in items if isinstance(x, dict)]
results = list(await asyncio.gather(*[_process_item(item) for item in valid_items])) if valid_items else []

View File

@@ -19,6 +19,68 @@ def _context_text(context: dict[str, Any]) -> str:
except Exception: return str(context or {})[:16000]
def _compact_aoferta_context(context: dict[str, Any]) -> str:
"""High-signal context for the proactive-offer LLM.
The generic output context can contain very large MCP/workflow payloads. Sending
the first N characters of that payload may truncate the customer request and the
resolved transaction target, which makes the auditor treat legitimate fallback
guidance as a new unsolicited action. Keep only authoritative facts needed by
AOFERTA and cap nested operational results.
"""
ctx = context or {}
history = []
for item in list(ctx.get('conversation_history') or [])[-8:]:
if isinstance(item, dict):
role = str(item.get('role') or item.get('type') or '')
content = str(item.get('content') or '')
if content:
history.append({'role': role, 'content': content[:1200]})
pre = ctx.get('transaction_pre_validation') if isinstance(ctx.get('transaction_pre_validation'), dict) else {}
requested = pre.get('requested_arguments') if isinstance(pre.get('requested_arguments'), dict) else {}
resolved = pre.get('resolved_arguments') if isinstance(pre.get('resolved_arguments'), dict) else {}
def compact_result(value: Any, depth: int = 0) -> Any:
if depth > 6:
return None
if isinstance(value, list):
return [compact_result(v, depth + 1) for v in value[:8]]
if not isinstance(value, dict):
return value if isinstance(value, (str, int, float, bool)) or value is None else str(value)[:500]
keep = {
'tool_name', 'ok', 'error', 'status', 'transaction_status', 'workflow_status',
'subject', 'name', 'resolved_subject', 'resolved_subjects', 'success', 'reason',
'mensagem', 'message', 'terminal_status', 'effective_tool_name',
'requested_arguments', 'resolved_arguments', 'items', 'results', 'errors',
'cancelados', 'nao_cancelados', 'nao_encontrados', 'output', 'result',
}
out = {}
for k, v in value.items():
if k in keep:
cv = compact_result(v, depth + 1)
if cv not in (None, {}, []):
out[k] = cv
return out
payload = {
'current_user_message': str(ctx.get('current_user_message') or '')[:1200],
'current_intent': str(ctx.get('current_intent') or ctx.get('intent') or ''),
'current_route': str(ctx.get('current_route') or ctx.get('route') or ''),
'transaction_status': str(ctx.get('transaction_status') or ''),
'conversation_history': history,
'transaction_request': {
'requested_arguments': compact_result(requested),
'resolved_arguments': compact_result(resolved),
'effective_tool_name': pre.get('effective_tool_name'),
'confirmation_message': pre.get('confirmation_message'),
},
'current_execution': compact_result(ctx.get('mcp_results') or ctx.get('tool_result') or []),
}
return json.dumps(payload, ensure_ascii=False, default=str)[:16000]
def _authorized_human_handoff(context: dict[str, Any]) -> bool:
@@ -209,71 +271,211 @@ class TimProactiveOfferRail(_TimPromptRail):
},
},
)
return await super().evaluate(text, context)
# Terminal/partial-success responses still need semantic auditing, but with
# compact authoritative transaction evidence. Do not use a completed-state
# bypass: the LLM must still block genuinely new targets/actions.
llm = _llm(context)
if llm is None:
return RailDecision(code=self.code, allowed=False, reason='LLM do framework indisponível para guardrail TIM', metadata={'external': True, 'fail_closed': True})
compact_context = _compact_aoferta_context(context)
prompt = self.prompt_builder(text or '', compact_context)
raw = await llm.ainvoke(
[{'role':'system','content':'Responda apenas JSON válido, sem markdown.'}, {'role':'user','content':prompt}],
profile_name=self.profile_name,
component_name=f'guardrail.external.{self.code.lower()}',
generation_name=f'guardrail.external.{self.code.lower()}',
)
out = _parse_json(raw)
return RailDecision(
code=self.code,
allowed=bool(out.get('allowed', False)),
reason=str(out.get('reason') or out.get('label') or ''),
sanitized_text=text,
metadata={
'external': True,
'domain':'TIM_CONTAS',
'mechanism': 'llm_semantic_compact_transaction_context',
'data':out,
},
)
class TimPrematureActionRail(_TimPromptRail):
code='TIM_REVPREC'; stage='output'; profile_name='grl'; prompt_builder=staticmethod(build_revprec_prompt)
@staticmethod
def _structured_insufficient_evidence_message(text: str, context: dict[str, Any]) -> bool:
"""Allow an epistemically conservative tool/workflow answer without LLM re-judgment.
_MESSAGE_KEYS = {
'mensagem', 'message', 'response', 'response_text', 'final_answer',
'customer_message', 'customer_response', 'answer', 'text',
}
The bypass is intentionally structural and exact-message based. An arbitrary
assistant sentence saying "não sei" is *not* enough: the current-turn tool
result must explicitly declare ``epistemic_status=insufficient_evidence`` and
expose the same ``mensagem`` that is being sent to the customer.
@classmethod
def _current_execution_evidence(cls, context: dict[str, Any]) -> list[dict[str, Any]]:
"""Return compact, current-turn execution evidence only.
REVPREC must judge whether an operational claim is supported by what actually
happened in this turn. Conversation history, LTM and previous workflow state
are deliberately excluded from this evidence set.
"""
expected = " ".join(str(text or "").split())
if not expected:
return False
roots = []
ctx = context or {}
for key in ('mcp_results', 'tool_result', 'evidence'):
value = ctx.get(key)
if value is not None:
roots.append(value)
roots = ctx.get('mcp_results')
if roots is None:
roots = ctx.get('tool_result')
if roots is None:
return []
if isinstance(roots, dict):
roots = [roots]
if not isinstance(roots, (list, tuple)):
return []
def walk(value: Any) -> bool:
if isinstance(value, dict):
status = str(value.get('epistemic_status') or '').strip().lower()
message = " ".join(str(value.get('mensagem') or '').split())
if status == 'insufficient_evidence' and message and message == expected:
return True
return any(walk(item) for item in value.values())
def compact(value: Any, depth: int = 0) -> Any:
if depth > 7:
return None
if isinstance(value, (str, int, float, bool)) or value is None:
if isinstance(value, str):
return value[:2000]
return value
if isinstance(value, (list, tuple)):
return any(walk(item) for item in value)
return False
out = [compact(v, depth + 1) for v in value[:20]]
return [v for v in out if v not in (None, {}, [])]
if not isinstance(value, dict):
return str(value)[:500]
keep = {
'tool_name', 'server_name', 'ok', 'error', 'status', 'workflow_name',
'execution_id', 'success', 'allowed', 'reason', 'subject', 'name',
'resolved_subject', 'resolved_subjects', 'value', 'amount', 'protocol',
'protocolo', 'mensagem', 'message', 'response', 'response_text',
'final_answer', 'customer_message', 'customer_response', 'answer', 'text',
'epistemic_status', 'output', 'result', 'items', 'results', 'errors',
'cancelados', 'nao_cancelados', 'nao_encontrados', 'terminal_status',
}
out = {}
for k, v in value.items():
if k in keep:
cv = compact(v, depth + 1)
if cv not in (None, {}, []):
out[k] = cv
return out
return any(walk(root) for root in roots)
evidence = []
for item in roots:
if not isinstance(item, dict):
continue
# Only successful/current tool executions can prove that an action happened.
# Failed results are still included so a contradictory success claim can be blocked.
cv = compact(item)
if isinstance(cv, dict) and cv:
evidence.append(cv)
return evidence
@classmethod
def _authoritative_output_messages(cls, context: dict[str, Any]) -> list[str]:
"""Extract canonical customer-facing messages from current successful tool outputs.
This is generic: no tool/workflow names are known here. Only message-like fields
underneath the current result/output tree are considered; state/input/history are
intentionally ignored.
"""
ctx = context or {}
roots = ctx.get('mcp_results')
if roots is None:
roots = ctx.get('tool_result')
if isinstance(roots, dict):
roots = [roots]
if not isinstance(roots, (list, tuple)):
return []
found: list[str] = []
def walk(value: Any, *, in_output: bool = False) -> None:
if isinstance(value, dict):
for k, v in value.items():
key = str(k).lower()
child_output = in_output or key in {'output', 'result', 'results'}
if child_output and key in cls._MESSAGE_KEYS and isinstance(v, str) and v.strip():
found.append(' '.join(v.split()))
if key not in {'state', 'input', 'metadata', 'conversation_history', 'history'}:
walk(v, in_output=child_output)
elif isinstance(value, (list, tuple)):
for v in value:
walk(v, in_output=in_output)
for item in roots:
if not isinstance(item, dict):
continue
if item.get('ok') is False:
continue
walk(item.get('result', item), in_output=True)
return list(dict.fromkeys(found))
@classmethod
def _candidate_is_authoritative_output(cls, text: str, context: dict[str, Any]) -> bool:
candidate = ' '.join(str(text or '').split())
if not candidate:
return False
return candidate in cls._authoritative_output_messages(context)
async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision:
if self._structured_insufficient_evidence_message(text, context):
# Strongest possible proof: the exact customer-facing candidate was emitted by
# the successful current-turn tool/workflow itself. REVPREC is about premature
# operational claims, so re-asking an LLM whether this exact authoritative result
# "really happened" only adds nondeterminism. Other safety rails still run.
if self._candidate_is_authoritative_output(text, context):
return RailDecision(
code=self.code,
allowed=True,
reason='insufficient_evidence_non_assertive',
reason='current_execution_authoritative_output',
sanitized_text=text,
metadata={
'external': True,
'domain': 'TIM_CONTAS',
'mechanism': 'deterministic_epistemic_bypass',
'epistemic_status': 'insufficient_evidence',
'mechanism': 'deterministic_current_execution_evidence',
'terminal_action': 'retry',
},
)
evidence = self._current_execution_evidence(context)
llm = _llm(context)
if llm is None:
return RailDecision(code=self.code, allowed=False, reason='LLM do framework indisponível para guardrail TIM', metadata={'external': True, 'fail_closed': True})
prompt = self.prompt_builder(text or '', _context_text(context))
return RailDecision(
code=self.code,
allowed=False,
reason='LLM do framework indisponível para guardrail TIM',
metadata={'external': True, 'fail_closed': True},
)
# For composed/paraphrased answers, ask the semantic rail the correct question:
# whether the operational claim is unsupported or contradicted by current-turn
# evidence. This replaces the obsolete assumption that reaching REVPREC means
# no tool ran.
evidence_text = json.dumps(evidence, ensure_ascii=False, default=str)[:14000]
prompt = self.prompt_builder(text or '', evidence_text)
raw = await llm.ainvoke(
[{'role':'system','content':'Responda apenas com o dígito 1 ou 0, sem texto adicional.'}, {'role':'user','content':prompt}],
profile_name=self.profile_name, component_name='guardrail.external.tim_revprec', generation_name='guardrail.external.tim_revprec')
[
{'role':'system','content':'Responda apenas com o dígito 1 ou 0, sem texto adicional.'},
{'role':'user','content':prompt},
],
profile_name=self.profile_name,
component_name='guardrail.external.tim_revprec',
generation_name='guardrail.external.tim_revprec',
)
output = str(getattr(raw, 'content', raw) or '').strip()
digits = [ch for ch in output if ch in '01']
# Prompt original: 1 = violação / verbalização prematura; 0 = permitido.
# 1 = unsupported/contradicted operational completion claim; 0 = allowed.
allowed = not digits or digits[-1] != '1'
return RailDecision(code=self.code, allowed=allowed, reason='' if allowed else 'verbalização prematura segundo política TIM Contas', sanitized_text=text, metadata={'external':True,'domain':'TIM_CONTAS','raw':output[:100], 'terminal_action':'retry'})
return RailDecision(
code=self.code,
allowed=allowed,
reason='' if allowed else 'resultado operacional afirmado sem suporte na evidência atual',
sanitized_text=text,
metadata={
'external': True,
'domain':'TIM_CONTAS',
'raw':output[:100],
'terminal_action':'retry',
'mechanism':'llm_current_execution_evidence',
'current_execution_evidence_count': len(evidence),
},
)
class TimPhraseologyRail(_TimPromptRail):
code='TIM_FRASEOLOGIA'; stage='output'; profile_name='grl'; prompt_builder=staticmethod(build_fraseologia_prompt)

View File

@@ -38,4 +38,13 @@ class TimGroundednessJudge(_TimJudge):
class TimResponseQualityJudge(_TimJudge):
name='tim_response_quality'
async def evaluate(self, question, answer, context):
return self._result(await self._invoke(build_rqlt_prompt(str(question or ''), str(answer or ''))))
out = await self._invoke(build_rqlt_prompt(str(question or ''), str(answer or '')))
# RQLT contract is explicitly 0..10. The base normalizer cannot infer
# that score=1 means 1/10 (it also supports judges that already emit
# 0..1), so normalize this judge at its boundary.
try:
out = dict(out or {})
out['score'] = float(out.get('score', 0)) / 10.0
except Exception:
out = {**dict(out or {}), 'score': 0.0}
return self._result(out)

View File

@@ -52,6 +52,19 @@ Decida na ordem, PARE no primeiro match:
mesmo alvo, a fala do agente que apenas pede CONFIRMACAO da transacao e
allowed=true. A confirmacao NAO precisa repetir a justificativa do cliente
("nao reconheco", "esta caro" etc.); o pedido transacional anterior basta.
CONTINUIDADE POR CANAL ALTERNATIVO: se a acao transacional foi explicitamente
pedida pelo cliente para o mesmo alvo e a execucao por este canal falhou, ficou
indisponivel ou nao pode ser concluida, orientar o cliente sobre COMO concluir
ESSA MESMA acao em outro canal oficial e allowed=true. Isso nao cria uma nova
oferta: apenas informa o caminho operacional para cumprir o pedido ja existente.
Bloqueie somente se a orientacao introduzir outra acao, outro alvo ou ampliar o
escopo alem do que o cliente pediu.
EVIDENCIA ESTRUTURADA: quando o contexto operacional trouxer alvo solicitado/
resolvido e resultado da execucao (sucesso, falha parcial, item nao encontrado),
trate esses campos como evidencia autoritativa do pedido atual. Se a resposta
orientar outro canal para o MESMO alvo cuja execucao falhou, allowed=true, mesmo
que a ultima mensagem do cliente seja apenas uma confirmacao como "sim".
Vale tambem trocar uma variante transacional por outra DA MESMA FAMILIA sobre
o MESMO escopo, sempre limitada ao valor JA COBRADO no item (ressarcimento <->
devolucao <-> reembolso <-> cancelamento <-> credito em fatura): negar o dobro e

View File

@@ -1,95 +1,54 @@
"""Prompt do rail REVPREC — "o agente disse que cancelou algo?".
Reescrito em 2026-08-06. A versão anterior (207 linhas, algoritmo de 9 passos, saída
`{allowed,label,reason,score}`) julgava PROMESSA FUTURA sem autorização e, por
construção, deixava passar exatamente o caso que interessa: o passo 2 dela dava OK a
"resultado no PASSADO ou PRESENTE". Foi descartada inteira.
O rail agora responde UMA pergunta binária: a última fala do agente afirma que um
cancelamento / retirada de valor / contestação já aconteceu?
Por que isso funciona sem falso positivo na ação legítima: o rail só roda quando o
ORQUESTRADOR responde em TEXTO. Quando a ação acontece de verdade, ela vem de uma tool
call — e `apply_output_rails` sai antes dos rails LLM quando há `tool_calls` no turno
(pipeline.py, invariante do early-exit), assim como a fala canônica do
`ResponseComposer` entra com `skip_rails=True`. Ou seja: se esta pergunta chega ao LLM,
o agente está afirmando uma ação que ele NÃO tem tool para executar.
Saída BINÁRIA com polaridade INVERTIDA em relação a PINJ/COER: aqui `1` = achou a
afirmação = bloqueia; `0` = fala limpa. A pergunta fica na forma positiva ("disse que
cancelou?") porque é ela que dá acurácia; a inversão mora no `llm_client`
(`_BINARY_BLOCK_DIGIT`).
Duas fronteiras que o prompt precisa manter, e que os casos da suíte travam:
- **histórico**: um cancelamento feito do jeito certo em turno anterior FICA no
histórico e não foi avaliado por este rail; só o bloco "Resposta:" é julgado;
- **protocolo**: o número de protocolo só é emitido depois da tool rodar, então uma
fala que o traz relata execução real → 0.
"""
"""REVPREC: detecta verbalização de resultado operacional sem evidência atual."""
from __future__ import annotations
def build_revprec_prompt(text: str, context: str = "") -> str:
"""Monta o prompt do rail REVPREC.
"""Compara a fala candidata com a evidência estrutural do turno atual.
Args:
text: última fala gerada pelo agente (a que será julgada).
context: histórico já formatado por ``prompts._context.format_context_block``
(a última fala do agente é PODADA de lá — ela vem em ``text``).
Returns:
Prompt cuja resposta esperada é um único caractere: ``1`` ou ``0``.
Polaridade: 1 = a fala afirma conclusão/efeito operacional não comprovado ou
contradito pela evidência; 0 = permitido.
"""
return f"""Você audita UMA fala do agente de fatura da TIM e responde 1 ou 0.
return f"""Você audita UMA fala de um agente de atendimento TIM.
O agente NÃO executa cancelamento, contestação nem retirada de valor. Quando a ação
acontece de verdade, é o sistema que responde ao cliente — nunca esta fala. Logo,
afirmar aqui que já cancelou/retirou/contestou é sempre falso.
Sua tarefa NÃO é decidir se a frase "soa" como ação concluída. Sua tarefa é comparar
as afirmações da RESPOSTA com a EVIDÊNCIA REAL DO TURNO ATUAL.
ANTES DE TUDO: se a fala traz NÚMERO DE PROTOCOLO — um ou vários, no formato "PRT..."
bruto, vocalizado ("pê erre tê nove cê dois...") ou dígitos depois da palavra protocolo —
responda 0 e pare. O protocolo só é emitido DEPOIS de a ação rodar de verdade; com ele a
fala relata execução real, inclusive quando RECAPITULA cancelamentos já feitos, por mais
concluída que soe.
Responda 1 SOMENTE quando a resposta afirmar que uma ação/efeito operacional já foi
concluído (por exemplo cancelamento, contestação, retirada de valor, crédito, reembolso,
envio ou alteração) e essa conclusão NÃO estiver suportada pela evidência atual, ou
estiver contradita por ela.
Responda 1 quando a fala AFIRMA que um cancelamento, retirada de valor, contestação ou
reembolso JÁ ACONTECEU. Ex.: "cancelamento confirmado", "já foi cancelado", "cancelado
com sucesso", "cancelei o serviço", "retiramos o valor", "sua contestação foi
registrada", "o reembolso já foi processado", "a cobrança não vai mais aparecer porque
removi o serviço".
Responda 0 quando:
- a evidência atual comprova a ação/resultado afirmado;
- a resposta apenas explica dados de fatura, cobrança, desconto, plano, valor, data,
status ou motivo presentes na evidência;
- a resposta pede confirmação/permissão ou solicita parâmetro;
- a resposta anuncia uma ação futura sem afirmar que já ocorreu;
- a resposta nega que a ação ocorreu;
- a resposta expressa ausência de evidência ou incerteza;
- não existe afirmação de conclusão operacional.
Responda 0 em todo o resto. Em particular:
- PERGUNTA ou PEDIDO DE PERMISSÃO, mesmo citando cancelamento: "Podemos seguir com o
cancelamento do serviço X, no valor de R$ 12,00, você confirma?", "Gostaria de
cancelar esse serviço?", "Posso seguir?".
- ESCOPO ou intenção reconhecida, sem ação feita: "Entendi que você deseja falar sobre
os serviços X e Y. Correto?", "Vou verificar isso para você".
- PROMESSA sem afirmar execução: "vou cancelar", "seguiremos com o cancelamento, aguarde
um instante", "o valor será retirado da sua fatura". Aqui a pergunta é se a ação foi
DADA COMO FEITA; anúncio do que vem depois não é.
- DESCRIÇÃO DA FATURA, não ação do agente: "Foi removido um desconto de R$ 6,00", "foi
adicionada a cobrança do X", "esse serviço foi cobrado em duas datas" — isso compara
faturas e explica cobranças; não cancela nada.
- ORIENTAÇÃO a outro canal: "ligue para *144 e solicite o cancelamento", "pelo app do
parceiro você consegue cancelar".
- NEGATIVA de ação: "não consigo cancelar por aqui", "ainda não cancelei", "esse serviço
não pode ser cancelado neste atendimento".
- AUSÊNCIA DE EVIDÊNCIA ou incerteza explícita: "os dados disponíveis não informam o
motivo", "não há evidência suficiente para confirmar a causa", "não foi possível
identificar o motivo". Isso NÃO afirma execução nem resultado operacional; responda 0.
- EXPLICAÇÃO, valor, data, encerramento, saudação, ou qualquer assunto que não seja
ação de cancelamento dada como feita.
REGRAS IMPORTANTES:
1. Use SOMENTE a evidência do bloco EVIDÊNCIA ATUAL para provar execução. Não use
histórico, memória ou suposições.
Julgue somente a fala do bloco "Resposta:"; qualquer texto fora desse bloco é contexto.
2. Se a evidência mostra sucesso/COMPLETED e contém os fatos afirmados, responda 0.
3. Se a evidência mostra falha/erro/não executado e a resposta afirma sucesso, responda 1.
4. Se não existe evidência de execução e a resposta afirma que uma ação transacional já
foi realizada, responda 1.
5. Descrição de algo que aconteceu na conta/fatura (ex.: desconto expirou, cobrança foi
lançada) não é "ação prematura" se isso estiver suportado pelos dados atuais.
6. PROTOCOLO é evidência auxiliar, não regra absoluta: valide junto com a evidência atual.
7. DESCRIÇÃO DA FATURA (cobrança, desconto, data, status, motivo) não é execução de ação
pelo agente quando estiver sustentada pelos dados atuais.
O HISTÓRICO é só contexto. Um cancelamento feito corretamente em turno anterior APARECE
lá e NÃO conta — ele não passou por esta auditoria e não é o que se julga agora. Julgue
somente a fala do bloco "Resposta:".
------------------------------------{context}
Resposta:
---------------- EVIDÊNCIA ATUAL ----------------
{context or '[]'}
---------------- RESPOSTA ----------------
{text}
------------------------------------
--------------------------------------------------
A fala do bloco "Resposta:" afirma que um cancelamento, retirada de valor ou
contestação já aconteceu?
A RESPOSTA contém alguma afirmação de resultado operacional concluído que NÃO esteja
suportada (ou esteja contradita) pela EVIDÊNCIA ATUAL?
Responda APENAS 1 ou 0, sem mais nada."""
Responda APENAS 1 ou 0."""

View File

@@ -413,7 +413,12 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
}
)
await checkpoints.put(agent_session_id, {"state": result, "message_id": message_id})
# IMPORTANT: the LangGraph checkpointer uses ``thread_id == agent_session_id``
# in the same checkpoint repository. Writing a gateway/debug snapshot under
# that same key overwrites the native LangGraph checkpoint and drops live
# transactional channels (active_transaction, pending_tool_call, confirmation
# snapshot, etc.). Keep the human/debug snapshot in a separate namespace.
await checkpoints.put(f"gateway:{agent_session_id}", {"state": result, "message_id": message_id})
await sse_hub.emit(agent_session_id, "workflow.completed", {"session_id": agent_session_id, "route": result.get("route"), "intent": result.get("intent")}) if emit_sse else None
answer = result.get("final_answer") or result.get("answer") or ""
@@ -658,7 +663,7 @@ async def get_session_messages(session_id: str, limit: int = 50):
@app.get("/sessions/{session_id}/checkpoint")
async def get_session_checkpoint(session_id: str):
return {"session_id": session_id, "checkpoint": await checkpoints.get_latest(session_id)}
return {"session_id": session_id, "checkpoint": await checkpoints.get_latest(f"gateway:{session_id}")}
@app.on_event("shutdown")

View File

@@ -423,8 +423,52 @@ class AgentWorkflow:
):
boundary_pending = bool(state.get("operational_context_boundary_pending"))
tx_status = str(state.get("transaction_status") or "").strip().upper()
terminal_interaction = tx_status in {"COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"}
reset_operational_context = boundary_pending or terminal_interaction
active_tx = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {}
active_tx_status = str(active_tx.get("status") or "").strip().upper()
nonterminal_tx_statuses = {
"COLLECTING_PARAMETERS",
"AWAITING_CONFIRMATION",
"EXECUTING",
"PAUSED",
"WAITING_INPUT",
}
# A new active transaction has precedence over terminal evidence from
# the previous transaction in the same session. Without this guard,
# a stale state["transaction_status"] == COMPLETED can tombstone an
# AWAITING_CONFIRMATION transaction that was just opened in the
# current turn.
active_transaction_pending = bool(active_tx) and active_tx_status in nonterminal_tx_statuses
# A confirmation latch is itself authoritative live transaction state.
# Depending on checkpoint serialization/order, active_transaction may
# not yet be rehydrated while pending_tool_call + confirmation_required
# are present. Never let terminal evidence from the previous transaction
# tombstone a valid confirmation for the new one.
pending_call = state.get("pending_tool_call") if isinstance(state.get("pending_tool_call"), dict) else {}
pending_confirmation = bool(
state.get("confirmation_required")
and pending_call.get("tool_name")
and isinstance(pending_call.get("arguments"), dict)
)
if pending_confirmation and not active_transaction_pending:
pending_args = dict(pending_call.get("arguments") or {})
active_tx = {
"transaction_id": str(pending_args.get("transaction_id") or state.get("transaction_id") or ""),
"tool_name": str(pending_call.get("tool_name") or ""),
"arguments": pending_args,
"status": "AWAITING_CONFIRMATION",
}
state["active_transaction"] = active_tx
state["transaction_status"] = "AWAITING_CONFIRMATION"
tx_status = "AWAITING_CONFIRMATION"
active_tx_status = "AWAITING_CONFIRMATION"
active_transaction_pending = True
terminal_interaction = (
tx_status in {"COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"}
and not active_transaction_pending
)
reset_operational_context = (boundary_pending or terminal_interaction) and not active_transaction_pending
# The durable history/checkpoint is preserved, but the first turn
# after a completed workflow must look operationally like a fresh