Skip to content

022 — Eval harness conversacional (fixture-based regression de conversa)

Pesquisa NotebookLM (mel_finance, 2026-06-03) bateu na régua: pre-launch, simular as 15 conversas mais comuns e medir taxa de erro. >20% de erro = ajusta antes de ir ao ar. Pós-launch, curadoria semanal de 30min revisando conversas onde o bot errou, ficou em loop, ou o casal pediu humano. Sem essa régua, qualidade conversacional degrada silenciosamente — o agente continua “respondendo”, mas vai perdendo fidelidade ao tom (ADR 008), inventando dados (021), pulando confirmações (013), ou simplesmente quebrando o fluxo de uma família de scenarios.

Esse cenário introduz a infraestrutura mínima pra regressão conversacional: EvalRunner (Domain Service em planning/domain/services/ — capability-named, igual FeasibilityCheck, BudgetAlerts, ReminderService) roda um array de ConversationFixture e devolve EvalReport[]. Cada fixture descreve uma conversa canônica (1-N turns), o setup do mundo (qual Budget/Goal/Household existir antes), o que o LLM mock deve devolver em cada turn, e as expectations observáveis (tool calls feitas, args, texto da resposta bate regex, side effects nos repositories).

O harness não inventa nada novo no domínio do agente — é um driver de cima do AgentChat.ask já existente (008). A única novidade real é MelToneGuard, um helper regex-based que reusa as regras concretas do ADR 008 (sem juridiquês, frase curta, ≤ 1 emoji) pra checar texto. Não vira VO/aggregate — é função pura.

Tier strategy (cross-link ADR 002):

  • Tier 1 (escopo desse cenário) — harness roda no tier domain, com MockLanguageModelV1 scripted (mesmo pattern dos cenários 008/013/018/021). Valida que o stack end-to-end (router → AgentChat.ask → tools → repositories) executa sem throw + tool calls esperadas acontecem na ordem certa + resposta final bate patterns (regex tolerante + MelToneGuard).
  • Tier 2 (TODO deferido) — mesmo harness, mas com LLM real (Gemini 2.5 Flash via OpenRouter) gated por env EVAL_REAL_LLM=1. Mesmo fixture catalog, driver diferente. Custa tokens — fica como npm run eval:real opt-in pra rodar manualmente pré-launch e na curadoria semanal. Quando aparecer, o EvalRunner aceita model: LanguageModelV1 no construtor (DI), fixtures permanecem iguais.

Uso recomendado (processo, não cenário):

  • Pré-launch: rodar o catálogo das 15 fixtures canônicas (Tier 2 quando existir) — se >20% falhar, ajusta system prompt / fixture / regra antes do go-live.
  • Curadoria semanal (30min): revisar conversas reais onde o bot errou, transformar as mais sintomáticas em novas fixtures que reproduzem o caso. Catálogo cresce orgânico. Eval previne regressão.

Scenario: Fixture passa quando tool calls e texto batem patterns

Section titled “Scenario: Fixture passa quando tool calls e texto batem patterns”
  • Given uma ConversationFixture budget-total-query configurada com:
    • setup que monta um Budget em BRL com RecurringExpense somando R$ 2.820 pra junho/2026 (mesma fixture do 008);
    • 1 turn com user: "quanto a gente tem de orçamento esse mês?";
    • mockLLM.toolCalls: [{ id, name: "budgetTotal", args: { month: "2026-06" } }] + text: "O orçamento previsto pra junho é R$ 2.820.";
    • expect: toolCallsMade: ["budgetTotal"], toolCallArgsMatching: { budgetTotal: { month: "2026-06" } }, responseTextMatching: /R\$\s?2\.820/i, responseTextNotMatching: /prezado|estimado/i.
  • When evalRunner.run(fixture)
  • Then result.name === "budget-total-query"
  • And result.passed === true
  • And result.turnsRun === 1
  • And result.failures === []
  • And result.durationMs é um número finito (>= 0)
  • And MelToneGuard.check("O orçamento previsto pra junho é R$ 2.820.").passes === true (texto bate o tom oficial)

Scenario: Fixture falha quando tool esperada não foi chamada

Section titled “Scenario: Fixture falha quando tool esperada não foi chamada”
  • Given uma ConversationFixture com 1 turn cujo expect.toolCallsMade: ["budgetTotal"]
  • And o mockLLM configurado pra não chamar nenhuma tool (só responde texto solto — força o gap entre expect e realidade)
  • When evalRunner.run(fixture)
  • Then result.passed === false
  • And result.turnsRun === 1 (turn rodou até o fim mesmo com expect falhando)
  • And result.failures.length === 1
  • And result.failures[0].turn === 1
  • And result.failures[0].reason bate em /tool .*budgetTotal.* not called|expected tool/i

Scenario: Anti-pattern detection — texto com “prezado” falha

Section titled “Scenario: Anti-pattern detection — texto com “prezado” falha”
  • Given uma ConversationFixture cujo expect.responseTextNotMatching: /prezado|estimado|por gentileza/i
  • And o mockLLM configurado pra responder com text: "Prezado casal, o total previsto é R$ 2.820." (viola ADR 008)
  • When evalRunner.run(fixture)
  • Then result.passed === false
  • And result.failures contém uma entry com turn === 1 e reason batendo em /anti-pattern|matched forbidden|responseTextNotMatching/i
  • And MelToneGuard.check("Prezado casal, o total previsto é R$ 2.820.").passes === false
  • And MelToneGuard.check(...).violations lista pelo menos uma violação batendo em /prezado/i
  • Given 5 ConversationFixture (mix de pass e fail proposital — 3 passam, 2 falham)
  • When evalRunner.runAll(fixtures)
  • Then summary.totalFixtures === 5
  • And summary.passed === 3
  • And summary.failed === 2
  • And summary.passed + summary.failed === summary.totalFixtures
  • And summary.passRate é número entre 0 e 1 (0.6 nesse caso)
  • And summary.reports.length === 5
  • And a ordem de summary.reports preserva a ordem dos fixtures de entrada (reports[i].name === fixtures[i].name)
  • ConversationFixture é POJO type, não classe — descrição declarativa de uma conversa scriptada. Sem invariante de domínio: validação acontece no EvalRunner.run. Fixtures novas se adicionam só criando arquivos em planning/domain/eval/fixtures/ (TODO impl session).
  • EvalContext é bag de aggregates+repositories — setup retorna o que os expects.sideEffects precisam pra verificar mutação. Tipagem genérica (Record<string, unknown> no contract; cada fixture tipa o que precisar internamente).
  • EvalRunner é Domain Service stateless em planning/ — capability-named (mesmo padrão de FeasibilityCheck, BudgetAlerts, ReminderService). Não tem aggregate root próprio — planning/ é capability context. Construtor recebe { model: LanguageModelV1 } na assinatura aspiracional, mas a primeira impl pode pegar o model de dentro da fixture (TBD impl session — spec usa override por fixture pra simplificar). Mantém domínio agnóstico de LLM (gotcha 008): o runner não conhece detalhes de provider/streaming, só LanguageModelV1.
  • EvalReport.failures é array de {turn, reason} — formato flat, sem subtipo discriminado. Razão é string humana descritiva. Reusa pattern “Result flat com optional fields, não union discriminada” (021/014/015).
  • MelToneGuard.check(text) é função pura, sem instância — wrapper sobre regex hard-coded das regras concretas do ADR 008. Não vira VO porque não há identidade nem invariante composta; é um checker idempotent. Reusable em runtime (alert.message, reminder.message) se um dia quiser validar inputs do domínio, mas hoje só o eval consome.
src/contexts/planning/domain/eval/ConversationFixture.ts
import type { ChatMessage } from "../../../agent/domain";
export interface ConversationFixture<C = Record<string, unknown>> {
name: string;
description?: string;
setup?: () => Promise<EvalContext<C>>;
turns: Array<FixtureTurn<C>>;
}
export interface EvalContext<C = Record<string, unknown>> {
// Bag opaca — cada fixture preenche o que precisar.
// Convenção: aggregates + repositories + tools usadas no expect.sideEffects.
// Ex: { budget, household, householdRepo, budgetRepo, agentTools }
state: C;
}
export interface FixtureTurn<C = Record<string, unknown>> {
user: ChatMessage;
mockLLM: {
toolCalls?: Array<{ id: string; name: string; args: unknown }>;
text: string;
};
expect: {
toolCallsMade?: string[]; // nomes na ordem
toolCallArgsMatching?: Record<string, RegExp | Partial<unknown>>;
responseTextMatching?: RegExp;
responseTextNotMatching?: RegExp;
sideEffects?: (ctx: EvalContext<C>) => boolean; // true = passou
};
}
// src/contexts/planning/domain/eval/EvalReport.ts
export interface EvalReport {
name: string;
passed: boolean;
turnsRun: number;
failures: Array<{ turn: number; reason: string }>;
durationMs: number;
}
export interface EvalSummary {
totalFixtures: number;
passed: number;
failed: number;
passRate: number; // 0..1
reports: EvalReport[];
}
// src/contexts/planning/domain/services/EvalRunner.ts
EvalRunner.create({ model?: LanguageModelV1 }): EvalRunner
// model opcional — fixture pode trazer mockLLM dedicado por turn (default no spec).
// Quando tier 2 (real LLM, EVAL_REAL_LLM=1) chegar, model vira obrigatório
// e o mockLLM do fixture é ignorado.
evalRunner.run(fixture: ConversationFixture): Promise<EvalReport>
evalRunner.runAll(fixtures: ConversationFixture[]): Promise<EvalSummary>
// src/contexts/planning/domain/eval/MelToneGuard.ts
export const MelToneGuard = {
check(text: string): { passes: boolean; violations: string[] };
};
// Regras codificadas (extraídas do ADR 008):
// - sem "prezado|estimado|caro\\(?a\\)?|atenciosamente|por gentileza|por favor"
// - sem juridiquês: "conforme acordado|salvo melhor juízo|sem prejuízo de|tendo em vista"
// - sem emoji decorativo (✨ 🎉 🚀 ❤️ 😊) — ⚠️ 👍 👎 💸 permitidos
// - frase média ≤ 15 palavras (split em "." / "!" / "?", checa a maior)
// - máximo 1 emoji por mensagem
// Cada violação vira string descritiva em `violations[]`. `passes = violations.length === 0`.
  • EvalRunner em planning/domain/services/, não agent/ — capability-named (igual FeasibilityCheck, BudgetAlerts, ReminderService). Eval é leitura/avaliação cross-context (lê AgentChat, tools, repositories pra observar comportamento); planning/ já hospeda análises stateless transversais. agent/ segue puro: orquestra LLM e tools, nada de avaliar a si mesmo. Reusa gotcha “Contexts pura de cross-aggregate read podem ser nomeados pela capability”.
  • ConversationFixture traz mockLLM por turn, não por fixture global — flexibilidade pra cada turn ter seu próprio script. Mantém o MockLanguageModelV1 montado dentro do runner por fixture (não vaza pra caller). Quando tier 2 chegar com LLM real, o mockLLM é ignorado — só expect resta como contract observável.
  • expect.toolCallArgsMatching aceita RegExp ou Partial<unknown> — flexibilidade entre “string match” e “deep partial”. Spec exemplo usa Partial<unknown> (objeto literal); regex fica pra strings opacas (ex: operationId: /^op-\d+$/). Impl decide implementação interna (deep equal ou regex test); contract aceita os dois.
  • expect.sideEffects é função (ctx) => boolean — fixture decide o que verificar. Runner não tenta inferir mutação por reflection (frágil). Retorno true = passou; false vira failures[].reason: "sideEffect predicate returned false". Pra debug, fixture pode lançar dentro do predicate com mensagem rica.
  • MelToneGuard é function/const, não classeMelToneGuard.check(text) é estática (exporta como const com check). Sem instância, sem state. Reusa gotcha “no per-VO unit tests” — invariantes do guard surgem nos scenarios do 022 (anti-pattern detection). Quando aparecer config por casal (gotcha “Mensagem customizada por casal” do 021 — out-of-scope hoje), promove pra função builder makeMelToneGuard(config). Hoje hard-coded suficiente.
  • runAll retorna EvalSummary com reports[] na ordem de entrada — determinismo cross-run. Caller que quiser ordenar por falha primeiro filtra no consumo. Mantém shape simples + previsível.
  • durationMs é informativo, não asserção — fixture não assertia “rodou em < Xms”. Eval no tier 1 (mock) é sub-segundo por design; tier 2 (real LLM) tem variância natural. Caller (curadoria semanal) usa o número pra detectar drift de latência se quiser.
  • 5 fixtures iniciais são TODO impl session — spec demonstra 4 scenarios usando fixtures inline (objeto literal pequeno por scenario). Os 5 fixtures canônicos (catalog inicial) ficam pra impl session criar arquivos reais em planning/domain/eval/fixtures/ (.ts exportando ConversationFixture instâncias). Descrição abaixo serve de spec dos artifacts:
    • budget-total-query — user pergunta total do mês; BudgetTool.budgetTotal é chamado; texto contém R$ + valor; sem “prezado”.
    • create-goal-flow — user descreve meta; 2 turns: CreateGoalTool propose (preview, persisted:false), depois confirm (persisted:true); receipt OK no segundo turn.
    • record-spend-fast-path — user “gastei 50 mercado”; RecordSpendTool fast-path (020) executa em 1 turn (persisted:true); texto curto (≤ 10 palavras), reusa MelToneGuard.
    • admit-out-of-scope — user “qual a previsão do bitcoin amanhã?”; nenhuma tool chamada; texto contém admit pattern (/não sei|não tenho|isso eu/i) + capability offer (/orçamento|meta|fatura/i) — bate o pattern do cenário 021.
    • empty-budget-not-found — user pergunta total; Budget vazio; tool retorna {found:false}; texto contém “não tenho registros” / “sem dados” + offer de ação (/registrar|começar/i) — bate pattern do 021.
  • Tier 2 real-LLM é env-gated, não defaultEVAL_REAL_LLM=1 npm run eval:real (script TBD impl session). Sem isso, fixtures rodam com mock. Mock é determinístico e custa zero; real é variante manual. Curadoria semanal usa tier 2 quando existir; CI futuro (ADR 002 não tem CI ainda) só roda tier 1.
  • Spec inline fixtures — não carrega arquivos do filesystem — cada scenario monta fixture literal dentro do it. Mantém spec independente dos arquivos fixtures/*.ts (que ficam pra impl session). Spec valida o harness; fixtures canônicas validam o agente quando impl chegar.
  • Cenário 008 — sem touchup. AgentChat.ask é chamado igual; harness é driver de cima.
  • Cenário 012EvalRunner não toca AgentMemory direto. Fixture pode opt-in injetando AgentChat com memory se quiser testar persistência (TODO impl session). Hoje fixtures iniciais são stateless.
  • Cenário 013create-goal-flow exercita propose→confirm. Sem mudança no contract de write tools.
  • Cenário 017CreateGoalTool é reusado pela fixture create-goal-flow (aspiracional, import paralelo).
  • Cenário 018 — multi-turn helper pattern (turn({...}) recriando AgentChat por turn) é reusado pelo EvalRunner internamente (detalhe de impl; spec não exige).
  • Cenário 020RecordSpendTool fast-path é reusado pela fixture record-spend-fast-path.
  • Cenário 021 — duas fixtures (admit-out-of-scope, empty-budget-not-found) verificam os patterns codificados. EvalRunner re-asserta o anti-hallucination contract via regex, sem duplicar invariante.
  • ADR 002 — adiciona tier 2 opt-in (real LLM) como TODO; tier 1 já cobre. Sem mudança imediata na config.
  • ADR 008MelToneGuard codifica regras concretas; cross-link explícito no ADR. Quando regra mudar (ex: i18n), guard atualiza junto.
  • Implementação dos 5 fixtures canônicos em arquivos .ts — esse cenário define o contract (ConversationFixture shape + 4 scenarios validando o runner). Fixtures iniciais ficam pra impl session criar em planning/domain/eval/fixtures/.
  • Tier 2 real-LLM — port + script npm run eval:real + integração OpenRouter. TODO explícito. Tier 1 (mock) cobre regressão de stack; tier 2 cobre regressão de qualidade de prompt + modelo.
  • Curadoria semanal automatizada — processo manual (30min/semana). Não é cenário testável. Documentado no doc como uso recomendado.
  • Eval com persistência de histórico (memory) — fixtures stateless por enquanto. Quando fixture quiser exercitar memória cross-turn (ex: “no turn 3, usar working memory do turn 1”), extende setup pra incluir AgentMemory mockado. Hoje fora do escopo.
  • Eval com canal WhatsApp (wa-001/002/003) — fixtures hoje rodam contra AgentChat.ask direto, não pelo router de canal. Quando canal entrar, fixture pode opt-in pra passar pelo WhatsAppMessageRouter (estende EvalRunner ou novo WhatsAppEvalRunner). Fora desse cenário.
  • Métricas de qualidade conversacional além de pass/fail — token usage, latency p95, distribuição de tool calls, etc. Hoje o EvalReport traz só durationMs. Telemetria rica fica pra cenário próprio quando aparecer dor.
  • Snapshot testing de respostas exatas — fixture só asserta regex/pattern, não wording inteiro. Snapshot completo amarra impl + LLM mock de forma frágil (mesma régua de 008 “validamos tool calls, não wording”). Snapshot fica fora.
  • Eval de adversarial inputs (prompt injection, jailbreak, abuse) — escopo de segurança, não regressão conversacional. Cenário separado quando aparecer.
  • Auto-geração de fixtures a partir de conversas reais — pegar conversa logada e virar fixture automaticamente. Curadoria semanal é manual hoje. Auto-gen fica pra quando o catálogo crescer e o overhead de transcrição doer.
  • i18n do MelToneGuard — regras hard-coded PT-BR (alinhado com ADR 008 que também é PT-BR). Quando i18n chegar, guard vira MelToneGuard.forLocale("pt-BR") ou similar. Hoje PT-BR direto.
  • Eval inline na pipeline de chat real — rodar guard como pre-flight check antes de mandar resposta do LLM real pro casal. Tentador mas adiciona latency; melhor deixar guard como teste, não runtime guard. Quando aparecer caso (LLM real saindo do tom em prod), revisitar.
  1. Criar tipos ConversationFixture, EvalContext, FixtureTurn, EvalReport, EvalSummary em src/contexts/planning/domain/eval/ (POJOs, sem invariante de domínio).
  2. Implementar EvalRunner em src/contexts/planning/domain/services/EvalRunner.ts:
    • .run(fixture): monta AgentChat com MockLanguageModelV1 scripted, executa cada turn em sequência, coleta toolCalls + reply, valida expect, devolve EvalReport.
    • .runAll(fixtures): itera + agrega em EvalSummary preservando ordem.
  3. Implementar MelToneGuard.check(text) em src/contexts/planning/domain/eval/MelToneGuard.ts — regex array + frase split + emoji counter.
  4. Criar 5 fixtures canônicas em src/contexts/planning/domain/eval/fixtures/*.ts (cada uma export default uma ConversationFixture).
  5. Atualizar barrel src/contexts/planning/domain/index.ts com exports.
  6. Passar os 4 scenarios desse spec. Specs 008/013/017/018/020/021 continuam verdes sem touchup.
  7. TODO deferido (tier 2): script npm run eval:real env-gated por EVAL_REAL_LLM=1 + integração @openrouter/ai-sdk-provider (Gemini 2.5 Flash). Quando entrar, EvalRunner aceita model: LanguageModelV1 real no construtor e ignora mockLLM da fixture.