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 import { notFound, permanentRedirect } from 'next/navigation'; 2 import { notFound, permanentRedirect } from 'next/navigation';
3 import { EntityPage, type EntityPageParams } from '@/components/entity/entity-page'; 3 import { EntityPage, type EntityPageParams } from '@/components/entity/entity-page';
4 import { entityMetadata, loadEntity } from '@/components/entity/load'; 4 import { entityMetadata, loadEntity } from '@/components/entity/load';
5+import { PaperPage } from '@/components/research/paper-page';
5 import { api, safe } from '@/lib/api'; 6 import { api, safe } from '@/lib/api';
6 import { PATH_TYPES, routes } from '@/lib/site'; 7 import { PATH_TYPES, routes } from '@/lib/site';
7 8
8 /** 9 /**
9 * Generic entity page for /providers, /benchmarks, /hardware, /papers, /frameworks, /datasets, /tools, /repositories. 10 * Generic entity page for /providers, /benchmarks, /hardware, /papers, /frameworks, /datasets, /tools, /repositories.
10 * (/models and /companies have their own segment folders; static segments win over this dynamic one.) 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 type Params = { params: Promise<{ type: string; slug: string }>; searchParams: Promise<EntityPageParams> }; 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 const d = await loadEntity(type, slug); 26 const d = await loadEntity(type, slug);
25 const canonical = routes.entity(d); 27 const canonical = routes.entity(d);
26 if (canonical !== `/${type}/${encodeURIComponent(slug)}`) permanentRedirect(canonical); 28 if (canonical !== `/${type}/${encodeURIComponent(slug)}`) permanentRedirect(canonical);
29+ if (d.entity_type === 'paper') return <PaperPage d={d} canonical={canonical} />;
27 const related = await safe(api.entityRelated(d.slug, 10)); 30 const related = await safe(api.entityRelated(d.slug, 10));
28 return <EntityPage d={d} canonical={canonical} related={related?.items} asof={sp.asof} historyProperty={sp.property} />; 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 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import Link from 'next/link'; 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 import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; 5 import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
5 import { LiveAgo } from '@/components/ui/live'; 6 import { LiveAgo } from '@/components/ui/live';
6 import { Unavailable } from '@/components/ui/unavailable'; 7 import { Unavailable } from '@/components/ui/unavailable';
@@ -16,9 +17,11 @@ export default async function AdminRunsPage({ searchParams }: { searchParams: Pr
16 const limit = Math.min(500, Math.max(10, Number(sp.limit) || 100)); 17 const limit = Math.min(500, Math.max(10, Number(sp.limit) || 100));
17 const [runs, connectors] = await Promise.all([load(adminApi.runs({ connector: sp.connector, limit })), load(adminApi.connectors())]); 18 const [runs, connectors] = await Promise.all([load(adminApi.runs({ connector: sp.connector, limit })), load(adminApi.connectors())]);
18 const names = connectors.ok ? connectors.data.items.map((c) => c.name).sort() : []; 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 return ( 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 <AdminFilters 25 <AdminFilters
23 action="/admin/runs" 26 action="/admin/runs"
24 className="mb-4" 27 className="mb-4"
@@ -48,10 +51,11 @@ export default async function AdminRunsPage({ searchParams }: { searchParams: Pr
48 <Th num>Events</Th> 51 <Th num>Events</Th>
49 <Th>Error</Th> 52 <Th>Error</Th>
50 <Th>Run id</Th> 53 <Th>Run id</Th>
54+ <Th>Rollback</Th>
51 </tr> 55 </tr>
52 </thead> 56 </thead>
53 <tbody> 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 {runs.data.items.map((r) => ( 59 {runs.data.items.map((r) => (
56 <tr key={r.id}> 60 <tr key={r.id}>
57 <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> 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 <Td num className="tnum text-xs">{fmtInt(r.events_emitted)}</Td> 73 <Td num className="tnum text-xs">{fmtInt(r.events_emitted)}</Td>
70 <Td className="text-xs text-danger"><Trunc text={r.error} max={50} /></Td> 74 <Td className="text-xs text-danger"><Trunc text={r.error} max={50} /></Td>
71 <Td><Mono>{r.id}</Mono></Td> 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 </tr> 79 </tr>
73 ))} 80 ))}
74 </tbody> 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 <p className="mb-2 text-xs"> 66 <p className="mb-2 text-xs">
67 <Link href="/admin/documents" className="link">← Documents</Link> · <Link href={`/admin/documents/${encodeURIComponent(s.document_id)}`} className="link">document</Link> 67 <Link href="/admin/documents" className="link">← Documents</Link> · <Link href={`/admin/documents/${encodeURIComponent(s.document_id)}`} className="link">document</Link>
68 </p> 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 <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_22rem]"> 74 <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_22rem]">
71 <div className="min-w-0 space-y-8"> 75 <div className="min-w-0 space-y-8">
72 <section> 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 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3 import { notFound } from 'next/navigation'; 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 import { EntityLink } from '@/components/ui/entity'; 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 import { EmptyState, Unavailable } from '@/components/ui/unavailable'; 11 import { EmptyState, Unavailable } from '@/components/ui/unavailable';
8−import { api, safe } from '@/lib/api'; 12+import { apiD3, safe } from '@/lib/api';
9 import { fmtDate, fmtInt, num } from '@/lib/format'; 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 const VALID = /^\d{4}-\d{2}-\d{2}$/; 17 const VALID = /^\d{4}-\d{2}-\d{2}$/;
14 18
15 function shift(date: string, days: number): string { 19 function shift(date: string, days: number): string {
@@ -21,69 +25,136 @@ function shift(date: string, days: number): string {
21 export async function generateMetadata({ params }: Params): Promise<Metadata> { 25 export async function generateMetadata({ params }: Params): Promise<Metadata> {
22 const { date } = await params; 26 const { date } = await params;
23 if (!VALID.test(date)) return { title: 'Not found', robots: { index: false } }; 27 if (!VALID.test(date)) return { title: 'Not found', robots: { index: false } };
24− const title = `What changed in AI on ${fmtDate(date)}`; 28+ const title = `Today in AI — ${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) } }; 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 const { date } = await params; 34 const { date } = await params;
35+ const sp = await searchParams;
30 if (!VALID.test(date) || Number.isNaN(new Date(`${date}T00:00:00Z`).getTime())) notFound(); 36 if (!VALID.test(date) || Number.isNaN(new Date(`${date}T00:00:00Z`).getTime())) notFound();
31 const today = new Date().toISOString().slice(0, 10); 37 const today = new Date().toISOString().slice(0, 10);
32 if (date > today) notFound(); 38 if (date > today) notFound();
33− const d = await safe(api.changesDaily(date)); 39+ const backfill = sp.include_backfill === '1';
34− const total = d ? Object.values(d.counts ?? {}).reduce<number>((n, v) => n + (num(v) ?? 0), 0) : 0; 40+ const d = await safe(apiD3.changesDaily(date, 30, backfill));
35− const cats = d ? Object.entries(d.counts ?? {}).sort((a, b) => (num(b[1]) ?? 0) - (num(a[1]) ?? 0)) : []; 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 return ( 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 <PageHeader 51 <PageHeader
40− eyebrow={<><Link href={routes.changes()} className="hover:text-ink">Changes</Link><span aria-hidden>/</span><span>Daily digest</span></>} 52+ eyebrow={
41− title={<>What changed in AI on <span className="text-ink-2">{fmtDate(date)}</span></>} 53+ <>
42− lede={d ? `${fmtInt(total)} events across ${cats.length} categories, generated from the database — nothing editorial.` : undefined} 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 aside={ 67 aside={
44 <nav className="flex items-center gap-2 text-sm" aria-label="Day navigation"> 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> 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">
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>} 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 </nav> 82 </nav>
48 } 83 }
49 /> 84 />
50 <div className="pb-16"> 85 <div className="pb-16">
51 {!d ? ( 86 {!d ? (
52 <Unavailable what="Daily digest" /> 87 <Unavailable what="Daily digest" />
53− ) : total === 0 && !d.new_models?.length ? ( 88+ ) : total === 0 && !d.new_models?.length && present.length === 0 ? (
54− <EmptyState title="Nothing recorded on this day">Connectors may not have observed changes, or the day predates the archive.</EmptyState> 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 && ( 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()}` }))} />}
58− <StatGrid cols={cats.length >= 6 ? 6 : cats.length >= 4 ? 4 : 3}> 101+ <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-3">
59− {cats.slice(0, 6).map(([c, n]) => ( 102+ <span className="inline-flex items-center gap-1">
60− <Stat key={c} label={categoryLabel(c)} value={fmtInt(n)} href={`/changes?category=${encodeURIComponent(c)}&since=${date}`} /> 103+ Dates are when events <em>occurred</em>
61− ))} 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." />
62− </StatGrid> 105+ </span>
63− )} 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 {d.new_models?.length > 0 && ( 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 <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3"> 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 <li key={m.id} className="flex items-baseline justify-between gap-3 border-b border-rule py-2 text-sm"> 126 <li key={m.id} className="flex items-baseline justify-between gap-3 border-b border-rule py-2 text-sm">
69 <EntityLink e={m} className="font-medium" /> 127 <EntityLink e={m} className="font-medium" />
70 <span className="shrink-0 text-xs text-ink-3">{m.organization?.name ?? ''}</span> 128 <span className="shrink-0 text-xs text-ink-3">{m.organization?.name ?? ''}</span>
71 </li> 129 </li>
72 ))} 130 ))}
73 </ul> 131 </ul>
132+ {d.new_models.length > 30 && <Note className="mt-2">First 30 of {fmtInt(d.new_models.length)}.</Note>}
74 </Section> 133 </Section>
75 )} 134 )}
76− {d.sections 135+
77− .filter((s) => s.items.length) 136+ {present.map((s) => {
78− .map((s) => ( 137+ const shown = s.items.length;
79− <Section key={s.category} eyebrow={categoryLabel(s.category)} title={s.label} action={{ href: `/changes?category=${encodeURIComponent(s.category)}`, label: 'All' }}> 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 <ul className="border-t border-rule"> 142 <ul className="border-t border-rule">
81− {s.items.map((e) => ( 143+ {s.items.map((it) => (
82− <ChangeRow key={e.id} e={e} live={false} /> 144+ <TodayItemRow key={it.id} it={it} />
83 ))} 145 ))}
84 </ul> 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 </Section> 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 </div> 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