bugfix: aofertas

This commit is contained in:
2026-08-21 10:44:34 -03:00
parent e063e4b500
commit a231f063ed
8 changed files with 141 additions and 7 deletions

View File

@@ -0,0 +1,36 @@
# Correção aOfertas + Contas — 2026-08-21
## Problema
O guardrail AOFERTA podia receber uma confirmação transacional isolada, por exemplo
`Você confirma cancelar o TIM Music?`, sem o pedido do cliente no contexto. Com isso,
um cancelamento legitimamente solicitado podia ser interpretado como oferta proativa.
Além disso, o histórico do Contas usa principalmente `dict` (`role`/`content`), enquanto
o formatador de contexto do guardrail tratava apenas objetos de mensagem LangChain.
## Correções aplicadas
1. `app/workflows/agent_graph.py`
- `_output_guardrail_context()` passa `conversation_history` aos guardrails de saída.
- inclui `user_text` corrente quando ainda não está no histórico;
- evita duplicação da última mensagem do cliente;
- mantém `history_texts` para compatibilidade.
2. `agent_framework_oci/.../prompts/_context.py`
- histórico em `dict` (`role`, `content`) passa a ser formatado corretamente;
- preserva suporte às mensagens LangChain existentes.
3. `agent_framework_oci/.../prompts/ausencia_oferta_proativa.py`
- explicita que uma confirmação da mesma transação já solicitada pelo cliente é permitida;
- não exige que a confirmação repita a justificativa da solicitação;
- marcadores de oferta adicional continuam bloqueados.
4. `config/guardrails.yaml`
- `AOFERTA` foi reativado, pois estava comentado na versão recebida.
## Validação
Os testes específicos da correção passaram: 47/47.
O teste global de paridade ainda aponta `FRASEOLOGIA` desabilitado em
`config/guardrails.yaml`. Essa configuração já veio assim no pacote recebido e não foi
alterada por esta correção, pois está fora do escopo aOfertas/Contas.

View File

@@ -74,11 +74,21 @@ def _format_conversation_history(
msgs.pop()
lines: list[str] = []
for msg in msgs:
cls = type(msg).__name__
if cls in _SKIPPED_CLASSES:
continue
role = _ROLE_BY_CLASS.get(cls, cls.lower())
content = _message_content_to_str(getattr(msg, "content", ""))
if isinstance(msg, dict):
role = str(msg.get("role") or msg.get("type") or "").lower()
if role in {"system", "tool", "function"}:
continue
if role == "human":
role = "user"
elif role in {"ai", "bot"}:
role = "assistant"
content = _message_content_to_str(msg.get("content", ""))
else:
cls = type(msg).__name__
if cls in _SKIPPED_CLASSES:
continue
role = _ROLE_BY_CLASS.get(cls, cls.lower())
content = _message_content_to_str(getattr(msg, "content", ""))
if content.strip():
lines.append(f"[{role}] {_truncate(content, per_message_limit)}")
return "\n".join(lines)

View File

@@ -48,6 +48,10 @@ Decida na ordem, PARE no primeiro match:
casa aqui, siga para o passo 5.
Vale o pedido generico ("quero cancelar", "todos") sobre o que a conversa
trata, e vale confirmar ou pedir permissao para executar essa acao.
IMPORTANTE: se o cliente acabou de PEDIR cancelamento/contestacao/ajuste do
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.
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

@@ -148,6 +148,34 @@ class AgentWorkflow:
enxergar evidências MCP. O domínio não implementa rails, apenas devolve dados.
"""
ctx = dict(state.get("context", {}) or {})
# Guardrails de saída (especialmente AOFERTA) precisam saber o que o
# cliente efetivamente pediu. Sem o histórico, uma confirmação legítima
# como "Você confirma cancelar ...?" parece uma oferta proativa isolada.
# O estado do Contas mantém history como lista de dicts; preservamos esse
# formato e acrescentamos o user_text corrente quando ele ainda não foi
# persistido no histórico.
history = list(state.get("history") or [])
user_text = str(state.get("user_text") or state.get("sanitized_input") or "").strip()
if user_text:
last_user_text = ""
for item in reversed(history):
if isinstance(item, dict) and str(item.get("role", "")).lower() in {"user", "human"}:
last_user_text = str(item.get("content") or "").strip()
break
if type(item).__name__ == "HumanMessage":
last_user_text = str(getattr(item, "content", "") or "").strip()
break
if last_user_text != user_text:
history.append({"role": "user", "content": user_text})
if history:
ctx["conversation_history"] = history
ctx["history_texts"] = [
str(item.get("content") or "") if isinstance(item, dict)
else str(getattr(item, "content", "") or "")
for item in history
]
mcp_results = state.get("mcp_results") or []
ctx["evidence"] = mcp_results or ctx.get("evidence")
ctx["tool_result"] = mcp_results or ctx.get("tool_result")

View File

@@ -11,9 +11,9 @@ input:
output:
- {code: OOS, enabled: true}
# - {code: AOFERTA, enabled: true}
- {code: AOFERTA, enabled: true}
- {code: REVPREC, enabled: true}
# - {code: FRASEOLOGIA, enabled: true}
- {code: FRASEOLOGIA, enabled: true}
- {code: PINJ, enabled: true}
- {code: DLEX_OUT, enabled: true}
- {code: TOXOUT, enabled: true}

View File

@@ -99,3 +99,12 @@ def test_upsell_continua_bloqueando_apos_mudanca():
assert r.allowed is False
assert r.code == "AOFERTA"
assert r.data.get("label") == "OFERTA_PROATIVA_INDEVIDA"
def test_prompt_aoferta_confirmacao_nao_exige_repetir_justificativa():
prompt = build_aoferta_prompt(
"Você confirma cancelar o TIM Music?",
"\nHistorico da conversa:\n[user] Quero cancelar o TIM Music\n",
)
assert "A confirmacao NAO precisa repetir a justificativa" in prompt
assert "o pedido transacional anterior basta" in prompt

View File

@@ -359,3 +359,37 @@ def test_finalizacao_informacional_infer_tipo_de_billing_analysis_json():
assert _infer_informational_vas_types(
["YouTube Premium Mensal", "Aluguel de Filme 3"], json.dumps(payload, ensure_ascii=False)
) == {"estrategico", "avulso"}
def test_output_guardrail_context_inclui_pedido_atual_para_aoferta():
from app.workflows.agent_graph import AgentWorkflow
ctx = AgentWorkflow._output_guardrail_context(
{
"context": {},
"history": [
{"role": "assistant", "content": "O TIM Music custa R$ 12,90."},
],
"user_text": "Quero cancelar o TIM Music",
"mcp_results": [],
}
)
assert ctx["conversation_history"][-1] == {
"role": "user",
"content": "Quero cancelar o TIM Music",
}
assert ctx["history_texts"][-1] == "Quero cancelar o TIM Music"
def test_output_guardrail_context_nao_duplica_user_text_ja_no_historico():
from app.workflows.agent_graph import AgentWorkflow
ctx = AgentWorkflow._output_guardrail_context(
{
"context": {},
"history": [{"role": "user", "content": "Quero cancelar o TIM Music"}],
"user_text": "Quero cancelar o TIM Music",
"mcp_results": [],
}
)
assert len(ctx["conversation_history"]) == 1

View File

@@ -0,0 +1,13 @@
from agent_framework.guardrails.calibrated.prompts._context import format_context_block
def test_format_context_block_suporta_history_em_dict_do_contas():
context = {
"conversation_history": [
{"role": "assistant", "content": "O TIM Music custa R$ 12,90."},
{"role": "user", "content": "Quero cancelar o TIM Music"},
]
}
block = format_context_block(context)
assert "[assistant] O TIM Music custa R$ 12,90." in block
assert "[user] Quero cancelar o TIM Music" in block