SPB Git

spb/ai-atlas

Public

web D3: graph flagship (pan/zoom/drag canvas, 7 explore modes, progressive expansion, inspector), Timeline 2.0 (lanes + brush, backfill toggle, occurred/observed/recorded), Time Machine, Diff 2.0 (typed sections + OG), Today in AI 2.0 (grouped releases, backfill honesty, day nav), changes feed on occurred_at cursor, Search 3.0 (compiled chips, /models mapping, unrecognised terms, dense model table), Explore query builder (saved queries), papers/organizations/researchers pages, /claims/[id], sources health center, methodology 2.0 from payload, developers request builder, admin workbenches (quality, entity resolution, anomalies, extractions, quarantine, audit, rollback), sitemap shards for families/artifacts/licenses + daily digests, BreadcrumbList JSON-LD, qa/d3.mjs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 12, 2026) parent db98c6f Browse files

65 changed files +7,900 −1,098

65 changed files
added apps/web/qa/d3.mjs +338 −0
@@ -0,0 +1,338 @@
1 +/**
2 + * D3 QA sweep (temporal · graph · research · changes · search · explore · claims · admin · SEO).
3 + * Widths 320 · 360 · 375 · 390 · 430 · 768 · 1366 · 1440 · 1920, dark + light — HTTP status, zero console errors, no horizontal
4 + * overflow, screenshot — plus assertions: graph draws ≥ 2 nodes, compiled chips on /search, backfill honesty line on the digest,
5 + * admin login → /admin/quality → /admin/entity-resolution (token never in HTML), OG image routes, sitemap shards.
6 + * Run: node qa/d3.mjs [BASE_URL] [API_URL] [ADMIN_TOKEN] (defaults http://localhost:8343, http://127.0.0.1:8332, AIA_ADMIN_TOKEN from ../../.env)
7 + * QUICK=1 limits widths to 390 and 1440.
8 + */
9 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
10 +import { existsSync, mkdirSync, readFileSync } from 'node:fs';
11 +
12 +const BASE = process.argv[2] ?? 'http://localhost:8343';
13 +const API = process.argv[3] ?? 'http://127.0.0.1:8332';
14 +function envToken() {
15 + for (const p of [new URL('../../../.env', import.meta.url).pathname, new URL('../.env', import.meta.url).pathname]) {
16 + if (!existsSync(p)) continue;
17 + const m = /^AIA_ADMIN_TOKEN=(.+)$/m.exec(readFileSync(p, 'utf8'));
18 + if (m) return m[1].trim().replace(/^["']|["']$/g, '');
19 + }
20 + return null;
21 +}
22 +const TOKEN = process.argv[4] ?? process.env.AIA_ADMIN_TOKEN ?? envToken();
23 +const OUT = new URL('./screens/d3/', import.meta.url).pathname;
24 +mkdirSync(OUT, { recursive: true });
25 +const WIDTHS = process.env.QUICK === '1' ? [390, 1440] : [320, 360, 375, 390, 430, 768, 1366, 1440, 1920];
26 +const THEMES = ['dark', 'light'];
27 +const today = new Date().toISOString().slice(0, 10);
28 +const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
29 +
30 +const j = async (path, headers = {}) => {
31 + const r = await fetch(`${API}/api/v1${path}`, { headers: { accept: 'application/json', ...headers } });
32 + if (!r.ok) throw new Error(`API ${r.status} ${path}`);
33 + return r.json();
34 +};
35 +
36 +// ---- discover slugs live (nothing hardcoded)
37 +const trending = await j('/trending?kind=views&type=model&limit=1&days=7').catch(() => null);
38 +const models = await j('/models?limit=1&sort=quality');
39 +const modelSlug = trending?.items?.[0]?.slug ?? models.items[0].slug;
40 +const papers = await j('/papers?limit=60&sort=updated');
41 +const paperSlug = (papers.items.find((p) => (p.counts?.relations ?? 0) > 2) ?? papers.items[0]).slug;
42 +const orgs = await j('/companies?limit=1&sort=models');
43 +const orgSlug = orgs.items.find((o) => o.slug === 'anthropic')?.slug ?? orgs.items[0].slug;
44 +const claims = await j(`/entities/${encodeURIComponent(modelSlug)}/claims?limit=1`);
45 +const claimId = claims.items[0]?.id;
46 +const researchers = await j('/explore/researcher?limit=1');
47 +const researcherSlug = researchers.items[0]?.slug;
48 +let snapshotId = null;
49 +if (TOKEN) {
50 + try {
51 + const docs = await j('/admin/documents?limit=1', { 'x-aia-admin-token': TOKEN });
52 + const doc = await j(`/admin/documents/${docs.items[0].id}`, { 'x-aia-admin-token': TOKEN });
53 + snapshotId = Array.isArray(doc.snapshots) && doc.snapshots[0] ? doc.snapshots[0].id : null;
54 + } catch {
55 + snapshotId = null;
56 + }
57 +}
58 +console.log(`root model ${modelSlug} · paper ${paperSlug} · org ${orgSlug} · claim ${claimId} · researcher ${researcherSlug} · snapshot ${snapshotId ?? '—'} · token ${TOKEN ? 'yes' : 'NO'}`);
59 +
60 +const PAGES = [
61 + { path: '/graph', check: 'graph' },
62 + { path: `/graph?node=${orgSlug}&mode=company`, check: 'graph' },
63 + { path: `/graph/${modelSlug}?mode=lineage`, check: 'graph' },
64 + { path: '/timeline', check: 'timeline' },
65 + { path: '/timeline?include_backfill=1&year=2025', check: 'timeline' },
66 + { path: '/time-machine?date=2025-06-01', check: 'tm' },
67 + { path: `/diff?a=${weekAgo}&b=${today}`, check: 'diff' },
68 + { path: '/changes' },
69 + { path: `/changes/${today}`, check: 'digest' },
70 + { path: '/search?q=open+reasoning+models+over+30B+released+in+2026', check: 'search' },
71 + { path: '/explore', check: 'builder' },
72 + { path: '/papers' },
73 + { path: `/papers/${paperSlug}` },
74 + { path: '/companies' },
75 + { path: `/companies/${orgSlug}` },
76 + ...(researcherSlug ? [{ path: `/explore/researcher/${researcherSlug}` }] : []),
77 + ...(claimId ? [{ path: `/claims/${claimId}` }] : []),
78 + { path: '/sources' },
79 + { path: '/methodology' },
80 + { path: '/developers', check: 'builder-dev' },
81 + { path: '/graph/does-not-exist-xyz', expected: 404 },
82 + { path: '/claims/claim_does_not_exist', expected: 404 },
83 +];
84 +const LIGHT_SUBSET = new Set(['/graph', `/graph/${modelSlug}?mode=lineage`, '/timeline', '/time-machine?date=2025-06-01', `/diff?a=${weekAgo}&b=${today}`, `/changes/${today}`, '/search?q=open+reasoning+models+over+30B+released+in+2026', '/explore', `/papers/${paperSlug}`, `/companies/${orgSlug}`, `/claims/${claimId}`, '/sources', '/methodology', '/developers']);
85 +
86 +const filterErrors = (errors, expected) => errors.filter((e) => !/favicon|Failed to load resource: the server responded with a status of 404|the server responded with a status of 429/.test(e)).filter((e) => !(expected === 404 && /Encountered a script tag while rendering React component/.test(e)));
87 +
88 +const browser = await chromium.launch();
89 +let failures = 0;
90 +const slug = (p) => p.replace(/^\//, '').replace(/[^a-z0-9]+/gi, '_').slice(0, 60) || 'home';
91 +
92 +async function checks(page, kind) {
93 + const problems = [];
94 + if (kind === 'graph') {
95 + await page.waitForSelector('[data-graph-canvas] [data-node]', { timeout: 20000 }).catch(() => undefined);
96 + const n = await page.locator('[data-graph-canvas] [data-node]').count();
97 + if (n < 2) problems.push(`graph nodes=${n}`);
98 + if (!(await page.locator('[data-graph-count]').count())) problems.push('graph count line missing');
99 + }
100 + if (kind === 'search') {
101 + const n = await page.locator('[data-compiled-chip]').count();
102 + if (n < 1) problems.push(`compiled chips=${n}`);
103 + if (!(await page.locator('[data-open-builder]').count())) problems.push('builder link missing');
104 + }
105 + if (kind === 'digest') {
106 + const text = await page.evaluate(() => document.body.innerText);
107 + if (!/occurred/i.test(text)) problems.push('digest lacks occurred caption');
108 + }
109 + if (kind === 'tm') {
110 + if (!(await page.locator('[data-tm-banner]').count())) problems.push('time-machine banner missing');
111 + const text = await page.evaluate(() => document.body.innerText);
112 + if (!/reconstructed/i.test(text)) problems.push('time-machine lacks reconstructed note');
113 + }
114 + if (kind === 'diff') {
115 + const text = await page.evaluate(() => document.body.innerText);
116 + if (!/new models/i.test(text) || !/price changes/i.test(text)) problems.push('diff sections missing');
117 + }
118 + if (kind === 'timeline') {
119 + if (!(await page.locator('[data-timeline-workbench]').count()) && !(await page.evaluate(() => /No events/i.test(document.body.innerText)))) problems.push('timeline workbench missing');
120 + }
121 + if (kind === 'builder' && !(await page.locator('[data-query-builder]').count())) problems.push('query builder missing');
122 + if (kind === 'builder-dev' && !(await page.locator('[data-request-builder]').count())) problems.push('request builder missing');
123 + return problems;
124 +}
125 +
126 +for (const theme of THEMES) {
127 + for (const width of WIDTHS) {
128 + const mobile = width < 768;
129 + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme });
130 + await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme);
131 + const page = await ctx.newPage();
132 + for (const { path, check, expected: exp } of PAGES) {
133 + if (theme === 'light' && !LIGHT_SUBSET.has(path)) continue;
134 + if (theme === 'light' && ![390, 1440, 320, 1920].includes(width)) continue;
135 + const errors = [];
136 + page.on('pageerror', (e) => errors.push(String(e)));
137 + page.on('console', (m) => {
138 + if (m.type() === 'error') errors.push(m.text());
139 + });
140 + const t0 = Date.now();
141 + const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` }));
142 + await page.waitForTimeout(700);
143 + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1);
144 + const expected = exp ?? 200;
145 + const status = res.status();
146 + const filtered = filterErrors(errors, expected);
147 + const problems = status === 200 && check ? await checks(page, check) : [];
148 + const ok = status === expected && overflow <= 0 && filtered.length === 0 && problems.length === 0;
149 + if (!ok) failures++;
150 + console.log(`${ok ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${String(width).padStart(4)} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} ${path}${problems.length ? ' :: ' + problems.join('; ') : ''}${filtered.length ? ' :: ' + filtered[0].slice(0, 160) : ''}`);
151 + if ([320, 390, 1440, 1920].includes(width)) await page.screenshot({ path: `${OUT}${theme}-${width}-${slug(path)}.png`, fullPage: width >= 768 }).catch(() => undefined);
152 + page.removeAllListeners('pageerror');
153 + page.removeAllListeners('console');
154 + }
155 + await ctx.close();
156 + }
157 +}
158 +
159 +// ---- flows
160 +async function flow(name, width, fn) {
161 + const ctx = await browser.newContext({ viewport: { width, height: width < 768 ? 844 : 900 }, colorScheme: 'dark', isMobile: width < 768, hasTouch: width < 768 });
162 + await ctx.addInitScript(() => localStorage.setItem('aia-theme', 'dark'));
163 + const page = await ctx.newPage();
164 + const errors = [];
165 + page.on('pageerror', (e) => errors.push(String(e)));
166 + page.on('console', (m) => {
167 + if (m.type() === 'error') errors.push(m.text());
168 + });
169 + try {
170 + await fn(page);
171 + const filtered = filterErrors(errors, 200);
172 + if (filtered.length) throw new Error(`console: ${filtered[0].slice(0, 160)}`);
173 + console.log(`OK flow ${name} @${width}`);
174 + } catch (e) {
175 + failures++;
176 + console.log(`FAIL flow ${name} @${width} :: ${e.message.slice(0, 200)}`);
177 + await page.screenshot({ path: `${OUT}flow-${name}-${width}-fail.png`, fullPage: true }).catch(() => undefined);
178 + }
179 + await ctx.close();
180 +}
181 +
182 +for (const width of [390, 1440]) {
183 + // Graph: select a node → inspector shows it; expand loads more nodes (or reports already expanded).
184 + await flow('graph-inspect-expand', width, async (page) => {
185 + await page.goto(`${BASE}/graph/${modelSlug}?mode=lineage`, { waitUntil: 'networkidle' });
186 + await page.waitForSelector('[data-graph-canvas] [data-node]', { timeout: 20000 });
187 + const before = await page.locator('[data-graph-canvas] [data-node]').count();
188 + const nodes = page.locator('[data-graph-canvas] [data-node]');
189 + const target = nodes.nth(Math.min(1, before - 1));
190 + await target.dispatchEvent('pointerdown', { pointerId: 1, clientX: 0, clientY: 0, isPrimary: true, button: 0 });
191 + await target.dispatchEvent('pointerup', { pointerId: 1, clientX: 0, clientY: 0, isPrimary: true, button: 0 });
192 + // Fallback: keyboard selection is always available.
193 + await target.focus();
194 + await page.keyboard.press('Enter');
195 + await page.waitForTimeout(400);
196 + const inspectors = await page.locator('[data-graph-inspector]').count();
197 + if (inspectors < 1) throw new Error('inspector not rendered after selection');
198 + const expand = page.locator('[data-graph-expand]:not([disabled]):visible').first();
199 + if (await expand.count()) {
200 + await expand.click();
201 + await page.waitForTimeout(2500);
202 + const after = await page.locator('[data-graph-canvas] [data-node]').count();
203 + if (after < before) throw new Error(`nodes shrank ${before} → ${after}`);
204 + }
205 + await page.screenshot({ path: `${OUT}flow-graph-${width}.png` });
206 + });
207 +
208 + // Graph: switch mode via the rail (desktop) → count line updates without navigation error.
209 + if (width >= 1024)
210 + await flow('graph-mode-switch', width, async (page) => {
211 + await page.goto(`${BASE}/graph?node=${orgSlug}&mode=company`, { waitUntil: 'networkidle' });
212 + await page.waitForSelector('[data-graph-canvas] [data-node]', { timeout: 20000 });
213 + await page.locator('[data-graph-mode="provider"]').click();
214 + await page.waitForTimeout(2500);
215 + if (!/mode=provider/.test(page.url())) throw new Error(`url not updated: ${page.url()}`);
216 + const text = await page.locator('[data-graph-count]').innerText();
217 + if (!/nodes/.test(text)) throw new Error('count line missing after mode switch');
218 + });
219 +
220 + // Search: remove a compiled chip → re-query without its words.
221 + await flow('search-chip-remove', width, async (page) => {
222 + await page.goto(`${BASE}/search?q=open+reasoning+models+over+30B+released+in+2026`, { waitUntil: 'networkidle' });
223 + const chips = await page.locator('[data-compiled-chip]').count();
224 + if (chips < 2) throw new Error(`chips=${chips}`);
225 + const remove = page.locator('[data-compiled-chip] a[aria-label^="Remove"]').first();
226 + const label = await remove.getAttribute('aria-label');
227 + const before = page.url();
228 + await Promise.all([page.waitForURL((u) => u.toString() !== before), remove.click()]);
229 + await page.waitForLoadState('networkidle');
230 + const after = await page.locator('[data-compiled-chip]').count();
231 + if (after >= chips) throw new Error(`chips not reduced after "${label}": ${chips} → ${after}`);
232 + });
233 +
234 + // Explore builder: set a filter, run → /models URL with the param; save a query → appears in the list.
235 + await flow('explore-builder', width, async (page) => {
236 + await page.goto(`${BASE}/explore`, { waitUntil: 'networkidle' });
237 + await page.locator('[data-query-builder] select').nth(2).selectOption({ index: 3 }); // Parameters ≥ 7B
238 + await page.waitForTimeout(300);
239 + const target = await page.locator('[data-target-url]').innerText();
240 + if (!/\/models\?.*min_params=/.test(target)) throw new Error(`target url ${target}`);
241 + if (!/type=model/.test(page.url())) throw new Error(`share url not mirrored: ${page.url()}`);
242 + await page.locator('[data-save-query]').click();
243 + await page.waitForTimeout(300);
244 + if ((await page.locator('[data-saved-queries] li').count()) < 1) throw new Error('saved query not listed');
245 + await Promise.all([page.waitForURL(/\/models\?/), page.locator('[data-run-query]').click()]);
246 + });
247 +
248 + // Time machine: presets navigate and the banner says reconstructed / observed.
249 + await flow('time-machine-presets', width, async (page) => {
250 + await page.goto(`${BASE}/time-machine`, { waitUntil: 'networkidle' });
251 + await Promise.all([page.waitForURL(/date=2024-01-01/), page.locator('a[href*="date=2024-01-01"]').first().click()]);
252 + await page.waitForLoadState('networkidle');
253 + const text = await page.locator('[data-tm-banner]').innerText();
254 + if (!/reconstructed|observed/i.test(text)) throw new Error('banner missing basis');
255 + await Promise.all([page.waitForURL(/scope=benchmarks/), page.locator('a[href*="scope=benchmarks"]').first().click()]);
256 + });
257 +
258 + // Changes feed: load more via cursor.
259 + await flow('changes-load-more', width, async (page) => {
260 + await page.goto(`${BASE}/changes`, { waitUntil: 'networkidle' });
261 + const before = await page.locator('[data-event-id]').count();
262 + const btn = page.locator('[data-load-more]');
263 + if (!(await btn.count())) return; // fewer than 50 events: nothing to page
264 + await btn.click();
265 + await page.waitForTimeout(2500);
266 + const after = await page.locator('[data-event-id]').count();
267 + if (after <= before) throw new Error(`load more did not append (${before} → ${after})`);
268 + });
269 +
270 + // Developers: request builder → Try → status line.
271 + await flow('developers-try', width, async (page) => {
272 + await page.goto(`${BASE}/developers`, { waitUntil: 'networkidle' });
273 + await page.locator('[data-route-picker]').selectOption('stats');
274 + await page.locator('[data-try]').click();
275 + await page.waitForSelector('[data-try-result] pre', { timeout: 20000 });
276 + const text = await page.locator('[data-try-result]').innerText();
277 + if (!/HTTP 200/.test(text)) throw new Error(`try result: ${text.slice(0, 80)}`);
278 + });
279 +
280 + // Admin: login → quality → entity-resolution → anomalies → audit → extractions; token never in HTML.
281 + if (TOKEN)
282 + await flow('admin-workbenches', width, async (page) => {
283 + await page.goto(`${BASE}/admin`, { waitUntil: 'networkidle' });
284 + if ((await page.content()).includes(TOKEN)) throw new Error('token leaked before login');
285 + await page.locator('input[type="password"]').first().fill(TOKEN);
286 + await Promise.all([page.waitForURL(/\/admin\/(overview|connectors)/, { timeout: 30000 }), page.locator('form button[type="submit"]').first().click()]);
287 + const pages = ['/admin/quality', '/admin/entity-resolution', '/admin/anomalies', '/admin/quarantine', '/admin/audit', '/admin/runs', ...(snapshotId ? [`/admin/extractions/${snapshotId}`] : [])];
288 + for (const p of pages) {
289 + const r = await page.goto(`${BASE}${p}`, { waitUntil: 'networkidle', timeout: 60000 });
290 + if (r.status() !== 200) throw new Error(`${p} → ${r.status()}`);
291 + const html = await page.content();
292 + if (html.includes(TOKEN)) throw new Error(`token leaked in ${p}`);
293 + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
294 + if (overflow > 0) throw new Error(`${p} overflow ${overflow}`);
295 + if (p === '/admin/quality' && !(await page.locator('[data-quality-tiles] li').count())) throw new Error('quality tiles missing');
296 + if (p === '/admin/entity-resolution' && !(await page.locator('[data-resolution-pair]').count()) && !/No candidate pairs/.test(await page.evaluate(() => document.body.innerText))) throw new Error('resolution pairs missing');
297 + if (p === '/admin/runs' && !(await page.locator('[data-rollback]').count())) throw new Error('rollback buttons missing');
298 + if (p.startsWith('/admin/extractions') && !(await page.locator('[data-extraction-text]').count())) throw new Error('extraction text missing');
299 + await page.screenshot({ path: `${OUT}admin-${width}-${slug(p)}.png`, fullPage: width >= 768 });
300 + }
301 + // Sources shows operator columns when the cookie exists (cookie path is /admin, so /sources must not see it → public view).
302 + await page.goto(`${BASE}/sources`, { waitUntil: 'networkidle' });
303 + if ((await page.content()).includes(TOKEN)) throw new Error('token leaked in /sources');
304 + // Rollback button opens a confirmation field, disabled until the id is typed.
305 + await page.goto(`${BASE}/admin/runs`, { waitUntil: 'networkidle' });
306 + const rb = page.locator('[data-rollback]:not([disabled])').first();
307 + if (await rb.count()) {
308 + await rb.click();
309 + const confirm = page.locator('[data-rollback-form] button[type="submit"]');
310 + if (!(await confirm.isDisabled())) throw new Error('rollback confirm enabled before typing the id');
311 + }
312 + });
313 +}
314 +
315 +// ---- OG images and sitemap (plain HTTP)
316 +for (const p of ['/graph/og', `/graph/og?node=${modelSlug}&mode=lineage`, '/time-machine/og?date=2025-06-01', `/diff/og?a=${weekAgo}&b=${today}`, `/changes/${today}/opengraph-image`, `/companies/${orgSlug}/opengraph-image`, `/papers/${paperSlug}/opengraph-image`]) {
317 + const r = await fetch(BASE + p);
318 + const ok = r.status === 200 && /image\/png/.test(r.headers.get('content-type') ?? '');
319 + if (!ok) failures++;
320 + console.log(`${ok ? 'OK ' : 'FAIL'} og ${r.status} ${r.headers.get('content-type')} ${p}`);
321 +}
322 +{
323 + const idx = await (await fetch(`${BASE}/sitemap.xml`)).text();
324 + const st = await (await fetch(`${BASE}/sitemap/static.xml`)).text();
325 + const okIdx = /model_family-0\.xml/.test(idx) && /license-0\.xml/.test(idx) && /artifact-0\.xml/.test(idx) && /changes\.xml/.test(idx);
326 + const okSt = ['/time-machine', '/graph', '/frontier', '/pulse', '/families', '/licenses', '/agents', '/calculator', '/run-locally', '/find-a-model', '/open'].every((r) => st.includes(`<loc>`) && st.includes(r));
327 + if (!okIdx || !okSt) failures++;
328 + console.log(`${okIdx && okSt ? 'OK ' : 'FAIL'} sitemap index(model_family/license/artifact/changes)=${okIdx} static(new routes)=${okSt}`);
329 + const fam = await fetch(`${BASE}/sitemap/model_family-0.xml`);
330 + const famBody = await fam.text();
331 + const okFam = fam.status === 200 && /\/families\//.test(famBody);
332 + if (!okFam) failures++;
333 + console.log(`${okFam ? 'OK ' : 'FAIL'} sitemap model_family-0 ${fam.status}`);
334 +}
335 +
336 +await browser.close();
337 +console.log(failures ? `\n${failures} failure(s)` : '\nAll D3 checks passed');
338 +process.exit(failures ? 1 : 0);
added apps/web/src/app/[type]/[slug]/opengraph-image.tsx +59 −0
@@ -0,0 +1,59 @@
1 +import { ImageResponse } from 'next/og';
2 +import { Fallback, Wallpaper } from '@/components/brand/og';
3 +import { api, safe } from '@/lib/api';
4 +import { fmtDate, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format';
5 +import { PATH_TYPES, SITE_NAME, typeLabel } from '@/lib/site';
6 +import type { EntityDetail } from '@/lib/types';
7 +
8 +export const runtime = 'nodejs';
9 +export const alt = `Entity on ${SITE_NAME}`;
10 +export const size = { width: 1200, height: 630 };
11 +export const contentType = 'image/png';
12 +
13 +/** Per-type facts for the generic entity OG image (papers, providers, benchmarks, hardware, frameworks, datasets, tools…). */
14 +function facts(d: EntityDetail): [string, string][] {
15 + const a = d.attributes ?? {};
16 + const out: [string, string][] = [];
17 + switch (d.entity_type) {
18 + case 'paper': {
19 + const authors = Array.isArray(a.authors) ? (a.authors as unknown[]).length : 0;
20 + if (typeof a.published_at === 'string') out.push(['Published', fmtDate(a.published_at)]);
21 + if (authors) out.push(['Authors', fmtInt(authors)]);
22 + if (typeof a.arxiv_id === 'string') out.push(['arXiv', a.arxiv_id]);
23 + const models = (d.relations ?? []).filter((g) => g.predicate === 'described_by').reduce((n, g) => n + g.items.filter((i) => i.entity_type === 'model').length, 0);
24 + if (models) out.push(['Models', fmtInt(models)]);
25 + break;
26 + }
27 + case 'benchmark':
28 + if (typeof a.category === 'string') out.push(['Category', a.category]);
29 + if (d.results?.length) out.push(['Results', fmtInt(d.results.length)]);
30 + if (typeof a.metric === 'string') out.push(['Metric', a.metric.slice(0, 18)]);
31 + break;
32 + case 'hardware':
33 + if (num(a.memory_gb) !== null) out.push(['Memory', `${fmtInt(a.memory_gb)} GB`]);
34 + if (num(a.memory_bandwidth_gbs) !== null) out.push(['Bandwidth', `${fmtInt(a.memory_bandwidth_gbs)} GB/s`]);
35 + if (typeof a.release_date === 'string') out.push(['Released', fmtDate(a.release_date)]);
36 + break;
37 + case 'provider':
38 + if (d.prices?.length) out.push(['Offers', fmtInt(d.prices.length)]);
39 + if (d.models?.total) out.push(['Models', fmtInt(d.models.total)]);
40 + break;
41 + default:
42 + if (num(a.parameter_count) !== null) out.push(['Params', fmtParams(a.parameter_count)]);
43 + if (num(a.context_length) !== null) out.push(['Context', fmtTokens(a.context_length)]);
44 + if (typeof a.latest_version === 'string') out.push(['Version', a.latest_version]);
45 + if (num(a['metric.stars']) !== null) out.push(['Stars', fmtInt(a['metric.stars'])]);
46 + if (typeof a.license === 'string') out.push(['License', a.license.slice(0, 18)]);
47 + }
48 + return out.slice(0, 4);
49 +}
50 +
51 +export default async function EntityOgImage({ params }: { params: Promise<{ type: string; slug: string }> }) {
52 + const { type, slug } = await params;
53 + const def = PATH_TYPES[type];
54 + const d = def ? await safe(def.api === 'entities' ? api.entity(slug) : api.entityOfType(def.api, slug)) : null;
55 + if (!d) return new ImageResponse(<Fallback label={def?.singular ?? 'Entity'} />, { ...size });
56 + const eyebrow = `${typeLabel(d.entity_type)}${d.organization ? ` · ${d.organization.name}` : ''}`;
57 + const subtitle = d.description ? d.description.slice(0, 140) : typeof d.attributes?.abstract === 'string' ? (d.attributes.abstract as string).slice(0, 140) : `${typeLabel(d.entity_type)} on ${SITE_NAME}: specifications, relations, history and sources.`;
58 + return new ImageResponse(<Wallpaper eyebrow={eyebrow} title={d.name} subtitle={subtitle} counters={facts(d)} footer={`www.ai-atlas.co/${type}/${d.slug}`} markPx={200} />, { ...size });
59 +}
modified apps/web/src/app/[type]/[slug]/page.tsx +3 −0
@@ -2,12 +2,14 @@ import type { Metadata } from 'next';
2 2 import { notFound, permanentRedirect } from 'next/navigation';
3 3 import { EntityPage, type EntityPageParams } from '@/components/entity/entity-page';
4 4 import { entityMetadata, loadEntity } from '@/components/entity/load';
5 +import { PaperPage } from '@/components/research/paper-page';
5 6 import { api, safe } from '@/lib/api';
6 7 import { PATH_TYPES, routes } from '@/lib/site';
7 8
8 9 /**
9 10 * Generic entity page for /providers, /benchmarks, /hardware, /papers, /frameworks, /datasets, /tools, /repositories.
10 11 * (/models and /companies have their own segment folders; static segments win over this dynamic one.)
12 + * Papers render the dedicated research page (D3); every other type renders the shared `EntityPage`.
11 13 */
12 14 type Params = { params: Promise<{ type: string; slug: string }>; searchParams: Promise<EntityPageParams> };
13 15
@@ -24,6 +26,7 @@ export default async function GenericEntityPage({ params, searchParams }: Params
24 26 const d = await loadEntity(type, slug);
25 27 const canonical = routes.entity(d);
26 28 if (canonical !== `/${type}/${encodeURIComponent(slug)}`) permanentRedirect(canonical);
29 + if (d.entity_type === 'paper') return <PaperPage d={d} canonical={canonical} />;
27 30 const related = await safe(api.entityRelated(d.slug, 10));
28 31 return <EntityPage d={d} canonical={canonical} related={related?.items} asof={sp.asof} historyProperty={sp.property} />;
29 32 }
added apps/web/src/app/admin/anomalies/page.tsx +159 −0
@@ -0,0 +1,159 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ActionButton, AdminFilters, AdminTitle, JsonPre, Mono, Notice, StatusChip } from '@/components/admin/ui';
4 +import { EntityBadge } from '@/components/ui/badges';
5 +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
6 +import { LiveAgo } from '@/components/ui/live';
7 +import { Pagination, withParams } from '@/components/ui/pagination';
8 +import { EmptyState, Unavailable } from '@/components/ui/unavailable';
9 +import { anomalyAction } from '@/lib/admin/actions';
10 +import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
11 +import { cn } from '@/lib/cn';
12 +import { fmtDateTime, fmtInt, fmtValue } from '@/lib/format';
13 +import { routes } from '@/lib/site';
14 +
15 +export const metadata: Metadata = { title: 'Anomalies', robots: { index: false, follow: false } };
16 +export const dynamic = 'force-dynamic';
17 +
18 +const LIMIT = 50;
19 +const SEV: Record<string, string> = { critical: 'text-danger', warning: 'text-warning', info: 'text-ink-3' };
20 +
21 +export default async function AdminAnomaliesPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
22 + await requireAdmin();
23 + const sp = await searchParams;
24 + const current: Record<string, string | undefined> = { status: sp.status ?? 'open' };
25 + if (sp.severity) current.severity = sp.severity;
26 + if (sp.check) current.check = sp.check;
27 + if (sp.offset) current.offset = sp.offset;
28 + const offset = Math.max(0, Number(current.offset) || 0);
29 + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/admin/anomalies', current, patch);
30 + const ret = href({});
31 + const res = await load(adminApi.anomalies({ status: current.status, severity: current.severity, check: current.check, limit: LIMIT, offset }));
32 + const byCheck = res.ok ? (res.data.by_check ?? []) : [];
33 + const checks = [...new Set(byCheck.map((c) => c.check_name))].sort();
34 + return (
35 + <>
36 + <AdminTitle title="Anomalies" count={res.ok ? fmtInt(res.data.total) : undefined} lede="Impossible or suspicious values flagged by the checks in the methodology. Flags are never deleted: resolve when the data was fixed, ignore when the value is right despite the rule, reopen if it comes back.">
37 + <Link href={`${routes.methodology()}#anomalies`} className="text-xs text-ink-3 hover:text-ink">
38 + Check definitions →
39 + </Link>
40 + </AdminTitle>
41 + <Notice notice={sp.notice} level={sp.level} />
42 + {byCheck.length > 0 && (
43 + <ul className="mb-4 flex flex-wrap gap-1.5 text-xs">
44 + {byCheck.map((c) => (
45 + <li key={`${c.check_name}-${c.status}`}>
46 + <Link href={href({ check: current.check === c.check_name ? undefined : c.check_name, offset: undefined })} className={cn('inline-flex h-7 items-center gap-1.5 border px-2', current.check === c.check_name ? 'border-accent text-accent' : 'border-rule text-ink-2 hover:border-rule-strong')}>
47 + <span className={SEV[c.severity] ?? 'text-ink-3'}>{c.severity}</span>
48 + <span className="mono">{c.check_name}</span>
49 + <span className="tnum text-ink-3">{fmtInt(c.n)}</span>
50 + </Link>
51 + </li>
52 + ))}
53 + </ul>
54 + )}
55 + <AdminFilters
56 + action="/admin/anomalies"
57 + className="mb-4"
58 + fields={[
59 + { kind: 'select', name: 'status', label: 'Status', value: current.status, any: 'open', options: ['open', 'resolved', 'ignored', 'all'].map((s) => ({ value: s, label: s })) },
60 + { kind: 'select', name: 'severity', label: 'Severity', value: current.severity, options: ['critical', 'warning', 'info'].map((s) => ({ value: s, label: s })) },
61 + { kind: 'select', name: 'check', label: 'Check', value: current.check, options: checks.map((c) => ({ value: c, label: c })) },
62 + ]}
63 + />
64 + {!res.ok ? (
65 + <Unavailable what="Anomalies" reason={res.error} />
66 + ) : res.data.items.length === 0 ? (
67 + <EmptyState title="No anomaly matches" />
68 + ) : (
69 + <>
70 + <DataTable compact scroll caption="Anomalies">
71 + <thead>
72 + <tr>
73 + <Th>Severity</Th>
74 + <Th>Check</Th>
75 + <Th>Entity</Th>
76 + <Th>Message</Th>
77 + <Th num>Value</Th>
78 + <Th>Status</Th>
79 + <Th>Seen</Th>
80 + <Th>Actions</Th>
81 + </tr>
82 + </thead>
83 + <tbody>
84 + {res.data.items.length === 0 && <EmptyRow cols={8}>No rows.</EmptyRow>}
85 + {res.data.items.map((a) => (
86 + <tr key={a.id}>
87 + <Td>
88 + <span className={cn('text-xs font-medium', SEV[a.severity] ?? 'text-ink-3')}>{a.severity}</span>
89 + </Td>
90 + <Td>
91 + <Mono>{a.check_name}</Mono>
92 + </Td>
93 + <Td primary>
94 + {a.slug ? (
95 + <span className="inline-flex items-center gap-1.5">
96 + <EntityBadge type={a.entity_type ?? 'model'} small />
97 + <Link href={routes.entity({ entity_type: a.entity_type ?? 'model', slug: a.slug })} className="text-ink hover:text-accent hover:underline">
98 + {a.entity_name ?? a.slug}
99 + </Link>
100 + </span>
101 + ) : (
102 + <Mono>{a.entity_id ?? '—'}</Mono>
103 + )}
104 + </Td>
105 + <Td className="max-w-[24rem] text-xs text-ink-2">
106 + {a.message}
107 + {a.detail && (
108 + <details className="mt-1">
109 + <summary className="cursor-pointer text-[11px] text-ink-3 hover:text-ink">detail</summary>
110 + <JsonPre value={a.detail} maxHeight="10rem" />
111 + </details>
112 + )}
113 + </Td>
114 + <Td num className="tnum text-xs">{fmtValue(a.value)}</Td>
115 + <Td>
116 + <StatusChip value={a.status} />
117 + {a.resolution && typeof a.resolution.note === 'string' && <span className="block text-[11px] text-ink-3">{a.resolution.note}</span>}
118 + </Td>
119 + <Td className="text-xs text-ink-3" title={`first ${fmtDateTime(a.first_seen_at)} · last ${fmtDateTime(a.last_seen_at)}`}>
120 + <LiveAgo at={a.last_seen_at} />
121 + </Td>
122 + <Td>
123 + <div className="flex items-center gap-1">
124 + {a.status !== 'resolved' && (
125 + <form action={anomalyAction}>
126 + <input type="hidden" name="id" value={a.id} />
127 + <input type="hidden" name="status" value="resolved" />
128 + <input type="hidden" name="return" value={ret} />
129 + <ActionButton tone="positive" title="The data was corrected">Resolve</ActionButton>
130 + </form>
131 + )}
132 + {a.status !== 'ignored' && (
133 + <form action={anomalyAction}>
134 + <input type="hidden" name="id" value={a.id} />
135 + <input type="hidden" name="status" value="ignored" />
136 + <input type="hidden" name="return" value={ret} />
137 + <ActionButton title="The value is right despite the rule">Ignore</ActionButton>
138 + </form>
139 + )}
140 + {a.status !== 'open' && (
141 + <form action={anomalyAction}>
142 + <input type="hidden" name="id" value={a.id} />
143 + <input type="hidden" name="status" value="open" />
144 + <input type="hidden" name="return" value={ret} />
145 + <ActionButton tone="accent">Reopen</ActionButton>
146 + </form>
147 + )}
148 + </div>
149 + </Td>
150 + </tr>
151 + ))}
152 + </tbody>
153 + </DataTable>
154 + <Pagination total={res.data.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
155 + </>
156 + )}
157 + </>
158 + );
159 +}
added apps/web/src/app/admin/audit/page.tsx +91 −0
@@ -0,0 +1,91 @@
1 +import type { Metadata } from 'next';
2 +import { AdminFilters, AdminTitle, JsonPre, Mono, Notice } from '@/components/admin/ui';
3 +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
4 +import { LiveAgo } from '@/components/ui/live';
5 +import { Pagination, withParams } from '@/components/ui/pagination';
6 +import { Note } from '@/components/ui/section';
7 +import { EmptyState, Unavailable } from '@/components/ui/unavailable';
8 +import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
9 +import { cn } from '@/lib/cn';
10 +import { fmtDateTime, fmtInt } from '@/lib/format';
11 +
12 +export const metadata: Metadata = { title: 'Audit log', robots: { index: false, follow: false } };
13 +export const dynamic = 'force-dynamic';
14 +const LIMIT = 50;
15 +
16 +export default async function AdminAuditPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
17 + await requireAdmin();
18 + const sp = await searchParams;
19 + const current: Record<string, string | undefined> = {};
20 + if (sp.action) current.action = sp.action;
21 + if (sp.offset) current.offset = sp.offset;
22 + const offset = Math.max(0, Number(current.offset) || 0);
23 + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/admin/audit', current, patch);
24 + const res = await load(adminApi.audit({ action: current.action, limit: LIMIT, offset }));
25 + return (
26 + <>
27 + <AdminTitle title="Audit log" count={res.ok ? fmtInt(res.data.total) : undefined} lede="Every admin call except the polling GETs (overview, infrastructure, llm/health, audit) writes a row: actor, action, target, payload, IP." />
28 + <Notice notice={sp.notice} level={sp.level} />
29 + <AdminFilters action="/admin/audit" className="mb-4" fields={[{ kind: 'text', name: 'action', label: 'Action contains', value: current.action, placeholder: 'entity-resolution, rollback, POST…' }]} />
30 + {!res.ok ? (
31 + <Unavailable what="Audit log" reason={res.error} />
32 + ) : res.data.items.length === 0 ? (
33 + <EmptyState title="No audit rows match" />
34 + ) : (
35 + <>
36 + <DataTable compact scroll caption="Audit log">
37 + <thead>
38 + <tr>
39 + <Th>When</Th>
40 + <Th>Actor</Th>
41 + <Th>Action</Th>
42 + <Th>Target</Th>
43 + <Th>Payload</Th>
44 + <Th>IP</Th>
45 + <Th>Id</Th>
46 + </tr>
47 + </thead>
48 + <tbody>
49 + {res.data.items.length === 0 && <EmptyRow cols={7}>No rows.</EmptyRow>}
50 + {res.data.items.map((r) => {
51 + const mutating = !r.action.startsWith('GET ');
52 + return (
53 + <tr key={String(r.id)}>
54 + <Td className="text-xs text-ink-3" title={fmtDateTime(r.created_at)}>
55 + <LiveAgo at={r.created_at} />
56 + </Td>
57 + <Td>
58 + <Mono>{r.actor}</Mono>
59 + </Td>
60 + <Td primary>
61 + <span className={cn('mono text-xs', mutating ? 'font-medium text-ink' : 'text-ink-2')}>{r.action}</span>
62 + </Td>
63 + <Td>{r.target ? <Mono>{r.target}</Mono> : <span className="text-ink-3">—</span>}</Td>
64 + <Td className="max-w-[26rem]">
65 + {r.payload ? (
66 + <details>
67 + <summary className="cursor-pointer text-[11px] text-ink-3 hover:text-ink">{r.payload.method ? `${String(r.payload.method)} ${String(r.payload.path ?? '')}` : 'payload'}</summary>
68 + <JsonPre value={r.payload} maxHeight="10rem" />
69 + </details>
70 + ) : (
71 + <span className="text-ink-3">—</span>
72 + )}
73 + </Td>
74 + <Td>
75 + <Mono>{r.ip ?? '—'}</Mono>
76 + </Td>
77 + <Td>
78 + <Mono>{String(r.id)}</Mono>
79 + </Td>
80 + </tr>
81 + );
82 + })}
83 + </tbody>
84 + </DataTable>
85 + <Pagination total={res.data.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
86 + </>
87 + )}
88 + <Note className="mt-4">Rows are append-only; this page itself is not logged (polling GET).</Note>
89 + </>
90 + );
91 +}
added apps/web/src/app/admin/entity-resolution/page.tsx +120 −0
@@ -0,0 +1,120 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ResolutionPair } from '@/components/admin/resolution-pair';
4 +import { AdminFilters, AdminTitle, Mono, Notice } from '@/components/admin/ui';
5 +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
6 +import { Pagination, withParams } from '@/components/ui/pagination';
7 +import { Note } from '@/components/ui/section';
8 +import { EmptyState, Unavailable } from '@/components/ui/unavailable';
9 +import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
10 +import { fmtDateTime, fmtInt, fmtPct, num } from '@/lib/format';
11 +import { routes } from '@/lib/site';
12 +
13 +export const metadata: Metadata = { title: 'Entity resolution', robots: { index: false, follow: false } };
14 +export const dynamic = 'force-dynamic';
15 +
16 +const LIMIT = 20;
17 +const TYPES = ['model', 'company', 'organization', 'lab', 'paper', 'benchmark', 'provider', 'hardware', 'framework', 'dataset'];
18 +
19 +export default async function EntityResolutionPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
20 + await requireAdmin();
21 + const sp = await searchParams;
22 + const current: Record<string, string | undefined> = { type: sp.type ?? 'model', status: sp.status ?? 'pending' };
23 + if (sp.threshold) current.threshold = sp.threshold;
24 + if (sp.offset) current.offset = sp.offset;
25 + const offset = Math.max(0, Number(current.offset) || 0);
26 + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/admin/entity-resolution', current, patch);
27 + const ret = href({});
28 + const [res, decided] = await Promise.all([load(adminApi.entityResolution({ type: current.type, status: current.status, threshold: current.threshold, limit: LIMIT, offset })), current.status === 'decided' ? Promise.resolve(null) : load(adminApi.entityResolution({ type: current.type, status: 'decided', limit: 10 }))]);
29 + return (
30 + <>
31 + <AdminTitle title="Entity resolution" count={res.ok ? fmtInt(res.data.total) : undefined} lede="Candidate pairs side by side. Nothing is merged until you decide; decisions persist (resolution_decisions) and are applied by the API: merge, alias, variant-of, family member — or keep separate / defer.">
32 + <Link href="/admin/quality" className="text-xs text-ink-3 hover:text-ink">
33 + Data quality →
34 + </Link>
35 + </AdminTitle>
36 + <Notice notice={sp.notice} level={sp.level} />
37 + <AdminFilters
38 + action="/admin/entity-resolution"
39 + className="mb-4"
40 + fields={[
41 + { kind: 'select', name: 'type', label: 'Entity type', value: current.type, any: 'model', options: TYPES.map((t) => ({ value: t, label: t })) },
42 + { kind: 'select', name: 'status', label: 'Status', value: current.status, any: 'pending', options: ['pending', 'decided', 'all'].map((s) => ({ value: s, label: s })) },
43 + { kind: 'select', name: 'threshold', label: 'Similarity ≥', value: current.threshold, any: '0.8 (default)', options: ['0.6', '0.7', '0.8', '0.9', '0.95'].map((v) => ({ value: v, label: v })) },
44 + ]}
45 + />
46 + {!res.ok ? (
47 + <Unavailable what="Entity resolution" reason={res.error} />
48 + ) : res.data.items.length === 0 ? (
49 + <EmptyState title="No candidate pairs">{current.status === 'pending' ? 'Nothing above the similarity threshold awaits a decision for this type.' : 'No decided pairs for this type yet.'}</EmptyState>
50 + ) : (
51 + <>
52 + <p className="mb-3 text-xs text-ink-3">
53 + Threshold {fmtPct((num(res.data.threshold) ?? 0) * 100, 0)} · signals: review queue, trigram similarity, shared variant key. {res.data.note}
54 + </p>
55 + <ul className="space-y-6" data-resolution-list>
56 + {res.data.items.map((it) => (
57 + <li key={`${it.a.id}-${it.b.id}`}>
58 + <ResolutionPair item={it} decisions={res.data.decisions} returnTo={ret} />
59 + </li>
60 + ))}
61 + </ul>
62 + <Pagination total={res.data.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
63 + </>
64 + )}
65 + {decided && decided.ok && (
66 + <section className="mt-10">
67 + <h2 className="text-base font-semibold tracking-tight">
68 + Decision history <span className="tnum text-sm font-normal text-ink-3">{fmtInt(decided.data.total)}</span>
69 + </h2>
70 + <Note className="mb-3 mt-1">Latest decided pairs for this type (persisted in resolution_decisions; every POST is also in the audit log).</Note>
71 + <DataTable compact scroll caption="Decision history">
72 + <thead>
73 + <tr>
74 + <Th>A</Th>
75 + <Th>B</Th>
76 + <Th>Decision</Th>
77 + <Th>Note</Th>
78 + <Th>Decided</Th>
79 + </tr>
80 + </thead>
81 + <tbody>
82 + {decided.data.items.length === 0 && <EmptyRow cols={5}>No decision recorded yet.</EmptyRow>}
83 + {decided.data.items.map((it) => {
84 + const d = (typeof it.decision === 'string' ? { decision: it.decision } : it.decision) as { decision: string; note?: string | null; decided_at?: string | null; applied?: boolean } | null;
85 + return (
86 + <tr key={`${it.a.id}-${it.b.id}`}>
87 + <Td primary>
88 + <Link href={routes.entity(it.a)} className="text-ink hover:text-accent">
89 + {it.a.name}
90 + </Link>{' '}
91 + <Mono>{it.a.slug}</Mono>
92 + </Td>
93 + <Td>
94 + <Link href={routes.entity(it.b)} className="text-ink hover:text-accent">
95 + {it.b.name}
96 + </Link>{' '}
97 + <Mono>{it.b.slug}</Mono>
98 + </Td>
99 + <Td>
100 + <Mono>{d?.decision ?? '—'}</Mono>
101 + {d && 'applied' in d && d.applied !== undefined && <span className="ml-1 text-[11px] text-ink-3">{d.applied ? 'applied' : 'recorded'}</span>}
102 + </Td>
103 + <Td className="text-xs text-ink-2">{d && typeof d.note === 'string' ? d.note : '—'}</Td>
104 + <Td className="tnum text-xs text-ink-3">{d && typeof d.decided_at === 'string' ? fmtDateTime(d.decided_at) : '—'}</Td>
105 + </tr>
106 + );
107 + })}
108 + </tbody>
109 + </DataTable>
110 + <p className="mt-2 text-xs">
111 + <Link href={href({ status: 'decided', offset: undefined })} className="link">
112 + All decided pairs →
113 + </Link>{' '}
114 + · <Link href="/admin/audit?action=entity-resolution" className="link">Audit log →</Link>
115 + </p>
116 + </section>
117 + )}
118 + </>
119 + );
120 +}
added apps/web/src/app/admin/extractions/[snapshot]/page.tsx +342 −0
@@ -0,0 +1,342 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { AdminTitle, JsonPre, KindChip, Mono, Notice, StatusChip } from '@/components/admin/ui';
4 +import { SectionNav } from '@/components/layout/terminal';
5 +import { EntityBadge, TierBadge } from '@/components/ui/badges';
6 +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
7 +import { Note } from '@/components/ui/section';
8 +import { Unavailable } from '@/components/ui/unavailable';
9 +import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
10 +import type { ExtractionPayload, ExtractionSpan } from '@/lib/admin/types';
11 +import { cn } from '@/lib/cn';
12 +import { fmtBytes, fmtDateTime, fmtInt, fmtValue, num } from '@/lib/format';
13 +import { routes } from '@/lib/site';
14 +
15 +export const metadata: Metadata = { title: 'Extraction debugger', robots: { index: false, follow: false } };
16 +export const dynamic = 'force-dynamic';
17 +
18 +const STAGES = [
19 + { id: 'raw', label: 'Raw' },
20 + { id: 'normalized', label: 'Normalized' },
21 + { id: 'deterministic', label: 'Deterministic' },
22 + { id: 'llm', label: 'LLM' },
23 + { id: 'candidates', label: 'Candidates' },
24 + { id: 'claims', label: 'Claims' },
25 + { id: 'relations', label: 'Relations' },
26 + { id: 'reconciliation', label: 'Reconciliation' },
27 + { id: 'events', label: 'Events' },
28 +];
29 +
30 +/** Text excerpt with the located claim values highlighted (offsets are into the cleaned text). */
31 +function HighlightedText({ text, spans }: { text: string; spans: ExtractionSpan[] }) {
32 + const found = spans.filter((s) => s.found && typeof s.offset === 'number' && s.offset >= 0 && s.offset < text.length && s.match).sort((a, b) => (a.offset ?? 0) - (b.offset ?? 0));
33 + const parts: React.ReactNode[] = [];
34 + let cursor = 0;
35 + for (const s of found) {
36 + const start = s.offset as number;
37 + const len = (s.match as string).length;
38 + if (start < cursor) continue;
39 + parts.push(text.slice(cursor, start));
40 + parts.push(
41 + <mark key={s.claim_id} id={`span-${s.claim_id}`} className="rounded-[2px] bg-accent-soft px-0.5 text-ink outline outline-1 outline-accent/40" title={`${s.property} = ${fmtValue(s.value, s.property)}`}>
42 + {text.slice(start, start + len)}
43 + </mark>,
44 + );
45 + cursor = start + len;
46 + }
47 + parts.push(text.slice(cursor));
48 + return <>{parts}</>;
49 +}
50 +
51 +function Stage({ id, title, count, children, lede }: { id: string; title: string; count?: number | null; children: React.ReactNode; lede?: string }) {
52 + return (
53 + <section id={id} className="scroll-mt-24 border-t border-rule py-5">
54 + <h2 className="text-base font-semibold tracking-tight">
55 + {title} {count !== undefined && <span className="tnum text-sm font-normal text-ink-3">{fmtInt(count)}</span>}
56 + </h2>
57 + {lede && <p className="mt-0.5 text-xs text-ink-3">{lede}</p>}
58 + <div className="mt-3">{children}</div>
59 + </section>
60 + );
61 +}
62 +
63 +function ClaimsTable({ claims, spans, caption }: { claims: ExtractionPayload['claims']; spans: ExtractionSpan[]; caption: string }) {
64 + const spanById = new Map(spans.map((s) => [s.claim_id, s]));
65 + return (
66 + <DataTable compact scroll caption={caption}>
67 + <thead>
68 + <tr>
69 + <Th>Entity</Th>
70 + <Th>Property</Th>
71 + <Th>Value</Th>
72 + <Th>Raw</Th>
73 + <Th>Status</Th>
74 + <Th>Conf.</Th>
75 + <Th>Tier</Th>
76 + <Th>Located in text</Th>
77 + <Th>Claim</Th>
78 + </tr>
79 + </thead>
80 + <tbody>
81 + {claims.length === 0 && <EmptyRow cols={9}>No claims at this stage.</EmptyRow>}
82 + {claims.map((c) => {
83 + const s = spanById.get(c.id);
84 + return (
85 + <tr key={c.id}>
86 + <Td primary>{c.entity_slug ? <Link href={routes.entity({ entity_type: 'model', slug: c.entity_slug })} className="text-ink hover:text-accent">{c.entity_slug}</Link> : <Mono>{c.entity_id ?? '—'}</Mono>}</Td>
87 + <Td>
88 + <Mono>{c.property}</Mono>
89 + </Td>
90 + <Td className="tnum text-xs">{fmtValue(c.value, c.property)}{c.unit ? <span className="text-ink-3"> {c.unit}</span> : null}</Td>
91 + <Td className="text-xs text-ink-3">{c.value_raw === null || c.value_raw === undefined ? '—' : String(c.value_raw)}</Td>
92 + <Td>
93 + <StatusChip value={c.status} />
94 + </Td>
95 + <Td className="text-xs text-ink-2">{c.confidence}</Td>
96 + <Td>
97 + <TierBadge tier={num(c.tier)} />
98 + </Td>
99 + <Td className="text-xs">
100 + {s?.found ? (
101 + <a href={`#span-${c.id}`} className="text-positive hover:underline">
102 + found @ {s.offset}
103 + </a>
104 + ) : s ? (
105 + <span className="text-warning" title={s.tried?.length ? `tried: ${s.tried.join(', ')}` : undefined}>
106 + not found
107 + </span>
108 + ) : (
109 + <span className="text-ink-3">—</span>
110 + )}
111 + </Td>
112 + <Td>
113 + <Link href={routes.claim(c.id)} className="mono text-[11px] text-accent hover:underline">
114 + {c.id}
115 + </Link>
116 + </Td>
117 + </tr>
118 + );
119 + })}
120 + </tbody>
121 + </DataTable>
122 + );
123 +}
124 +
125 +export default async function ExtractionPage({ params, searchParams }: { params: Promise<{ snapshot: string }>; searchParams: Promise<Record<string, string | undefined>> }) {
126 + await requireAdmin();
127 + const { snapshot } = await params;
128 + const sp = await searchParams;
129 + const res = await load(adminApi.extraction(snapshot, 20000));
130 + if (!res.ok) {
131 + return (
132 + <>
133 + <AdminTitle title="Extraction debugger" />
134 + <Unavailable what="Extraction" reason={res.error} />
135 + </>
136 + );
137 + }
138 + const x = res.data;
139 + const det = x.claims.filter((c) => c.extractor !== 'llm');
140 + const llm = x.claims.filter((c) => c.extractor === 'llm');
141 + const text = x.text ?? '';
142 + return (
143 + <>
144 + <AdminTitle title="Extraction debugger" count={<Mono>{x.id}</Mono>} lede="The pipeline for one snapshot: raw fetch → cleaned text → deterministic claims → LLM claims → entity candidates → claims written → relations → reconciliation with the previous snapshot → events. Value locations are a best-effort search in the text; not-found is reported, never inferred.">
145 + <Link href={`/admin/snapshots/${encodeURIComponent(x.id)}`} className="text-xs text-ink-3 hover:text-ink">
146 + Snapshot record →
147 + </Link>
148 + {x.document_id && (
149 + <Link href={`/admin/documents/${encodeURIComponent(x.document_id)}`} className="text-xs text-ink-3 hover:text-ink">
150 + Document →
151 + </Link>
152 + )}
153 + </AdminTitle>
154 + <Notice notice={sp.notice} level={sp.level} />
155 + <SectionNav items={STAGES} className="mb-2" />
156 +
157 + <Stage id="raw" title="RAW" lede="What was fetched (paths are never exposed).">
158 + <dl className="kv [&>div]:py-1 text-sm">
159 + <div>
160 + <dt>URL</dt>
161 + <dd>
162 + <a href={x.final_url ?? x.url} target="_blank" rel="noopener noreferrer" className="link break-all text-xs">
163 + {x.final_url ?? x.url}
164 + </a>
165 + </dd>
166 + </div>
167 + <div>
168 + <dt>Observed</dt>
169 + <dd className="tnum">{fmtDateTime(x.observed_at)}</dd>
170 + </div>
171 + <div>
172 + <dt>HTTP · type · size</dt>
173 + <dd className="tnum">
174 + {fmtValue(x.http_status)} · {x.content_type ?? '—'} · {fmtBytes(x.byte_size)}
175 + </dd>
176 + </div>
177 + <div>
178 + <dt>Hashes</dt>
179 + <dd>
180 + <Mono>content {x.content_hash?.slice(0, 16) ?? '—'}</Mono> <Mono>text {x.text_hash?.slice(0, 16) ?? '—'}</Mono>
181 + </dd>
182 + </div>
183 + <div>
184 + <dt>Connector · parser · transport</dt>
185 + <dd>
186 + <Mono>{x.connector_name ?? '—'}</Mono> <Mono>parser v{x.parser_version ?? '—'}</Mono> <Mono>{x.transport ?? '—'}</Mono>
187 + </dd>
188 + </div>
189 + <div>
190 + <dt>Run · document</dt>
191 + <dd>
192 + <Mono>{x.run_id ?? '—'}</Mono> · <Mono>{x.document_id}</Mono> {x.doc_type && <KindChip value={x.doc_type} />}
193 + </dd>
194 + </div>
195 + <div>
196 + <dt>Entity</dt>
197 + <dd>
198 + {x.entity_slug ? (
199 + <span className="inline-flex items-center gap-1.5">
200 + <EntityBadge type="model" small />
201 + <Link href={routes.entity({ entity_type: 'model', slug: x.entity_slug })} className="text-ink hover:text-accent">
202 + {x.entity_name ?? x.entity_slug}
203 + </Link>
204 + </span>
205 + ) : (
206 + '—'
207 + )}
208 + </dd>
209 + </div>
210 + <div>
211 + <dt>Flags</dt>
212 + <dd className="text-xs">
213 + raw {x.has_raw ? 'archived' : '—'} · text {x.has_text ? 'yes' : '—'} · structured {x.has_structured || x.structured ? 'yes' : '—'} · changed {x.changed ? 'yes' : 'no'} · <StatusChip value={x.processing_status} />
214 + </dd>
215 + </div>
216 + </dl>
217 + {x.structured != null && (
218 + <details className="mt-3">
219 + <summary className="cursor-pointer text-xs text-ink-3 hover:text-ink">Structured data (JSON-LD, OG, embedded JSON)</summary>
220 + <JsonPre value={x.structured} maxHeight="20rem" />
221 + </details>
222 + )}
223 + </Stage>
224 +
225 + <Stage id="normalized" title="NORMALIZED" count={num(x.text_chars)} lede={`Cleaned text${x.text_truncated ? ' (excerpt — truncated to 20 kB)' : ''}; ${fmtInt(x.spans_found)} of ${fmtInt(x.spans.length)} claim values located and highlighted.`}>
226 + {text ? (
227 + <pre className="scrollbar-thin max-h-[32rem] overflow-auto whitespace-pre-wrap border border-rule bg-surface p-3 text-[12px] leading-relaxed text-ink-2" data-extraction-text>
228 + <HighlightedText text={text} spans={x.spans} />
229 + </pre>
230 + ) : (
231 + <p className="text-sm text-ink-3">No cleaned text{x.text_error ? ` — ${x.text_error}` : ''}.</p>
232 + )}
233 + </Stage>
234 +
235 + <Stage id="deterministic" title="DETERMINISTIC" count={det.length} lede="Rule-based extraction (tables, JSON-LD, meta, regex) — always runs first.">
236 + <ClaimsTable claims={det} spans={x.spans} caption="Deterministic claims" />
237 + </Stage>
238 +
239 + <Stage id="llm" title="LLM" count={llm.length + x.llm_jobs.length} lede="LLM jobs for this snapshot and the claims they produced (one tier lower than deterministic).">
240 + {x.llm_jobs.length > 0 && <JsonPre value={x.llm_jobs} maxHeight="14rem" />}
241 + {x.llm_jobs.length === 0 && llm.length === 0 ? <p className="text-sm text-ink-3">No LLM extraction for this snapshot (deterministic only).</p> : <ClaimsTable claims={llm} spans={x.spans} caption="LLM claims" />}
242 + </Stage>
243 +
244 + <Stage id="candidates" title="CANDIDATES" count={x.entity_candidates.length} lede="Entities the resolver matched or created for this document.">
245 + <DataTable compact caption="Entity candidates">
246 + <thead>
247 + <tr>
248 + <Th>Entity</Th>
249 + <Th>Type</Th>
250 + <Th>Identity</Th>
251 + <Th>Merged into</Th>
252 + <Th>Id</Th>
253 + </tr>
254 + </thead>
255 + <tbody>
256 + {x.entity_candidates.length === 0 && <EmptyRow cols={5}>No candidate.</EmptyRow>}
257 + {x.entity_candidates.map((c) => (
258 + <tr key={c.id}>
259 + <Td primary>
260 + <Link href={routes.entity({ entity_type: c.entity_type, slug: c.slug })} className="text-ink hover:text-accent">
261 + {c.canonical_name}
262 + </Link>
263 + </Td>
264 + <Td>
265 + <EntityBadge type={c.entity_type} small />
266 + </Td>
267 + <Td className="text-xs text-ink-2">{c.identity_confidence ?? '—'}</Td>
268 + <Td>{c.merged_into ? <Mono>{c.merged_into}</Mono> : <span className="text-ink-3">—</span>}</Td>
269 + <Td>
270 + <Mono>{c.id}</Mono>
271 + </Td>
272 + </tr>
273 + ))}
274 + </tbody>
275 + </DataTable>
276 + </Stage>
277 +
278 + <Stage id="claims" title="CLAIMS" count={x.claims.length} lede="Every claim written from this snapshot, with its status after the writer's temporal rules.">
279 + <ClaimsTable claims={x.claims} spans={x.spans} caption="All claims" />
280 + {(x.results.length > 0 || x.prices.length > 0) && (
281 + <div className="mt-4 grid gap-4 md:grid-cols-2">
282 + <div>
283 + <p className="eyebrow mb-1">Benchmark results {fmtInt(x.results.length)}</p>
284 + <JsonPre value={x.results} maxHeight="14rem" />
285 + </div>
286 + <div>
287 + <p className="eyebrow mb-1">Prices {fmtInt(x.prices.length)}</p>
288 + <JsonPre value={x.prices} maxHeight="14rem" />
289 + </div>
290 + </div>
291 + )}
292 + </Stage>
293 +
294 + <Stage id="relations" title="RELATIONS" count={x.relations.length}>
295 + {x.relations.length ? <JsonPre value={x.relations} maxHeight="18rem" /> : <p className="text-sm text-ink-3">No relation written from this snapshot.</p>}
296 + </Stage>
297 +
298 + <Stage id="reconciliation" title="RECONCILIATION" lede="Against the previous snapshot of the same document: what changed, and how the writer treated each claim (confirm / supersede / conflict).">
299 + {x.previous_snapshot ? (
300 + <dl className="kv [&>div]:py-1 text-sm">
301 + <div>
302 + <dt>Previous snapshot</dt>
303 + <dd>
304 + <Link href={`/admin/extractions/${encodeURIComponent(x.previous_snapshot.id)}`} className="mono text-xs text-accent hover:underline">
305 + {x.previous_snapshot.id}
306 + </Link>{' '}
307 + <span className="tnum text-xs text-ink-3">{fmtDateTime(x.previous_snapshot.observed_at)}</span>
308 + </dd>
309 + </div>
310 + <div>
311 + <dt>Content changed</dt>
312 + <dd className="text-xs">{x.previous_snapshot.content_hash && x.content_hash ? (x.previous_snapshot.content_hash === x.content_hash ? 'no (same hash)' : 'yes (hash differs)') : '—'}</dd>
313 + </div>
314 + <div>
315 + <dt>Claim outcomes</dt>
316 + <dd className="text-xs">
317 + {['current', 'superseded', 'conflicting', 'retracted'].map((s) => (
318 + <span key={s} className={cn('mr-3 tnum', s === 'conflicting' && x.claims.some((c) => c.status === s) && 'text-danger')}>
319 + {s} {fmtInt(x.claims.filter((c) => c.status === s).length)}
320 + </span>
321 + ))}
322 + </dd>
323 + </div>
324 + </dl>
325 + ) : (
326 + <p className="text-sm text-ink-3">First snapshot of this document — nothing to reconcile against.</p>
327 + )}
328 + {x.diff != null && (
329 + <details className="mt-3">
330 + <summary className="cursor-pointer text-xs text-ink-3 hover:text-ink">Diff payload</summary>
331 + <JsonPre value={x.diff} maxHeight="18rem" />
332 + </details>
333 + )}
334 + </Stage>
335 +
336 + <Stage id="events" title="EVENTS" count={x.events.length} lede="Change events emitted by this snapshot (material properties only).">
337 + {x.events.length ? <JsonPre value={x.events} maxHeight="18rem" /> : <p className="text-sm text-ink-3">No event emitted.</p>}
338 + </Stage>
339 + {x.note && <Note className="mt-4">{x.note}</Note>}
340 + </>
341 + );
342 +}
added apps/web/src/app/admin/quality/page.tsx +187 −0
@@ -0,0 +1,187 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { AdminTitle, JsonPre, KindChip, Mono, Notice, StatusChip } from '@/components/admin/ui';
4 +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
5 +import { Note } from '@/components/ui/section';
6 +import { Unavailable } from '@/components/ui/unavailable';
7 +import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
8 +import type { AdminQuality, CountSample } from '@/lib/admin/types';
9 +import { cn } from '@/lib/cn';
10 +import { fmtAgo, fmtInt, num } from '@/lib/format';
11 +import { routes } from '@/lib/site';
12 +
13 +export const metadata: Metadata = { title: 'Data quality', robots: { index: false, follow: false } };
14 +export const dynamic = 'force-dynamic';
15 +
16 +type Tile = { key: string; label: string; count: number | null; sample: unknown[]; href?: string; tone: 'neutral' | 'warn' | 'danger'; hint: string };
17 +
18 +function tile(key: string, label: string, cs: CountSample<unknown> | undefined, hint: string, opts: { href?: string; danger?: number } = {}): Tile {
19 + const count = num(cs?.count);
20 + const tone: Tile['tone'] = count === null || count === 0 ? 'neutral' : opts.danger !== undefined && count >= opts.danger ? 'danger' : 'warn';
21 + return { key, label, count, sample: (cs?.sample ?? []) as unknown[], href: opts.href, tone, hint };
22 +}
23 +
24 +function SampleRow({ s }: { s: unknown }) {
25 + if (typeof s === 'string') return <span className="mono text-xs">{s}</span>;
26 + const o = (s ?? {}) as Record<string, unknown>;
27 + const slug = typeof o.slug === 'string' ? o.slug : null;
28 + const type = typeof o.entity_type === 'string' ? o.entity_type : 'model';
29 + const name = typeof o.name === 'string' ? o.name : typeof o.model === 'string' ? o.model : typeof o.reason === 'string' ? o.reason : typeof o.raw === 'string' ? `${String(o.domain ?? '')}: ${o.raw}` : null;
30 + return (
31 + <span className="flex flex-wrap items-center gap-x-2 text-xs">
32 + {slug ? (
33 + <Link href={routes.entity({ entity_type: type, slug })} className="text-ink hover:text-accent hover:underline">
34 + {name ?? slug}
35 + </Link>
36 + ) : (
37 + <span className="text-ink">{name ?? <Mono>{JSON.stringify(o).slice(0, 80)}</Mono>}</span>
38 + )}
39 + {typeof o.provider === 'string' && <span className="text-ink-3">· {o.provider}</span>}
40 + {typeof o.provider_model_id === 'string' && <Mono>{o.provider_model_id}</Mono>}
41 + {Array.isArray(o.quant_formats) && o.quant_formats.length > 0 && <Mono>{(o.quant_formats as unknown[]).join(', ')}</Mono>}
42 + {typeof o.health === 'string' && <StatusChip value={o.health} />}
43 + {typeof o.last_success_at === 'string' && <span className="text-ink-3">last success {fmtAgo(o.last_success_at)}</span>}
44 + {typeof o.count === 'number' && <span className="tnum text-ink-3">× {o.count}</span>}
45 + {typeof o.id === 'string' && !slug && <Mono>{o.id}</Mono>}
46 + </span>
47 + );
48 +}
49 +
50 +export default async function AdminQualityPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
51 + await requireAdmin();
52 + const sp = await searchParams;
53 + const res = await load(adminApi.quality());
54 + const q: AdminQuality | null = res.ok ? res.data : null;
55 + const tiles: Tile[] = q
56 + ? [
57 + tile('pending_decisions', 'Pending resolution decisions', q.duplicate_candidates?.pending_decisions, 'Candidate pairs awaiting a decision in Entity resolution.', { href: '/admin/entity-resolution' }),
58 + tile('review_merge_candidates', 'Merge candidates (review queue)', q.duplicate_candidates?.review_merge_candidates, 'merge_candidate items in the v1 review queue.', { href: '/admin/review?kind=merge_candidate' }),
59 + tile('unmapped_taxonomy_rows', 'Unmapped taxonomy rows', q.taxonomy_violations?.unmapped_taxonomy_rows, 'Raw labels the ontology could not map (domain: raw).'),
60 + tile('openness_unknown_vocab', 'Openness outside vocabulary', q.taxonomy_violations?.openness_unknown_vocab, 'Openness values not in open-source | open-weights | restricted-weights | proprietary | unknown.', { danger: 1 }),
61 + tile('status_unknown_vocab', 'Status outside vocabulary', q.taxonomy_violations?.status_unknown_vocab, 'Status values not in the status vocabulary.', { danger: 1 }),
62 + tile('license_unclassified', 'Unclassified licences', q.taxonomy_violations?.license_unclassified, 'Licence labels without a canonical key.'),
63 + tile('impossible_values', 'Impossible values (anomalies)', { count: q.impossible_values?.count, sample: q.impossible_values?.sample ?? [] }, 'Open anomaly flags by check — see Anomalies for resolve / ignore.', { href: '/admin/anomalies' }),
64 + tile('conflicting_t1_claims', 'Conflicting tier-1 claims', q.conflicting_t1_claims, 'Two official sources disagree on a current value.', { danger: 1, href: '/admin/review?kind=conflict' }),
65 + tile('models_without_organization', 'Models without organization', q.models_without_organization, 'Canonical models with no developer relation.'),
66 + tile('models_without_release_source', 'Models without release source', q.models_without_release_source, 'Canonical models whose release date has no sourced claim.'),
67 + tile('models_without_parameters', 'Models without parameters', q.models_without_parameters, 'Canonical models with no parameter_count claim (proprietary models are expected here).'),
68 + tile('orphan_benchmark_results', 'Orphan benchmark results', q.orphan_benchmark_results, 'Current results whose model was merged or is an artifact.', { danger: 1 }),
69 + tile('benchmarks_without_results', 'Benchmarks without results', q.benchmarks_without_results, 'Registered benchmarks no connector feeds yet.'),
70 + tile('unresolved_provider_deployments', 'Unresolved provider deployments', q.unresolved_provider_deployments, 'Price rows whose provider_model_id matches no identifier.'),
71 + tile('quantisations_typed_as_models', 'Quantisations typed as models', q.quantisations_typed_as_models, 'Name analysis says artifact but the row is a model.', { href: '/admin/entity-resolution?type=model' }),
72 + tile('stale_sources', 'Stale sources', q.stale_sources, 'Last success older than 3× the connector interval.', { href: '/admin/connectors' }),
73 + tile('empty_public_categories', 'Empty public categories', q.empty_public_categories, 'Entity types with a public listing but zero rows.'),
74 + tile('quarantined_runs_pending', 'Quarantined runs pending', q.quarantined_runs_pending, 'Held connector runs awaiting release or discard.', { href: '/admin/quarantine', danger: 1 }),
75 + ]
76 + : [];
77 + const queue = q?.review_queue_priority ?? [];
78 + const byCheck = q?.impossible_values?.by_check ?? [];
79 + return (
80 + <>
81 + <AdminTitle title="Data quality" lede="Live health of the dataset: duplicates, taxonomy, impossible values, coverage gaps, stale sources. Counts are computed on request; nothing here deletes anything — every tile points at a review action.">
82 + <Link href={routes.methodology()} className="text-xs text-ink-3 hover:text-ink">
83 + Anomaly checks →
84 + </Link>
85 + </AdminTitle>
86 + <Notice notice={sp.notice} level={sp.level} />
87 + {!res.ok ? (
88 + <Unavailable what="Quality dashboard" reason={res.error} />
89 + ) : (
90 + <>
91 + <ul className="grid grid-cols-2 border-l border-t border-rule md:grid-cols-3 xl:grid-cols-6" data-quality-tiles>
92 + {tiles.map((t) => (
93 + <li key={t.key} className="border-b border-r border-rule">
94 + <details className="group h-full">
95 + <summary className="block cursor-pointer list-none px-3 py-2.5 hover:bg-surface-2 [&::-webkit-details-marker]:hidden">
96 + <p className="eyebrow leading-tight">{t.label}</p>
97 + <p className={cn('tnum mt-1 text-2xl font-semibold leading-none tracking-tight', t.tone === 'danger' && 'text-danger', t.tone === 'warn' && 'text-warning', t.tone === 'neutral' && 'text-ink')}>{t.count === null ? '—' : fmtInt(t.count)}</p>
98 + <p className="mt-1 text-[11px] leading-snug text-ink-3">{t.hint}</p>
99 + </summary>
100 + <div className="border-t border-rule px-3 py-2">
101 + {t.sample.length === 0 ? (
102 + <p className="text-xs text-ink-3">No sample.</p>
103 + ) : (
104 + <ul className="space-y-1">
105 + {t.sample.slice(0, 8).map((s, i) => (
106 + <li key={i}>
107 + <SampleRow s={s} />
108 + </li>
109 + ))}
110 + </ul>
111 + )}
112 + {t.href && (
113 + <Link href={t.href} className="link mt-2 inline-block text-xs">
114 + Open review action →
115 + </Link>
116 + )}
117 + </div>
118 + </details>
119 + </li>
120 + ))}
121 + </ul>
122 + {byCheck.length > 0 && (
123 + <p className="mt-3 flex flex-wrap gap-1.5 text-xs">
124 + {byCheck.map((c) => (
125 + <Link key={c.check_name} href={`/admin/anomalies?check=${encodeURIComponent(c.check_name)}`} className="inline-flex h-7 items-center gap-1.5 border border-rule px-2 text-ink-2 hover:border-rule-strong hover:text-ink">
126 + <span className={c.severity === 'critical' ? 'text-danger' : c.severity === 'warning' ? 'text-warning' : 'text-ink-3'}>{c.severity}</span>
127 + <span className="mono">{c.check_name}</span>
128 + <span className="tnum text-ink-3">{fmtInt(c.n)}</span>
129 + </Link>
130 + ))}
131 + </p>
132 + )}
133 +
134 + <h2 className="mt-8 text-base font-semibold tracking-tight">
135 + Prioritized review queue <span className="tnum text-sm font-normal text-ink-3">{fmtInt(queue.length)}</span>
136 + </h2>
137 + <Note className="mb-3 mt-1">Frontier models, benchmark leaders, the largest parameter / context claims, price anomalies, major organizations and duplicates of frontier models come first.</Note>
138 + <DataTable compact scroll caption="Prioritized review queue">
139 + <thead>
140 + <tr>
141 + <Th>Kind</Th>
142 + <Th>Item</Th>
143 + <Th>Reasons</Th>
144 + <Th>Detail</Th>
145 + <Th>Action</Th>
146 + </tr>
147 + </thead>
148 + <tbody>
149 + {queue.length === 0 && <EmptyRow cols={5}>Nothing prioritized.</EmptyRow>}
150 + {queue.map((it) => {
151 + const slug = typeof it.slug === 'string' ? it.slug : null;
152 + const type = typeof it.entity_type === 'string' ? it.entity_type : 'model';
153 + const href = it.kind === 'anomaly' ? `/admin/anomalies?check=${encodeURIComponent(String(it.check ?? ''))}` : it.kind === 'duplicate' || it.kind === 'merge_candidate' || it.kind === 'resolution' ? '/admin/entity-resolution' : it.kind === 'conflict' ? '/admin/review?kind=conflict' : '/admin/review';
154 + return (
155 + <tr key={`${it.kind}-${it.id}`}>
156 + <Td>
157 + <KindChip value={it.kind} />
158 + {typeof it.severity === 'string' && <span className={cn('ml-1 text-[11px]', it.severity === 'critical' ? 'text-danger' : 'text-warning')}>{it.severity}</span>}
159 + </Td>
160 + <Td primary>
161 + {slug ? (
162 + <Link href={routes.entity({ entity_type: type, slug })} className="text-ink hover:text-accent hover:underline">
163 + {typeof it.name === 'string' ? it.name : slug}
164 + </Link>
165 + ) : (
166 + <Mono>{it.id}</Mono>
167 + )}
168 + {typeof it.check === 'string' && <Mono className="block">{it.check}</Mono>}
169 + </Td>
170 + <Td className="text-xs text-ink-2">{it.reasons?.join(' · ') || '—'}</Td>
171 + <Td className="max-w-[28rem] text-xs text-ink-2">{typeof it.message === 'string' ? it.message : <JsonPre value={Object.fromEntries(Object.entries(it).filter(([k]) => !['kind', 'id', 'reasons', 'slug', 'entity_id', 'check', 'severity', 'name', 'entity_type'].includes(k)))} maxHeight="6rem" />}</Td>
172 + <Td>
173 + <Link href={href} className="link text-xs">
174 + Review →
175 + </Link>
176 + </Td>
177 + </tr>
178 + );
179 + })}
180 + </tbody>
181 + </DataTable>
182 + {q?.note && <Note className="mt-3">{q.note}</Note>}
183 + </>
184 + )}
185 + </>
186 + );
187 +}
added apps/web/src/app/admin/quarantine/page.tsx +101 −0
@@ -0,0 +1,101 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ActionButton, AdminFilters, AdminTitle, JsonPre, Mono, Notice, StatusChip } from '@/components/admin/ui';
4 +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
5 +import { LiveAgo } from '@/components/ui/live';
6 +import { Pagination, withParams } from '@/components/ui/pagination';
7 +import { Note } from '@/components/ui/section';
8 +import { EmptyState, Unavailable } from '@/components/ui/unavailable';
9 +import { quarantineAction } from '@/lib/admin/actions';
10 +import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
11 +import { fmtDateTime, fmtInt } from '@/lib/format';
12 +
13 +export const metadata: Metadata = { title: 'Quarantine', robots: { index: false, follow: false } };
14 +export const dynamic = 'force-dynamic';
15 +const LIMIT = 50;
16 +
17 +export default async function AdminQuarantinePage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
18 + await requireAdmin();
19 + const sp = await searchParams;
20 + const current: Record<string, string | undefined> = { status: sp.status ?? 'pending' };
21 + if (sp.offset) current.offset = sp.offset;
22 + const offset = Math.max(0, Number(current.offset) || 0);
23 + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/admin/quarantine', current, patch);
24 + const ret = href({});
25 + const res = await load(adminApi.quarantine({ status: current.status, limit: LIMIT, offset }));
26 + return (
27 + <>
28 + <AdminTitle title="Quarantine" count={res.ok ? fmtInt(res.data.total) : undefined} lede="Connector runs held back by the writer (breakage suspicion, implausible volumes). Release writes the held facts; discard drops them. Both are audited.">
29 + <Link href="/admin/runs" className="text-xs text-ink-3 hover:text-ink">
30 + Runs →
31 + </Link>
32 + </AdminTitle>
33 + <Notice notice={sp.notice} level={sp.level} />
34 + <AdminFilters action="/admin/quarantine" className="mb-4" fields={[{ kind: 'select', name: 'status', label: 'Status', value: current.status, any: 'pending', options: ['pending', 'released', 'discarded', 'all'].map((s) => ({ value: s, label: s })) }]} />
35 + {!res.ok ? (
36 + <Unavailable what="Quarantine" reason={res.error} />
37 + ) : res.data.items.length === 0 ? (
38 + <EmptyState title="Nothing in quarantine">No held run for this status.</EmptyState>
39 + ) : (
40 + <>
41 + <DataTable compact scroll caption="Quarantined runs">
42 + <thead>
43 + <tr>
44 + <Th>Id</Th>
45 + <Th>Connector</Th>
46 + <Th>Run</Th>
47 + <Th>Reason</Th>
48 + <Th>Counts</Th>
49 + <Th>Status</Th>
50 + <Th>Created</Th>
51 + <Th>Actions</Th>
52 + </tr>
53 + </thead>
54 + <tbody>
55 + {res.data.items.length === 0 && <EmptyRow cols={8}>No rows.</EmptyRow>}
56 + {res.data.items.map((q) => (
57 + <tr key={q.id}>
58 + <Td primary>
59 + <Mono>{q.id}</Mono>
60 + </Td>
61 + <Td>
62 + <Mono>{q.connector_name ?? '—'}</Mono>
63 + </Td>
64 + <Td>{q.run_id ? <Link href={`/admin/runs?connector=${encodeURIComponent(q.connector_name ?? '')}`} className="mono text-[11px] text-accent hover:underline">{q.run_id}</Link> : '—'}</Td>
65 + <Td className="max-w-[20rem] text-xs text-ink-2">{q.reason ?? '—'}</Td>
66 + <Td className="text-xs">{q.counts ? <JsonPre value={q.counts} maxHeight="6rem" /> : '—'}</Td>
67 + <Td>
68 + <StatusChip value={q.status} />
69 + </Td>
70 + <Td className="text-xs text-ink-3" title={q.created_at ? fmtDateTime(q.created_at) : undefined}>{q.created_at ? <LiveAgo at={q.created_at} /> : '—'}</Td>
71 + <Td>
72 + {q.status === 'pending' ? (
73 + <div className="flex items-center gap-1">
74 + <form action={quarantineAction}>
75 + <input type="hidden" name="id" value={q.id} />
76 + <input type="hidden" name="action" value="release" />
77 + <input type="hidden" name="return" value={ret} />
78 + <ActionButton tone="positive" title="Write the held facts">Release</ActionButton>
79 + </form>
80 + <form action={quarantineAction}>
81 + <input type="hidden" name="id" value={q.id} />
82 + <input type="hidden" name="action" value="discard" />
83 + <input type="hidden" name="return" value={ret} />
84 + <ActionButton tone="danger" title="Drop the held facts (audited)">Discard</ActionButton>
85 + </form>
86 + </div>
87 + ) : (
88 + <span className="text-xs text-ink-3">—</span>
89 + )}
90 + </Td>
91 + </tr>
92 + ))}
93 + </tbody>
94 + </DataTable>
95 + <Pagination total={res.data.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
96 + </>
97 + )}
98 + <Note className="mt-4">The release / discard endpoints answer 501 while the canonical service has not shipped them; the notice above reports the API's answer verbatim.</Note>
99 + </>
100 + );
101 +}
modified apps/web/src/app/admin/runs/page.tsx +10 −3
@@ -1,6 +1,7 @@
1 1 import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 −import { AdminFilters, AdminTitle, Mono, StatusChip, Trunc } from '@/components/admin/ui';
3 +import { RollbackButton } from '@/components/admin/rollback-button';
4 +import { AdminFilters, AdminTitle, Mono, Notice, StatusChip, Trunc } from '@/components/admin/ui';
4 5 import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
5 6 import { LiveAgo } from '@/components/ui/live';
6 7 import { Unavailable } from '@/components/ui/unavailable';
@@ -16,9 +17,11 @@ export default async function AdminRunsPage({ searchParams }: { searchParams: Pr
16 17 const limit = Math.min(500, Math.max(10, Number(sp.limit) || 100));
17 18 const [runs, connectors] = await Promise.all([load(adminApi.runs({ connector: sp.connector, limit })), load(adminApi.connectors())]);
18 19 const names = connectors.ok ? connectors.data.items.map((c) => c.name).sort() : [];
20 + const ret = `/admin/runs${sp.connector ? `?connector=${encodeURIComponent(sp.connector)}` : ''}`;
19 21 return (
20 22 <>
21 − <AdminTitle title="Runs" count={runs.ok ? fmtInt(runs.data.total) : undefined} />
23 + <AdminTitle title="Runs" count={runs.ok ? fmtInt(runs.data.total) : undefined} lede="Rollback retracts a run's claims, closes its relations / prices / results and flags its events as backfill — only rows carrying that run_id; nothing is deleted." />
24 + <Notice notice={sp.notice} level={sp.level} />
22 25 <AdminFilters
23 26 action="/admin/runs"
24 27 className="mb-4"
@@ -48,10 +51,11 @@ export default async function AdminRunsPage({ searchParams }: { searchParams: Pr
48 51 <Th num>Events</Th>
49 52 <Th>Error</Th>
50 53 <Th>Run id</Th>
54 + <Th>Rollback</Th>
51 55 </tr>
52 56 </thead>
53 57 <tbody>
54 − {runs.data.items.length === 0 && <EmptyRow cols={15}>No runs match.</EmptyRow>}
58 + {runs.data.items.length === 0 && <EmptyRow cols={16}>No runs match.</EmptyRow>}
55 59 {runs.data.items.map((r) => (
56 60 <tr key={r.id}>
57 61 <Td><Link href={`/admin/runs?connector=${encodeURIComponent(r.connector_name)}`} className="mono text-xs font-medium text-ink hover:text-accent">{r.connector_name}</Link></Td>
@@ -69,6 +73,9 @@ export default async function AdminRunsPage({ searchParams }: { searchParams: Pr
69 73 <Td num className="tnum text-xs">{fmtInt(r.events_emitted)}</Td>
70 74 <Td className="text-xs text-danger"><Trunc text={r.error} max={50} /></Td>
71 75 <Td><Mono>{r.id}</Mono></Td>
76 + <Td>
77 + <RollbackButton runId={r.id} connector={r.connector_name} returnTo={ret} disabled={r.status === 'running' || r.status === 'queued' || Boolean((r.meta as { rolled_back?: unknown } | null)?.rolled_back)} />
78 + </Td>
72 79 </tr>
73 80 ))}
74 81 </tbody>
modified apps/web/src/app/admin/snapshots/[id]/page.tsx +5 −1
@@ -66,7 +66,11 @@ export default async function AdminSnapshotPage({ params }: { params: Promise<{
66 66 <p className="mb-2 text-xs">
67 67 <Link href="/admin/documents" className="link">← Documents</Link> · <Link href={`/admin/documents/${encodeURIComponent(s.document_id)}`} className="link">document</Link>
68 68 </p>
69 − <AdminTitle title={`Snapshot ${fmtDateTime(s.observed_at)}`} lede={<Mono>{s.id}</Mono>} />
69 + <AdminTitle title={`Snapshot ${fmtDateTime(s.observed_at)}`} lede={<Mono>{s.id}</Mono>}>
70 + <Link href={`/admin/extractions/${encodeURIComponent(s.id)}`} className="inline-flex h-8 items-center border border-rule px-2.5 text-xs text-ink-2 hover:border-rule-strong hover:text-ink">
71 + Extraction debugger →
72 + </Link>
73 + </AdminTitle>
70 74 <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_22rem]">
71 75 <div className="min-w-0 space-y-8">
72 76 <section>
added apps/web/src/app/changes/[date]/opengraph-image.tsx +21 −0
@@ -0,0 +1,21 @@
1 +import { ImageResponse } from 'next/og';
2 +import { Wallpaper } from '@/components/brand/og';
3 +import { TODAY_LABELS } from '@/components/changes/today-sections';
4 +import { apiD3, safe } from '@/lib/api';
5 +import { fmtDate, fmtInt, num } from '@/lib/format';
6 +import { SITE_NAME } from '@/lib/site';
7 +
8 +export const runtime = 'nodejs';
9 +export const alt = `Today in AI on ${SITE_NAME}`;
10 +export const size = { width: 1200, height: 630 };
11 +export const contentType = 'image/png';
12 +
13 +/** OG image for /changes/[date]: the day and its live section counts. */
14 +export default async function DailyOgImage({ params }: { params: Promise<{ date: string }> }) {
15 + const { date } = await params;
16 + const ok = /^\d{4}-\d{2}-\d{2}$/.test(date);
17 + const d = ok ? await safe(apiD3.changesDaily(date, 1)) : null;
18 + const sections = (d?.today ?? []).filter((s) => (num(s.total) ?? 0) > 0).slice(0, 4);
19 + const counters: [string, string][] = d ? [['Events', fmtInt(d.total)], ...sections.map((s) => [s.label ?? TODAY_LABELS[s.key] ?? s.key, fmtInt(s.total)] as [string, string])] : [];
20 + return new ImageResponse(<Wallpaper eyebrow="Today in AI" title={ok ? fmtDate(date) : 'Daily digest'} subtitle="Major releases · price moves · benchmark moves · model changes · research · open weights · deprecations · providers · hardware" counters={counters} footer={`www.ai-atlas.co/changes/${ok ? date : ''}`} markPx={220} />, { ...size });
21 +}
modified apps/web/src/app/changes/[date]/page.tsx +106 −35
@@ -1,15 +1,19 @@
1 1 import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 3 import { notFound } from 'next/navigation';
4 −import { ChangeRow } from '@/components/changes/change-row';
4 +import { orderedSections, QuietLine, TODAY_FEED_HREF, TODAY_LABELS, TodayItemRow } from '@/components/changes/today-sections';
5 +import { DataStrip, SectionNav } from '@/components/layout/terminal';
6 +import { BreadcrumbLd } from '@/components/meta/breadcrumb-ld';
7 +import { ImportanceMark } from '@/components/ui/badges';
5 8 import { EntityLink } from '@/components/ui/entity';
6 −import { Container, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section';
9 +import { Hint } from '@/components/ui/hint';
10 +import { Container, Note, PageHeader, Section } from '@/components/ui/section';
7 11 import { EmptyState, Unavailable } from '@/components/ui/unavailable';
8 −import { api, safe } from '@/lib/api';
12 +import { apiD3, safe } from '@/lib/api';
9 13 import { fmtDate, fmtInt, num } from '@/lib/format';
10 −import { categoryLabel, routes, SITE_NAME } from '@/lib/site';
14 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
11 15
12 −type Params = { params: Promise<{ date: string }> };
16 +type Params = { params: Promise<{ date: string }>; searchParams: Promise<{ include_backfill?: string }> };
13 17 const VALID = /^\d{4}-\d{2}-\d{2}$/;
14 18
15 19 function shift(date: string, days: number): string {
@@ -21,69 +25,136 @@ function shift(date: string, days: number): string {
21 25 export async function generateMetadata({ params }: Params): Promise<Metadata> {
22 26 const { date } = await params;
23 27 if (!VALID.test(date)) return { title: 'Not found', robots: { index: false } };
24 − const title = `What changed in AI on ${fmtDate(date)}`;
25 − return { title, description: `Daily digest of AI ecosystem changes for ${fmtDate(date)}: new models, prices, benchmarks, deprecations and announcements — generated from the ${SITE_NAME} database.`, alternates: { canonical: routes.changesDay(date) } };
28 + const title = `Today in AI — ${fmtDate(date)}`;
29 + const description = `What happened in AI on ${fmtDate(date)}: major releases, price moves, benchmark moves, model changes, research, open-weight releases, deprecations, provider and hardware changes — generated from the ${SITE_NAME} change log.`;
30 + return { title, description, alternates: { canonical: routes.changesDay(date) }, openGraph: { title: `${title} | ${SITE_NAME}`, description, url: `${SITE_URL}${routes.changesDay(date)}`, type: 'article' } };
26 31 }
27 32
28 −export default async function DailyPage({ params }: Params) {
33 +export default async function DailyPage({ params, searchParams }: Params) {
29 34 const { date } = await params;
35 + const sp = await searchParams;
30 36 if (!VALID.test(date) || Number.isNaN(new Date(`${date}T00:00:00Z`).getTime())) notFound();
31 37 const today = new Date().toISOString().slice(0, 10);
32 38 if (date > today) notFound();
33 − const d = await safe(api.changesDaily(date));
34 − const total = d ? Object.values(d.counts ?? {}).reduce<number>((n, v) => n + (num(v) ?? 0), 0) : 0;
35 − const cats = d ? Object.entries(d.counts ?? {}).sort((a, b) => (num(b[1]) ?? 0) - (num(a[1]) ?? 0)) : [];
39 + const backfill = sp.include_backfill === '1';
40 + const d = await safe(apiD3.changesDaily(date, 30, backfill));
41 + const { present, quiet } = orderedSections(d?.today);
42 + const total = num(d?.total) ?? (d ? Object.values(d.counts ?? {}).reduce<number>((n, v) => n + (num(v) ?? 0), 0) : 0);
43 + const excluded = num(d?.backfill_excluded) ?? 0;
44 + const prev = d?.previous_day ?? shift(date, -1);
45 + const next = d?.next_day && d.next_day <= today ? d.next_day : date < today ? shift(date, 1) : null;
46 + const strip = (d?.today ?? []).filter((s) => (num(s.total) ?? 0) > 0).slice(0, 8);
36 47
37 48 return (
38 − <Container>
49 + <Container wide>
50 + <BreadcrumbLd items={[{ name: SITE_NAME, href: '/' }, { name: 'Changes', href: '/changes' }, { name: fmtDate(date), href: routes.changesDay(date) }]} />
39 51 <PageHeader
40 − eyebrow={<><Link href={routes.changes()} className="hover:text-ink">Changes</Link><span aria-hidden>/</span><span>Daily digest</span></>}
41 − title={<>What changed in AI on <span className="text-ink-2">{fmtDate(date)}</span></>}
42 − lede={d ? `${fmtInt(total)} events across ${cats.length} categories, generated from the database — nothing editorial.` : undefined}
52 + eyebrow={
53 + <>
54 + <Link href={routes.changes()} className="hover:text-ink">
55 + Changes
56 + </Link>
57 + <span aria-hidden>/</span>
58 + <span>Today in AI</span>
59 + </>
60 + }
61 + title={
62 + <>
63 + What changed in AI on <span className="text-ink-2">{fmtDate(date)}</span>
64 + </>
65 + }
66 + lede={d ? <>{fmtInt(total)} events occurred on this UTC day{d.today ? ` across ${present.length} sections` : ''} — grouped when several documents describe one release, generated from the database, nothing editorial.</> : undefined}
43 67 aside={
44 68 <nav className="flex items-center gap-2 text-sm" aria-label="Day navigation">
45 − <Link href={routes.changesDay(shift(date, -1))} className="inline-flex h-10 items-center border border-rule px-3 text-ink-2 hover:text-ink" rel="prev">‹ {fmtDate(shift(date, -1))}</Link>
46 − {date < today && <Link href={routes.changesDay(shift(date, 1))} className="inline-flex h-10 items-center border border-rule px-3 text-ink-2 hover:text-ink" rel="next">{fmtDate(shift(date, 1))} ›</Link>}
69 + <Link href={routes.changesDay(prev)} className="inline-flex h-10 items-center border border-rule px-3 text-ink-2 hover:text-ink" rel="prev">
70 + ‹ {fmtDate(prev)}
71 + </Link>
72 + {next && (
73 + <Link href={routes.changesDay(next)} className="inline-flex h-10 items-center border border-rule px-3 text-ink-2 hover:text-ink" rel="next">
74 + {fmtDate(next)} ›
75 + </Link>
76 + )}
77 + {date !== today && (
78 + <Link href={routes.changesDay(today)} className="inline-flex h-10 items-center px-2 text-accent hover:underline">
79 + today
80 + </Link>
81 + )}
47 82 </nav>
48 83 }
49 84 />
50 85 <div className="pb-16">
51 86 {!d ? (
52 87 <Unavailable what="Daily digest" />
53 − ) : total === 0 && !d.new_models?.length ? (
54 − <EmptyState title="Nothing recorded on this day">Connectors may not have observed changes, or the day predates the archive.</EmptyState>
88 + ) : total === 0 && !d.new_models?.length && present.length === 0 ? (
89 + <EmptyState title="Nothing occurred on this day">
90 + {excluded > 0 ? (
91 + <>
92 + {fmtInt(excluded)} back-filled historical events fall on this date but are not “today” news — see them on the <Link href={`/timeline?year=${date.slice(0, 4)}&include_backfill=1`} className="link">timeline with backfill</Link> or <Link href={`${routes.changesDay(date)}?include_backfill=1`} className="link">include them here</Link>.
93 + </>
94 + ) : (
95 + 'Connectors observed no material change occurring on this UTC day.'
96 + )}
97 + </EmptyState>
55 98 ) : (
56 99 <>
57 − {cats.length > 0 && (
58 − <StatGrid cols={cats.length >= 6 ? 6 : cats.length >= 4 ? 4 : 3}>
59 − {cats.slice(0, 6).map(([c, n]) => (
60 − <Stat key={c} label={categoryLabel(c)} value={fmtInt(n)} href={`/changes?category=${encodeURIComponent(c)}&since=${date}`} />
61 − ))}
62 − </StatGrid>
63 − )}
100 + {strip.length > 0 && <DataStrip dense items={strip.map((s) => ({ label: s.label ?? TODAY_LABELS[s.key] ?? s.key, value: fmtInt(s.total), href: `#${s.key.toLowerCase()}` }))} />}
101 + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-3">
102 + <span className="inline-flex items-center gap-1">
103 + Dates are when events <em>occurred</em>
104 + <Hint align="right" text="occurred_at = effective date when a source states one, else the observation time. Hover any date in the list for the observation time. `today` folds events sharing a group_key (one release seen in several documents) into one row." />
105 + </span>
106 + {excluded > 0 && (
107 + <span data-backfill-excluded>
108 + {fmtInt(excluded)} back-filled historical {excluded === 1 ? 'event' : 'events'} excluded ·{' '}
109 + <Link href={`/timeline?year=${date.slice(0, 4)}&include_backfill=1`} className="link">
110 + see them on the timeline
111 + </Link>{' '}
112 + ·{' '}
113 + <Link href={`${routes.changesDay(date)}${backfill ? '' : '?include_backfill=1'}`} className="link">
114 + {backfill ? 'hide backfill' : 'include here'}
115 + </Link>
116 + </span>
117 + )}
118 + {backfill && <span className="rounded-[3px] bg-surface-2 px-1.5 text-[10px] uppercase tracking-wide">including backfill</span>}
119 + </p>
120 + {present.length > 1 && <SectionNav items={present.map((s) => ({ id: s.key.toLowerCase(), label: s.label ?? TODAY_LABELS[s.key] ?? s.key }))} className="mt-3" />}
121 +
64 122 {d.new_models?.length > 0 && (
65 − <Section eyebrow="New models" title={<>{fmtInt(d.new_models.length)} new model{d.new_models.length === 1 ? '' : 's'}</>} hairline={false}>
123 + <Section eyebrow="New models" title={<>{fmtInt(d.new_models.length)} new model{d.new_models.length === 1 ? '' : 's'}</>} hairline={false} action={{ href: `/models?year_from=${date.slice(0, 4)}&sort=release`, label: 'Models by release' }}>
66 124 <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
67 − {d.new_models.map((m) => (
125 + {d.new_models.slice(0, 30).map((m) => (
68 126 <li key={m.id} className="flex items-baseline justify-between gap-3 border-b border-rule py-2 text-sm">
69 127 <EntityLink e={m} className="font-medium" />
70 128 <span className="shrink-0 text-xs text-ink-3">{m.organization?.name ?? ''}</span>
71 129 </li>
72 130 ))}
73 131 </ul>
132 + {d.new_models.length > 30 && <Note className="mt-2">First 30 of {fmtInt(d.new_models.length)}.</Note>}
74 133 </Section>
75 134 )}
76 − {d.sections
77 − .filter((s) => s.items.length)
78 − .map((s) => (
79 − <Section key={s.category} eyebrow={categoryLabel(s.category)} title={s.label} action={{ href: `/changes?category=${encodeURIComponent(s.category)}`, label: 'All' }}>
135 +
136 + {present.map((s) => {
137 + const shown = s.items.length;
138 + const tot = num(s.total) ?? shown;
139 + const feed = TODAY_FEED_HREF[s.key]?.(date);
140 + return (
141 + <Section key={s.key} id={s.key.toLowerCase()} eyebrow={s.label ?? TODAY_LABELS[s.key] ?? s.key} title={<>{s.label ?? TODAY_LABELS[s.key] ?? s.key} <span className="tnum text-base font-normal text-ink-3">{fmtInt(tot)}</span></>} action={feed ? { href: feed, label: 'Feed' } : undefined}>
80 142 <ul className="border-t border-rule">
81 − {s.items.map((e) => (
82 − <ChangeRow key={e.id} e={e} live={false} />
143 + {s.items.map((it) => (
144 + <TodayItemRow key={it.id} it={it} />
83 145 ))}
84 146 </ul>
147 + {tot > shown && <Note className="mt-2">First {fmtInt(shown)} of {fmtInt(tot)}{feed ? <> — <Link href={feed} className="link">all in the feed →</Link></> : null}</Note>}
148 + {s.items.some((it) => it.importance >= 3) && (
149 + <p className="mt-2 flex items-center gap-1.5 text-[11px] text-ink-3">
150 + <ImportanceMark importance={3} /> major · <ImportanceMark importance={2} /> important · <ImportanceMark importance={1} /> notable
151 + </p>
152 + )}
85 153 </Section>
86 − ))}
154 + );
155 + })}
156 + <QuietLine quiet={quiet} labels={d.labels} />
157 + {d.note && <Note className="mt-4">{d.note}</Note>}
87 158 </>
88 159 )}
89 160 </div>
modified apps/web/src/app/changes/page.tsx +110 −60
Large diff (207 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/claims/[id]/page.tsx +271 −0
Large diff (271 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/companies/[slug]/opengraph-image.tsx +18 −32
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/companies/[slug]/page.tsx +5 −7
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/companies/page.tsx +47 −12
Large diff (120 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/developers/page.tsx +79 −130
Large diff (268 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/developers/request-builder.tsx +155 −0
Large diff (155 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/developers/routes.ts +60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/diff/og/route.tsx +22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/diff/page.tsx +139 −102
Large diff (312 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/explore/[type]/[slug]/page.tsx +8 −2
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/explore/builder.tsx +404 −0
Large diff (404 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/explore/page.tsx +42 −18
Large diff (99 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/graph/[slug]/page.tsx +104 −113
Large diff (264 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/graph/og/route.tsx +25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/graph/page.tsx +141 −0
Large diff (141 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/methodology/page.tsx +295 −54
Large diff (423 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/papers/page.tsx +215 −96
Large diff (322 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/search/compiled.ts +144 −0
Large diff (144 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/search/page.tsx +228 −75
Large diff (344 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/sources/page.tsx +159 −50
Large diff (246 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/time-machine/og/route.tsx +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/time-machine/page.tsx +179 −0
Large diff (179 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/timeline/page.tsx +141 −113
Large diff (303 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/resolution-pair.tsx +162 −0
Large diff (162 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/rollback-button.tsx +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/admin/shell.tsx +6 −1
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/changes/change-row.tsx +32 −14
Large diff (98 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/changes/load-more.tsx +10 −8
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/changes/rail-filters.tsx +165 −0
Large diff (165 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/changes/today-sections.tsx +92 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/graph/graph-canvas.tsx +405 −0
Large diff (405 lines) is not loaded by default to keep the page fast.Load diff
deleted apps/web/src/components/graph/graph-explorer.tsx +0 −167
Large diff (167 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/graph/graph-workbench.tsx +365 −0
Large diff (365 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/graph/modes.ts +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/graph/node-search-link.tsx +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/graph/node-search.tsx +94 −0
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/meta/breadcrumb-ld.tsx +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/meta/sitemap-data.ts +56 −4
Large diff (82 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/research/organization-page.tsx +355 −0
Large diff (355 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/research/paper-page.tsx +272 −0
Large diff (272 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/research/researcher-page.tsx +177 −0
Large diff (177 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/temporal/dates.ts +29 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/temporal/diff-sections.tsx +306 −0
Large diff (306 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/temporal/time-machine-tables.tsx +221 −0
Large diff (221 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/timeline/lanes.ts +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/timeline/mobile-lane-strip.tsx +56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/timeline/timeline-workbench.tsx +145 −0
Large diff (145 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/admin/actions.ts +55 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/admin/admin-api.ts +12 −1
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/admin/types.ts +159 −0
Large diff (162 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/client-api.ts +26 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme