diff --git a/apps/web/app/activity/page.tsx b/apps/web/app/activity/page.tsx index c38bc88..2e8f701 100644 --- a/apps/web/app/activity/page.tsx +++ b/apps/web/app/activity/page.tsx @@ -11,6 +11,7 @@ import { buildActivityFeed, resolveActivitySortOrder, } from "../../lib/activityFeed"; +import { attachActivityCompareLinks } from "../../lib/activityCompare"; import { collectArtifactFacets } from "../../lib/artifactFacets"; import { loadBenchmarkArtifacts, loadRunArtifacts } from "../../lib/data"; import { buildQueryString, paginateItems } from "../../lib/listPagination"; @@ -51,16 +52,20 @@ export default async function ActivityPage({ loadBenchmarkArtifacts(), ]); const { models, presets } = collectArtifactFacets(runs, benchmarks); - const feed = buildActivityFeed(runs, benchmarks, { - kind, - q: params.q, - model: params.model, - preset: params.preset, - fast: params.fast, - from: params.from, - to: params.to, - sort, - }); + const feed = attachActivityCompareLinks( + buildActivityFeed(runs, benchmarks, { + kind, + q: params.q, + model: params.model, + preset: params.preset, + fast: params.fast, + from: params.from, + to: params.to, + sort, + }), + runs, + benchmarks, + ); const paging = paginateItems(feed, params, { defaultPageSize: 25, maxPageSize: 100, @@ -280,6 +285,21 @@ export default async function ActivityPage({ ), }, + { + key: "compare", + label: "Compare", + hideOnMobile: true, + render: (row) => { + const href = ( + row as { compareHref?: string } + ).compareHref; + return href ? ( + Compare + ) : ( + + ); + }, + }, ]} data={paging.paged.map((entry) => ({ ...entry, @@ -289,15 +309,29 @@ export default async function ActivityPage({ `${(row as { kind: string }).kind}-${(row as { id: string }).id}` } renderCardActions={(row) => ( - - Open{" "} - {(row as { kind: string }).kind === "run" - ? "trace" - : "benchmark"} - + <> + + Open{" "} + {(row as { kind: string }).kind === "run" + ? "trace" + : "benchmark"} + + {(row as { compareHref?: string }) + .compareHref ? ( + + Compare + + ) : null} + )} /> )} diff --git a/apps/web/app/api/activity/route.ts b/apps/web/app/api/activity/route.ts index 29e814a..61a605e 100644 --- a/apps/web/app/api/activity/route.ts +++ b/apps/web/app/api/activity/route.ts @@ -4,6 +4,7 @@ import { type ActivityFeedFilters, } from "../../../lib/activityFeed"; import { activityEntriesToCsv } from "../../../lib/activityExport"; +import { attachActivityCompareLinks } from "../../../lib/activityCompare"; import { loadBenchmarkArtifacts, loadRunArtifacts } from "../../../lib/data"; import { parseListPagination } from "../_shared/pagination"; @@ -33,7 +34,11 @@ export async function GET(request: Request) { ), }; - const feed = buildActivityFeed(runs, benchmarks, filters); + const feed = attachActivityCompareLinks( + buildActivityFeed(runs, benchmarks, filters), + runs, + benchmarks, + ); const { offset, limit } = parseListPagination(url.searchParams); const items = feed.slice(offset, offset + limit); diff --git a/apps/web/app/api/benchmarks/[id]/route.ts b/apps/web/app/api/benchmarks/[id]/route.ts index 9b59fd4..1495aee 100644 --- a/apps/web/app/api/benchmarks/[id]/route.ts +++ b/apps/web/app/api/benchmarks/[id]/route.ts @@ -1,15 +1,74 @@ -import { loadBenchmarkById } from "../../../../lib/data"; +import { + loadAnalysisIndex, + loadBenchmarkById, + loadBenchmarkPairsById, +} from "../../../../lib/data"; +import { + buildBenchmarkRunRoster, + sortBenchmarkRunRoster, +} from "../../../../lib/benchmarkRunRoster"; +import { findMostSimilarPeerRunId } from "../../../../lib/benchmarkPeers"; +import { benchmarkRunRosterToCsv } from "../../../../lib/listExport"; +import { buildBenchmarkOutlierLookup } from "../../../../lib/outlierLookup"; export async function GET( request: Request, context: { params: Promise<{ id: string }> }, ) { const { id } = await context.params; + const url = new URL(request.url); + const format = url.searchParams.get("format") ?? "json"; const benchmark = await loadBenchmarkById(id); if (!benchmark) { return Response.json({ error: "benchmark not found" }, { status: 404 }); } - const download = new URL(request.url).searchParams.get("download") === "1"; + + if (format === "roster-csv") { + const [pairsData, index] = await Promise.all([ + loadBenchmarkPairsById(id), + loadAnalysisIndex(), + ]); + const pairs = + pairsData.pairs.length > 0 + ? pairsData.pairs + : (benchmark.payload.summary?.stability?.pairs ?? []); + const rosterRunIds = + pairsData.runIds.length > 0 + ? pairsData.runIds + : (benchmark.payload.runIds ?? []); + const outlierLookup = buildBenchmarkOutlierLookup(index, benchmark.id); + const roster = sortBenchmarkRunRoster( + buildBenchmarkRunRoster({ + runIds: rosterRunIds, + pairs, + modes: benchmark.payload.modes, + }), + ).map((row) => { + const peerRunId = findMostSimilarPeerRunId( + row.runId, + rosterRunIds, + pairs, + ); + return { + ...row, + peerRunId, + peerCompareHref: peerRunId + ? `/runs/compare?left=${row.runId}&right=${peerRunId}` + : null, + isOutlier: outlierLookup.has(row.runId), + }; + }); + const csv = benchmarkRunRosterToCsv(roster); + return new Response(csv, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${id}-roster.csv"`, + "Cache-Control": "public, max-age=60", + }, + }); + } + + const download = url.searchParams.get("download") === "1"; const headers = download ? { "Content-Disposition": `attachment; filename="${id}.json"`, diff --git a/apps/web/app/api/questions/hub/route.ts b/apps/web/app/api/questions/hub/route.ts new file mode 100644 index 0000000..b56af3d --- /dev/null +++ b/apps/web/app/api/questions/hub/route.ts @@ -0,0 +1,61 @@ +import { + filterBenchmarkArtifacts, + filterRunArtifacts, + loadBenchmarkArtifacts, + loadRunArtifacts, +} from "../../../../lib/data"; +import { questionHubArtifactsToCsv } from "../../../../lib/listExport"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const question = (url.searchParams.get("question") ?? "").trim(); + if (!question) { + return Response.json( + { error: "question query parameter is required" }, + { status: 400 }, + ); + } + + const format = url.searchParams.get("format") ?? "json"; + const filters = { + 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, + }; + + const [allRuns, allBenchmarks] = await Promise.all([ + loadRunArtifacts(), + loadBenchmarkArtifacts(), + ]); + const runs = filterRunArtifacts(allRuns, filters).filter( + (run) => run.question === question, + ); + const benchmarks = filterBenchmarkArtifacts(allBenchmarks, filters).filter( + (benchmark) => benchmark.question === question, + ); + + if (format === "csv") { + const csv = questionHubArtifactsToCsv(question, runs, benchmarks); + const slug = question.slice(0, 48).replace(/[^\w.-]+/g, "_"); + return new Response(csv, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="question-hub-${slug || "export"}.csv"`, + "Cache-Control": "public, max-age=60", + }, + }); + } + + return Response.json({ + question, + totals: { + runs: runs.length, + benchmarks: benchmarks.length, + }, + runs, + benchmarks, + }); +} diff --git a/apps/web/app/api/routes.test.ts b/apps/web/app/api/routes.test.ts index 5287980..5e959c7 100644 --- a/apps/web/app/api/routes.test.ts +++ b/apps/web/app/api/routes.test.ts @@ -32,6 +32,7 @@ import { GET as getOutliers } from "./outliers/route"; import { GET as getCatalog } from "./catalog/route"; import { POST as postAnalysisRebuild } from "./analysis/rebuild/route"; import { GET as getQuestions } from "./questions/route"; +import { GET as getQuestionsHub } from "./questions/hub/route"; import { GET as getPairs } from "./pairs/route"; import { GET as getErrors } from "./errors/route"; @@ -2314,6 +2315,165 @@ describe("web api routes", () => { expect(await csvResponse.text()).toContain("Shared topic"); }); + it("exports question hub artifacts as CSV", async () => { + const dir = await makeTempDir(); + process.env.RUNS_DIR = dir; + await writeFile( + join(dir, "run_a.json"), + JSON.stringify({ + kind: "run", + id: "run_a", + question: "Hub topic", + metadata: { + createdAt: "2026-01-02T00:00:00.000Z", + model: "gpt-a", + pipelinePreset: "standard", + fastMode: false, + }, + run: { id: "run_a", finalAnswer: "A", steps: [], metrics: {} }, + }), + "utf-8", + ); + await writeFile( + join(dir, "benchmark_a.json"), + JSON.stringify({ + kind: "benchmark", + id: "benchmark_a", + question: "Hub topic", + metadata: { + createdAt: "2026-01-01T00:00:00.000Z", + model: "gpt-a", + pipelinePreset: "standard", + fastMode: false, + }, + payload: { + runs: 2, + modeCount: 1, + modeSizes: [2], + divergenceEntropy: 0.2, + summary: { stability: { pairwiseMean: 0.9, pairs: [] } }, + }, + }), + "utf-8", + ); + + const response = await getQuestionsHub( + new Request( + "http://localhost/api/questions/hub?question=Hub%20topic&format=csv", + ), + ); + expect(response.status).toBe(200); + const csv = await response.text(); + expect(csv).toContain("Hub topic"); + expect(csv).toContain("run_a"); + expect(csv).toContain("benchmark_a"); + }); + + it("filters runs API by pipeline errors", async () => { + const dir = await makeTempDir(); + process.env.RUNS_DIR = dir; + await writeFile( + join(dir, "run_ok.json"), + JSON.stringify({ + kind: "run", + id: "run_ok", + question: "Alpha", + metadata: { + createdAt: "2026-01-01T00:00:00.000Z", + model: "gpt-a", + pipelinePreset: "standard", + fastMode: false, + }, + run: { id: "run_ok", finalAnswer: "A", steps: [], metrics: {} }, + }), + "utf-8", + ); + await writeFile( + join(dir, "run_fail.json"), + JSON.stringify({ + kind: "run", + id: "run_fail", + question: "Beta", + metadata: { + createdAt: "2026-01-02T00:00:00.000Z", + model: "gpt-a", + pipelinePreset: "standard", + fastMode: false, + }, + run: { + id: "run_fail", + finalAnswer: "B", + steps: [ + { + agentName: "Solver", + role: "solver", + createdAt: "2026-01-02T00:00:00.000Z", + error: "timeout", + }, + ], + metrics: {}, + }, + }), + "utf-8", + ); + + const response = await getRuns( + new Request("http://localhost/api/runs?errors=true"), + ); + expect(response.status).toBe(200); + const json = (await response.json()) as { + filtered: number; + items: Array<{ id: string }>; + }; + expect(json.filtered).toBe(1); + expect(json.items[0].id).toBe("run_fail"); + }); + + it("exports benchmark roster CSV", async () => { + const dir = await makeTempDir(); + process.env.RUNS_DIR = dir; + await writeFile( + join(dir, "benchmark_roster.json"), + JSON.stringify({ + kind: "benchmark", + id: "benchmark_roster", + question: "Roster topic", + metadata: { + createdAt: "2026-01-01T00:00:00.000Z", + model: "gpt-a", + pipelinePreset: "standard", + fastMode: false, + }, + payload: { + runs: 2, + modeCount: 1, + modeSizes: [2], + divergenceEntropy: 0.2, + runIds: ["run_a", "run_b"], + modes: [{ members: [0, 1] }], + summary: { + stability: { + pairwiseMean: 0.5, + pairs: [{ i: 0, j: 1, similarity: 0.5 }], + }, + }, + }, + }), + "utf-8", + ); + + const response = await getBenchmarkById( + new Request( + "http://localhost/api/benchmarks/benchmark_roster?format=roster-csv", + ), + { params: Promise.resolve({ id: "benchmark_roster" }) }, + ); + expect(response.status).toBe(200); + const csv = await response.text(); + expect(csv).toContain("run_a"); + expect(csv).toContain("avgSimilarity"); + }); + it("returns filtered activity feed as JSON and CSV", async () => { const dir = await makeTempDir(); process.env.RUNS_DIR = dir; diff --git a/apps/web/app/api/runs/route.ts b/apps/web/app/api/runs/route.ts index c5bc604..b01bf95 100644 --- a/apps/web/app/api/runs/route.ts +++ b/apps/web/app/api/runs/route.ts @@ -1,23 +1,43 @@ -import { filterRunArtifacts, loadRunArtifacts } from "../../../lib/data"; +import { filterRunArtifacts, loadAnalysisIndex, loadRunArtifacts } from "../../../lib/data"; import { resolveRunSortOrder, sortRunArtifacts, } from "../../../lib/artifactSort"; import { runArtifactsToCsv } from "../../../lib/listExport"; +import { buildOutlierRunIdSet } from "../../../lib/outlierLookup"; +import { + applyRunListExtraFilters, + buildPipelineErrorRunIdSet, + parseRunListExtraFilters, +} from "../../../lib/runListFilters"; import { parseListPagination } from "../_shared/pagination"; export async function GET(request: Request) { const url = new URL(request.url); const format = url.searchParams.get("format") ?? "json"; - const runs = await loadRunArtifacts(); - const filtered = filterRunArtifacts(runs, { - 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, + const [runs, index] = await Promise.all([ + loadRunArtifacts(), + loadAnalysisIndex(), + ]); + const extraFilters = parseRunListExtraFilters({ + outlier: url.searchParams.get("outlier") ?? undefined, + errors: url.searchParams.get("errors") ?? undefined, }); + const filtered = applyRunListExtraFilters( + filterRunArtifacts(runs, { + 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, + }), + extraFilters, + { + outlierRunIds: buildOutlierRunIdSet(index), + errorRunIds: buildPipelineErrorRunIdSet(runs), + }, + ); const sort = resolveRunSortOrder(url.searchParams.get("sort") ?? undefined); const { offset, limit, page } = parseListPagination(url.searchParams); const sorted = sortRunArtifacts(filtered, sort); diff --git a/apps/web/app/benchmarks/[id]/page.tsx b/apps/web/app/benchmarks/[id]/page.tsx index 93b9c36..dbbbb1e 100644 --- a/apps/web/app/benchmarks/[id]/page.tsx +++ b/apps/web/app/benchmarks/[id]/page.tsx @@ -143,6 +143,13 @@ export default async function BenchmarkDetailPage({ href={`/api/benchmarks/${benchmark.id}?download=1`} filename={`${benchmark.id}.json`} /> + + Export roster CSV + Pipeline timing + + Runs with errors + diff --git a/apps/web/app/outliers/page.tsx b/apps/web/app/outliers/page.tsx index 77ae2f3..16c0662 100644 --- a/apps/web/app/outliers/page.tsx +++ b/apps/web/app/outliers/page.tsx @@ -79,6 +79,12 @@ export default async function OutliersPage({ All benchmarks + + Outlier runs list + diff --git a/apps/web/app/questions/view/page.tsx b/apps/web/app/questions/view/page.tsx index 8c05f5d..b11b9d1 100644 --- a/apps/web/app/questions/view/page.tsx +++ b/apps/web/app/questions/view/page.tsx @@ -309,6 +309,23 @@ export default async function QuestionHubPage({ Compare benchmarks (picker) ) : null} + + Export hub CSV + diff --git a/apps/web/app/runs/page.tsx b/apps/web/app/runs/page.tsx index c083b2a..a9a828c 100644 --- a/apps/web/app/runs/page.tsx +++ b/apps/web/app/runs/page.tsx @@ -17,6 +17,12 @@ import { } from "../../lib/data"; import { buildIndexRunLookup } from "../../lib/indexRunLookup"; import { buildOutlierRunIdSet } from "../../lib/outlierLookup"; +import { + applyRunListExtraFilters, + buildPipelineErrorRunIdSet, + hasActiveRunListExtraFilters, + parseRunListExtraFilters, +} from "../../lib/runListFilters"; import { resolveRunSortOrder, sortRunArtifacts, @@ -35,6 +41,8 @@ type RunsSearchParams = { fast?: string; from?: string; to?: string; + outlier?: string; + errors?: string; sort?: string; page?: string; pageSize?: string; @@ -50,18 +58,24 @@ export default async function RunsPage({ loadBenchmarkArtifacts(), loadAnalysisIndex(), ]); + const params = await searchParams; const indexLookup = index ? buildIndexRunLookup(index) : null; const outlierRunIds = buildOutlierRunIdSet(index); + const errorRunIds = buildPipelineErrorRunIdSet(runs); + const extraFilters = parseRunListExtraFilters(params); const { models, presets } = collectArtifactFacets(runs, benchmarks); - const params = await searchParams; - const filtered = filterRunArtifacts(runs, { - q: params.q, - model: params.model, - preset: params.preset, - fast: params.fast, - from: params.from, - to: params.to, - }); + const filtered = applyRunListExtraFilters( + filterRunArtifacts(runs, { + q: params.q, + model: params.model, + preset: params.preset, + fast: params.fast, + from: params.from, + to: params.to, + }), + extraFilters, + { outlierRunIds, errorRunIds }, + ); const sort = resolveRunSortOrder(params.sort); const sorted = sortRunArtifacts(filtered, sort); const paging = paginateItems(sorted, params, { @@ -120,6 +134,24 @@ export default async function RunsPage({ className="input" title="Created at or before" /> + +