bugfix: aofertas
This commit is contained in:
36
FIX_AOFERTAS_CONTAS_20260821.md
Normal file
36
FIX_AOFERTAS_CONTAS_20260821.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
13
tests/migration/test_guardrail_context_dict_history.py
Normal file
13
tests/migration/test_guardrail_context_dict_history.py
Normal 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
|
||||
Reference in New Issue
Block a user