From 8c48ed4e41f64948d0faec075630504e2da46e64 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Jul 2026 08:07:24 +0000 Subject: [PATCH] feat(web): pairs filters, error/quality trends, question hub picker - Add model/preset/date filters to benchmark pairs explorer and API - Show pipeline error trend charts on /errors (over time + by agent) - Show judge rubric quality trend chart on /quality - Improve /questions/view empty state with popular research topics table - Extend InsightFilterCard with configurable entity label Co-authored-by: Eamon Boyle --- apps/web/app/api/pairs/route.ts | 19 ++- apps/web/app/errors/page.tsx | 12 ++ apps/web/app/pairs/page.tsx | 29 +++- apps/web/app/quality/page.tsx | 5 + apps/web/app/questions/view/page.tsx | 117 ++++++++++++- apps/web/components/InsightFilterCard.tsx | 4 +- .../charts/PipelineErrorTrendCharts.tsx | 154 ++++++++++++++++++ .../components/charts/QualityTrendCharts.tsx | 105 ++++++++++++ apps/web/lib/glossary.ts | 4 + apps/web/lib/pairsExplorer.filter.test.ts | 59 +++++++ apps/web/lib/pairsExplorer.ts | 76 ++++++++- apps/web/lib/pipelineErrorTrends.test.ts | 56 +++++++ apps/web/lib/pipelineErrorTrends.ts | 76 +++++++++ apps/web/lib/qualityTrends.test.ts | 55 +++++++ apps/web/lib/qualityTrends.ts | 41 +++++ 15 files changed, 798 insertions(+), 14 deletions(-) create mode 100644 apps/web/components/charts/PipelineErrorTrendCharts.tsx create mode 100644 apps/web/components/charts/QualityTrendCharts.tsx create mode 100644 apps/web/lib/pairsExplorer.filter.test.ts create mode 100644 apps/web/lib/pipelineErrorTrends.test.ts create mode 100644 apps/web/lib/pipelineErrorTrends.ts create mode 100644 apps/web/lib/qualityTrends.test.ts create mode 100644 apps/web/lib/qualityTrends.ts diff --git a/apps/web/app/api/pairs/route.ts b/apps/web/app/api/pairs/route.ts index cf32240..01749d1 100644 --- a/apps/web/app/api/pairs/route.ts +++ b/apps/web/app/api/pairs/route.ts @@ -1,16 +1,33 @@ import { loadBenchmarkArtifacts } from "../../../lib/data"; +import type { ArtifactFilterParams } from "../../../lib/data"; import { pairsExplorerToCsv } from "../../../lib/listExport"; import { buildBenchmarkPairDetails, buildBenchmarkPairSummaries, + filterPairSummaries, } from "../../../lib/pairsExplorer"; +function readPairFilters(url: URL): ArtifactFilterParams { + return { + q: url.searchParams.get("q") ?? undefined, + model: url.searchParams.get("model") ?? undefined, + preset: url.searchParams.get("preset") ?? undefined, + fast: url.searchParams.get("fast") ?? undefined, + from: url.searchParams.get("from") ?? undefined, + to: url.searchParams.get("to") ?? undefined, + }; +} + export async function GET(request: Request) { const url = new URL(request.url); const format = url.searchParams.get("format") ?? "json"; const benchmark = url.searchParams.get("benchmark") ?? undefined; + const filters = readPairFilters(url); const benchmarks = await loadBenchmarkArtifacts(); - const summaries = await buildBenchmarkPairSummaries(benchmarks); + const summaries = filterPairSummaries( + await buildBenchmarkPairSummaries(benchmarks), + filters, + ); if (benchmark) { const details = await buildBenchmarkPairDetails(benchmark, benchmarks); diff --git a/apps/web/app/errors/page.tsx b/apps/web/app/errors/page.tsx index 3f08bce..cfb8cc5 100644 --- a/apps/web/app/errors/page.tsx +++ b/apps/web/app/errors/page.tsx @@ -18,6 +18,11 @@ import { buildPipelineErrorRows, collectPipelineErrorAgents, } from "../../lib/pipelineErrors"; +import { + buildPipelineErrorByAgent, + buildPipelineErrorTrendSeries, +} from "../../lib/pipelineErrorTrends"; +import { PipelineErrorTrendCharts } from "../../components/charts/PipelineErrorTrendCharts"; export const metadata: Metadata = { title: "Pipeline errors", @@ -43,6 +48,8 @@ export default async function PipelineErrorsPage({ agent: params.agent, }); const errorAgents = collectPipelineErrorAgents(filteredRuns); + const errorTrendSeries = buildPipelineErrorTrendSeries(rows); + const errorAgentSeries = buildPipelineErrorByAgent(rows); const uniqueRuns = new Set(rows.map((row) => row.runId)).size; if (allRuns.length === 0) { @@ -192,6 +199,11 @@ export default async function PipelineErrorsPage({ /> + +

Failed steps

{rows.length === 0 ? ( diff --git a/apps/web/app/pairs/page.tsx b/apps/web/app/pairs/page.tsx index 4514c78..cf92b4d 100644 --- a/apps/web/app/pairs/page.tsx +++ b/apps/web/app/pairs/page.tsx @@ -1,22 +1,28 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { InsightFilterCard } from "../../components/InsightFilterCard"; import { MetricCard } from "../../components/MetricCard"; import { ResponsiveTable, TruncateText, } from "../../components/ResponsiveTable"; -import { loadBenchmarkArtifacts } from "../../lib/data"; +import { collectArtifactFacets } from "../../lib/artifactFacets"; +import { + loadBenchmarkArtifacts, + type ArtifactFilterParams, +} from "../../lib/data"; import { buildQueryString } from "../../lib/listPagination"; import { buildBenchmarkPairDetails, buildBenchmarkPairSummaries, + filterPairSummaries, } from "../../lib/pairsExplorer"; export const metadata: Metadata = { title: "Benchmark pairs", }; -type PairsSearchParams = { +type PairsSearchParams = ArtifactFilterParams & { benchmark?: string; }; @@ -26,11 +32,13 @@ export default async function PairsExplorerPage({ searchParams: Promise; }) { const params = await searchParams; - const benchmarks = await loadBenchmarkArtifacts(); - const summaries = await buildBenchmarkPairSummaries(benchmarks); + const allBenchmarks = await loadBenchmarkArtifacts(); + const { models, presets } = collectArtifactFacets([], allBenchmarks); + const allSummaries = await buildBenchmarkPairSummaries(allBenchmarks); + const summaries = filterPairSummaries(allSummaries, params); const selectedBenchmark = (params.benchmark ?? "").trim(); const details = selectedBenchmark - ? await buildBenchmarkPairDetails(selectedBenchmark, benchmarks) + ? await buildBenchmarkPairDetails(selectedBenchmark, allBenchmarks) : { summary: null, pairs: [] }; return ( @@ -57,6 +65,17 @@ export default async function PairsExplorerPage({
+ + {summaries.length > 0 ? (
+ + {summary.withQualityScores === 0 ? (

diff --git a/apps/web/app/questions/view/page.tsx b/apps/web/app/questions/view/page.tsx index f44b661..8c05f5d 100644 --- a/apps/web/app/questions/view/page.tsx +++ b/apps/web/app/questions/view/page.tsx @@ -14,14 +14,18 @@ import { filterBenchmarkArtifacts, filterRunArtifacts, loadAnalysisIndex, + loadBenchmarkArtifacts, loadBenchmarksByQuestion, + loadRunArtifacts, loadRunsByQuestion, } from "../../../lib/data"; import { buildQueryString } from "../../../lib/listPagination"; import { + groupArtifactsByQuestion, questionHubHref, listQuestionInsightLinks, } from "../../../lib/questionGroups"; +import { buildTopQuestions } from "../../../lib/topQuestions"; import { buildQuestionExperimentMatrix, buildMatrixCellRunCompareHref, @@ -56,14 +60,115 @@ export default async function QuestionHubPage({ const params = await searchParams; const question = (params.question ?? "").trim(); if (!question) { + const [runs, benchmarks] = await Promise.all([ + loadRunArtifacts(), + loadBenchmarkArtifacts(), + ]); + const topQuestions = buildTopQuestions( + groupArtifactsByQuestion(runs, benchmarks), + 12, + ); + return (

-

Question hub

-

- Pick a question from the{" "} - questions explorer to see all - runs and benchmarks for that topic. -

+
+

Question hub

+

+ Open a research question to compare every run and + benchmark on that topic — or browse the full{" "} + questions explorer. +

+
+ + All questions + + + Search artifacts + +
+
+ + {topQuestions.length === 0 ? ( +
+

+ No questions yet. Run debate experiments locally, + then open the questions explorer once artifacts are + available. +

+
+ ) : ( +
+

+ Popular research topics +

+

+ Questions with the most runs and benchmarks in the + artifact store. +

+ ( + + + + ), + }, + { key: "runCount", label: "Runs" }, + { key: "benchmarkCount", label: "Benchmarks" }, + { + key: "totalExperiments", + label: "Total", + }, + { + key: "latestCreatedAt", + label: "Latest", + hideOnMobile: true, + render: (row) => + new Date( + ( + row as { + latestCreatedAt: string; + } + ).latestCreatedAt, + ).toLocaleString(), + }, + ]} + data={topQuestions} + getRowId={(row) => + (row as { question: string }).question + } + renderCardActions={(row) => ( + + Open hub + + )} + /> +
+ )}
); } diff --git a/apps/web/components/InsightFilterCard.tsx b/apps/web/components/InsightFilterCard.tsx index 2143835..81f2487 100644 --- a/apps/web/components/InsightFilterCard.tsx +++ b/apps/web/components/InsightFilterCard.tsx @@ -11,6 +11,7 @@ type InsightFilterCardProps = { totalRuns: number; filteredRuns: number; preserveKeys?: string[]; + entityLabel?: string; }; export function InsightFilterCard({ @@ -21,6 +22,7 @@ export function InsightFilterCard({ totalRuns, filteredRuns, preserveKeys = [], + entityLabel = "runs", }: InsightFilterCardProps) { const clearHref = (() => { const query = new URLSearchParams(); @@ -38,7 +40,7 @@ export function InsightFilterCard({ - {filteredRuns} of {totalRuns} runs + {filteredRuns} of {totalRuns} {entityLabel} } > diff --git a/apps/web/components/charts/PipelineErrorTrendCharts.tsx b/apps/web/components/charts/PipelineErrorTrendCharts.tsx new file mode 100644 index 0000000..7eed84e --- /dev/null +++ b/apps/web/components/charts/PipelineErrorTrendCharts.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { InfoTooltip } from "../InfoTooltip"; +import type { + PipelineErrorAgentPoint, + PipelineErrorTrendPoint, +} from "../../lib/pipelineErrorTrends"; + +type PipelineErrorTrendChartsProps = { + trendSeries: PipelineErrorTrendPoint[]; + agentSeries: PipelineErrorAgentPoint[]; +}; + +export function PipelineErrorTrendCharts({ + trendSeries, + agentSeries, +}: PipelineErrorTrendChartsProps) { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + if (trendSeries.length === 0 && agentSeries.length === 0) { + return null; + } + + if (!mounted) { + return ( +
+
+
+
+ ); + } + + return ( +
+
+

+ Errors over time + +

+ {trendSeries.length === 0 ? ( +

+ No dated errors in the current filter scope. +

+ ) : ( + + + + + + + + + + + + )} +
+
+

+ Errors by agent + +

+ {agentSeries.length === 0 ? ( +

+ No agent failures in the current filter scope. +

+ ) : ( + + + + + + + + + + + )} +
+
+ ); +} diff --git a/apps/web/components/charts/QualityTrendCharts.tsx b/apps/web/components/charts/QualityTrendCharts.tsx new file mode 100644 index 0000000..4fbae61 --- /dev/null +++ b/apps/web/components/charts/QualityTrendCharts.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { InfoTooltip } from "../InfoTooltip"; +import type { QualityTrendPoint } from "../../lib/qualityTrends"; + +type QualityTrendChartsProps = { + series: QualityTrendPoint[]; +}; + +export function QualityTrendCharts({ series }: QualityTrendChartsProps) { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + if (series.length === 0) { + return null; + } + + if (!mounted) { + return
; + } + + return ( +
+

+ Judge rubric scores over time + +

+ + + + + + + + + + + + + +
+ ); +} diff --git a/apps/web/lib/glossary.ts b/apps/web/lib/glossary.ts index 376118c..68eae43 100644 --- a/apps/web/lib/glossary.ts +++ b/apps/web/lib/glossary.ts @@ -144,6 +144,10 @@ export const GLOSSARY: Record = { "Compare key metrics (mode count, entropy, stability) between two benchmarks.", pipelineTiming: "Average and median wall-clock duration per agent step, computed from trace timestamps.", + pipelineErrors: + "Failed agent steps over time — spikes often indicate model outages, parse failures, or timeouts.", + qualityTrend: + "Judge rubric scores (1–5) across filtered runs in chronological order.", }; export function getGlossaryEntry(key: string): string | undefined { diff --git a/apps/web/lib/pairsExplorer.filter.test.ts b/apps/web/lib/pairsExplorer.filter.test.ts new file mode 100644 index 0000000..65bc228 --- /dev/null +++ b/apps/web/lib/pairsExplorer.filter.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { filterPairSummaries } from "./pairsExplorer"; +import type { BenchmarkPairSummaryRow } from "./pairsExplorer"; + +function makeSummary( + overrides: Partial = {}, +): BenchmarkPairSummaryRow { + return { + benchmarkId: "bench_a", + question: "What is policy X?", + model: "gpt-test", + pipelinePreset: "standard", + fastMode: false, + createdAt: "2025-06-15T12:00:00.000Z", + runCount: 3, + pairCount: 2, + minSimilarity: 0.7, + maxSimilarity: 0.9, + avgSimilarity: 0.8, + ...overrides, + }; +} + +describe("filterPairSummaries", () => { + const rows = [ + makeSummary(), + makeSummary({ + benchmarkId: "bench_b", + question: "Climate impact study", + model: "claude-test", + pipelinePreset: "research_deep", + fastMode: true, + createdAt: "2025-07-01T12:00:00.000Z", + }), + ]; + + it("filters by question text", () => { + const filtered = filterPairSummaries(rows, { q: "climate" }); + expect(filtered).toHaveLength(1); + expect(filtered[0].benchmarkId).toBe("bench_b"); + }); + + it("filters by model and preset", () => { + expect(filterPairSummaries(rows, { model: "claude" })).toHaveLength(1); + expect(filterPairSummaries(rows, { preset: "standard" })).toHaveLength( + 1, + ); + }); + + it("filters by fast mode and date range", () => { + expect(filterPairSummaries(rows, { fast: "true" })).toHaveLength(1); + expect( + filterPairSummaries(rows, { from: "2025-06-20T00:00:00" }), + ).toHaveLength(1); + expect( + filterPairSummaries(rows, { to: "2025-06-20T00:00:00" }), + ).toHaveLength(1); + }); +}); diff --git a/apps/web/lib/pairsExplorer.ts b/apps/web/lib/pairsExplorer.ts index 346b1dc..f7b4b3d 100644 --- a/apps/web/lib/pairsExplorer.ts +++ b/apps/web/lib/pairsExplorer.ts @@ -1,4 +1,4 @@ -import type { BenchmarkArtifact } from "./data"; +import type { ArtifactFilterParams, BenchmarkArtifact } from "./data"; import { loadBenchmarkPairsExport } from "./data"; export type BenchmarkPairSummaryRow = { @@ -6,6 +6,8 @@ export type BenchmarkPairSummaryRow = { question: string; model: string; pipelinePreset: string; + fastMode: boolean; + createdAt: string; runCount: number; pairCount: number; minSimilarity: number | null; @@ -13,6 +15,69 @@ export type BenchmarkPairSummaryRow = { avgSimilarity: number | null; }; +function normalize(value: string | undefined): string { + return (value ?? "").trim().toLowerCase(); +} + +function parseDateInput(value: string | undefined): Date | undefined { + if (!value) return undefined; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return undefined; + return date; +} + +function parseFastFilter(value: string | undefined): boolean | undefined { + const normalized = normalize(value); + if (normalized === "true") return true; + if (normalized === "false") return false; + return undefined; +} + +export function filterPairSummaries( + rows: BenchmarkPairSummaryRow[], + filters: ArtifactFilterParams, +): BenchmarkPairSummaryRow[] { + const q = normalize(filters.q); + const model = normalize(filters.model); + const preset = normalize(filters.preset); + const fast = parseFastFilter(filters.fast); + const fromDate = parseDateInput(filters.from); + const toDate = parseDateInput(filters.to); + + return rows.filter((row) => { + if (q) { + const haystack = + `${row.benchmarkId} ${row.question} ${row.model} ${row.pipelinePreset}`.toLowerCase(); + if (!haystack.includes(q)) return false; + } + if (model && !row.model.toLowerCase().includes(model)) { + return false; + } + if (preset && row.pipelinePreset.toLowerCase() !== preset) { + return false; + } + if (typeof fast === "boolean" && row.fastMode !== fast) { + return false; + } + const createdAt = new Date(row.createdAt); + if ( + fromDate && + !Number.isNaN(createdAt.getTime()) && + createdAt < fromDate + ) { + return false; + } + if ( + toDate && + !Number.isNaN(createdAt.getTime()) && + createdAt > toDate + ) { + return false; + } + return true; + }); +} + export type BenchmarkPairDetailRow = { runIdA: string; runIdB: string; @@ -67,6 +132,9 @@ export async function buildBenchmarkPairSummaries( question: benchmark?.question ?? "(unknown question)", model: benchmark?.metadata.model ?? "—", pipelinePreset: benchmark?.metadata.pipelinePreset ?? "—", + fastMode: benchmark?.metadata.fastMode ?? false, + createdAt: + benchmark?.metadata.createdAt ?? "1970-01-01T00:00:00.000Z", runCount: entry.runIds?.length ?? benchmark?.payload.runs ?? 0, ...stats, }); @@ -84,6 +152,8 @@ export async function buildBenchmarkPairSummaries( question: benchmark.question, model: benchmark.metadata.model, pipelinePreset: benchmark.metadata.pipelinePreset, + fastMode: benchmark.metadata.fastMode, + createdAt: benchmark.metadata.createdAt, runCount: runIds.length || benchmark.payload.runs, ...stats, }); @@ -121,6 +191,8 @@ export async function buildBenchmarkPairDetails( question: benchmark.question, model: benchmark.metadata.model, pipelinePreset: benchmark.metadata.pipelinePreset, + fastMode: benchmark.metadata.fastMode, + createdAt: benchmark.metadata.createdAt, runCount: runIds.length || benchmark.payload.runs, ...summarizePairs(rawPairs, runIds), } @@ -130,6 +202,8 @@ export async function buildBenchmarkPairDetails( question: "(unknown question)", model: "—", pipelinePreset: "—", + fastMode: false, + createdAt: "1970-01-01T00:00:00.000Z", runCount: runIds.length, ...summarizePairs(rawPairs, runIds), } diff --git a/apps/web/lib/pipelineErrorTrends.test.ts b/apps/web/lib/pipelineErrorTrends.test.ts new file mode 100644 index 0000000..5db4ddf --- /dev/null +++ b/apps/web/lib/pipelineErrorTrends.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + buildPipelineErrorByAgent, + buildPipelineErrorTrendSeries, +} from "./pipelineErrorTrends"; +import type { PipelineErrorRow } from "./pipelineErrors"; + +function makeError( + overrides: Partial = {}, +): PipelineErrorRow { + return { + runId: "run_a", + question: "Q", + model: "gpt-test", + pipelinePreset: "standard", + fastMode: false, + agentName: "SolverAgent", + stepIndex: 1, + error: "timeout", + createdAt: "2025-06-15T12:00:00.000Z", + traceHref: "/runs/run_a#step-1", + ...overrides, + }; +} + +describe("pipeline error trends", () => { + it("groups errors by day and counts affected runs", () => { + const series = buildPipelineErrorTrendSeries([ + makeError(), + makeError({ + runId: "run_b", + createdAt: "2025-06-15T18:00:00.000Z", + }), + makeError({ + runId: "run_c", + createdAt: "2025-06-16T12:00:00.000Z", + }), + ]); + + expect(series).toHaveLength(2); + expect(series[0].errorCount).toBe(2); + expect(series[0].runCount).toBe(2); + expect(series[1].errorCount).toBe(1); + }); + + it("counts errors by agent", () => { + const agents = buildPipelineErrorByAgent([ + makeError(), + makeError({ agentName: "JudgeAgent" }), + makeError({ agentName: "JudgeAgent" }), + ]); + + expect(agents[0]).toEqual({ agent: "JudgeAgent", errorCount: 2 }); + expect(agents[1]).toEqual({ agent: "SolverAgent", errorCount: 1 }); + }); +}); diff --git a/apps/web/lib/pipelineErrorTrends.ts b/apps/web/lib/pipelineErrorTrends.ts new file mode 100644 index 0000000..6154ede --- /dev/null +++ b/apps/web/lib/pipelineErrorTrends.ts @@ -0,0 +1,76 @@ +import type { PipelineErrorRow } from "./pipelineErrors"; + +export type PipelineErrorTrendPoint = { + label: string; + dateKey: string; + errorCount: number; + runCount: number; +}; + +export type PipelineErrorAgentPoint = { + agent: string; + errorCount: number; +}; + +function dayKey(createdAt: string): string | null { + const date = new Date(createdAt); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString().slice(0, 10); +} + +function formatDayLabel(dateKey: string): string { + const date = new Date(`${dateKey}T12:00:00.000Z`); + if (Number.isNaN(date.getTime())) return dateKey; + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +export function buildPipelineErrorTrendSeries( + rows: PipelineErrorRow[], +): PipelineErrorTrendPoint[] { + const byDay = new Map< + string, + { errorCount: number; runIds: Set } + >(); + + for (const row of rows) { + const key = dayKey(row.createdAt); + if (!key) continue; + const bucket = byDay.get(key) ?? { + errorCount: 0, + runIds: new Set(), + }; + bucket.errorCount += 1; + bucket.runIds.add(row.runId); + byDay.set(key, bucket); + } + + return [...byDay.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([dateKey, bucket]) => ({ + label: formatDayLabel(dateKey), + dateKey, + errorCount: bucket.errorCount, + runCount: bucket.runIds.size, + })); +} + +export function buildPipelineErrorByAgent( + rows: PipelineErrorRow[], +): PipelineErrorAgentPoint[] { + const counts = new Map(); + for (const row of rows) { + counts.set(row.agentName, (counts.get(row.agentName) ?? 0) + 1); + } + + return [...counts.entries()] + .map(([agent, errorCount]) => ({ agent, errorCount })) + .sort((a, b) => { + if (b.errorCount !== a.errorCount) { + return b.errorCount - a.errorCount; + } + return a.agent.localeCompare(b.agent); + }); +} diff --git a/apps/web/lib/qualityTrends.test.ts b/apps/web/lib/qualityTrends.test.ts new file mode 100644 index 0000000..721ba58 --- /dev/null +++ b/apps/web/lib/qualityTrends.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { buildQualityTrendSeries } from "./qualityTrends"; +import type { QualityRunRow } from "./qualityInsights"; + +describe("buildQualityTrendSeries", () => { + it("returns chronologically sorted runs with rubric scores", () => { + const rows: QualityRunRow[] = [ + { + id: "run_b", + question: "Q", + model: "gpt-test", + preset: "research_deep", + createdAt: "2025-06-20T00:00:00.000Z", + coherence: 4, + completeness: null, + factualRisk: 2, + uncertaintyHandling: null, + issueCount: 1, + traceHref: "/runs/run_b", + }, + { + id: "run_a", + question: "Q", + model: "gpt-test", + preset: "research_deep", + createdAt: "2025-06-10T00:00:00.000Z", + coherence: 3, + completeness: 4, + factualRisk: null, + uncertaintyHandling: 3, + issueCount: 0, + traceHref: "/runs/run_a", + }, + { + id: "run_c", + question: "Q", + model: "gpt-test", + preset: "standard", + createdAt: "2025-06-30T00:00:00.000Z", + coherence: null, + completeness: null, + factualRisk: null, + uncertaintyHandling: null, + issueCount: 0, + traceHref: "/runs/run_c", + }, + ]; + + const series = buildQualityTrendSeries(rows); + expect(series).toHaveLength(2); + expect(series[0].label).toContain("run_a"); + expect(series[1].label).toContain("run_b"); + expect(series[1].coherence).toBe(4); + }); +}); diff --git a/apps/web/lib/qualityTrends.ts b/apps/web/lib/qualityTrends.ts new file mode 100644 index 0000000..f24e386 --- /dev/null +++ b/apps/web/lib/qualityTrends.ts @@ -0,0 +1,41 @@ +import type { QualityRunRow } from "./qualityInsights"; + +export type QualityTrendPoint = { + label: string; + createdAt: string; + coherence: number | null; + completeness: number | null; + factualRisk: number | null; + uncertaintyHandling: number | null; +}; + +function shortLabel(id: string, createdAt: string, index: number): string { + const date = new Date(createdAt); + const stamp = Number.isNaN(date.getTime()) + ? `run-${index + 1}` + : `${date.getMonth() + 1}/${date.getDate()}`; + return `${stamp}-${id.slice(-6)}`; +} + +export function buildQualityTrendSeries( + rows: QualityRunRow[], +): QualityTrendPoint[] { + return rows + .filter( + (row) => + row.coherence != null || + row.completeness != null || + row.factualRisk != null || + row.uncertaintyHandling != null, + ) + .slice() + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)) + .map((row, index) => ({ + label: shortLabel(row.id, row.createdAt, index), + createdAt: row.createdAt, + coherence: row.coherence, + completeness: row.completeness, + factualRisk: row.factualRisk, + uncertaintyHandling: row.uncertaintyHandling, + })); +}