You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Feature: agent evaluation platform (prompt stats + AB test + LLM-as-judge + thumbs + branches)
A self-hosted evaluation layer for the chat agent. Captures every prompt execution, lets admins compare prompt versions head-to-head, runs LLM-as-judge scoring over a human-curated test set, AB-routes traffic between prompt variants, and surfaces user thumbs-up/down + assistant-ui message branching into the same dataset. The output is a single dashboard an admin uses to decide "should we ship the new system prompt?"
This is a sizable issue because it bundles 7 related surfaces that share infrastructure. They're listed in dependency order.
Goals
Capture every chat run with prompt version + tokens + latency + outcome. Backed by a new eval_run table, populated from the existing observability callback + the agent graph.
Compare prompt versions side-by-side in the admin dashboard: same input → different outputs, plus aggregate metrics (win rate, latency p50/p95, token cost).
LLM-as-judge scoring on a configurable rubric. Runs on a schedule over the test set; the judge model is separate from the agent model so it doesn't have the same blind spots.
AB routing between prompt variants — sticky per session, weighted by traffic %, with the assignment recorded so we can attribute outcomes.
Thumbs up/down on assistant messages. Lands in eval_feedback and joins against eval_run.
assistant-ui message branching — the existing <BranchPickerPrimitive> (verified in @assistant-ui/react@0.14.26/dist/index.d.ts) lets a user pick between alternative assistant responses. We track which branch the user kept and feed it back into the eval dataset.
Why a single feature, not seven
The seven sub-items share infrastructure: the eval_run row, the dashboard, the variant model. Splitting them into seven issues would mean each later issue re-derives the same schema. One issue, seven milestones.
Out of scope
Hosted eval platforms (Braintrust, LangSmith, Langfuse). We build a lean self-hosted version; revisit if evaluation needs outgrow it.
Online reinforcement learning (DPO / PPO loops). The data model is forward-compatible (we capture chosen vs rejected) but the RL training loop is a separate future issue.
Public eval leaderboards / share links — single-tenant, operator-only dashboard.
Per-tenant eval — single admin's view; multi-org is out of scope.
// One row per prompt template version. The agent reads "active variant" via// a separate pointer table so we can change the active variant without// rewriting history.exportconstpromptTemplate=pgTable("prompt_template",{id: text("id").primaryKey(),// nanoidagent: text("agent").notNull(),// "chat" | "weather" | "crypto" | "code" | "summarize" | ...parentId: text("parent_id"),// previous version (nullable for v1)content: text("content").notNull(),// the actual prompt bodynotes: text("notes"),// admin's changelogcreatedByUserId: text("created_by_user_id").references(()=>user.id),createdAt: timestamp("created_at").defaultNow().notNull(),},(t)=>[index("prompt_template_agent_idx").on(t.agent)]);// AB routing: a variant is a labeled branch with traffic weight.// "active" variants sum to 100% (enforced server-side).exportconstpromptVariant=pgTable("prompt_variant",{id: text("id").primaryKey(),templateId: text("template_id").notNull().references(()=>promptTemplate.id,{onDelete: "cascade"}),label: text("label").notNull(),// "control", "treatment-A", …trafficWeight: integer("traffic_weight").notNull(),// 0..100enabled: boolean("enabled").notNull().default(true),createdAt: timestamp("created_at").defaultNow().notNull(),},(t)=>[index("prompt_variant_template_idx").on(t.templateId)]);// Sticky assignment per (user, template) so a user always sees the same// variant during one experiment window. Re-assigned on variant disable.exportconstpromptVariantAssignment=pgTable("prompt_variant_assignment",{userId: text("user_id").notNull().references(()=>user.id,{onDelete: "cascade"}),templateId: text("template_id").notNull().references(()=>promptTemplate.id,{onDelete: "cascade"}),variantId: text("variant_id").notNull().references(()=>promptVariant.id,{onDelete: "cascade"}),assignedAt: timestamp("assigned_at").defaultNow().notNull(),},(t)=>[primaryKey({columns: [t.userId,t.templateId]})]);
Runs + branches + feedback
exportconstevalRun=pgTable("eval_run",{id: text("id").primaryKey(),threadId: text("thread_id").notNull(),userId: text("user_id").notNull().references(()=>user.id,{onDelete: "cascade"}),agent: text("agent").notNull(),// matches promptTemplate.agenttemplateId: text("template_id").notNull().references(()=>promptTemplate.id),variantId: text("variant_id").notNull().references(()=>promptVariant.id),branchId: text("branch_id"),// null if not branchedinputTokens: integer("input_tokens"),outputTokens: integer("output_tokens"),totalMs: integer("total_ms").notNull(),status: text("status").notNull(),// 'success' | 'error' | 'rate_limited'errorMessage: text("error_message"),rubricId: text("rubric_id").references(()=>evalRubric.id),kbDocumentIds: text("kb_document_ids").array(),// empty if no @-mentioncreatedAt: timestamp("created_at").defaultNow().notNull(),},(t)=>[index("eval_run_user_idx").on(t.userId),index("eval_run_variant_idx").on(t.variantId),index("eval_run_thread_idx").on(t.threadId),index("eval_run_created_idx").on(t.createdAt),]);exportconstevalFeedback=pgTable("eval_feedback",{id: text("id").primaryKey(),runId: text("run_id").notNull().references(()=>evalRun.id,{onDelete: "cascade"}),userId: text("user_id").notNull().references(()=>user.id,{onDelete: "cascade"}),rating: text("rating").notNull(),// 'up' | 'down'reason: text("reason"),// optional free-textcreatedAt: timestamp("created_at").defaultNow().notNull(),},(t)=>[index("eval_feedback_run_idx").on(t.runId),uniqueIndex("eval_feedback_user_run_idx").on(t.userId,t.runId),// one rating per user per run]);
templateId + variantId are populated by a new assignPromptVariantNode at the START of the agent graph (between quotaCheckNode and routerAgent). For non-admin users, it picks a variant per (userId, agent) — sticky via promptVariantAssignment. Admin users always get the "control" variant by default; admins can override per-thread.
weightedPick is a single-pass random — no bias, no PRNG state in DB. The DB-row sticky assignment is the consistency guarantee.
LLM-as-judge
// backend/eval/judge.tsexportasyncfunctionjudgeRun(runId: string,rubricId: string): Promise<EvalJudgment>{construn=awaitloadEvalRun(runId);construbric=awaitloadRubric(rubricId);constprompt=buildJudgePrompt(run,rubric);// see references belowconstout=awaitjudgeModel.invoke(prompt);// separate from agent modelconst{ scores, reasoning }=parseJudgeOutput(out);awaitdb.insert(evalJudgment).values({id: nanoid(12), runId, rubricId, scores, reasoning,totalCostTokens: estimateTokens(out)});}
Reference: the established LLM-as-judge pattern (Zheng et al., 2023 — "Judging LLM-as-a-Judge"). Keep the rubric dimensions explicit (relevance, accuracy, helpfulness, instruction-following); ask for a 1-5 per dimension plus a one-paragraph reasoning; require a JSON output envelope so parsing is robust. Hardcode a sensible default rubric (name: "default") seeded at startup.
The judge model is configured per-rubric (column judge_model). Default to a different model than the agent (cost + independence). Use the existing key pool (#14).
API surface (new routes, all withAuth + requireAdmin except feedback)
Route
Method
Auth
Notes
/api/eval/runs
GET
admin
Paginated runs list, filterable by variant / agent / date.
/api/eval/runs/[id]
GET
admin
Single run + linked feedback + judgment + branch.
/api/eval/runs/compare
POST
admin
Body: { variantIds, since, until } → aggregates.
/api/eval/templates
GET/POST/PATCH
admin
Prompt template CRUD.
/api/eval/variants
GET/POST/PATCH
admin
Variant CRUD + traffic weight updates.
/api/eval/test-sets
GET/POST
admin
Test set CRUD.
/api/eval/test-sets/[id]/cases
GET/POST/PATCH/DELETE
admin
Bulk case management; CSV import endpoint.
/api/eval/test-sets/[id]/run
POST
admin
Spawns an eval_test_run; background job.
/api/eval/test-runs/[id]
GET
admin
Batch run progress + per-case results.
/api/eval/rubrics
GET/POST
admin
Rubric CRUD.
/api/eval/feedback
POST
user
Body: { runId, rating, reason? }. ONE per (user, run).
/api/eval/feedback/[runId]
GET
user (own)
Returns the user's own rating.
/api/eval/branches/keep
POST
user
Body: { threadId, branchId }. Records which branch the user kept.
Rule #9: every route withAuth. Admin routes additionally gated by requireAdmin (#14). feedback is the only user-facing route — a user can rate their own runs only.
Two icons (ThumbsUp, ThumbsDown per rule #8 — but rule #8 is for tool-UI; chat-thread icons are fine; verify). One click submits POST /api/eval/feedback. Subsequent clicks are debounced.
Branch picker
assistant-ui exposes <BranchPickerPrimitive> (verified in dist/index.d.ts). Wire it into the assistant message chrome:
When the user picks a branch (or sends a new message after switching), POST /api/eval/branches/keep records it. The branchId flows into the next eval_run row so we can compute "this user prefers branch X of variant Y" stats.
Rule #8 says tool-UI buttons are text-only. Chat-thread action buttons can have icons because they're not tool cards — verify before shipping.
"Why these buttons?" — discoverability
A small inline hint the first time a user sees the feedback buttons: "👎 helps us improve — pick a reason". After the first interaction, hide.
tests/lib/eval/assign-variant.test.ts — seed 2 variants (70/30); assert that over 1000 simulated users, the assignment distribution is within ±5% of the weights.
tests/lib/eval/assign-variant.test.ts — sticky: call twice with the same user; assert the same variant comes back; disable one variant, assert re-assignment to the other.
tests/backend/agent/eval-record.test.ts — graph test: run a chat through agent; assert eval_run row exists with the right variant_id from the assignment table; assert branch_id matches the message's branch index.
tests/api/eval/feedback.test.ts — POST /api/eval/feedback twice with the same (user, runId); assert second is 409 (unique constraint), not a duplicate row.
tests/backend/eval/judge.test.ts — mocked judge model returns a JSON envelope; assert eval_judgment row has the parsed scores + reasoning; malformed JSON from judge → graceful error path + audit row, no crash.
tests/api/eval/compare.test.ts — seed 2 variants × 100 runs each; query /api/eval/runs/compare; assert win-rate + latency aggregates match the seeds.
tests/frontend/admin/eval-dashboard.test.tsx — render dashboard with mocked responses; assert per-admin data isolation (cross-admin doesn't render).
tests/observability/eval-coverage.test.ts — a thumbs-down on a chat run produces an observability span tagged eval_feedback_down so the existing per-turn panel can render it.
Notes for the implementer
Capture is cheap; judge is expensive. Don't run the judge on every run — gate it on (a) explicit admin "judge this run" action, OR (b) nightly batch over recent runs, OR (c) all runs where rating === 'down'. Don't default to (a + b + c all on). Make it configurable.
Prompt variants vs. prompt versions: a version is an immutable row; a variant is a label that points at a version. Don't conflate them. A/B experiment creates new variants pointing at the same or different versions.
Don't run the judge on prompt X with the same model that produced prompt X's output. Use a different model (different family or different temperature) to break the bias loop. Make this a hard rule in the rubric config.
Throttle LLM-as-judge at the system level — it's a new cost center. Default rate limit: 100 judgments per hour per admin. Configurable via env.
Don't leak user PII into eval runs. The eval_run table stores user_id (FK) but the input/output payloads should be reviewed for PII before LLM-as-judge is invoked. For MVP, store the input/output as-is and trust the data classification of the underlying chat.
CSV import for test cases — schema: input, expected_output (optional), kb_document_ids (semicolon-separated), rubric_weights (json). Validate on import; reject malformed rows with a per-row error report.
The branch picker is built into assistant-ui — don't roll our own. Verified in @assistant-ui/react@0.14.26/dist/index.d.ts.
Feature: agent evaluation platform (prompt stats + AB test + LLM-as-judge + thumbs + branches)
A self-hosted evaluation layer for the chat agent. Captures every prompt execution, lets admins compare prompt versions head-to-head, runs LLM-as-judge scoring over a human-curated test set, AB-routes traffic between prompt variants, and surfaces user thumbs-up/down + assistant-ui message branching into the same dataset. The output is a single dashboard an admin uses to decide "should we ship the new system prompt?"
This is a sizable issue because it bundles 7 related surfaces that share infrastructure. They're listed in dependency order.
Goals
eval_runtable, populated from the existing observability callback + the agent graph.{ input, expected_output?, kb_document_ids?, rubric_weights? }.eval_feedbackand joins againsteval_run.<BranchPickerPrimitive>(verified in@assistant-ui/react@0.14.26/dist/index.d.ts) lets a user pick between alternative assistant responses. We track which branch the user kept and feed it back into the eval dataset.Why a single feature, not seven
The seven sub-items share infrastructure: the
eval_runrow, the dashboard, the variant model. Splitting them into seven issues would mean each later issue re-derives the same schema. One issue, seven milestones.Out of scope
Architecture overview
Data model (Drizzle,
lib/eval/schema.ts)Prompt versions + variants
Runs + branches + feedback
Test sets + judge
All new tables
ON DELETE CASCADEfromuser(per the existing pattern indocs/DB.md). Updatedocs/DB.mdin the same commit (rule #1 spirit).Capture flow — agent integration
A new
recordEvalRunNoderuns at the END of theagentgraph (aftertriggerBackgroundAgentNode), capturing the metadata of the just-finished turn:templateId+variantIdare populated by a newassignPromptVariantNodeat the START of the agent graph (betweenquotaCheckNodeandrouterAgent). For non-admin users, it picks a variant per(userId, agent)— sticky viapromptVariantAssignment. Admin users always get the "control" variant by default; admins can override per-thread.AB routing algorithm
weightedPickis a single-pass random — no bias, no PRNG state in DB. The DB-row sticky assignment is the consistency guarantee.LLM-as-judge
Reference: the established LLM-as-judge pattern (Zheng et al., 2023 — "Judging LLM-as-a-Judge"). Keep the rubric dimensions explicit (relevance, accuracy, helpfulness, instruction-following); ask for a 1-5 per dimension plus a one-paragraph reasoning; require a JSON output envelope so parsing is robust. Hardcode a sensible default rubric (
name: "default") seeded at startup.The judge model is configured per-rubric (column
judge_model). Default to a different model than the agent (cost + independence). Use the existing key pool (#14).API surface (new routes, all
withAuth+requireAdminexcept feedback)/api/eval/runs/api/eval/runs/[id]/api/eval/runs/compare{ variantIds, since, until }→ aggregates./api/eval/templates/api/eval/variants/api/eval/test-sets/api/eval/test-sets/[id]/cases/api/eval/test-sets/[id]/runeval_test_run; background job./api/eval/test-runs/[id]/api/eval/rubrics/api/eval/feedback{ runId, rating, reason? }. ONE per (user, run)./api/eval/feedback/[runId]/api/eval/branches/keep{ threadId, branchId }. Records which branch the user kept.Rule #9: every route
withAuth. Admin routes additionally gated byrequireAdmin(#14).feedbackis the only user-facing route — a user can rate their own runs only.Update
docs/APIS.mdin the same commit (rule #1).Frontend — admin dashboard
/admin/evalpage (server component,requireAdminguard, mirrors the Memory tab chrome — rule #11):recharts(already in dep tree? verify; if not, hand-rolled SVG is fine per rule feat(backend): code sub-agent + lazy-register key-needing tools + shared UI primitives #5).components/tool-ui/primitives/), create new version.eval_test_runs with per-case results.Per-user scoping is automatic because all admin routes are
requireAdminand only return data for the admin viewing. No cross-tenant concerns.Frontend — chat-side additions
Thumbs up/down
components/assistant-ui/thread.tsxalready has message chrome. Add an action button row at the bottom of each assistant message:Two icons (
ThumbsUp,ThumbsDownper rule #8 — but rule #8 is for tool-UI; chat-thread icons are fine; verify). One click submitsPOST /api/eval/feedback. Subsequent clicks are debounced.Branch picker
assistant-ui exposes
<BranchPickerPrimitive>(verified indist/index.d.ts). Wire it into the assistant message chrome:When the user picks a branch (or sends a new message after switching),
POST /api/eval/branches/keeprecords it. ThebranchIdflows into the nexteval_runrow so we can compute "this user prefers branch X of variant Y" stats.Rule #8 says tool-UI buttons are text-only. Chat-thread action buttons can have icons because they're not tool cards — verify before shipping.
"Why these buttons?" — discoverability
A small inline hint the first time a user sees the feedback buttons: "👎 helps us improve — pick a reason". After the first interaction, hide.
Tool-call surface (rule #10)
Admin-only — guarded by
role === 'admin'inside the tool'sinvoke. Lazy-registered (rule #10). Add a row todocs/TOOLS.md(rule #10).Acceptance criteria
eval_runrow with template_id, variant_id, branch_id, tokens, latency, status.assignPromptVariantNodeis the first node of the agent graph (afterquotaCheckNode); sticky per(user, template).(user, run); visible distribution in the dashboard.<BranchPickerPrimitive>wired; user's kept branch recorded viaPOST /api/eval/branches/keep.eval_judgmentrows; scores per-dimension + reasoning.pnpm lint,pnpm typecheck,pnpm testgreen.docs/APIS.mdupdated (rule feat(001): stage 1 — user auth (Better Auth + email verification + thread ownership) #1).docs/TOOLS.mdupdated (rule [Bug]: Memory tab Socials row missing email per linked provider #10).docs/DB.mdupdated with new tables.docs/AUTH.mdenv table updated (EVAL_ENABLED,JUDGE_MODEL).TDD plan (rule #2)
tests/lib/eval/assign-variant.test.ts— seed 2 variants (70/30); assert that over 1000 simulated users, the assignment distribution is within ±5% of the weights.tests/lib/eval/assign-variant.test.ts— sticky: call twice with the same user; assert the same variant comes back; disable one variant, assert re-assignment to the other.tests/backend/agent/eval-record.test.ts— graph test: run a chat throughagent; asserteval_runrow exists with the rightvariant_idfrom the assignment table; assertbranch_idmatches the message's branch index.tests/api/eval/feedback.test.ts— POST/api/eval/feedbacktwice with the same(user, runId); assert second is 409 (unique constraint), not a duplicate row.tests/backend/eval/judge.test.ts— mocked judge model returns a JSON envelope; asserteval_judgmentrow has the parsed scores + reasoning; malformed JSON from judge → graceful error path + audit row, no crash.tests/api/eval/compare.test.ts— seed 2 variants × 100 runs each; query/api/eval/runs/compare; assert win-rate + latency aggregates match the seeds.tests/frontend/admin/eval-dashboard.test.tsx— render dashboard with mocked responses; assert per-admin data isolation (cross-admin doesn't render).tests/observability/eval-coverage.test.ts— a thumbs-down on a chat run produces an observability span taggedeval_feedback_downso the existing per-turn panel can render it.Notes for the implementer
rating === 'down'. Don't default to (a + b + c all on). Make it configurable.eval_runtable storesuser_id(FK) but the input/output payloads should be reviewed for PII before LLM-as-judge is invoked. For MVP, store the input/output as-is and trust the data classification of the underlying chat.input, expected_output (optional), kb_document_ids (semicolon-separated), rubric_weights (json). Validate on import; reject malformed rows with a per-row error report.@assistant-ui/react@0.14.26/dist/index.d.ts.memory-view.tsxpatterns; chart primitives match the stat-card pattern in observability.Related
kbDocumentIdsoneval_runlinks to this.@-mention of KB docs lands ineval_run.kbDocumentIdsfor attribution.requireAdmingate + judge model keys come from here.assignPromptVariantNodesits AFTERquotaCheckNode, so a quota-rejected run never consumes a variant slot.backend/agent.ts— whereassignPromptVariantNode+recordEvalRunNodeland.lib/observability/callback-collector.ts— span integration for thumbs / judge.@assistant-ui/reactBranchPickerPrimitive— verified indist/index.d.ts.docs/APIS.md— new routes (rule feat(001): stage 1 — user auth (Better Auth + email verification + thread ownership) #1).docs/DB.md— schema.docs/TOOLS.md—get_eval_summary(rule [Bug]: Memory tab Socials row missing email per linked provider #10).docs/AUTH.md—EVAL_ENABLED+JUDGE_MODELenv vars.