Saltearse al contenido

Agent Evals

Agent Evals es un arnés de verdad-de-referencia en YAML que ejecuta preguntas enlatadas a través de la superficie AI Ask e informa dónde el LLM divergió de las expectativas. Cada caso es un archivo YAML comprometido junto a su modelo semántico; CI ejecuta la suite y hace fallar el build ante cualquier regresión.

Se entrega en saiku v4.7 como saiku#1424.

Por qué evals

Formato de archivo

Las suites viven como YAML bajo saiku-home/evals/. Una suite por archivo. Cada suite apunta a un cubo; cada caso de la suite se ejecuta contra él.

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 de la suite

CampoRequeridoTipoNotas
namestringMostrado en el informe y el resumen de CI
descriptionnostringTexto libre mostrado en la cabecera del informe
cubeobjectconnectionName / catalog / schema / cubeName
casesarrayLista no vacía de casos de verdad-de-referencia

Campos del caso

CampoRequeridoTipoNotas
namestringÚnico dentro de la suite; usado en las rutas de discrepancia
questionstringPregunta en lenguaje natural alimentada a /ai/ask
historyno{role,content}[]Turnos previos para sembrar la ask (vacío para casos de un solo disparo)
expectedIntentnostringQUERY | INSIGHT | VIEW_CHANGE | REFUSED (insensible a mayúsculas)
expectedRefusalContainsnostringSubcadena que la razón del rechazo debe contener
expectedRowsno{key:value}[]Filas estáticas esperadas para QUERY. Vea Comparación de filas.
referenceQuerynoobjectVerdad-de-referencia a prueba de deriva — un AiQueryRequest tipado ejecutado en vivo contra el mismo cubo; sus filas se convierten en el conjunto esperado. Sobrevive a cambios de datos que romperían los expectedRows codificados a mano. Prefiéralo para los casos QUERY.
orderMattersnobooleanPor defecto true. Cuando es false, ambos lados ordenan por claves antes del diff.
expectedInsightContainsnostring[]Subcadenas que el markdown de insight debe contener
toleranceno{absolute,relative}Tolerancia numérica para comparaciones de filas; ambas por defecto 0.0 (exacto)

Comparación de filas

Normalización de claves

Las claves de columna se comparan de forma insensible a mayúsculas con espacios en blanco, guiones bajos y guiones eliminados:

  • Store Sales
  • storeSales
  • store_sales
  • STORE-SALES

Todas normalizan a la misma clave. Evita que un renombrado en la forma de salida del schema-projector haga fallar cada eval que referencia la columna.

Parseo numérico

Los valores esperados que parsean como números se comparan numéricamente con tolerancia. El parser es indulgente sobre cómo los autores de evals escriben números:

Valor YAMLParseado comoNotas
500500Enteros
500.0500.0Decimales
"$565,238.13"565238.13Prefijo de moneda + separador de miles eliminado
"(500)"-500Negativos entre paréntesis
"12%"12% final eliminado

Los valores esperados no numéricos se comparan como cadenas (recortadas de espacios en blanco en ambos lados).

Tolerancia

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

Una celda pasa si cualquiera de las tolerancias se satisface — defina ambas cuando quiera “5 céntimos absolutos O 0,1% relativo, lo que sea más laxo”.

Ambas por defecto son cero (coincidencia exacta).

Las claves faltantes son asimétricas

  • Una clave esperada que no está en la fila real es una discrepancia — el autor del eval escribió una expectativa que el schema no produce.
  • Una clave real que no está en la expectativa no es una discrepancia — las expectativas son aditivas, así que hacer crecer el schema con una nueva columna no rompe cada eval que la precede.

Informes

El runner emite un informe agregado por suite con un 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"

O como JSON con formato bonito para archivos de CI mediante EvalReportWriter.toJson().

Códigos de error de parseo

Los fallos estructurados de parseo YAML llevan un código estable para que CI pueda distinguir “el autor escribió un YAML roto” de “la superficie de ask produjo una respuesta incorrecta”:

CódigoCuándo
MALFORMED_YAMLEl parser YAML rechazó el archivo.
MISSING_FIELDUn campo requerido está ausente (name, cube, cases, question).
BLANK_FIELDUn campo string requerido está presente pero vacío.
TYPE_MISMATCHUn campo tiene el tipo incorrecto (p. ej. cases es un mapa, no un array).
INVALID_TOLERANCEtolerance.absolute o tolerance.relative es negativa.

Ejecutar una suite

Los evals se ejecutan contra un servidor en vivo — ejercitan la misma ruta de ask + ejecución que tocan sus usuarios. Apunte el CLI incluido saiku eval a un launcher en ejecución; hace POST al endpoint de ejecución de admin e informa la tasa de aprobados, devolviendo un código de salida distinto de cero ante cualquier regresión para que caiga directamente en CI.

Ventana de terminal
# 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 salida: 0 = cada suite pasó · 1 = una suite regresó · 2 = error de transporte/config. Añada --no-fail-on-regression para una ejecución de solo informe que nunca sale con código distinto de cero.

Monitorización de precisión en vivo

Los evals no son solo una puerta de CI. Cada ejecución se puntúa y persiste (H2, en su saiku-home), y el dashboard Admin → Agent evals grafica la tasa de aprobados a lo largo del tiempo por suite — así puede vigilar la deriva de precisión en un despliegue de producción, no solo detectarla en un pull request. Programe el CLI saiku eval en un cron para alimentar la tendencia continuamente.

Ejemplo incluido

Una suite funcional se entrega con el launcher en saiku-launcher/src/main/resources/seed/evals/foodmart-sales.eval.yaml.

Cuatro casos de base a través de dos intents, todos a prueba de deriva:

  • QUERY — store-sales-by-country + unit-sales-by-product-family, cada uno con un referenceQuery como verdad-de-referencia (ejecutado en vivo, así que un refresco de datos no puede romperlos)
  • REFUSED — refuse-general-knowledge + refuse-coding-help con una comprobación de subcadena de razón

No-objetivos para v1

  • Adaptador de grabación — un adaptador que escribe cada respuesta en vivo a un archivo de fixture para que un adaptador de fixture posterior pueda reproducir de forma determinista sin gastar presupuesto de LLM. Diseño listo; implementación diferida.
  • Diff estructural del AiQueryRequest emitido — múltiples peticiones estructuralmente diferentes pueden producir las mismas filas, así que el diff de filas es una mejor verdad-de-referencia.

A dónde ir después