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()
|
msgs.pop()
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for msg in msgs:
|
for msg in msgs:
|
||||||
cls = type(msg).__name__
|
if isinstance(msg, dict):
|
||||||
if cls in _SKIPPED_CLASSES:
|
role = str(msg.get("role") or msg.get("type") or "").lower()
|
||||||
continue
|
if role in {"system", "tool", "function"}:
|
||||||
role = _ROLE_BY_CLASS.get(cls, cls.lower())
|
continue
|
||||||
content = _message_content_to_str(getattr(msg, "content", ""))
|
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():
|
if content.strip():
|
||||||
lines.append(f"[{role}] {_truncate(content, per_message_limit)}")
|
lines.append(f"[{role}] {_truncate(content, per_message_limit)}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ Decida na ordem, PARE no primeiro match:
|
|||||||
casa aqui, siga para o passo 5.
|
casa aqui, siga para o passo 5.
|
||||||
Vale o pedido generico ("quero cancelar", "todos") sobre o que a conversa
|
Vale o pedido generico ("quero cancelar", "todos") sobre o que a conversa
|
||||||
trata, e vale confirmar ou pedir permissao para executar essa acao.
|
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
|
Vale tambem trocar uma variante transacional por outra DA MESMA FAMILIA sobre
|
||||||
o MESMO escopo, sempre limitada ao valor JA COBRADO no item (ressarcimento <->
|
o MESMO escopo, sempre limitada ao valor JA COBRADO no item (ressarcimento <->
|
||||||
devolucao <-> reembolso <-> cancelamento <-> credito em fatura): negar o dobro e
|
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.
|
enxergar evidências MCP. O domínio não implementa rails, apenas devolve dados.
|
||||||
"""
|
"""
|
||||||
ctx = dict(state.get("context", {}) or {})
|
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 []
|
mcp_results = state.get("mcp_results") or []
|
||||||
ctx["evidence"] = mcp_results or ctx.get("evidence")
|
ctx["evidence"] = mcp_results or ctx.get("evidence")
|
||||||
ctx["tool_result"] = mcp_results or ctx.get("tool_result")
|
ctx["tool_result"] = mcp_results or ctx.get("tool_result")
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ input:
|
|||||||
|
|
||||||
output:
|
output:
|
||||||
- {code: OOS, enabled: true}
|
- {code: OOS, enabled: true}
|
||||||
# - {code: AOFERTA, enabled: true}
|
- {code: AOFERTA, enabled: true}
|
||||||
- {code: REVPREC, enabled: true}
|
- {code: REVPREC, enabled: true}
|
||||||
# - {code: FRASEOLOGIA, enabled: true}
|
- {code: FRASEOLOGIA, enabled: true}
|
||||||
- {code: PINJ, enabled: true}
|
- {code: PINJ, enabled: true}
|
||||||
- {code: DLEX_OUT, enabled: true}
|
- {code: DLEX_OUT, enabled: true}
|
||||||
- {code: TOXOUT, enabled: true}
|
- {code: TOXOUT, enabled: true}
|
||||||
|
|||||||
@@ -99,3 +99,12 @@ def test_upsell_continua_bloqueando_apos_mudanca():
|
|||||||
assert r.allowed is False
|
assert r.allowed is False
|
||||||
assert r.code == "AOFERTA"
|
assert r.code == "AOFERTA"
|
||||||
assert r.data.get("label") == "OFERTA_PROATIVA_INDEVIDA"
|
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(
|
assert _infer_informational_vas_types(
|
||||||
["YouTube Premium Mensal", "Aluguel de Filme 3"], json.dumps(payload, ensure_ascii=False)
|
["YouTube Premium Mensal", "Aluguel de Filme 3"], json.dumps(payload, ensure_ascii=False)
|
||||||
) == {"estrategico", "avulso"}
|
) == {"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