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, comMockLanguageModelV1scripted (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 comonpm run eval:realopt-in pra rodar manualmente pré-launch e na curadoria semanal. Quando aparecer, oEvalRunneraceitamodel: LanguageModelV1no 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
ConversationFixturebudget-total-queryconfigurada com:setupque monta umBudgetem BRL comRecurringExpensesomando 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
ConversationFixturecom 1 turn cujoexpect.toolCallsMade: ["budgetTotal"] - And o
mockLLMconfigurado 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].reasonbate 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
ConversationFixturecujoexpect.responseTextNotMatching: /prezado|estimado|por gentileza/i - And o
mockLLMconfigurado pra responder comtext: "Prezado casal, o total previsto é R$ 2.820."(viola ADR 008) - When
evalRunner.run(fixture) - Then
result.passed === false - And
result.failurescontém uma entry comturn === 1ereasonbatendo em/anti-pattern|matched forbidden|responseTextNotMatching/i - And
MelToneGuard.check("Prezado casal, o total previsto é R$ 2.820.").passes === false - And
MelToneGuard.check(...).violationslista pelo menos uma violação batendo em/prezado/i
Scenario: runAll retorna summary agregado
Section titled “Scenario: runAll retorna summary agregado”- 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.6nesse caso) - And
summary.reports.length === 5 - And a ordem de
summary.reportspreserva a ordem dos fixtures de entrada (reports[i].name === fixtures[i].name)
Modelo
Section titled “Modelo”ConversationFixtureé POJO type, não classe — descrição declarativa de uma conversa scriptada. Sem invariante de domínio: validação acontece noEvalRunner.run. Fixtures novas se adicionam só criando arquivos emplanning/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 emplanning/— capability-named (mesmo padrão deFeasibilityCheck,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.
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.tsexport 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.tsEvalRunner.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.tsexport 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`.Decisões de design
Section titled “Decisões de design”EvalRunneremplanning/domain/services/, nãoagent/— capability-named (igualFeasibilityCheck,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”.ConversationFixturetrazmockLLMpor turn, não por fixture global — flexibilidade pra cada turn ter seu próprio script. Mantém oMockLanguageModelV1montado dentro do runner por fixture (não vaza pra caller). Quando tier 2 chegar com LLM real, omockLLMé ignorado — sóexpectresta como contract observável.expect.toolCallArgsMatchingaceitaRegExpouPartial<unknown>— flexibilidade entre “string match” e “deep partial”. Spec exemplo usaPartial<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 virafailures[].reason: "sideEffect predicate returned false". Pra debug, fixture pode lançar dentro do predicate com mensagem rica.MelToneGuardé function/const, não classe —MelToneGuard.check(text)é estática (exporta como const comcheck). 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 buildermakeMelToneGuard(config). Hoje hard-coded suficiente.runAllretornaEvalSummarycomreports[]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/(.tsexportandoConversationFixtureinstâncias). Descrição abaixo serve de spec dos artifacts:budget-total-query— user pergunta total do mês;BudgetTool.budgetTotalé chamado; texto contémR$+ valor; sem “prezado”.create-goal-flow— user descreve meta; 2 turns:CreateGoalToolpropose (preview,persisted:false), depois confirm (persisted:true); receipt OK no segundo turn.record-spend-fast-path— user “gastei 50 mercado”;RecordSpendToolfast-path (020) executa em 1 turn (persisted:true); texto curto (≤ 10 palavras), reusaMelToneGuard.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 default —
EVAL_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 arquivosfixtures/*.ts(que ficam pra impl session). Spec valida o harness; fixtures canônicas validam o agente quando impl chegar.
Impacto em outros cenários
Section titled “Impacto em outros cenários”- Cenário 008 — sem touchup.
AgentChat.aské chamado igual; harness é driver de cima. - Cenário 012 —
EvalRunnernão tocaAgentMemorydireto. Fixture pode opt-in injetandoAgentChatcom memory se quiser testar persistência (TODO impl session). Hoje fixtures iniciais são stateless. - Cenário 013 —
create-goal-flowexercita propose→confirm. Sem mudança no contract de write tools. - Cenário 017 —
CreateGoalToolé reusado pela fixturecreate-goal-flow(aspiracional, import paralelo). - Cenário 018 — multi-turn helper pattern (
turn({...})recriando AgentChat por turn) é reusado peloEvalRunnerinternamente (detalhe de impl; spec não exige). - Cenário 020 —
RecordSpendToolfast-path é reusado pela fixturerecord-spend-fast-path. - Cenário 021 — duas fixtures (
admit-out-of-scope,empty-budget-not-found) verificam os patterns codificados.EvalRunnerre-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 008 —
MelToneGuardcodifica regras concretas; cross-link explícito no ADR. Quando regra mudar (ex: i18n), guard atualiza junto.
Fora de escopo
Section titled “Fora de escopo”- Implementação dos 5 fixtures canônicos em arquivos
.ts— esse cenário define o contract (ConversationFixtureshape + 4 scenarios validando o runner). Fixtures iniciais ficam pra impl session criar emplanning/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
setuppra incluirAgentMemorymockado. Hoje fora do escopo. - Eval com canal WhatsApp (wa-001/002/003) — fixtures hoje rodam contra
AgentChat.askdireto, não pelo router de canal. Quando canal entrar, fixture pode opt-in pra passar peloWhatsAppMessageRouter(estendeEvalRunnerou novoWhatsAppEvalRunner). 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
EvalReporttraz 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 viraMelToneGuard.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.
Próximo passo
Section titled “Próximo passo”- Criar tipos
ConversationFixture,EvalContext,FixtureTurn,EvalReport,EvalSummaryemsrc/contexts/planning/domain/eval/(POJOs, sem invariante de domínio). - Implementar
EvalRunneremsrc/contexts/planning/domain/services/EvalRunner.ts:.run(fixture): montaAgentChatcomMockLanguageModelV1scripted, executa cada turn em sequência, coletatoolCalls+reply, validaexpect, devolveEvalReport..runAll(fixtures): itera + agrega emEvalSummarypreservando ordem.
- Implementar
MelToneGuard.check(text)emsrc/contexts/planning/domain/eval/MelToneGuard.ts— regex array + frase split + emoji counter. - Criar 5 fixtures canônicas em
src/contexts/planning/domain/eval/fixtures/*.ts(cada uma export default umaConversationFixture). - Atualizar barrel
src/contexts/planning/domain/index.tscom exports. - Passar os 4 scenarios desse spec. Specs 008/013/017/018/020/021 continuam verdes sem touchup.
- TODO deferido (tier 2): script
npm run eval:realenv-gated porEVAL_REAL_LLM=1+ integração@openrouter/ai-sdk-provider(Gemini 2.5 Flash). Quando entrar,EvalRunneraceitamodel: LanguageModelV1real no construtor e ignoramockLLMda fixture.