From a231f063ed147c06bded6905dee2848594497f0d Mon Sep 17 00:00:00 2001 From: "cristiano.hoshikawa" Date: Fri, 21 Aug 2026 10:44:34 -0300 Subject: [PATCH] bugfix: aofertas --- FIX_AOFERTAS_CONTAS_20260821.md | 36 +++++++++++++++++++ .../guardrails/calibrated/prompts/_context.py | 20 ++++++++--- .../prompts/ausencia_oferta_proativa.py | 4 +++ app/workflows/agent_graph.py | 28 +++++++++++++++ config/guardrails.yaml | 4 +-- .../original_test_ausencia_oferta_proativa.py | 9 +++++ tests/migration/test_adversarial_parity.py | 34 ++++++++++++++++++ .../test_guardrail_context_dict_history.py | 13 +++++++ 8 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 FIX_AOFERTAS_CONTAS_20260821.md create mode 100644 tests/migration/test_guardrail_context_dict_history.py diff --git a/FIX_AOFERTAS_CONTAS_20260821.md b/FIX_AOFERTAS_CONTAS_20260821.md new file mode 100644 index 0000000..337bb6a --- /dev/null +++ b/FIX_AOFERTAS_CONTAS_20260821.md @@ -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. diff --git a/agent_framework_oci/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/_context.py b/agent_framework_oci/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/_context.py index a0ad60c..cf51808 100644 --- a/agent_framework_oci/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/_context.py +++ b/agent_framework_oci/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/_context.py @@ -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) diff --git a/agent_framework_oci/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/ausencia_oferta_proativa.py b/agent_framework_oci/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/ausencia_oferta_proativa.py index 9d3794a..fea01a8 100644 --- a/agent_framework_oci/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/ausencia_oferta_proativa.py +++ b/agent_framework_oci/libs/agent_framework/src/agent_framework/guardrails/calibrated/prompts/ausencia_oferta_proativa.py @@ -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 diff --git a/app/workflows/agent_graph.py b/app/workflows/agent_graph.py index 84d1743..40b21d9 100644 --- a/app/workflows/agent_graph.py +++ b/app/workflows/agent_graph.py @@ -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") diff --git a/config/guardrails.yaml b/config/guardrails.yaml index 0d1ddc9..3001eb8 100644 --- a/config/guardrails.yaml +++ b/config/guardrails.yaml @@ -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} diff --git a/tests/migration/original_test_ausencia_oferta_proativa.py b/tests/migration/original_test_ausencia_oferta_proativa.py index 5bb609a..2202fca 100644 --- a/tests/migration/original_test_ausencia_oferta_proativa.py +++ b/tests/migration/original_test_ausencia_oferta_proativa.py @@ -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 diff --git a/tests/migration/test_adversarial_parity.py b/tests/migration/test_adversarial_parity.py index a35106e..86d40e0 100644 --- a/tests/migration/test_adversarial_parity.py +++ b/tests/migration/test_adversarial_parity.py @@ -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 diff --git a/tests/migration/test_guardrail_context_dict_history.py b/tests/migration/test_guardrail_context_dict_history.py new file mode 100644 index 0000000..c02d82f --- /dev/null +++ b/tests/migration/test_guardrail_context_dict_history.py @@ -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