Pular para o conteúdo

Agent Evals

Agent Evals é um harness de ground-truth em YAML que roda perguntas prontas através da superfície AI Ask e reporta onde o LLM divergiu das expectativas. Cada caso é um arquivo YAML commitado ao lado do seu modelo semântico; a CI roda a suíte e falha o build em qualquer regressão.

Distribuído no saiku v4.7 como saiku#1424.

Por que evals

Formato de arquivo

Suítes vivem como YAML sob saiku-home/evals/. Uma suíte por arquivo. Cada suíte visa um cubo; todo caso na suíte roda contra ele.

saiku-home/evals/foodmart-sales.eval.yaml
name: foodmart-sales-evals
description: >
Ground-truth cases for the FoodMart Sales cube. Baseline for AI ask
regressions when the LLM provider, model, or prompt changes.
cube:
connectionName: unknown_foodmart
catalog: FoodMart
schema: FoodMart
cubeName: Sales
cases:
# QUERY case — expected rows compared with tolerance.
- name: sales-by-country
question: show me store sales by country
expectedIntent: QUERY
expectedRows:
- {country: "USA", storeSales: 565238.13}
- {country: "Canada", storeSales: 79063.11}
- {country: "Mexico", storeSales: 51298.13}
tolerance:
relative: 0.001 # 0.1% relative — masks sub-cent warehouse drift
orderMatters: true
# INSIGHT case — insight markdown must contain each string.
- name: trend-analysis
question: are sales trending up week-on-week?
expectedIntent: INSIGHT
expectedInsightContains:
- Store Sales
- week-on-week
# REFUSED case — model must decline off-topic with a matching reason.
- name: refuse-off-topic
question: what's the weather in Paris?
expectedIntent: REFUSED
expectedRefusalContains: cube
# Multi-turn case — history seeds the ask.
- name: follow-up-turn
question: now break it down by state
history:
- {role: user, content: show store sales by country}
- {role: assistant, content: 3 rows returned.}
expectedIntent: QUERY
expectedRows:
- {state: "CA", storeSales: 214893.10}

Campos da suíte

CampoObrigatórioTipoNotas
namesimstringExibido no report e no resumo de CI
descriptionnãostringTexto livre mostrado no header do report
cubesimobjectconnectionName / catalog / schema / cubeName
casessimarrayLista não-vazia de casos de ground-truth

Campos do caso

CampoObrigatórioTipoNotas
namesimstringÚnico dentro da suíte; usado em paths de mismatch
questionsimstringPergunta em linguagem natural alimentada ao /ai/ask
historynão{role,content}[]Turnos anteriores para semear o ask (vazio para casos single-shot)
expectedIntentnãostringQUERY | INSIGHT | VIEW_CHANGE | REFUSED (case-insensitive)
expectedRefusalContainsnãostringSubstring que o motivo da recusa deve conter
expectedRowsnão{key:value}[]Linhas esperadas estáticas para QUERY. Veja Comparação de linhas.
referenceQuerynãoobjectGround truth à prova de drift — um AiQueryRequest tipado executado ao vivo contra o mesmo cubo; suas linhas viram o conjunto esperado. Sobrevive a mudanças de dados que quebrariam expectedRows hard-coded. Prefira isto para casos QUERY.
orderMattersnãobooleanDefault true. Quando false, ambos os lados ordenam por chaves antes do diff.
expectedInsightContainsnãostring[]Substrings que o markdown de insight deve conter
tolerancenão{absolute,relative}Tolerância numérica para comparações de linha; ambos default 0.0 (exato)

Comparação de linhas

Normalização de chave

Chaves de coluna comparam de forma case-insensitive com whitespace, underscores e hífens removidos:

  • Store Sales
  • storeSales
  • store_sales
  • STORE-SALES

Todas normalizam para a mesma chave. Previne que um rename no formato de saída do schema-projector falhe toda eval que referencia a coluna.

Parsing numérico

Valores esperados que parseiam como números são comparados numericamente com tolerância. O parser é tolerante sobre como autores de eval escrevem números:

Valor YAMLParseado comoNotas
500500Inteiros
500.0500.0Decimais
"$565,238.13"565238.13Prefixo de moeda + separador de milhar removidos
"(500)"-500Negativos entre parênteses
"12%"12% ao final removido

Valores esperados não-numéricos comparam como strings (whitespace aparado em ambos os lados).

Tolerância

tolerance:
absolute: 0.01 # cell passes if |actual - expected| <= 0.01
relative: 0.001 # cell passes if |actual - expected| / |expected| <= 0.001

Uma célula passa se qualquer tolerância for satisfeita — defina ambas quando você quer “5 centavos absoluto OU 0.1% relativo, o que for mais frouxo.”

Ambos default para zero (correspondência exata).

Chaves faltando são assimétricas

  • Uma chave esperada não presente na linha real é um mismatch — o autor da eval escreveu uma expectativa que o schema não produz.
  • Uma chave real não presente na expectativa não é um mismatch — expectativas são aditivas, então crescer o schema com uma nova coluna não quebra toda eval que a antecede.

Reports

O runner emite um report agregado por suíte com um resultado por caso:

Suite: foodmart-sales-evals
Description: Ground-truth cases for the FoodMart Sales cube
9/10 passed, 1 failed, 0 degraded, 0 skipped (elapsed 12483ms)
PASS: sales-by-country (854ms, intent=QUERY, model=claude-sonnet-4-6)
PASS: trend-analysis (742ms, intent=INSIGHT, model=claude-sonnet-4-6)
PASS: refuse-off-topic (312ms, intent=REFUSED, model=claude-sonnet-4-6)
FAIL: top-3-product-families (1024ms, intent=QUERY, model=claude-sonnet-4-6)
rows[0].productFamily: expected "Food", got "Drink"
rows[1].productFamily: expected "Non-Consumable", got "Food"
rows[2].productFamily: expected "Drink", got "Non-Consumable"

Ou como JSON pretty-printed para arquivos de CI via EvalReportWriter.toJson().

Códigos de erro de parse

Falhas estruturadas de parse-YAML carregam um código estável para que a CI possa distinguir “autor escreveu um YAML quebrado” de “a superfície ask produziu uma resposta errada”:

CódigoQuando
MALFORMED_YAMLO parser YAML rejeitou o arquivo.
MISSING_FIELDUm campo obrigatório está ausente (name, cube, cases, question).
BLANK_FIELDUm campo de string obrigatório está presente mas vazio.
TYPE_MISMATCHUm campo tem o tipo errado (ex.: cases é um mapping, não um array).
INVALID_TOLERANCEtolerance.absolute ou tolerance.relative é negativo.

Rodando uma suíte

Evals rodam contra um servidor ao vivo — elas exercitam o mesmo caminho de ask + execute que seus usuários batem. Aponte o CLI incluído saiku eval para um launcher em execução; ele faz POST ao endpoint de run de admin e reporta a taxa de aprovação, retornando um exit não-zero em qualquer regressão para que caia direto na CI.

Terminal window
# against a locally-running server (default admin/admin)
java -jar saiku-<version>.jar eval
# against a remote server
java -jar saiku-<version>.jar eval \
--server https://analytics.example.com \
--username admin --password '<secret>'

Códigos de saída: 0 = toda suíte passou · 1 = uma suíte regrediu · 2 = erro de transporte/config. Adicione --no-fail-on-regression para um run somente-report que nunca sai com não-zero.

Monitoramento de acurácia ao vivo

Evals não são só um gate de CI. Todo run é pontuado e persistido (H2, no seu saiku-home), e o dashboard Admin → Agent evals plota a taxa de aprovação ao longo do tempo por suíte — para que você possa vigiar o drift de acurácia em um deployment de produção, não apenas pegá-lo em um pull request. Agende o CLI saiku eval num cron para alimentar a tendência continuamente.

Exemplo incluído

Uma suíte funcional é distribuída com o launcher em saiku-launcher/src/main/resources/seed/evals/foodmart-sales.eval.yaml.

Quatro casos de baseline em dois intents, todos à prova de drift:

  • QUERY — store-sales-by-country + unit-sales-by-product-family, cada um com um referenceQuery como ground truth (executado ao vivo, então um refresh de dados não pode quebrá-los)
  • REFUSED — refuse-general-knowledge + refuse-coding-help com uma checagem de substring do motivo

Não-objetivos para v1

  • Adaptador de gravação — um adaptador que escreve cada resposta ao vivo em um arquivo de fixture para que um adaptador de fixture subsequente possa reproduzir deterministicamente sem gastar orçamento de LLM. Design pronto; implementação adiada.
  • Diff estrutural no AiQueryRequest emitido — múltiplas requisições estruturalmente-diferentes podem produzir as mesmas linhas, então o row-diff é uma ground truth melhor.

Para onde ir a seguir