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.
name: foodmart-sales-evalsdescription: > 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
| Campo | Obrigatório | Tipo | Notas |
|---|---|---|---|
name | sim | string | Exibido no report e no resumo de CI |
description | não | string | Texto livre mostrado no header do report |
cube | sim | object | connectionName / catalog / schema / cubeName |
cases | sim | array | Lista não-vazia de casos de ground-truth |
Campos do caso
| Campo | Obrigatório | Tipo | Notas |
|---|---|---|---|
name | sim | string | Único dentro da suíte; usado em paths de mismatch |
question | sim | string | Pergunta em linguagem natural alimentada ao /ai/ask |
history | não | {role,content}[] | Turnos anteriores para semear o ask (vazio para casos single-shot) |
expectedIntent | não | string | QUERY | INSIGHT | VIEW_CHANGE | REFUSED (case-insensitive) |
expectedRefusalContains | não | string | Substring que o motivo da recusa deve conter |
expectedRows | não | {key:value}[] | Linhas esperadas estáticas para QUERY. Veja Comparação de linhas. |
referenceQuery | não | object | Ground 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. |
orderMatters | não | boolean | Default true. Quando false, ambos os lados ordenam por chaves antes do diff. |
expectedInsightContains | não | string[] | Substrings que o markdown de insight deve conter |
tolerance | nã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 SalesstoreSalesstore_salesSTORE-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 YAML | Parseado como | Notas |
|---|---|---|
500 | 500 | Inteiros |
500.0 | 500.0 | Decimais |
"$565,238.13" | 565238.13 | Prefixo de moeda + separador de milhar removidos |
"(500)" | -500 | Negativos 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.001Uma 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-evalsDescription: Ground-truth cases for the FoodMart Sales cube9/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ódigo | Quando |
|---|---|
MALFORMED_YAML | O parser YAML rejeitou o arquivo. |
MISSING_FIELD | Um campo obrigatório está ausente (name, cube, cases, question). |
BLANK_FIELD | Um campo de string obrigatório está presente mas vazio. |
TYPE_MISMATCH | Um campo tem o tipo errado (ex.: cases é um mapping, não um array). |
INVALID_TOLERANCE | tolerance.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.
# against a locally-running server (default admin/admin)java -jar saiku-<version>.jar eval
# against a remote serverjava -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.
# admin-gated; runs every suite in saiku-home/evals/ synchronouslycurl -sS -X POST -u admin:admin \ http://localhost:8080/saiku/admin/ai-evals/run | jqO painel de admin lê os resultados persistidos —
GET /saiku/admin/ai-evals (cards por-suíte), .../{suite}/runs, e
.../{suite}/trend.
name: agent-evalson: [pull_request]
jobs: evals: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' - name: Run eval suite env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | # Boot the launcher, wait for health, run the suites. java -jar saiku-*.jar serve --port 8080 & npx wait-on http://localhost:8080/saiku/api/info java -jar saiku-*.jar evalMonitoramento 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
referenceQuerycomo 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
AiQueryRequestemitido — múltiplas requisições estruturalmente-diferentes podem produzir as mesmas linhas, então o row-diff é uma ground truth melhor.