Evaluations lab
Owner: W11. Source: apps/web/src/lib/evals/**, apps/web/src/db/schema/evals.ts,
apps/web/src/db/seeds/evals.ts, apps/web/src/worker/jobs/evals.ts,
apps/web/src/app/(console)/o/[org]/apps/[app]/evals/**,
apps/web/src/app/(console)/o/[org]/(org)/models/**,
apps/web/src/app/api/console/{evals,credentials,models}/**.
Contract: PLAN §10, §13, §15 W11.
An evaluation answers one question with evidence: is this prompt, on this model, at this reasoning level, better than what we run today? The same frozen set of cases is sent to every variant, each result keeps its trace, and the comparison is a matrix rather than a vibe.
Module key evals. Permissions evals.read, evals.write, evals.run,
evals.review; the organization-level models and vault page uses
credentials.read and credentials.write.
The model
| Table | Scope | Holds |
|---|---|---|
eval_datasets |
org + app | A named corpus with a snapshot_version and a draft/ready/archived status. |
eval_cases |
dataset | One case: input, expected, reference_data, locale, tags, checksum. Unique per (dataset_id, checksum). |
prompt_definitions |
org + app | A prompt identity. slug is unique per application, never globally. |
prompt_versions |
definition | Immutable text: system_template, user_template, checksum, release_status. |
eval_runs |
org + app | One experiment: dataset, config, status, summary. |
eval_variants |
run | One arm: label, prompt version, provider, model, reasoning level, parameters, optional credential. |
eval_case_results |
run | One (variant × case) execution: output, trace, usage, latency, cost_usd. |
eval_scores |
result | One grader's verdict: score, label, rationale, metadata. |
eval_artifacts |
result | Binary evidence, currently synthesized audio (5 MB ceiling). |
human_reviews |
result | One reviewer's decision, score, confidence and notes. |
product_prompt_links |
org + app | The connector prompt this definition mirrors, plus its gate policy. Unique per (app_id, product_slug). |
prompt_quality_gates |
org + app | One gate decision per run, and whether the application received it. |
provider_credentials |
org | Every module's secrets (see Credentials). |
model_catalog |
global | The reviewed catalog, seeded with org_id NULL. |
model_catalog_overrides |
org | A per-organization opt-out for one catalog model. |
Every page and route filters on org_id and app_id. A dataset, a run and
a prompt belong to one application: two applications can hold the same prompt
slug, and neither can read the other's cases.
Task types
A run's config.taskType decides how the runner executes a case.
| Task type | What runs | Typical graders |
|---|---|---|
text |
The rendered system and user templates go to the model. | exact_match, semantic |
tool |
The same call with reference_data.toolSchema attached; the trace records which tool was chosen. |
tool |
retrieval |
No model call. The rendered user template is asked of the application's connector (POST /agent-context) and the source ids it reports are graded. |
retrieval |
voice_tts |
The rendered template is synthesized; the audio is stored as an artifact and transcribed for scoring. | voice |
A text case may also opt into live context: with
reference_data.liveAgentContext = true the runner calls POST /agent-context
first and merges the returned variables into the template values, so the
prompt is graded against the context the application would really assemble.
Case contracts
A case is a JSON object. Imports accept a JSON array or JSONL (one object per line); both are deduplicated by checksum, so re-importing a file adds only what is new.
{
"externalId": "faq-17",
"input": { "question": "How do I cancel?" },
"expected": { "text": "Open account settings" },
"referenceData": { "relevantSourceIds": ["doc-1", "doc-2"] },
"locale": "en",
"tags": ["support", "billing"]
}
| Field | Required | Meaning |
|---|---|---|
input |
yes | Object. Its keys are the template variables: {{question}} renders input.question. |
expected |
no | What a grader compares against. expected.text (or expected.answer) for exact_match, expected.tool for the tool grader. |
referenceData |
no | Grading and execution material that is not part of the prompt: relevantSourceIds for retrieval, toolSchema for tool runs, liveAgentContext for live context. |
locale |
no | Defaults to en. Reported per dataset so a corpus can be checked for coverage. |
tags |
no | Free labels, at most twenty. |
externalId |
no | The id this case has in the system it came from. |
Per task type:
- text —
inputsupplies the variables,expected.textthe reference answer. - tool —
expected.toolnames the tool that should be called;referenceData.toolSchemais the array of tool definitions passed to the provider. - retrieval —
input.siteIdis required,input.question(or the rendered user template) is the query, andreferenceData.relevantSourceIdslists the sources that should come back. - voice_tts — the rendered user template is the speech text;
input.voiceId(ElevenLabs) orinput.voice(OpenAI) selects the voice.
No personal data. An import containing an email address, a phone number or a
national identifier is refused before a single row is written. A case that
somehow arrives with redaction_status = 'blocked' is excluded from execution.
Graders
src/lib/evals/graders.ts holds the deterministic ones; each returns a score in
0..1, a label, and a rationale that is stored with the result.
| Grader | Signal |
|---|---|
exact_match |
Normalized (trimmed, lower-cased, whitespace-collapsed) output against expected.text. |
retrieval |
F1 over referenceData.relevantSourceIds and the source ids the connector reported. Precision and recall are kept in the metadata. |
tool |
The captured tool name against expected.tool. |
semantic |
A blinded model-as-judge rubric, returning {score, label, rationale} (see Judge configuration). |
voice |
Word-error rate between the speech text and a Deepgram transcript of the generated audio; without a transcription credential the case is left for a human listener with a review label rather than scored on nothing. |
Automated scores are advisory. evals.review lets an operator attach a
decision, a score, a confidence and notes to any single result; reviews are
attributable and audited.
Running an evaluation
- The console posts to
/api/console/evalswithevals.run. The route re-checks the dataset (same application,ready), every model (enabled in the platform catalog and not opted out by this organization), and the connector prompt link when one is named. - It writes the run, its variants, one
eval_case_resultsrow per (variant × case), and enqueuesevals.runwithidempotency_key = evals.run:<id>. - The worker claims each queued result with
FOR UPDATE … SKIP LOCKED, so several workers can share one run. - Each result stores output, trace, token usage, latency and a list-price cost estimate derived from the catalog. A failing case is recorded with its error; the run keeps going.
- When the queue is empty the run is summarized per variant (mean score, mean latency, cost, failures) with a plain-language conclusion.
Quality gates through the connector
A run can gate a release of a prompt the application actually serves.
- Sync (
POST /api/console/evals/prompts?intent=sync,evals.write) calls the application's connectorGET /prompts. For each prompt the platform computes both checksums itself — the prompt checksum over system template + user template + runtime fingerprint, the benchmark checksum over the cases — and stores a prompt version plus a frozen dataset. Re-syncing an unchanged prompt writes nothing new; a changed benchmark snapshots a new dataset. - Benchmark opens the builder with the live prompt preloaded as the
Candidate. The run carries
productPromptSlug, the checksum it was opened with, and the gate policy from the link.?audit=1runs the same comparison without a gate. - Decide. After scoring,
evaluationGateDecisioncompares the Candidate's mean score withminimumScoreand its failure rate withmaximumFailureRate. No score at all isreview, neverpassed. - Report. The decision is written to
prompt_quality_gatesfirst, then sent to the application withPOST /prompts/{slug}/gate({ evalRunId, status, score, checksum, benchmarkChecksum }). A connector that refuses leaves the gate row withreport_errorset and the job retries. A passing gate promotes exactly the prompt version that was measured.
Because a gate names one checksum, the prompts page marks a prompt as drifted when its current checksum differs from the one the last gate measured — the application has changed the prompt since it was proven.
Credentials and the vault
One table, provider_credentials, holds every organization secret. The
environment column says which module a row belongs to:
| Environment | Written by | Used for |
|---|---|---|
evaluation |
this module | Provider keys for runs and the judge |
staging |
this module | A second key for the same provider |
destination |
destinations | Ad platform and webhook credentials |
notification |
metrics/alerting | Email, Slack and webhook channel secrets |
revenue |
revenue | Stripe, Polar and RevenueCat keys and webhook secrets |
ai |
assistant | The organization's model provider key |
connector |
connector | The application's Ed25519 connector private key |
A label is unique per (org_id, provider, label, environment), so two
organizations may both have a key labelled "Primary".
Reads go through src/lib/evals/credentials.ts:
credentialFor(orgId, provider, preferred?, environments = ["evaluation", "staging"]): Promise<string>
findCredential(orgId, provider, environments): Promise<CredentialMatch | null>
orgAiCredential(orgId, provider): Promise<CredentialMatch | null> // "ai", then "evaluation"
listOrgCredentials(orgId): Promise<CredentialListRow[]> // no ciphertext, last4 only
preferred is the id stored on a variant; it is honoured only when the row
still belongs to that organization, so a stale or forged id cannot reach another
tenant's secret. Plaintext is decrypted for exactly one provider call
(withDecryptedCredential) and never returned to a page or an API response.
Storing a key (credentials.write, plus a passkey verified in the last ten
minutes) queues credentials.validate when the provider is one the runner can
call. The job stores the verdict on the row: active with validated_at, or
invalid with the provider's message in validation_error. Revoking needs a
reason and a fresh passkey, and is permanent.
The model catalog
src/lib/evals/model-catalog.ts is the reviewed catalog: official model ids,
modalities, reasoning ladders and list prices, with CATALOG_VERIFIED_ON
recording when the prices were last checked against the providers. The seed
converges it into model_catalog (org_id NULL) on every run and disables —
never deletes — a model that has left the catalog, so historical runs still
resolve their rows.
Every entry also carries a pricingSource: the provider page its list price was
read from. It is a required field, so a price cannot enter the catalog without
somewhere to re-check it, and a review is a matter of opening each URL rather
than guessing where a number came from. Prices were last re-fetched from those
pages for all seven providers on 2026-09-03.
Two prices in the catalog are the full rate rather than the cheapest one a
request might attract, and both say so in their note: DeepSeek publishes a
peak and a half-price off-peak rate, and the catalog holds the peak one; OpenAI
and xAI publish a second, higher rate for long prompts, and the catalog holds
the standard one. estimateCostUsd models neither tier, which is why the run
detail labels its number a list price and not a bill.
An organization narrows the catalog through model_catalog_overrides; enabling
or disabling a model on /o/{org}/models writes one override row and never
touches the shared catalog. A model is offered to a run only when the platform
row is enabled and the organization has not opted out.
Judge configuration
The semantic grader uses a second model as a blinded judge.
- Provider and model come from
SPM_EVAL_JUDGE_PROVIDERandSPM_EVAL_JUDGE_MODEL(defaultopenai/gpt-5.6-luna). - The key is the organization's own credential for that provider, looked up in
aithenevaluation. - With no such credential the platform key (
SPM_AI_PROVIDER,SPM_AI_MODEL,SPM_AI_API_KEY) is used — and then the platform's provider and model are used with it, because a key only works for the provider that issued it. - With neither, the grader fails loudly rather than silently skipping a signal.
Worker jobs
| Kind | Payload | Does |
|---|---|---|
evals.run |
{ runId } |
Executes every queued case, summarizes the run, reports the gate. |
credentials.validate |
{ credentialId } |
Calls the provider's cheapest authenticated endpoint and records the verdict. Refuses a credential outside the job's organization. |
Neither is recurring: both are enqueued by a console mutation.
Pages
| Route | Permission | Shows |
|---|---|---|
/o/{org}/models |
credentials.read |
Model catalog with per-organization availability, every credential in the vault, and how a secret is protected. |
/o/{org}/apps/{app}/evals |
evals.read |
Runs with status filter, dataset, variants, cases, gate and leading score. |
…/evals/new |
evals.run |
The builder: two to six variants over one frozen dataset. |
…/evals/{id} |
evals.read |
Variant matrix, case-by-case comparison, voice artifacts, human review, gate notice. |
…/evals/datasets |
evals.read |
Snapshots with counts and lifecycle; import (JSONL or JSON) with evals.write. |
…/evals/prompts |
evals.read |
Connector prompts, drift, and the sync button with evals.write. |
Voice artifacts are streamed by /api/console/evals/artifacts/{id}?org={slug},
which resolves the artifact through its run's organization and refuses anyone
who is not a member with evals.read.
Verification
SPM_TEST_DATABASE_URL=… pnpm --filter @saaspro/web test -- tests/evals
The suite covers the catalog and its prices, dataset parsing and PII screening, the credential environment matrix, the runner's text, tool, retrieval, voice and gate paths against mocked providers and a mocked connector, the console mutation chain, cross-tenant refusals through the real routes, and the pages an operator reads.
Production quality review
Quality grades reviewed copies of observed production answers, without regenerating them. Sampling is paused by default and bounded by environment, retention and a daily case limit. See Production AI quality for the review queue, deterministic checks, separate optional judge approvals, version comparison and unknown cost coverage.
Publishing a reviewed corpus creates a frozen private dataset for the existing
comparison lab. Its cases, results and any artifacts require people.pii.read
and follow the original source's retention and deletion. These datasets cannot
be appended to. The optional production judge budget does not govern new
generation runs in the existing offline lab.
What the assistant can do here
Ask reads the production side of this module where the management API exposes it: AI traces with their costs, and sampled quality scores. That answers "what did conversations cost last week, and did quality move?" without leaving the page you are on.
Starting a run, editing a dataset, changing a grader or touching a model credential stays in the console. Those are authoring actions with a blast radius the assistant has no business guessing at, and the vault never hands a key to a tool in any case.