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>
65 changed files +7,900 −1,098
added
apps/web/qa/d3.mjs
+338 −0
@@ -0,0 +1,338 @@ | ||
| 1 | +/** | |
| 2 | + * D3 QA sweep (temporal · graph · research · changes · search · explore · claims · admin · SEO). | |
| 3 | + * Widths 320 · 360 · 375 · 390 · 430 · 768 · 1366 · 1440 · 1920, dark + light — HTTP status, zero console errors, no horizontal | |
| 4 | + * overflow, screenshot — plus assertions: graph draws ≥ 2 nodes, compiled chips on /search, backfill honesty line on the digest, | |
| 5 | + * admin login → /admin/quality → /admin/entity-resolution (token never in HTML), OG image routes, sitemap shards. | |
| 6 | + * Run: node qa/d3.mjs [BASE_URL] [API_URL] [ADMIN_TOKEN] (defaults http://localhost:8343, http://127.0.0.1:8332, AIA_ADMIN_TOKEN from ../../.env) | |
| 7 | + * QUICK=1 limits widths to 390 and 1440. | |
| 8 | + */ | |
| 9 | +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs'; | |
| 10 | +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; | |
| 11 | + | |
| 12 | +const BASE = process.argv[2] ?? 'http://localhost:8343'; | |
| 13 | +const API = process.argv[3] ?? 'http://127.0.0.1:8332'; | |
| 14 | +function envToken() { | |
| 15 | + for (const p of [new URL('../../../.env', import.meta.url).pathname, new URL('../.env', import.meta.url).pathname]) { | |
| 16 | + if (!existsSync(p)) continue; | |
| 17 | + const m = /^AIA_ADMIN_TOKEN=(.+)$/m.exec(readFileSync(p, 'utf8')); | |
| 18 | + if (m) return m[1].trim().replace(/^["']|["']$/g, ''); | |
| 19 | + } | |
| 20 | + return null; | |
| 21 | +} | |
| 22 | +const TOKEN = process.argv[4] ?? process.env.AIA_ADMIN_TOKEN ?? envToken(); | |
| 23 | +const OUT = new URL('./screens/d3/', import.meta.url).pathname; | |
| 24 | +mkdirSync(OUT, { recursive: true }); | |
| 25 | +const WIDTHS = process.env.QUICK === '1' ? [390, 1440] : [320, 360, 375, 390, 430, 768, 1366, 1440, 1920]; | |
| 26 | +const THEMES = ['dark', 'light']; | |
| 27 | +const today = new Date().toISOString().slice(0, 10); | |
| 28 | +const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10); | |
| 29 | + | |
| 30 | +const j = async (path, headers = {}) => { | |
| 31 | + const r = await fetch(`${API}/api/v1${path}`, { headers: { accept: 'application/json', ...headers } }); | |
| 32 | + if (!r.ok) throw new Error(`API ${r.status} ${path}`); | |
| 33 | + return r.json(); | |
| 34 | +}; | |
| 35 | + | |
| 36 | +// ---- discover slugs live (nothing hardcoded) | |
| 37 | +const trending = await j('/trending?kind=views&type=model&limit=1&days=7').catch(() => null); | |
| 38 | +const models = await j('/models?limit=1&sort=quality'); | |
| 39 | +const modelSlug = trending?.items?.[0]?.slug ?? models.items[0].slug; | |
| 40 | +const papers = await j('/papers?limit=60&sort=updated'); | |
| 41 | +const paperSlug = (papers.items.find((p) => (p.counts?.relations ?? 0) > 2) ?? papers.items[0]).slug; | |
| 42 | +const orgs = await j('/companies?limit=1&sort=models'); | |
| 43 | +const orgSlug = orgs.items.find((o) => o.slug === 'anthropic')?.slug ?? orgs.items[0].slug; | |
| 44 | +const claims = await j(`/entities/${encodeURIComponent(modelSlug)}/claims?limit=1`); | |
| 45 | +const claimId = claims.items[0]?.id; | |
| 46 | +const researchers = await j('/explore/researcher?limit=1'); | |
| 47 | +const researcherSlug = researchers.items[0]?.slug; | |
| 48 | +let snapshotId = null; | |
| 49 | +if (TOKEN) { | |
| 50 | + try { | |
| 51 | + const docs = await j('/admin/documents?limit=1', { 'x-aia-admin-token': TOKEN }); | |
| 52 | + const doc = await j(`/admin/documents/${docs.items[0].id}`, { 'x-aia-admin-token': TOKEN }); | |
| 53 | + snapshotId = Array.isArray(doc.snapshots) && doc.snapshots[0] ? doc.snapshots[0].id : null; | |
| 54 | + } catch { | |
| 55 | + snapshotId = null; | |
| 56 | + } | |
| 57 | +} | |
| 58 | +console.log(`root model ${modelSlug} · paper ${paperSlug} · org ${orgSlug} · claim ${claimId} · researcher ${researcherSlug} · snapshot ${snapshotId ?? '—'} · token ${TOKEN ? 'yes' : 'NO'}`); | |
| 59 | + | |
| 60 | +const PAGES = [ | |
| 61 | + { path: '/graph', check: 'graph' }, | |
| 62 | + { path: `/graph?node=${orgSlug}&mode=company`, check: 'graph' }, | |
| 63 | + { path: `/graph/${modelSlug}?mode=lineage`, check: 'graph' }, | |
| 64 | + { path: '/timeline', check: 'timeline' }, | |
| 65 | + { path: '/timeline?include_backfill=1&year=2025', check: 'timeline' }, | |
| 66 | + { path: '/time-machine?date=2025-06-01', check: 'tm' }, | |
| 67 | + { path: `/diff?a=${weekAgo}&b=${today}`, check: 'diff' }, | |
| 68 | + { path: '/changes' }, | |
| 69 | + { path: `/changes/${today}`, check: 'digest' }, | |
| 70 | + { path: '/search?q=open+reasoning+models+over+30B+released+in+2026', check: 'search' }, | |
| 71 | + { path: '/explore', check: 'builder' }, | |
| 72 | + { path: '/papers' }, | |
| 73 | + { path: `/papers/${paperSlug}` }, | |
| 74 | + { path: '/companies' }, | |
| 75 | + { path: `/companies/${orgSlug}` }, | |
| 76 | + ...(researcherSlug ? [{ path: `/explore/researcher/${researcherSlug}` }] : []), | |
| 77 | + ...(claimId ? [{ path: `/claims/${claimId}` }] : []), | |
| 78 | + { path: '/sources' }, | |
| 79 | + { path: '/methodology' }, | |
| 80 | + { path: '/developers', check: 'builder-dev' }, | |
| 81 | + { path: '/graph/does-not-exist-xyz', expected: 404 }, | |
| 82 | + { path: '/claims/claim_does_not_exist', expected: 404 }, | |
| 83 | +]; | |
| 84 | +const LIGHT_SUBSET = new Set(['/graph', `/graph/${modelSlug}?mode=lineage`, '/timeline', '/time-machine?date=2025-06-01', `/diff?a=${weekAgo}&b=${today}`, `/changes/${today}`, '/search?q=open+reasoning+models+over+30B+released+in+2026', '/explore', `/papers/${paperSlug}`, `/companies/${orgSlug}`, `/claims/${claimId}`, '/sources', '/methodology', '/developers']); | |
| 85 | + | |
| 86 | +const filterErrors = (errors, expected) => errors.filter((e) => !/favicon|Failed to load resource: the server responded with a status of 404|the server responded with a status of 429/.test(e)).filter((e) => !(expected === 404 && /Encountered a script tag while rendering React component/.test(e))); | |
| 87 | + | |
| 88 | +const browser = await chromium.launch(); | |
| 89 | +let failures = 0; | |
| 90 | +const slug = (p) => p.replace(/^\//, '').replace(/[^a-z0-9]+/gi, '_').slice(0, 60) || 'home'; | |
| 91 | + | |
| 92 | +async function checks(page, kind) { | |
| 93 | + const problems = []; | |
| 94 | + if (kind === 'graph') { | |
| 95 | + await page.waitForSelector('[data-graph-canvas] [data-node]', { timeout: 20000 }).catch(() => undefined); | |
| 96 | + const n = await page.locator('[data-graph-canvas] [data-node]').count(); | |
| 97 | + if (n < 2) problems.push(`graph nodes=${n}`); | |
| 98 | + if (!(await page.locator('[data-graph-count]').count())) problems.push('graph count line missing'); | |
| 99 | + } | |
| 100 | + if (kind === 'search') { | |
| 101 | + const n = await page.locator('[data-compiled-chip]').count(); | |
| 102 | + if (n < 1) problems.push(`compiled chips=${n}`); | |
| 103 | + if (!(await page.locator('[data-open-builder]').count())) problems.push('builder link missing'); | |
| 104 | + } | |
| 105 | + if (kind === 'digest') { | |
| 106 | + const text = await page.evaluate(() => document.body.innerText); | |
| 107 | + if (!/occurred/i.test(text)) problems.push('digest lacks occurred caption'); | |
| 108 | + } | |
| 109 | + if (kind === 'tm') { | |
| 110 | + if (!(await page.locator('[data-tm-banner]').count())) problems.push('time-machine banner missing'); | |
| 111 | + const text = await page.evaluate(() => document.body.innerText); | |
| 112 | + if (!/reconstructed/i.test(text)) problems.push('time-machine lacks reconstructed note'); | |
| 113 | + } | |
| 114 | + if (kind === 'diff') { | |
| 115 | + const text = await page.evaluate(() => document.body.innerText); | |
| 116 | + if (!/new models/i.test(text) || !/price changes/i.test(text)) problems.push('diff sections missing'); | |
| 117 | + } | |
| 118 | + if (kind === 'timeline') { | |
| 119 | + if (!(await page.locator('[data-timeline-workbench]').count()) && !(await page.evaluate(() => /No events/i.test(document.body.innerText)))) problems.push('timeline workbench missing'); | |
| 120 | + } | |
| 121 | + if (kind === 'builder' && !(await page.locator('[data-query-builder]').count())) problems.push('query builder missing'); | |
| 122 | + if (kind === 'builder-dev' && !(await page.locator('[data-request-builder]').count())) problems.push('request builder missing'); | |
| 123 | + return problems; | |
| 124 | +} | |
| 125 | + | |
| 126 | +for (const theme of THEMES) { | |
| 127 | + for (const width of WIDTHS) { | |
| 128 | + const mobile = width < 768; | |
| 129 | + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme }); | |
| 130 | + await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme); | |
| 131 | + const page = await ctx.newPage(); | |
| 132 | + for (const { path, check, expected: exp } of PAGES) { | |
| 133 | + if (theme === 'light' && !LIGHT_SUBSET.has(path)) continue; | |
| 134 | + if (theme === 'light' && ![390, 1440, 320, 1920].includes(width)) continue; | |
| 135 | + const errors = []; | |
| 136 | + page.on('pageerror', (e) => errors.push(String(e))); | |
| 137 | + page.on('console', (m) => { | |
| 138 | + if (m.type() === 'error') errors.push(m.text()); | |
| 139 | + }); | |
| 140 | + const t0 = Date.now(); | |
| 141 | + const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` })); | |
| 142 | + await page.waitForTimeout(700); | |
| 143 | + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1); | |
| 144 | + const expected = exp ?? 200; | |
| 145 | + const status = res.status(); | |
| 146 | + const filtered = filterErrors(errors, expected); | |
| 147 | + const problems = status === 200 && check ? await checks(page, check) : []; | |
| 148 | + const ok = status === expected && overflow <= 0 && filtered.length === 0 && problems.length === 0; | |
| 149 | + if (!ok) failures++; | |
| 150 | + console.log(`${ok ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${String(width).padStart(4)} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} ${path}${problems.length ? ' :: ' + problems.join('; ') : ''}${filtered.length ? ' :: ' + filtered[0].slice(0, 160) : ''}`); | |
| 151 | + if ([320, 390, 1440, 1920].includes(width)) await page.screenshot({ path: `${OUT}${theme}-${width}-${slug(path)}.png`, fullPage: width >= 768 }).catch(() => undefined); | |
| 152 | + page.removeAllListeners('pageerror'); | |
| 153 | + page.removeAllListeners('console'); | |
| 154 | + } | |
| 155 | + await ctx.close(); | |
| 156 | + } | |
| 157 | +} | |
| 158 | + | |
| 159 | +// ---- flows | |
| 160 | +async function flow(name, width, fn) { | |
| 161 | + const ctx = await browser.newContext({ viewport: { width, height: width < 768 ? 844 : 900 }, colorScheme: 'dark', isMobile: width < 768, hasTouch: width < 768 }); | |
| 162 | + await ctx.addInitScript(() => localStorage.setItem('aia-theme', 'dark')); | |
| 163 | + const page = await ctx.newPage(); | |
| 164 | + const errors = []; | |
| 165 | + page.on('pageerror', (e) => errors.push(String(e))); | |
| 166 | + page.on('console', (m) => { | |
| 167 | + if (m.type() === 'error') errors.push(m.text()); | |
| 168 | + }); | |
| 169 | + try { | |
| 170 | + await fn(page); | |
| 171 | + const filtered = filterErrors(errors, 200); | |
| 172 | + if (filtered.length) throw new Error(`console: ${filtered[0].slice(0, 160)}`); | |
| 173 | + console.log(`OK flow ${name} @${width}`); | |
| 174 | + } catch (e) { | |
| 175 | + failures++; | |
| 176 | + console.log(`FAIL flow ${name} @${width} :: ${e.message.slice(0, 200)}`); | |
| 177 | + await page.screenshot({ path: `${OUT}flow-${name}-${width}-fail.png`, fullPage: true }).catch(() => undefined); | |
| 178 | + } | |
| 179 | + await ctx.close(); | |
| 180 | +} | |
| 181 | + | |
| 182 | +for (const width of [390, 1440]) { | |
| 183 | + // Graph: select a node → inspector shows it; expand loads more nodes (or reports already expanded). | |
| 184 | + await flow('graph-inspect-expand', width, async (page) => { | |
| 185 | + await page.goto(`${BASE}/graph/${modelSlug}?mode=lineage`, { waitUntil: 'networkidle' }); | |
| 186 | + await page.waitForSelector('[data-graph-canvas] [data-node]', { timeout: 20000 }); | |
| 187 | + const before = await page.locator('[data-graph-canvas] [data-node]').count(); | |
| 188 | + const nodes = page.locator('[data-graph-canvas] [data-node]'); | |
| 189 | + const target = nodes.nth(Math.min(1, before - 1)); | |
| 190 | + await target.dispatchEvent('pointerdown', { pointerId: 1, clientX: 0, clientY: 0, isPrimary: true, button: 0 }); | |
| 191 | + await target.dispatchEvent('pointerup', { pointerId: 1, clientX: 0, clientY: 0, isPrimary: true, button: 0 }); | |
| 192 | + // Fallback: keyboard selection is always available. | |
| 193 | + await target.focus(); | |
| 194 | + await page.keyboard.press('Enter'); | |
| 195 | + await page.waitForTimeout(400); | |
| 196 | + const inspectors = await page.locator('[data-graph-inspector]').count(); | |
| 197 | + if (inspectors < 1) throw new Error('inspector not rendered after selection'); | |
| 198 | + const expand = page.locator('[data-graph-expand]:not([disabled]):visible').first(); | |
| 199 | + if (await expand.count()) { | |
| 200 | + await expand.click(); | |
| 201 | + await page.waitForTimeout(2500); | |
| 202 | + const after = await page.locator('[data-graph-canvas] [data-node]').count(); | |
| 203 | + if (after < before) throw new Error(`nodes shrank ${before} → ${after}`); | |
| 204 | + } | |
| 205 | + await page.screenshot({ path: `${OUT}flow-graph-${width}.png` }); | |
| 206 | + }); | |
| 207 | + | |
| 208 | + // Graph: switch mode via the rail (desktop) → count line updates without navigation error. | |
| 209 | + if (width >= 1024) | |
| 210 | + await flow('graph-mode-switch', width, async (page) => { | |
| 211 | + await page.goto(`${BASE}/graph?node=${orgSlug}&mode=company`, { waitUntil: 'networkidle' }); | |
| 212 | + await page.waitForSelector('[data-graph-canvas] [data-node]', { timeout: 20000 }); | |
| 213 | + await page.locator('[data-graph-mode="provider"]').click(); | |
| 214 | + await page.waitForTimeout(2500); | |
| 215 | + if (!/mode=provider/.test(page.url())) throw new Error(`url not updated: ${page.url()}`); | |
| 216 | + const text = await page.locator('[data-graph-count]').innerText(); | |
| 217 | + if (!/nodes/.test(text)) throw new Error('count line missing after mode switch'); | |
| 218 | + }); | |
| 219 | + | |
| 220 | + // Search: remove a compiled chip → re-query without its words. | |
| 221 | + await flow('search-chip-remove', width, async (page) => { | |
| 222 | + await page.goto(`${BASE}/search?q=open+reasoning+models+over+30B+released+in+2026`, { waitUntil: 'networkidle' }); | |
| 223 | + const chips = await page.locator('[data-compiled-chip]').count(); | |
| 224 | + if (chips < 2) throw new Error(`chips=${chips}`); | |
| 225 | + const remove = page.locator('[data-compiled-chip] a[aria-label^="Remove"]').first(); | |
| 226 | + const label = await remove.getAttribute('aria-label'); | |
| 227 | + const before = page.url(); | |
| 228 | + await Promise.all([page.waitForURL((u) => u.toString() !== before), remove.click()]); | |
| 229 | + await page.waitForLoadState('networkidle'); | |
| 230 | + const after = await page.locator('[data-compiled-chip]').count(); | |
| 231 | + if (after >= chips) throw new Error(`chips not reduced after "${label}": ${chips} → ${after}`); | |
| 232 | + }); | |
| 233 | + | |
| 234 | + // Explore builder: set a filter, run → /models URL with the param; save a query → appears in the list. | |
| 235 | + await flow('explore-builder', width, async (page) => { | |
| 236 | + await page.goto(`${BASE}/explore`, { waitUntil: 'networkidle' }); | |
| 237 | + await page.locator('[data-query-builder] select').nth(2).selectOption({ index: 3 }); // Parameters ≥ 7B | |
| 238 | + await page.waitForTimeout(300); | |
| 239 | + const target = await page.locator('[data-target-url]').innerText(); | |
| 240 | + if (!/\/models\?.*min_params=/.test(target)) throw new Error(`target url ${target}`); | |
| 241 | + if (!/type=model/.test(page.url())) throw new Error(`share url not mirrored: ${page.url()}`); | |
| 242 | + await page.locator('[data-save-query]').click(); | |
| 243 | + await page.waitForTimeout(300); | |
| 244 | + if ((await page.locator('[data-saved-queries] li').count()) < 1) throw new Error('saved query not listed'); | |
| 245 | + await Promise.all([page.waitForURL(/\/models\?/), page.locator('[data-run-query]').click()]); | |
| 246 | + }); | |
| 247 | + | |
| 248 | + // Time machine: presets navigate and the banner says reconstructed / observed. | |
| 249 | + await flow('time-machine-presets', width, async (page) => { | |
| 250 | + await page.goto(`${BASE}/time-machine`, { waitUntil: 'networkidle' }); | |
| 251 | + await Promise.all([page.waitForURL(/date=2024-01-01/), page.locator('a[href*="date=2024-01-01"]').first().click()]); | |
| 252 | + await page.waitForLoadState('networkidle'); | |
| 253 | + const text = await page.locator('[data-tm-banner]').innerText(); | |
| 254 | + if (!/reconstructed|observed/i.test(text)) throw new Error('banner missing basis'); | |
| 255 | + await Promise.all([page.waitForURL(/scope=benchmarks/), page.locator('a[href*="scope=benchmarks"]').first().click()]); | |
| 256 | + }); | |
| 257 | + | |
| 258 | + // Changes feed: load more via cursor. | |
| 259 | + await flow('changes-load-more', width, async (page) => { | |
| 260 | + await page.goto(`${BASE}/changes`, { waitUntil: 'networkidle' }); | |
| 261 | + const before = await page.locator('[data-event-id]').count(); | |
| 262 | + const btn = page.locator('[data-load-more]'); | |
| 263 | + if (!(await btn.count())) return; // fewer than 50 events: nothing to page | |
| 264 | + await btn.click(); | |
| 265 | + await page.waitForTimeout(2500); | |
| 266 | + const after = await page.locator('[data-event-id]').count(); | |
| 267 | + if (after <= before) throw new Error(`load more did not append (${before} → ${after})`); | |
| 268 | + }); | |
| 269 | + | |
| 270 | + // Developers: request builder → Try → status line. | |
| 271 | + await flow('developers-try', width, async (page) => { | |
| 272 | + await page.goto(`${BASE}/developers`, { waitUntil: 'networkidle' }); | |
| 273 | + await page.locator('[data-route-picker]').selectOption('stats'); | |
| 274 | + await page.locator('[data-try]').click(); | |
| 275 | + await page.waitForSelector('[data-try-result] pre', { timeout: 20000 }); | |
| 276 | + const text = await page.locator('[data-try-result]').innerText(); | |
| 277 | + if (!/HTTP 200/.test(text)) throw new Error(`try result: ${text.slice(0, 80)}`); | |
| 278 | + }); | |
| 279 | + | |
| 280 | + // Admin: login → quality → entity-resolution → anomalies → audit → extractions; token never in HTML. | |
| 281 | + if (TOKEN) | |
| 282 | + await flow('admin-workbenches', width, async (page) => { | |
| 283 | + await page.goto(`${BASE}/admin`, { waitUntil: 'networkidle' }); | |
| 284 | + if ((await page.content()).includes(TOKEN)) throw new Error('token leaked before login'); | |
| 285 | + await page.locator('input[type="password"]').first().fill(TOKEN); | |
| 286 | + await Promise.all([page.waitForURL(/\/admin\/(overview|connectors)/, { timeout: 30000 }), page.locator('form button[type="submit"]').first().click()]); | |
| 287 | + const pages = ['/admin/quality', '/admin/entity-resolution', '/admin/anomalies', '/admin/quarantine', '/admin/audit', '/admin/runs', ...(snapshotId ? [`/admin/extractions/${snapshotId}`] : [])]; | |
| 288 | + for (const p of pages) { | |
| 289 | + const r = await page.goto(`${BASE}${p}`, { waitUntil: 'networkidle', timeout: 60000 }); | |
| 290 | + if (r.status() !== 200) throw new Error(`${p} → ${r.status()}`); | |
| 291 | + const html = await page.content(); | |
| 292 | + if (html.includes(TOKEN)) throw new Error(`token leaked in ${p}`); | |
| 293 | + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); | |
| 294 | + if (overflow > 0) throw new Error(`${p} overflow ${overflow}`); | |
| 295 | + if (p === '/admin/quality' && !(await page.locator('[data-quality-tiles] li').count())) throw new Error('quality tiles missing'); | |
| 296 | + if (p === '/admin/entity-resolution' && !(await page.locator('[data-resolution-pair]').count()) && !/No candidate pairs/.test(await page.evaluate(() => document.body.innerText))) throw new Error('resolution pairs missing'); | |
| 297 | + if (p === '/admin/runs' && !(await page.locator('[data-rollback]').count())) throw new Error('rollback buttons missing'); | |
| 298 | + if (p.startsWith('/admin/extractions') && !(await page.locator('[data-extraction-text]').count())) throw new Error('extraction text missing'); | |
| 299 | + await page.screenshot({ path: `${OUT}admin-${width}-${slug(p)}.png`, fullPage: width >= 768 }); | |
| 300 | + } | |
| 301 | + // Sources shows operator columns when the cookie exists (cookie path is /admin, so /sources must not see it → public view). | |
| 302 | + await page.goto(`${BASE}/sources`, { waitUntil: 'networkidle' }); | |
| 303 | + if ((await page.content()).includes(TOKEN)) throw new Error('token leaked in /sources'); | |
| 304 | + // Rollback button opens a confirmation field, disabled until the id is typed. | |
| 305 | + await page.goto(`${BASE}/admin/runs`, { waitUntil: 'networkidle' }); | |
| 306 | + const rb = page.locator('[data-rollback]:not([disabled])').first(); | |
| 307 | + if (await rb.count()) { | |
| 308 | + await rb.click(); | |
| 309 | + const confirm = page.locator('[data-rollback-form] button[type="submit"]'); | |
| 310 | + if (!(await confirm.isDisabled())) throw new Error('rollback confirm enabled before typing the id'); | |
| 311 | + } | |
| 312 | + }); | |
| 313 | +} | |
| 314 | + | |
| 315 | +// ---- OG images and sitemap (plain HTTP) | |
| 316 | +for (const p of ['/graph/og', `/graph/og?node=${modelSlug}&mode=lineage`, '/time-machine/og?date=2025-06-01', `/diff/og?a=${weekAgo}&b=${today}`, `/changes/${today}/opengraph-image`, `/companies/${orgSlug}/opengraph-image`, `/papers/${paperSlug}/opengraph-image`]) { | |
| 317 | + const r = await fetch(BASE + p); | |
| 318 | + const ok = r.status === 200 && /image\/png/.test(r.headers.get('content-type') ?? ''); | |
| 319 | + if (!ok) failures++; | |
| 320 | + console.log(`${ok ? 'OK ' : 'FAIL'} og ${r.status} ${r.headers.get('content-type')} ${p}`); | |
| 321 | +} | |
| 322 | +{ | |
| 323 | + const idx = await (await fetch(`${BASE}/sitemap.xml`)).text(); | |
| 324 | + const st = await (await fetch(`${BASE}/sitemap/static.xml`)).text(); | |
| 325 | + const okIdx = /model_family-0\.xml/.test(idx) && /license-0\.xml/.test(idx) && /artifact-0\.xml/.test(idx) && /changes\.xml/.test(idx); | |
| 326 | + const okSt = ['/time-machine', '/graph', '/frontier', '/pulse', '/families', '/licenses', '/agents', '/calculator', '/run-locally', '/find-a-model', '/open'].every((r) => st.includes(`<loc>`) && st.includes(r)); | |
| 327 | + if (!okIdx || !okSt) failures++; | |
| 328 | + console.log(`${okIdx && okSt ? 'OK ' : 'FAIL'} sitemap index(model_family/license/artifact/changes)=${okIdx} static(new routes)=${okSt}`); | |
| 329 | + const fam = await fetch(`${BASE}/sitemap/model_family-0.xml`); | |
| 330 | + const famBody = await fam.text(); | |
| 331 | + const okFam = fam.status === 200 && /\/families\//.test(famBody); | |
| 332 | + if (!okFam) failures++; | |
| 333 | + console.log(`${okFam ? 'OK ' : 'FAIL'} sitemap model_family-0 ${fam.status}`); | |
| 334 | +} | |
| 335 | + | |
| 336 | +await browser.close(); | |
| 337 | +console.log(failures ? `\n${failures} failure(s)` : '\nAll D3 checks passed'); | |
| 338 | +process.exit(failures ? 1 : 0); | |
added
apps/web/src/app/[type]/[slug]/opengraph-image.tsx
+59 −0
@@ -0,0 +1,59 @@ | ||
| 1 | +import { ImageResponse } from 'next/og'; | |
| 2 | +import { Fallback, Wallpaper } from '@/components/brand/og'; | |
| 3 | +import { api, safe } from '@/lib/api'; | |
| 4 | +import { fmtDate, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format'; | |
| 5 | +import { PATH_TYPES, SITE_NAME, typeLabel } from '@/lib/site'; | |
| 6 | +import type { EntityDetail } from '@/lib/types'; | |
| 7 | + | |
| 8 | +export const runtime = 'nodejs'; | |
| 9 | +export const alt = `Entity on ${SITE_NAME}`; | |
| 10 | +export const size = { width: 1200, height: 630 }; | |
| 11 | +export const contentType = 'image/png'; | |
| 12 | + | |
| 13 | +/** Per-type facts for the generic entity OG image (papers, providers, benchmarks, hardware, frameworks, datasets, tools…). */ | |
| 14 | +function facts(d: EntityDetail): [string, string][] { | |
| 15 | + const a = d.attributes ?? {}; | |
| 16 | + const out: [string, string][] = []; | |
| 17 | + switch (d.entity_type) { | |
| 18 | + case 'paper': { | |
| 19 | + const authors = Array.isArray(a.authors) ? (a.authors as unknown[]).length : 0; | |
| 20 | + if (typeof a.published_at === 'string') out.push(['Published', fmtDate(a.published_at)]); | |
| 21 | + if (authors) out.push(['Authors', fmtInt(authors)]); | |
| 22 | + if (typeof a.arxiv_id === 'string') out.push(['arXiv', a.arxiv_id]); | |
| 23 | + const models = (d.relations ?? []).filter((g) => g.predicate === 'described_by').reduce((n, g) => n + g.items.filter((i) => i.entity_type === 'model').length, 0); | |
| 24 | + if (models) out.push(['Models', fmtInt(models)]); | |
| 25 | + break; | |
| 26 | + } | |
| 27 | + case 'benchmark': | |
| 28 | + if (typeof a.category === 'string') out.push(['Category', a.category]); | |
| 29 | + if (d.results?.length) out.push(['Results', fmtInt(d.results.length)]); | |
| 30 | + if (typeof a.metric === 'string') out.push(['Metric', a.metric.slice(0, 18)]); | |
| 31 | + break; | |
| 32 | + case 'hardware': | |
| 33 | + if (num(a.memory_gb) !== null) out.push(['Memory', `${fmtInt(a.memory_gb)} GB`]); | |
| 34 | + if (num(a.memory_bandwidth_gbs) !== null) out.push(['Bandwidth', `${fmtInt(a.memory_bandwidth_gbs)} GB/s`]); | |
| 35 | + if (typeof a.release_date === 'string') out.push(['Released', fmtDate(a.release_date)]); | |
| 36 | + break; | |
| 37 | + case 'provider': | |
| 38 | + if (d.prices?.length) out.push(['Offers', fmtInt(d.prices.length)]); | |
| 39 | + if (d.models?.total) out.push(['Models', fmtInt(d.models.total)]); | |
| 40 | + break; | |
| 41 | + default: | |
| 42 | + if (num(a.parameter_count) !== null) out.push(['Params', fmtParams(a.parameter_count)]); | |
| 43 | + if (num(a.context_length) !== null) out.push(['Context', fmtTokens(a.context_length)]); | |
| 44 | + if (typeof a.latest_version === 'string') out.push(['Version', a.latest_version]); | |
| 45 | + if (num(a['metric.stars']) !== null) out.push(['Stars', fmtInt(a['metric.stars'])]); | |
| 46 | + if (typeof a.license === 'string') out.push(['License', a.license.slice(0, 18)]); | |
| 47 | + } | |
| 48 | + return out.slice(0, 4); | |
| 49 | +} | |
| 50 | + | |
| 51 | +export default async function EntityOgImage({ params }: { params: Promise<{ type: string; slug: string }> }) { | |
| 52 | + const { type, slug } = await params; | |
| 53 | + const def = PATH_TYPES[type]; | |
| 54 | + const d = def ? await safe(def.api === 'entities' ? api.entity(slug) : api.entityOfType(def.api, slug)) : null; | |
| 55 | + if (!d) return new ImageResponse(<Fallback label={def?.singular ?? 'Entity'} />, { ...size }); | |
| 56 | + const eyebrow = `${typeLabel(d.entity_type)}${d.organization ? ` · ${d.organization.name}` : ''}`; | |
| 57 | + const subtitle = d.description ? d.description.slice(0, 140) : typeof d.attributes?.abstract === 'string' ? (d.attributes.abstract as string).slice(0, 140) : `${typeLabel(d.entity_type)} on ${SITE_NAME}: specifications, relations, history and sources.`; | |
| 58 | + return new ImageResponse(<Wallpaper eyebrow={eyebrow} title={d.name} subtitle={subtitle} counters={facts(d)} footer={`www.ai-atlas.co/${type}/${d.slug}`} markPx={200} />, { ...size }); | |
| 59 | +} | |
modified
apps/web/src/app/[type]/[slug]/page.tsx
+3 −0
@@ -2,12 +2,14 @@ import type { Metadata } from 'next'; | ||
| 2 | 2 | import { notFound, permanentRedirect } from 'next/navigation'; |
| 3 | 3 | import { EntityPage, type EntityPageParams } from '@/components/entity/entity-page'; |
| 4 | 4 | import { entityMetadata, loadEntity } from '@/components/entity/load'; |
| 5 | +import { PaperPage } from '@/components/research/paper-page'; | |
| 5 | 6 | import { api, safe } from '@/lib/api'; |
| 6 | 7 | import { PATH_TYPES, routes } from '@/lib/site'; |
| 7 | 8 | |
| 8 | 9 | /** |
| 9 | 10 | * Generic entity page for /providers, /benchmarks, /hardware, /papers, /frameworks, /datasets, /tools, /repositories. |
| 10 | 11 | * (/models and /companies have their own segment folders; static segments win over this dynamic one.) |
| 12 | + * Papers render the dedicated research page (D3); every other type renders the shared `EntityPage`. | |
| 11 | 13 | */ |
| 12 | 14 | type Params = { params: Promise<{ type: string; slug: string }>; searchParams: Promise<EntityPageParams> }; |
| 13 | 15 | |
@@ -24,6 +26,7 @@ export default async function GenericEntityPage({ params, searchParams }: Params | ||
| 24 | 26 | const d = await loadEntity(type, slug); |
| 25 | 27 | const canonical = routes.entity(d); |
| 26 | 28 | if (canonical !== `/${type}/${encodeURIComponent(slug)}`) permanentRedirect(canonical); |
| 29 | + if (d.entity_type === 'paper') return <PaperPage d={d} canonical={canonical} />; | |
| 27 | 30 | const related = await safe(api.entityRelated(d.slug, 10)); |
| 28 | 31 | return <EntityPage d={d} canonical={canonical} related={related?.items} asof={sp.asof} historyProperty={sp.property} />; |
| 29 | 32 | } |
added
apps/web/src/app/admin/anomalies/page.tsx
+159 −0
@@ -0,0 +1,159 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { ActionButton, AdminFilters, AdminTitle, JsonPre, Mono, Notice, StatusChip } from '@/components/admin/ui'; | |
| 4 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 5 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 6 | +import { LiveAgo } from '@/components/ui/live'; | |
| 7 | +import { Pagination, withParams } from '@/components/ui/pagination'; | |
| 8 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 9 | +import { anomalyAction } from '@/lib/admin/actions'; | |
| 10 | +import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api'; | |
| 11 | +import { cn } from '@/lib/cn'; | |
| 12 | +import { fmtDateTime, fmtInt, fmtValue } from '@/lib/format'; | |
| 13 | +import { routes } from '@/lib/site'; | |
| 14 | + | |
| 15 | +export const metadata: Metadata = { title: 'Anomalies', robots: { index: false, follow: false } }; | |
| 16 | +export const dynamic = 'force-dynamic'; | |
| 17 | + | |
| 18 | +const LIMIT = 50; | |
| 19 | +const SEV: Record<string, string> = { critical: 'text-danger', warning: 'text-warning', info: 'text-ink-3' }; | |
| 20 | + | |
| 21 | +export default async function AdminAnomaliesPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 22 | + await requireAdmin(); | |
| 23 | + const sp = await searchParams; | |
| 24 | + const current: Record<string, string | undefined> = { status: sp.status ?? 'open' }; | |
| 25 | + if (sp.severity) current.severity = sp.severity; | |
| 26 | + if (sp.check) current.check = sp.check; | |
| 27 | + if (sp.offset) current.offset = sp.offset; | |
| 28 | + const offset = Math.max(0, Number(current.offset) || 0); | |
| 29 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/admin/anomalies', current, patch); | |
| 30 | + const ret = href({}); | |
| 31 | + const res = await load(adminApi.anomalies({ status: current.status, severity: current.severity, check: current.check, limit: LIMIT, offset })); | |
| 32 | + const byCheck = res.ok ? (res.data.by_check ?? []) : []; | |
| 33 | + const checks = [...new Set(byCheck.map((c) => c.check_name))].sort(); | |
| 34 | + return ( | |
| 35 | + <> | |
| 36 | + <AdminTitle title="Anomalies" count={res.ok ? fmtInt(res.data.total) : undefined} lede="Impossible or suspicious values flagged by the checks in the methodology. Flags are never deleted: resolve when the data was fixed, ignore when the value is right despite the rule, reopen if it comes back."> | |
| 37 | + <Link href={`${routes.methodology()}#anomalies`} className="text-xs text-ink-3 hover:text-ink"> | |
| 38 | + Check definitions → | |
| 39 | + </Link> | |
| 40 | + </AdminTitle> | |
| 41 | + <Notice notice={sp.notice} level={sp.level} /> | |
| 42 | + {byCheck.length > 0 && ( | |
| 43 | + <ul className="mb-4 flex flex-wrap gap-1.5 text-xs"> | |
| 44 | + {byCheck.map((c) => ( | |
| 45 | + <li key={`${c.check_name}-${c.status}`}> | |
| 46 | + <Link href={href({ check: current.check === c.check_name ? undefined : c.check_name, offset: undefined })} className={cn('inline-flex h-7 items-center gap-1.5 border px-2', current.check === c.check_name ? 'border-accent text-accent' : 'border-rule text-ink-2 hover:border-rule-strong')}> | |
| 47 | + <span className={SEV[c.severity] ?? 'text-ink-3'}>{c.severity}</span> | |
| 48 | + <span className="mono">{c.check_name}</span> | |
| 49 | + <span className="tnum text-ink-3">{fmtInt(c.n)}</span> | |
| 50 | + </Link> | |
| 51 | + </li> | |
| 52 | + ))} | |
| 53 | + </ul> | |
| 54 | + )} | |
| 55 | + <AdminFilters | |
| 56 | + action="/admin/anomalies" | |
| 57 | + className="mb-4" | |
| 58 | + fields={[ | |
| 59 | + { kind: 'select', name: 'status', label: 'Status', value: current.status, any: 'open', options: ['open', 'resolved', 'ignored', 'all'].map((s) => ({ value: s, label: s })) }, | |
| 60 | + { kind: 'select', name: 'severity', label: 'Severity', value: current.severity, options: ['critical', 'warning', 'info'].map((s) => ({ value: s, label: s })) }, | |
| 61 | + { kind: 'select', name: 'check', label: 'Check', value: current.check, options: checks.map((c) => ({ value: c, label: c })) }, | |
| 62 | + ]} | |
| 63 | + /> | |
| 64 | + {!res.ok ? ( | |
| 65 | + <Unavailable what="Anomalies" reason={res.error} /> | |
| 66 | + ) : res.data.items.length === 0 ? ( | |
| 67 | + <EmptyState title="No anomaly matches" /> | |
| 68 | + ) : ( | |
| 69 | + <> | |
| 70 | + <DataTable compact scroll caption="Anomalies"> | |
| 71 | + <thead> | |
| 72 | + <tr> | |
| 73 | + <Th>Severity</Th> | |
| 74 | + <Th>Check</Th> | |
| 75 | + <Th>Entity</Th> | |
| 76 | + <Th>Message</Th> | |
| 77 | + <Th num>Value</Th> | |
| 78 | + <Th>Status</Th> | |
| 79 | + <Th>Seen</Th> | |
| 80 | + <Th>Actions</Th> | |
| 81 | + </tr> | |
| 82 | + </thead> | |
| 83 | + <tbody> | |
| 84 | + {res.data.items.length === 0 && <EmptyRow cols={8}>No rows.</EmptyRow>} | |
| 85 | + {res.data.items.map((a) => ( | |
| 86 | + <tr key={a.id}> | |
| 87 | + <Td> | |
| 88 | + <span className={cn('text-xs font-medium', SEV[a.severity] ?? 'text-ink-3')}>{a.severity}</span> | |
| 89 | + </Td> | |
| 90 | + <Td> | |
| 91 | + <Mono>{a.check_name}</Mono> | |
| 92 | + </Td> | |
| 93 | + <Td primary> | |
| 94 | + {a.slug ? ( | |
| 95 | + <span className="inline-flex items-center gap-1.5"> | |
| 96 | + <EntityBadge type={a.entity_type ?? 'model'} small /> | |
| 97 | + <Link href={routes.entity({ entity_type: a.entity_type ?? 'model', slug: a.slug })} className="text-ink hover:text-accent hover:underline"> | |
| 98 | + {a.entity_name ?? a.slug} | |
| 99 | + </Link> | |
| 100 | + </span> | |
| 101 | + ) : ( | |
| 102 | + <Mono>{a.entity_id ?? '—'}</Mono> | |
| 103 | + )} | |
| 104 | + </Td> | |
| 105 | + <Td className="max-w-[24rem] text-xs text-ink-2"> | |
| 106 | + {a.message} | |
| 107 | + {a.detail && ( | |
| 108 | + <details className="mt-1"> | |
| 109 | + <summary className="cursor-pointer text-[11px] text-ink-3 hover:text-ink">detail</summary> | |
| 110 | + <JsonPre value={a.detail} maxHeight="10rem" /> | |
| 111 | + </details> | |
| 112 | + )} | |
| 113 | + </Td> | |
| 114 | + <Td num className="tnum text-xs">{fmtValue(a.value)}</Td> | |
| 115 | + <Td> | |
| 116 | + <StatusChip value={a.status} /> | |
| 117 | + {a.resolution && typeof a.resolution.note === 'string' && <span className="block text-[11px] text-ink-3">{a.resolution.note}</span>} | |
| 118 | + </Td> | |
| 119 | + <Td className="text-xs text-ink-3" title={`first ${fmtDateTime(a.first_seen_at)} · last ${fmtDateTime(a.last_seen_at)}`}> | |
| 120 | + <LiveAgo at={a.last_seen_at} /> | |
| 121 | + </Td> | |
| 122 | + <Td> | |
| 123 | + <div className="flex items-center gap-1"> | |
| 124 | + {a.status !== 'resolved' && ( | |
| 125 | + <form action={anomalyAction}> | |
| 126 | + <input type="hidden" name="id" value={a.id} /> | |
| 127 | + <input type="hidden" name="status" value="resolved" /> | |
| 128 | + <input type="hidden" name="return" value={ret} /> | |
| 129 | + <ActionButton tone="positive" title="The data was corrected">Resolve</ActionButton> | |
| 130 | + </form> | |
| 131 | + )} | |
| 132 | + {a.status !== 'ignored' && ( | |
| 133 | + <form action={anomalyAction}> | |
| 134 | + <input type="hidden" name="id" value={a.id} /> | |
| 135 | + <input type="hidden" name="status" value="ignored" /> | |
| 136 | + <input type="hidden" name="return" value={ret} /> | |
| 137 | + <ActionButton title="The value is right despite the rule">Ignore</ActionButton> | |
| 138 | + </form> | |
| 139 | + )} | |
| 140 | + {a.status !== 'open' && ( | |
| 141 | + <form action={anomalyAction}> | |
| 142 | + <input type="hidden" name="id" value={a.id} /> | |
| 143 | + <input type="hidden" name="status" value="open" /> | |
| 144 | + <input type="hidden" name="return" value={ret} /> | |
| 145 | + <ActionButton tone="accent">Reopen</ActionButton> | |
| 146 | + </form> | |
| 147 | + )} | |
| 148 | + </div> | |
| 149 | + </Td> | |
| 150 | + </tr> | |
| 151 | + ))} | |
| 152 | + </tbody> | |
| 153 | + </DataTable> | |
| 154 | + <Pagination total={res.data.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" /> | |
| 155 | + </> | |
| 156 | + )} | |
| 157 | + </> | |
| 158 | + ); | |
| 159 | +} | |
added
apps/web/src/app/admin/audit/page.tsx
+91 −0
@@ -0,0 +1,91 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { AdminFilters, AdminTitle, JsonPre, Mono, Notice } from '@/components/admin/ui'; | |
| 3 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 4 | +import { LiveAgo } from '@/components/ui/live'; | |
| 5 | +import { Pagination, withParams } from '@/components/ui/pagination'; | |
| 6 | +import { Note } from '@/components/ui/section'; | |
| 7 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 8 | +import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api'; | |
| 9 | +import { cn } from '@/lib/cn'; | |
| 10 | +import { fmtDateTime, fmtInt } from '@/lib/format'; | |
| 11 | + | |
| 12 | +export const metadata: Metadata = { title: 'Audit log', robots: { index: false, follow: false } }; | |
| 13 | +export const dynamic = 'force-dynamic'; | |
| 14 | +const LIMIT = 50; | |
| 15 | + | |
| 16 | +export default async function AdminAuditPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 17 | + await requireAdmin(); | |
| 18 | + const sp = await searchParams; | |
| 19 | + const current: Record<string, string | undefined> = {}; | |
| 20 | + if (sp.action) current.action = sp.action; | |
| 21 | + if (sp.offset) current.offset = sp.offset; | |
| 22 | + const offset = Math.max(0, Number(current.offset) || 0); | |
| 23 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/admin/audit', current, patch); | |
| 24 | + const res = await load(adminApi.audit({ action: current.action, limit: LIMIT, offset })); | |
| 25 | + return ( | |
| 26 | + <> | |
| 27 | + <AdminTitle title="Audit log" count={res.ok ? fmtInt(res.data.total) : undefined} lede="Every admin call except the polling GETs (overview, infrastructure, llm/health, audit) writes a row: actor, action, target, payload, IP." /> | |
| 28 | + <Notice notice={sp.notice} level={sp.level} /> | |
| 29 | + <AdminFilters action="/admin/audit" className="mb-4" fields={[{ kind: 'text', name: 'action', label: 'Action contains', value: current.action, placeholder: 'entity-resolution, rollback, POST…' }]} /> | |
| 30 | + {!res.ok ? ( | |
| 31 | + <Unavailable what="Audit log" reason={res.error} /> | |
| 32 | + ) : res.data.items.length === 0 ? ( | |
| 33 | + <EmptyState title="No audit rows match" /> | |
| 34 | + ) : ( | |
| 35 | + <> | |
| 36 | + <DataTable compact scroll caption="Audit log"> | |
| 37 | + <thead> | |
| 38 | + <tr> | |
| 39 | + <Th>When</Th> | |
| 40 | + <Th>Actor</Th> | |
| 41 | + <Th>Action</Th> | |
| 42 | + <Th>Target</Th> | |
| 43 | + <Th>Payload</Th> | |
| 44 | + <Th>IP</Th> | |
| 45 | + <Th>Id</Th> | |
| 46 | + </tr> | |
| 47 | + </thead> | |
| 48 | + <tbody> | |
| 49 | + {res.data.items.length === 0 && <EmptyRow cols={7}>No rows.</EmptyRow>} | |
| 50 | + {res.data.items.map((r) => { | |
| 51 | + const mutating = !r.action.startsWith('GET '); | |
| 52 | + return ( | |
| 53 | + <tr key={String(r.id)}> | |
| 54 | + <Td className="text-xs text-ink-3" title={fmtDateTime(r.created_at)}> | |
| 55 | + <LiveAgo at={r.created_at} /> | |
| 56 | + </Td> | |
| 57 | + <Td> | |
| 58 | + <Mono>{r.actor}</Mono> | |
| 59 | + </Td> | |
| 60 | + <Td primary> | |
| 61 | + <span className={cn('mono text-xs', mutating ? 'font-medium text-ink' : 'text-ink-2')}>{r.action}</span> | |
| 62 | + </Td> | |
| 63 | + <Td>{r.target ? <Mono>{r.target}</Mono> : <span className="text-ink-3">—</span>}</Td> | |
| 64 | + <Td className="max-w-[26rem]"> | |
| 65 | + {r.payload ? ( | |
| 66 | + <details> | |
| 67 | + <summary className="cursor-pointer text-[11px] text-ink-3 hover:text-ink">{r.payload.method ? `${String(r.payload.method)} ${String(r.payload.path ?? '')}` : 'payload'}</summary> | |
| 68 | + <JsonPre value={r.payload} maxHeight="10rem" /> | |
| 69 | + </details> | |
| 70 | + ) : ( | |
| 71 | + <span className="text-ink-3">—</span> | |
| 72 | + )} | |
| 73 | + </Td> | |
| 74 | + <Td> | |
| 75 | + <Mono>{r.ip ?? '—'}</Mono> | |
| 76 | + </Td> | |
| 77 | + <Td> | |
| 78 | + <Mono>{String(r.id)}</Mono> | |
| 79 | + </Td> | |
| 80 | + </tr> | |
| 81 | + ); | |
| 82 | + })} | |
| 83 | + </tbody> | |
| 84 | + </DataTable> | |
| 85 | + <Pagination total={res.data.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" /> | |
| 86 | + </> | |
| 87 | + )} | |
| 88 | + <Note className="mt-4">Rows are append-only; this page itself is not logged (polling GET).</Note> | |
| 89 | + </> | |
| 90 | + ); | |
| 91 | +} | |
added
apps/web/src/app/admin/entity-resolution/page.tsx
+120 −0
@@ -0,0 +1,120 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { ResolutionPair } from '@/components/admin/resolution-pair'; | |
| 4 | +import { AdminFilters, AdminTitle, Mono, Notice } from '@/components/admin/ui'; | |
| 5 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 6 | +import { Pagination, withParams } from '@/components/ui/pagination'; | |
| 7 | +import { Note } from '@/components/ui/section'; | |
| 8 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 9 | +import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api'; | |
| 10 | +import { fmtDateTime, fmtInt, fmtPct, num } from '@/lib/format'; | |
| 11 | +import { routes } from '@/lib/site'; | |
| 12 | + | |
| 13 | +export const metadata: Metadata = { title: 'Entity resolution', robots: { index: false, follow: false } }; | |
| 14 | +export const dynamic = 'force-dynamic'; | |
| 15 | + | |
| 16 | +const LIMIT = 20; | |
| 17 | +const TYPES = ['model', 'company', 'organization', 'lab', 'paper', 'benchmark', 'provider', 'hardware', 'framework', 'dataset']; | |
| 18 | + | |
| 19 | +export default async function EntityResolutionPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 20 | + await requireAdmin(); | |
| 21 | + const sp = await searchParams; | |
| 22 | + const current: Record<string, string | undefined> = { type: sp.type ?? 'model', status: sp.status ?? 'pending' }; | |
| 23 | + if (sp.threshold) current.threshold = sp.threshold; | |
| 24 | + if (sp.offset) current.offset = sp.offset; | |
| 25 | + const offset = Math.max(0, Number(current.offset) || 0); | |
| 26 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/admin/entity-resolution', current, patch); | |
| 27 | + const ret = href({}); | |
| 28 | + const [res, decided] = await Promise.all([load(adminApi.entityResolution({ type: current.type, status: current.status, threshold: current.threshold, limit: LIMIT, offset })), current.status === 'decided' ? Promise.resolve(null) : load(adminApi.entityResolution({ type: current.type, status: 'decided', limit: 10 }))]); | |
| 29 | + return ( | |
| 30 | + <> | |
| 31 | + <AdminTitle title="Entity resolution" count={res.ok ? fmtInt(res.data.total) : undefined} lede="Candidate pairs side by side. Nothing is merged until you decide; decisions persist (resolution_decisions) and are applied by the API: merge, alias, variant-of, family member — or keep separate / defer."> | |
| 32 | + <Link href="/admin/quality" className="text-xs text-ink-3 hover:text-ink"> | |
| 33 | + Data quality → | |
| 34 | + </Link> | |
| 35 | + </AdminTitle> | |
| 36 | + <Notice notice={sp.notice} level={sp.level} /> | |
| 37 | + <AdminFilters | |
| 38 | + action="/admin/entity-resolution" | |
| 39 | + className="mb-4" | |
| 40 | + fields={[ | |
| 41 | + { kind: 'select', name: 'type', label: 'Entity type', value: current.type, any: 'model', options: TYPES.map((t) => ({ value: t, label: t })) }, | |
| 42 | + { kind: 'select', name: 'status', label: 'Status', value: current.status, any: 'pending', options: ['pending', 'decided', 'all'].map((s) => ({ value: s, label: s })) }, | |
| 43 | + { kind: 'select', name: 'threshold', label: 'Similarity ≥', value: current.threshold, any: '0.8 (default)', options: ['0.6', '0.7', '0.8', '0.9', '0.95'].map((v) => ({ value: v, label: v })) }, | |
| 44 | + ]} | |
| 45 | + /> | |
| 46 | + {!res.ok ? ( | |
| 47 | + <Unavailable what="Entity resolution" reason={res.error} /> | |
| 48 | + ) : res.data.items.length === 0 ? ( | |
| 49 | + <EmptyState title="No candidate pairs">{current.status === 'pending' ? 'Nothing above the similarity threshold awaits a decision for this type.' : 'No decided pairs for this type yet.'}</EmptyState> | |
| 50 | + ) : ( | |
| 51 | + <> | |
| 52 | + <p className="mb-3 text-xs text-ink-3"> | |
| 53 | + Threshold {fmtPct((num(res.data.threshold) ?? 0) * 100, 0)} · signals: review queue, trigram similarity, shared variant key. {res.data.note} | |
| 54 | + </p> | |
| 55 | + <ul className="space-y-6" data-resolution-list> | |
| 56 | + {res.data.items.map((it) => ( | |
| 57 | + <li key={`${it.a.id}-${it.b.id}`}> | |
| 58 | + <ResolutionPair item={it} decisions={res.data.decisions} returnTo={ret} /> | |
| 59 | + </li> | |
| 60 | + ))} | |
| 61 | + </ul> | |
| 62 | + <Pagination total={res.data.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" /> | |
| 63 | + </> | |
| 64 | + )} | |
| 65 | + {decided && decided.ok && ( | |
| 66 | + <section className="mt-10"> | |
| 67 | + <h2 className="text-base font-semibold tracking-tight"> | |
| 68 | + Decision history <span className="tnum text-sm font-normal text-ink-3">{fmtInt(decided.data.total)}</span> | |
| 69 | + </h2> | |
| 70 | + <Note className="mb-3 mt-1">Latest decided pairs for this type (persisted in resolution_decisions; every POST is also in the audit log).</Note> | |
| 71 | + <DataTable compact scroll caption="Decision history"> | |
| 72 | + <thead> | |
| 73 | + <tr> | |
| 74 | + <Th>A</Th> | |
| 75 | + <Th>B</Th> | |
| 76 | + <Th>Decision</Th> | |
| 77 | + <Th>Note</Th> | |
| 78 | + <Th>Decided</Th> | |
| 79 | + </tr> | |
| 80 | + </thead> | |
| 81 | + <tbody> | |
| 82 | + {decided.data.items.length === 0 && <EmptyRow cols={5}>No decision recorded yet.</EmptyRow>} | |
| 83 | + {decided.data.items.map((it) => { | |
| 84 | + const d = (typeof it.decision === 'string' ? { decision: it.decision } : it.decision) as { decision: string; note?: string | null; decided_at?: string | null; applied?: boolean } | null; | |
| 85 | + return ( | |
| 86 | + <tr key={`${it.a.id}-${it.b.id}`}> | |
| 87 | + <Td primary> | |
| 88 | + <Link href={routes.entity(it.a)} className="text-ink hover:text-accent"> | |
| 89 | + {it.a.name} | |
| 90 | + </Link>{' '} | |
| 91 | + <Mono>{it.a.slug}</Mono> | |
| 92 | + </Td> | |
| 93 | + <Td> | |
| 94 | + <Link href={routes.entity(it.b)} className="text-ink hover:text-accent"> | |
| 95 | + {it.b.name} | |
| 96 | + </Link>{' '} | |
| 97 | + <Mono>{it.b.slug}</Mono> | |
| 98 | + </Td> | |
| 99 | + <Td> | |
| 100 | + <Mono>{d?.decision ?? '—'}</Mono> | |
| 101 | + {d && 'applied' in d && d.applied !== undefined && <span className="ml-1 text-[11px] text-ink-3">{d.applied ? 'applied' : 'recorded'}</span>} | |
| 102 | + </Td> | |
| 103 | + <Td className="text-xs text-ink-2">{d && typeof d.note === 'string' ? d.note : '—'}</Td> | |
| 104 | + <Td className="tnum text-xs text-ink-3">{d && typeof d.decided_at === 'string' ? fmtDateTime(d.decided_at) : '—'}</Td> | |
| 105 | + </tr> | |
| 106 | + ); | |
| 107 | + })} | |
| 108 | + </tbody> | |
| 109 | + </DataTable> | |
| 110 | + <p className="mt-2 text-xs"> | |
| 111 | + <Link href={href({ status: 'decided', offset: undefined })} className="link"> | |
| 112 | + All decided pairs → | |
| 113 | + </Link>{' '} | |
| 114 | + · <Link href="/admin/audit?action=entity-resolution" className="link">Audit log →</Link> | |
| 115 | + </p> | |
| 116 | + </section> | |
| 117 | + )} | |
| 118 | + </> | |
| 119 | + ); | |
| 120 | +} | |
added
apps/web/src/app/admin/extractions/[snapshot]/page.tsx
+342 −0
@@ -0,0 +1,342 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { AdminTitle, JsonPre, KindChip, Mono, Notice, StatusChip } from '@/components/admin/ui'; | |
| 4 | +import { SectionNav } from '@/components/layout/terminal'; | |
| 5 | +import { EntityBadge, TierBadge } from '@/components/ui/badges'; | |
| 6 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 7 | +import { Note } from '@/components/ui/section'; | |
| 8 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 9 | +import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api'; | |
| 10 | +import type { ExtractionPayload, ExtractionSpan } from '@/lib/admin/types'; | |
| 11 | +import { cn } from '@/lib/cn'; | |
| 12 | +import { fmtBytes, fmtDateTime, fmtInt, fmtValue, num } from '@/lib/format'; | |
| 13 | +import { routes } from '@/lib/site'; | |
| 14 | + | |
| 15 | +export const metadata: Metadata = { title: 'Extraction debugger', robots: { index: false, follow: false } }; | |
| 16 | +export const dynamic = 'force-dynamic'; | |
| 17 | + | |
| 18 | +const STAGES = [ | |
| 19 | + { id: 'raw', label: 'Raw' }, | |
| 20 | + { id: 'normalized', label: 'Normalized' }, | |
| 21 | + { id: 'deterministic', label: 'Deterministic' }, | |
| 22 | + { id: 'llm', label: 'LLM' }, | |
| 23 | + { id: 'candidates', label: 'Candidates' }, | |
| 24 | + { id: 'claims', label: 'Claims' }, | |
| 25 | + { id: 'relations', label: 'Relations' }, | |
| 26 | + { id: 'reconciliation', label: 'Reconciliation' }, | |
| 27 | + { id: 'events', label: 'Events' }, | |
| 28 | +]; | |
| 29 | + | |
| 30 | +/** Text excerpt with the located claim values highlighted (offsets are into the cleaned text). */ | |
| 31 | +function HighlightedText({ text, spans }: { text: string; spans: ExtractionSpan[] }) { | |
| 32 | + const found = spans.filter((s) => s.found && typeof s.offset === 'number' && s.offset >= 0 && s.offset < text.length && s.match).sort((a, b) => (a.offset ?? 0) - (b.offset ?? 0)); | |
| 33 | + const parts: React.ReactNode[] = []; | |
| 34 | + let cursor = 0; | |
| 35 | + for (const s of found) { | |
| 36 | + const start = s.offset as number; | |
| 37 | + const len = (s.match as string).length; | |
| 38 | + if (start < cursor) continue; | |
| 39 | + parts.push(text.slice(cursor, start)); | |
| 40 | + parts.push( | |
| 41 | + <mark key={s.claim_id} id={`span-${s.claim_id}`} className="rounded-[2px] bg-accent-soft px-0.5 text-ink outline outline-1 outline-accent/40" title={`${s.property} = ${fmtValue(s.value, s.property)}`}> | |
| 42 | + {text.slice(start, start + len)} | |
| 43 | + </mark>, | |
| 44 | + ); | |
| 45 | + cursor = start + len; | |
| 46 | + } | |
| 47 | + parts.push(text.slice(cursor)); | |
| 48 | + return <>{parts}</>; | |
| 49 | +} | |
| 50 | + | |
| 51 | +function Stage({ id, title, count, children, lede }: { id: string; title: string; count?: number | null; children: React.ReactNode; lede?: string }) { | |
| 52 | + return ( | |
| 53 | + <section id={id} className="scroll-mt-24 border-t border-rule py-5"> | |
| 54 | + <h2 className="text-base font-semibold tracking-tight"> | |
| 55 | + {title} {count !== undefined && <span className="tnum text-sm font-normal text-ink-3">{fmtInt(count)}</span>} | |
| 56 | + </h2> | |
| 57 | + {lede && <p className="mt-0.5 text-xs text-ink-3">{lede}</p>} | |
| 58 | + <div className="mt-3">{children}</div> | |
| 59 | + </section> | |
| 60 | + ); | |
| 61 | +} | |
| 62 | + | |
| 63 | +function ClaimsTable({ claims, spans, caption }: { claims: ExtractionPayload['claims']; spans: ExtractionSpan[]; caption: string }) { | |
| 64 | + const spanById = new Map(spans.map((s) => [s.claim_id, s])); | |
| 65 | + return ( | |
| 66 | + <DataTable compact scroll caption={caption}> | |
| 67 | + <thead> | |
| 68 | + <tr> | |
| 69 | + <Th>Entity</Th> | |
| 70 | + <Th>Property</Th> | |
| 71 | + <Th>Value</Th> | |
| 72 | + <Th>Raw</Th> | |
| 73 | + <Th>Status</Th> | |
| 74 | + <Th>Conf.</Th> | |
| 75 | + <Th>Tier</Th> | |
| 76 | + <Th>Located in text</Th> | |
| 77 | + <Th>Claim</Th> | |
| 78 | + </tr> | |
| 79 | + </thead> | |
| 80 | + <tbody> | |
| 81 | + {claims.length === 0 && <EmptyRow cols={9}>No claims at this stage.</EmptyRow>} | |
| 82 | + {claims.map((c) => { | |
| 83 | + const s = spanById.get(c.id); | |
| 84 | + return ( | |
| 85 | + <tr key={c.id}> | |
| 86 | + <Td primary>{c.entity_slug ? <Link href={routes.entity({ entity_type: 'model', slug: c.entity_slug })} className="text-ink hover:text-accent">{c.entity_slug}</Link> : <Mono>{c.entity_id ?? '—'}</Mono>}</Td> | |
| 87 | + <Td> | |
| 88 | + <Mono>{c.property}</Mono> | |
| 89 | + </Td> | |
| 90 | + <Td className="tnum text-xs">{fmtValue(c.value, c.property)}{c.unit ? <span className="text-ink-3"> {c.unit}</span> : null}</Td> | |
| 91 | + <Td className="text-xs text-ink-3">{c.value_raw === null || c.value_raw === undefined ? '—' : String(c.value_raw)}</Td> | |
| 92 | + <Td> | |
| 93 | + <StatusChip value={c.status} /> | |
| 94 | + </Td> | |
| 95 | + <Td className="text-xs text-ink-2">{c.confidence}</Td> | |
| 96 | + <Td> | |
| 97 | + <TierBadge tier={num(c.tier)} /> | |
| 98 | + </Td> | |
| 99 | + <Td className="text-xs"> | |
| 100 | + {s?.found ? ( | |
| 101 | + <a href={`#span-${c.id}`} className="text-positive hover:underline"> | |
| 102 | + found @ {s.offset} | |
| 103 | + </a> | |
| 104 | + ) : s ? ( | |
| 105 | + <span className="text-warning" title={s.tried?.length ? `tried: ${s.tried.join(', ')}` : undefined}> | |
| 106 | + not found | |
| 107 | + </span> | |
| 108 | + ) : ( | |
| 109 | + <span className="text-ink-3">—</span> | |
| 110 | + )} | |
| 111 | + </Td> | |
| 112 | + <Td> | |
| 113 | + <Link href={routes.claim(c.id)} className="mono text-[11px] text-accent hover:underline"> | |
| 114 | + {c.id} | |
| 115 | + </Link> | |
| 116 | + </Td> | |
| 117 | + </tr> | |
| 118 | + ); | |
| 119 | + })} | |
| 120 | + </tbody> | |
| 121 | + </DataTable> | |
| 122 | + ); | |
| 123 | +} | |
| 124 | + | |
| 125 | +export default async function ExtractionPage({ params, searchParams }: { params: Promise<{ snapshot: string }>; searchParams: Promise<Record<string, string | undefined>> }) { | |
| 126 | + await requireAdmin(); | |
| 127 | + const { snapshot } = await params; | |
| 128 | + const sp = await searchParams; | |
| 129 | + const res = await load(adminApi.extraction(snapshot, 20000)); | |
| 130 | + if (!res.ok) { | |
| 131 | + return ( | |
| 132 | + <> | |
| 133 | + <AdminTitle title="Extraction debugger" /> | |
| 134 | + <Unavailable what="Extraction" reason={res.error} /> | |
| 135 | + </> | |
| 136 | + ); | |
| 137 | + } | |
| 138 | + const x = res.data; | |
| 139 | + const det = x.claims.filter((c) => c.extractor !== 'llm'); | |
| 140 | + const llm = x.claims.filter((c) => c.extractor === 'llm'); | |
| 141 | + const text = x.text ?? ''; | |
| 142 | + return ( | |
| 143 | + <> | |
| 144 | + <AdminTitle title="Extraction debugger" count={<Mono>{x.id}</Mono>} lede="The pipeline for one snapshot: raw fetch → cleaned text → deterministic claims → LLM claims → entity candidates → claims written → relations → reconciliation with the previous snapshot → events. Value locations are a best-effort search in the text; not-found is reported, never inferred."> | |
| 145 | + <Link href={`/admin/snapshots/${encodeURIComponent(x.id)}`} className="text-xs text-ink-3 hover:text-ink"> | |
| 146 | + Snapshot record → | |
| 147 | + </Link> | |
| 148 | + {x.document_id && ( | |
| 149 | + <Link href={`/admin/documents/${encodeURIComponent(x.document_id)}`} className="text-xs text-ink-3 hover:text-ink"> | |
| 150 | + Document → | |
| 151 | + </Link> | |
| 152 | + )} | |
| 153 | + </AdminTitle> | |
| 154 | + <Notice notice={sp.notice} level={sp.level} /> | |
| 155 | + <SectionNav items={STAGES} className="mb-2" /> | |
| 156 | + | |
| 157 | + <Stage id="raw" title="RAW" lede="What was fetched (paths are never exposed)."> | |
| 158 | + <dl className="kv [&>div]:py-1 text-sm"> | |
| 159 | + <div> | |
| 160 | + <dt>URL</dt> | |
| 161 | + <dd> | |
| 162 | + <a href={x.final_url ?? x.url} target="_blank" rel="noopener noreferrer" className="link break-all text-xs"> | |
| 163 | + {x.final_url ?? x.url} | |
| 164 | + </a> | |
| 165 | + </dd> | |
| 166 | + </div> | |
| 167 | + <div> | |
| 168 | + <dt>Observed</dt> | |
| 169 | + <dd className="tnum">{fmtDateTime(x.observed_at)}</dd> | |
| 170 | + </div> | |
| 171 | + <div> | |
| 172 | + <dt>HTTP · type · size</dt> | |
| 173 | + <dd className="tnum"> | |
| 174 | + {fmtValue(x.http_status)} · {x.content_type ?? '—'} · {fmtBytes(x.byte_size)} | |
| 175 | + </dd> | |
| 176 | + </div> | |
| 177 | + <div> | |
| 178 | + <dt>Hashes</dt> | |
| 179 | + <dd> | |
| 180 | + <Mono>content {x.content_hash?.slice(0, 16) ?? '—'}</Mono> <Mono>text {x.text_hash?.slice(0, 16) ?? '—'}</Mono> | |
| 181 | + </dd> | |
| 182 | + </div> | |
| 183 | + <div> | |
| 184 | + <dt>Connector · parser · transport</dt> | |
| 185 | + <dd> | |
| 186 | + <Mono>{x.connector_name ?? '—'}</Mono> <Mono>parser v{x.parser_version ?? '—'}</Mono> <Mono>{x.transport ?? '—'}</Mono> | |
| 187 | + </dd> | |
| 188 | + </div> | |
| 189 | + <div> | |
| 190 | + <dt>Run · document</dt> | |
| 191 | + <dd> | |
| 192 | + <Mono>{x.run_id ?? '—'}</Mono> · <Mono>{x.document_id}</Mono> {x.doc_type && <KindChip value={x.doc_type} />} | |
| 193 | + </dd> | |
| 194 | + </div> | |
| 195 | + <div> | |
| 196 | + <dt>Entity</dt> | |
| 197 | + <dd> | |
| 198 | + {x.entity_slug ? ( | |
| 199 | + <span className="inline-flex items-center gap-1.5"> | |
| 200 | + <EntityBadge type="model" small /> | |
| 201 | + <Link href={routes.entity({ entity_type: 'model', slug: x.entity_slug })} className="text-ink hover:text-accent"> | |
| 202 | + {x.entity_name ?? x.entity_slug} | |
| 203 | + </Link> | |
| 204 | + </span> | |
| 205 | + ) : ( | |
| 206 | + '—' | |
| 207 | + )} | |
| 208 | + </dd> | |
| 209 | + </div> | |
| 210 | + <div> | |
| 211 | + <dt>Flags</dt> | |
| 212 | + <dd className="text-xs"> | |
| 213 | + raw {x.has_raw ? 'archived' : '—'} · text {x.has_text ? 'yes' : '—'} · structured {x.has_structured || x.structured ? 'yes' : '—'} · changed {x.changed ? 'yes' : 'no'} · <StatusChip value={x.processing_status} /> | |
| 214 | + </dd> | |
| 215 | + </div> | |
| 216 | + </dl> | |
| 217 | + {x.structured != null && ( | |
| 218 | + <details className="mt-3"> | |
| 219 | + <summary className="cursor-pointer text-xs text-ink-3 hover:text-ink">Structured data (JSON-LD, OG, embedded JSON)</summary> | |
| 220 | + <JsonPre value={x.structured} maxHeight="20rem" /> | |
| 221 | + </details> | |
| 222 | + )} | |
| 223 | + </Stage> | |
| 224 | + | |
| 225 | + <Stage id="normalized" title="NORMALIZED" count={num(x.text_chars)} lede={`Cleaned text${x.text_truncated ? ' (excerpt — truncated to 20 kB)' : ''}; ${fmtInt(x.spans_found)} of ${fmtInt(x.spans.length)} claim values located and highlighted.`}> | |
| 226 | + {text ? ( | |
| 227 | + <pre className="scrollbar-thin max-h-[32rem] overflow-auto whitespace-pre-wrap border border-rule bg-surface p-3 text-[12px] leading-relaxed text-ink-2" data-extraction-text> | |
| 228 | + <HighlightedText text={text} spans={x.spans} /> | |
| 229 | + </pre> | |
| 230 | + ) : ( | |
| 231 | + <p className="text-sm text-ink-3">No cleaned text{x.text_error ? ` — ${x.text_error}` : ''}.</p> | |
| 232 | + )} | |
| 233 | + </Stage> | |
| 234 | + | |
| 235 | + <Stage id="deterministic" title="DETERMINISTIC" count={det.length} lede="Rule-based extraction (tables, JSON-LD, meta, regex) — always runs first."> | |
| 236 | + <ClaimsTable claims={det} spans={x.spans} caption="Deterministic claims" /> | |
| 237 | + </Stage> | |
| 238 | + | |
| 239 | + <Stage id="llm" title="LLM" count={llm.length + x.llm_jobs.length} lede="LLM jobs for this snapshot and the claims they produced (one tier lower than deterministic)."> | |
| 240 | + {x.llm_jobs.length > 0 && <JsonPre value={x.llm_jobs} maxHeight="14rem" />} | |
| 241 | + {x.llm_jobs.length === 0 && llm.length === 0 ? <p className="text-sm text-ink-3">No LLM extraction for this snapshot (deterministic only).</p> : <ClaimsTable claims={llm} spans={x.spans} caption="LLM claims" />} | |
| 242 | + </Stage> | |
| 243 | + | |
| 244 | + <Stage id="candidates" title="CANDIDATES" count={x.entity_candidates.length} lede="Entities the resolver matched or created for this document."> | |
| 245 | + <DataTable compact caption="Entity candidates"> | |
| 246 | + <thead> | |
| 247 | + <tr> | |
| 248 | + <Th>Entity</Th> | |
| 249 | + <Th>Type</Th> | |
| 250 | + <Th>Identity</Th> | |
| 251 | + <Th>Merged into</Th> | |
| 252 | + <Th>Id</Th> | |
| 253 | + </tr> | |
| 254 | + </thead> | |
| 255 | + <tbody> | |
| 256 | + {x.entity_candidates.length === 0 && <EmptyRow cols={5}>No candidate.</EmptyRow>} | |
| 257 | + {x.entity_candidates.map((c) => ( | |
| 258 | + <tr key={c.id}> | |
| 259 | + <Td primary> | |
| 260 | + <Link href={routes.entity({ entity_type: c.entity_type, slug: c.slug })} className="text-ink hover:text-accent"> | |
| 261 | + {c.canonical_name} | |
| 262 | + </Link> | |
| 263 | + </Td> | |
| 264 | + <Td> | |
| 265 | + <EntityBadge type={c.entity_type} small /> | |
| 266 | + </Td> | |
| 267 | + <Td className="text-xs text-ink-2">{c.identity_confidence ?? '—'}</Td> | |
| 268 | + <Td>{c.merged_into ? <Mono>{c.merged_into}</Mono> : <span className="text-ink-3">—</span>}</Td> | |
| 269 | + <Td> | |
| 270 | + <Mono>{c.id}</Mono> | |
| 271 | + </Td> | |
| 272 | + </tr> | |
| 273 | + ))} | |
| 274 | + </tbody> | |
| 275 | + </DataTable> | |
| 276 | + </Stage> | |
| 277 | + | |
| 278 | + <Stage id="claims" title="CLAIMS" count={x.claims.length} lede="Every claim written from this snapshot, with its status after the writer's temporal rules."> | |
| 279 | + <ClaimsTable claims={x.claims} spans={x.spans} caption="All claims" /> | |
| 280 | + {(x.results.length > 0 || x.prices.length > 0) && ( | |
| 281 | + <div className="mt-4 grid gap-4 md:grid-cols-2"> | |
| 282 | + <div> | |
| 283 | + <p className="eyebrow mb-1">Benchmark results {fmtInt(x.results.length)}</p> | |
| 284 | + <JsonPre value={x.results} maxHeight="14rem" /> | |
| 285 | + </div> | |
| 286 | + <div> | |
| 287 | + <p className="eyebrow mb-1">Prices {fmtInt(x.prices.length)}</p> | |
| 288 | + <JsonPre value={x.prices} maxHeight="14rem" /> | |
| 289 | + </div> | |
| 290 | + </div> | |
| 291 | + )} | |
| 292 | + </Stage> | |
| 293 | + | |
| 294 | + <Stage id="relations" title="RELATIONS" count={x.relations.length}> | |
| 295 | + {x.relations.length ? <JsonPre value={x.relations} maxHeight="18rem" /> : <p className="text-sm text-ink-3">No relation written from this snapshot.</p>} | |
| 296 | + </Stage> | |
| 297 | + | |
| 298 | + <Stage id="reconciliation" title="RECONCILIATION" lede="Against the previous snapshot of the same document: what changed, and how the writer treated each claim (confirm / supersede / conflict)."> | |
| 299 | + {x.previous_snapshot ? ( | |
| 300 | + <dl className="kv [&>div]:py-1 text-sm"> | |
| 301 | + <div> | |
| 302 | + <dt>Previous snapshot</dt> | |
| 303 | + <dd> | |
| 304 | + <Link href={`/admin/extractions/${encodeURIComponent(x.previous_snapshot.id)}`} className="mono text-xs text-accent hover:underline"> | |
| 305 | + {x.previous_snapshot.id} | |
| 306 | + </Link>{' '} | |
| 307 | + <span className="tnum text-xs text-ink-3">{fmtDateTime(x.previous_snapshot.observed_at)}</span> | |
| 308 | + </dd> | |
| 309 | + </div> | |
| 310 | + <div> | |
| 311 | + <dt>Content changed</dt> | |
| 312 | + <dd className="text-xs">{x.previous_snapshot.content_hash && x.content_hash ? (x.previous_snapshot.content_hash === x.content_hash ? 'no (same hash)' : 'yes (hash differs)') : '—'}</dd> | |
| 313 | + </div> | |
| 314 | + <div> | |
| 315 | + <dt>Claim outcomes</dt> | |
| 316 | + <dd className="text-xs"> | |
| 317 | + {['current', 'superseded', 'conflicting', 'retracted'].map((s) => ( | |
| 318 | + <span key={s} className={cn('mr-3 tnum', s === 'conflicting' && x.claims.some((c) => c.status === s) && 'text-danger')}> | |
| 319 | + {s} {fmtInt(x.claims.filter((c) => c.status === s).length)} | |
| 320 | + </span> | |
| 321 | + ))} | |
| 322 | + </dd> | |
| 323 | + </div> | |
| 324 | + </dl> | |
| 325 | + ) : ( | |
| 326 | + <p className="text-sm text-ink-3">First snapshot of this document — nothing to reconcile against.</p> | |
| 327 | + )} | |
| 328 | + {x.diff != null && ( | |
| 329 | + <details className="mt-3"> | |
| 330 | + <summary className="cursor-pointer text-xs text-ink-3 hover:text-ink">Diff payload</summary> | |
| 331 | + <JsonPre value={x.diff} maxHeight="18rem" /> | |
| 332 | + </details> | |
| 333 | + )} | |
| 334 | + </Stage> | |
| 335 | + | |
| 336 | + <Stage id="events" title="EVENTS" count={x.events.length} lede="Change events emitted by this snapshot (material properties only)."> | |
| 337 | + {x.events.length ? <JsonPre value={x.events} maxHeight="18rem" /> : <p className="text-sm text-ink-3">No event emitted.</p>} | |
| 338 | + </Stage> | |
| 339 | + {x.note && <Note className="mt-4">{x.note}</Note>} | |
| 340 | + </> | |
| 341 | + ); | |
| 342 | +} | |
added
apps/web/src/app/admin/quality/page.tsx
+187 −0
@@ -0,0 +1,187 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { AdminTitle, JsonPre, KindChip, Mono, Notice, StatusChip } from '@/components/admin/ui'; | |
| 4 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 5 | +import { Note } from '@/components/ui/section'; | |
| 6 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 7 | +import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api'; | |
| 8 | +import type { AdminQuality, CountSample } from '@/lib/admin/types'; | |
| 9 | +import { cn } from '@/lib/cn'; | |
| 10 | +import { fmtAgo, fmtInt, num } from '@/lib/format'; | |
| 11 | +import { routes } from '@/lib/site'; | |
| 12 | + | |
| 13 | +export const metadata: Metadata = { title: 'Data quality', robots: { index: false, follow: false } }; | |
| 14 | +export const dynamic = 'force-dynamic'; | |
| 15 | + | |
| 16 | +type Tile = { key: string; label: string; count: number | null; sample: unknown[]; href?: string; tone: 'neutral' | 'warn' | 'danger'; hint: string }; | |
| 17 | + | |
| 18 | +function tile(key: string, label: string, cs: CountSample<unknown> | undefined, hint: string, opts: { href?: string; danger?: number } = {}): Tile { | |
| 19 | + const count = num(cs?.count); | |
| 20 | + const tone: Tile['tone'] = count === null || count === 0 ? 'neutral' : opts.danger !== undefined && count >= opts.danger ? 'danger' : 'warn'; | |
| 21 | + return { key, label, count, sample: (cs?.sample ?? []) as unknown[], href: opts.href, tone, hint }; | |
| 22 | +} | |
| 23 | + | |
| 24 | +function SampleRow({ s }: { s: unknown }) { | |
| 25 | + if (typeof s === 'string') return <span className="mono text-xs">{s}</span>; | |
| 26 | + const o = (s ?? {}) as Record<string, unknown>; | |
| 27 | + const slug = typeof o.slug === 'string' ? o.slug : null; | |
| 28 | + const type = typeof o.entity_type === 'string' ? o.entity_type : 'model'; | |
| 29 | + const name = typeof o.name === 'string' ? o.name : typeof o.model === 'string' ? o.model : typeof o.reason === 'string' ? o.reason : typeof o.raw === 'string' ? `${String(o.domain ?? '')}: ${o.raw}` : null; | |
| 30 | + return ( | |
| 31 | + <span className="flex flex-wrap items-center gap-x-2 text-xs"> | |
| 32 | + {slug ? ( | |
| 33 | + <Link href={routes.entity({ entity_type: type, slug })} className="text-ink hover:text-accent hover:underline"> | |
| 34 | + {name ?? slug} | |
| 35 | + </Link> | |
| 36 | + ) : ( | |
| 37 | + <span className="text-ink">{name ?? <Mono>{JSON.stringify(o).slice(0, 80)}</Mono>}</span> | |
| 38 | + )} | |
| 39 | + {typeof o.provider === 'string' && <span className="text-ink-3">· {o.provider}</span>} | |
| 40 | + {typeof o.provider_model_id === 'string' && <Mono>{o.provider_model_id}</Mono>} | |
| 41 | + {Array.isArray(o.quant_formats) && o.quant_formats.length > 0 && <Mono>{(o.quant_formats as unknown[]).join(', ')}</Mono>} | |
| 42 | + {typeof o.health === 'string' && <StatusChip value={o.health} />} | |
| 43 | + {typeof o.last_success_at === 'string' && <span className="text-ink-3">last success {fmtAgo(o.last_success_at)}</span>} | |
| 44 | + {typeof o.count === 'number' && <span className="tnum text-ink-3">× {o.count}</span>} | |
| 45 | + {typeof o.id === 'string' && !slug && <Mono>{o.id}</Mono>} | |
| 46 | + </span> | |
| 47 | + ); | |
| 48 | +} | |
| 49 | + | |
| 50 | +export default async function AdminQualityPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 51 | + await requireAdmin(); | |
| 52 | + const sp = await searchParams; | |
| 53 | + const res = await load(adminApi.quality()); | |
| 54 | + const q: AdminQuality | null = res.ok ? res.data : null; | |
| 55 | + const tiles: Tile[] = q | |
| 56 | + ? [ | |
| 57 | + tile('pending_decisions', 'Pending resolution decisions', q.duplicate_candidates?.pending_decisions, 'Candidate pairs awaiting a decision in Entity resolution.', { href: '/admin/entity-resolution' }), | |
| 58 | + tile('review_merge_candidates', 'Merge candidates (review queue)', q.duplicate_candidates?.review_merge_candidates, 'merge_candidate items in the v1 review queue.', { href: '/admin/review?kind=merge_candidate' }), | |
| 59 | + tile('unmapped_taxonomy_rows', 'Unmapped taxonomy rows', q.taxonomy_violations?.unmapped_taxonomy_rows, 'Raw labels the ontology could not map (domain: raw).'), | |
| 60 | + tile('openness_unknown_vocab', 'Openness outside vocabulary', q.taxonomy_violations?.openness_unknown_vocab, 'Openness values not in open-source | open-weights | restricted-weights | proprietary | unknown.', { danger: 1 }), | |
| 61 | + tile('status_unknown_vocab', 'Status outside vocabulary', q.taxonomy_violations?.status_unknown_vocab, 'Status values not in the status vocabulary.', { danger: 1 }), | |
| 62 | + tile('license_unclassified', 'Unclassified licences', q.taxonomy_violations?.license_unclassified, 'Licence labels without a canonical key.'), | |
| 63 | + tile('impossible_values', 'Impossible values (anomalies)', { count: q.impossible_values?.count, sample: q.impossible_values?.sample ?? [] }, 'Open anomaly flags by check — see Anomalies for resolve / ignore.', { href: '/admin/anomalies' }), | |
| 64 | + tile('conflicting_t1_claims', 'Conflicting tier-1 claims', q.conflicting_t1_claims, 'Two official sources disagree on a current value.', { danger: 1, href: '/admin/review?kind=conflict' }), | |
| 65 | + tile('models_without_organization', 'Models without organization', q.models_without_organization, 'Canonical models with no developer relation.'), | |
| 66 | + tile('models_without_release_source', 'Models without release source', q.models_without_release_source, 'Canonical models whose release date has no sourced claim.'), | |
| 67 | + tile('models_without_parameters', 'Models without parameters', q.models_without_parameters, 'Canonical models with no parameter_count claim (proprietary models are expected here).'), | |
| 68 | + tile('orphan_benchmark_results', 'Orphan benchmark results', q.orphan_benchmark_results, 'Current results whose model was merged or is an artifact.', { danger: 1 }), | |
| 69 | + tile('benchmarks_without_results', 'Benchmarks without results', q.benchmarks_without_results, 'Registered benchmarks no connector feeds yet.'), | |
| 70 | + tile('unresolved_provider_deployments', 'Unresolved provider deployments', q.unresolved_provider_deployments, 'Price rows whose provider_model_id matches no identifier.'), | |
| 71 | + tile('quantisations_typed_as_models', 'Quantisations typed as models', q.quantisations_typed_as_models, 'Name analysis says artifact but the row is a model.', { href: '/admin/entity-resolution?type=model' }), | |
| 72 | + tile('stale_sources', 'Stale sources', q.stale_sources, 'Last success older than 3× the connector interval.', { href: '/admin/connectors' }), | |
| 73 | + tile('empty_public_categories', 'Empty public categories', q.empty_public_categories, 'Entity types with a public listing but zero rows.'), | |
| 74 | + tile('quarantined_runs_pending', 'Quarantined runs pending', q.quarantined_runs_pending, 'Held connector runs awaiting release or discard.', { href: '/admin/quarantine', danger: 1 }), | |
| 75 | + ] | |
| 76 | + : []; | |
| 77 | + const queue = q?.review_queue_priority ?? []; | |
| 78 | + const byCheck = q?.impossible_values?.by_check ?? []; | |
| 79 | + return ( | |
| 80 | + <> | |
| 81 | + <AdminTitle title="Data quality" lede="Live health of the dataset: duplicates, taxonomy, impossible values, coverage gaps, stale sources. Counts are computed on request; nothing here deletes anything — every tile points at a review action."> | |
| 82 | + <Link href={routes.methodology()} className="text-xs text-ink-3 hover:text-ink"> | |
| 83 | + Anomaly checks → | |
| 84 | + </Link> | |
| 85 | + </AdminTitle> | |
| 86 | + <Notice notice={sp.notice} level={sp.level} /> | |
| 87 | + {!res.ok ? ( | |
| 88 | + <Unavailable what="Quality dashboard" reason={res.error} /> | |
| 89 | + ) : ( | |
| 90 | + <> | |
| 91 | + <ul className="grid grid-cols-2 border-l border-t border-rule md:grid-cols-3 xl:grid-cols-6" data-quality-tiles> | |
| 92 | + {tiles.map((t) => ( | |
| 93 | + <li key={t.key} className="border-b border-r border-rule"> | |
| 94 | + <details className="group h-full"> | |
| 95 | + <summary className="block cursor-pointer list-none px-3 py-2.5 hover:bg-surface-2 [&::-webkit-details-marker]:hidden"> | |
| 96 | + <p className="eyebrow leading-tight">{t.label}</p> | |
| 97 | + <p className={cn('tnum mt-1 text-2xl font-semibold leading-none tracking-tight', t.tone === 'danger' && 'text-danger', t.tone === 'warn' && 'text-warning', t.tone === 'neutral' && 'text-ink')}>{t.count === null ? '—' : fmtInt(t.count)}</p> | |
| 98 | + <p className="mt-1 text-[11px] leading-snug text-ink-3">{t.hint}</p> | |
| 99 | + </summary> | |
| 100 | + <div className="border-t border-rule px-3 py-2"> | |
| 101 | + {t.sample.length === 0 ? ( | |
| 102 | + <p className="text-xs text-ink-3">No sample.</p> | |
| 103 | + ) : ( | |
| 104 | + <ul className="space-y-1"> | |
| 105 | + {t.sample.slice(0, 8).map((s, i) => ( | |
| 106 | + <li key={i}> | |
| 107 | + <SampleRow s={s} /> | |
| 108 | + </li> | |
| 109 | + ))} | |
| 110 | + </ul> | |
| 111 | + )} | |
| 112 | + {t.href && ( | |
| 113 | + <Link href={t.href} className="link mt-2 inline-block text-xs"> | |
| 114 | + Open review action → | |
| 115 | + </Link> | |
| 116 | + )} | |
| 117 | + </div> | |
| 118 | + </details> | |
| 119 | + </li> | |
| 120 | + ))} | |
| 121 | + </ul> | |
| 122 | + {byCheck.length > 0 && ( | |
| 123 | + <p className="mt-3 flex flex-wrap gap-1.5 text-xs"> | |
| 124 | + {byCheck.map((c) => ( | |
| 125 | + <Link key={c.check_name} href={`/admin/anomalies?check=${encodeURIComponent(c.check_name)}`} className="inline-flex h-7 items-center gap-1.5 border border-rule px-2 text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 126 | + <span className={c.severity === 'critical' ? 'text-danger' : c.severity === 'warning' ? 'text-warning' : 'text-ink-3'}>{c.severity}</span> | |
| 127 | + <span className="mono">{c.check_name}</span> | |
| 128 | + <span className="tnum text-ink-3">{fmtInt(c.n)}</span> | |
| 129 | + </Link> | |
| 130 | + ))} | |
| 131 | + </p> | |
| 132 | + )} | |
| 133 | + | |
| 134 | + <h2 className="mt-8 text-base font-semibold tracking-tight"> | |
| 135 | + Prioritized review queue <span className="tnum text-sm font-normal text-ink-3">{fmtInt(queue.length)}</span> | |
| 136 | + </h2> | |
| 137 | + <Note className="mb-3 mt-1">Frontier models, benchmark leaders, the largest parameter / context claims, price anomalies, major organizations and duplicates of frontier models come first.</Note> | |
| 138 | + <DataTable compact scroll caption="Prioritized review queue"> | |
| 139 | + <thead> | |
| 140 | + <tr> | |
| 141 | + <Th>Kind</Th> | |
| 142 | + <Th>Item</Th> | |
| 143 | + <Th>Reasons</Th> | |
| 144 | + <Th>Detail</Th> | |
| 145 | + <Th>Action</Th> | |
| 146 | + </tr> | |
| 147 | + </thead> | |
| 148 | + <tbody> | |
| 149 | + {queue.length === 0 && <EmptyRow cols={5}>Nothing prioritized.</EmptyRow>} | |
| 150 | + {queue.map((it) => { | |
| 151 | + const slug = typeof it.slug === 'string' ? it.slug : null; | |
| 152 | + const type = typeof it.entity_type === 'string' ? it.entity_type : 'model'; | |
| 153 | + const href = it.kind === 'anomaly' ? `/admin/anomalies?check=${encodeURIComponent(String(it.check ?? ''))}` : it.kind === 'duplicate' || it.kind === 'merge_candidate' || it.kind === 'resolution' ? '/admin/entity-resolution' : it.kind === 'conflict' ? '/admin/review?kind=conflict' : '/admin/review'; | |
| 154 | + return ( | |
| 155 | + <tr key={`${it.kind}-${it.id}`}> | |
| 156 | + <Td> | |
| 157 | + <KindChip value={it.kind} /> | |
| 158 | + {typeof it.severity === 'string' && <span className={cn('ml-1 text-[11px]', it.severity === 'critical' ? 'text-danger' : 'text-warning')}>{it.severity}</span>} | |
| 159 | + </Td> | |
| 160 | + <Td primary> | |
| 161 | + {slug ? ( | |
| 162 | + <Link href={routes.entity({ entity_type: type, slug })} className="text-ink hover:text-accent hover:underline"> | |
| 163 | + {typeof it.name === 'string' ? it.name : slug} | |
| 164 | + </Link> | |
| 165 | + ) : ( | |
| 166 | + <Mono>{it.id}</Mono> | |
| 167 | + )} | |
| 168 | + {typeof it.check === 'string' && <Mono className="block">{it.check}</Mono>} | |
| 169 | + </Td> | |
| 170 | + <Td className="text-xs text-ink-2">{it.reasons?.join(' · ') || '—'}</Td> | |
| 171 | + <Td className="max-w-[28rem] text-xs text-ink-2">{typeof it.message === 'string' ? it.message : <JsonPre value={Object.fromEntries(Object.entries(it).filter(([k]) => !['kind', 'id', 'reasons', 'slug', 'entity_id', 'check', 'severity', 'name', 'entity_type'].includes(k)))} maxHeight="6rem" />}</Td> | |
| 172 | + <Td> | |
| 173 | + <Link href={href} className="link text-xs"> | |
| 174 | + Review → | |
| 175 | + </Link> | |
| 176 | + </Td> | |
| 177 | + </tr> | |
| 178 | + ); | |
| 179 | + })} | |
| 180 | + </tbody> | |
| 181 | + </DataTable> | |
| 182 | + {q?.note && <Note className="mt-3">{q.note}</Note>} | |
| 183 | + </> | |
| 184 | + )} | |
| 185 | + </> | |
| 186 | + ); | |
| 187 | +} | |
added
apps/web/src/app/admin/quarantine/page.tsx
+101 −0
@@ -0,0 +1,101 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { ActionButton, AdminFilters, AdminTitle, JsonPre, Mono, Notice, StatusChip } from '@/components/admin/ui'; | |
| 4 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 5 | +import { LiveAgo } from '@/components/ui/live'; | |
| 6 | +import { Pagination, withParams } from '@/components/ui/pagination'; | |
| 7 | +import { Note } from '@/components/ui/section'; | |
| 8 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 9 | +import { quarantineAction } from '@/lib/admin/actions'; | |
| 10 | +import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api'; | |
| 11 | +import { fmtDateTime, fmtInt } from '@/lib/format'; | |
| 12 | + | |
| 13 | +export const metadata: Metadata = { title: 'Quarantine', robots: { index: false, follow: false } }; | |
| 14 | +export const dynamic = 'force-dynamic'; | |
| 15 | +const LIMIT = 50; | |
| 16 | + | |
| 17 | +export default async function AdminQuarantinePage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 18 | + await requireAdmin(); | |
| 19 | + const sp = await searchParams; | |
| 20 | + const current: Record<string, string | undefined> = { status: sp.status ?? 'pending' }; | |
| 21 | + if (sp.offset) current.offset = sp.offset; | |
| 22 | + const offset = Math.max(0, Number(current.offset) || 0); | |
| 23 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/admin/quarantine', current, patch); | |
| 24 | + const ret = href({}); | |
| 25 | + const res = await load(adminApi.quarantine({ status: current.status, limit: LIMIT, offset })); | |
| 26 | + return ( | |
| 27 | + <> | |
| 28 | + <AdminTitle title="Quarantine" count={res.ok ? fmtInt(res.data.total) : undefined} lede="Connector runs held back by the writer (breakage suspicion, implausible volumes). Release writes the held facts; discard drops them. Both are audited."> | |
| 29 | + <Link href="/admin/runs" className="text-xs text-ink-3 hover:text-ink"> | |
| 30 | + Runs → | |
| 31 | + </Link> | |
| 32 | + </AdminTitle> | |
| 33 | + <Notice notice={sp.notice} level={sp.level} /> | |
| 34 | + <AdminFilters action="/admin/quarantine" className="mb-4" fields={[{ kind: 'select', name: 'status', label: 'Status', value: current.status, any: 'pending', options: ['pending', 'released', 'discarded', 'all'].map((s) => ({ value: s, label: s })) }]} /> | |
| 35 | + {!res.ok ? ( | |
| 36 | + <Unavailable what="Quarantine" reason={res.error} /> | |
| 37 | + ) : res.data.items.length === 0 ? ( | |
| 38 | + <EmptyState title="Nothing in quarantine">No held run for this status.</EmptyState> | |
| 39 | + ) : ( | |
| 40 | + <> | |
| 41 | + <DataTable compact scroll caption="Quarantined runs"> | |
| 42 | + <thead> | |
| 43 | + <tr> | |
| 44 | + <Th>Id</Th> | |
| 45 | + <Th>Connector</Th> | |
| 46 | + <Th>Run</Th> | |
| 47 | + <Th>Reason</Th> | |
| 48 | + <Th>Counts</Th> | |
| 49 | + <Th>Status</Th> | |
| 50 | + <Th>Created</Th> | |
| 51 | + <Th>Actions</Th> | |
| 52 | + </tr> | |
| 53 | + </thead> | |
| 54 | + <tbody> | |
| 55 | + {res.data.items.length === 0 && <EmptyRow cols={8}>No rows.</EmptyRow>} | |
| 56 | + {res.data.items.map((q) => ( | |
| 57 | + <tr key={q.id}> | |
| 58 | + <Td primary> | |
| 59 | + <Mono>{q.id}</Mono> | |
| 60 | + </Td> | |
| 61 | + <Td> | |
| 62 | + <Mono>{q.connector_name ?? '—'}</Mono> | |
| 63 | + </Td> | |
| 64 | + <Td>{q.run_id ? <Link href={`/admin/runs?connector=${encodeURIComponent(q.connector_name ?? '')}`} className="mono text-[11px] text-accent hover:underline">{q.run_id}</Link> : '—'}</Td> | |
| 65 | + <Td className="max-w-[20rem] text-xs text-ink-2">{q.reason ?? '—'}</Td> | |
| 66 | + <Td className="text-xs">{q.counts ? <JsonPre value={q.counts} maxHeight="6rem" /> : '—'}</Td> | |
| 67 | + <Td> | |
| 68 | + <StatusChip value={q.status} /> | |
| 69 | + </Td> | |
| 70 | + <Td className="text-xs text-ink-3" title={q.created_at ? fmtDateTime(q.created_at) : undefined}>{q.created_at ? <LiveAgo at={q.created_at} /> : '—'}</Td> | |
| 71 | + <Td> | |
| 72 | + {q.status === 'pending' ? ( | |
| 73 | + <div className="flex items-center gap-1"> | |
| 74 | + <form action={quarantineAction}> | |
| 75 | + <input type="hidden" name="id" value={q.id} /> | |
| 76 | + <input type="hidden" name="action" value="release" /> | |
| 77 | + <input type="hidden" name="return" value={ret} /> | |
| 78 | + <ActionButton tone="positive" title="Write the held facts">Release</ActionButton> | |
| 79 | + </form> | |
| 80 | + <form action={quarantineAction}> | |
| 81 | + <input type="hidden" name="id" value={q.id} /> | |
| 82 | + <input type="hidden" name="action" value="discard" /> | |
| 83 | + <input type="hidden" name="return" value={ret} /> | |
| 84 | + <ActionButton tone="danger" title="Drop the held facts (audited)">Discard</ActionButton> | |
| 85 | + </form> | |
| 86 | + </div> | |
| 87 | + ) : ( | |
| 88 | + <span className="text-xs text-ink-3">—</span> | |
| 89 | + )} | |
| 90 | + </Td> | |
| 91 | + </tr> | |
| 92 | + ))} | |
| 93 | + </tbody> | |
| 94 | + </DataTable> | |
| 95 | + <Pagination total={res.data.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" /> | |
| 96 | + </> | |
| 97 | + )} | |
| 98 | + <Note className="mt-4">The release / discard endpoints answer 501 while the canonical service has not shipped them; the notice above reports the API's answer verbatim.</Note> | |
| 99 | + </> | |
| 100 | + ); | |
| 101 | +} | |
modified
apps/web/src/app/admin/runs/page.tsx
+10 −3
@@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | −import { AdminFilters, AdminTitle, Mono, StatusChip, Trunc } from '@/components/admin/ui'; | |
| 3 | +import { RollbackButton } from '@/components/admin/rollback-button'; | |
| 4 | +import { AdminFilters, AdminTitle, Mono, Notice, StatusChip, Trunc } from '@/components/admin/ui'; | |
| 4 | 5 | import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; |
| 5 | 6 | import { LiveAgo } from '@/components/ui/live'; |
| 6 | 7 | import { Unavailable } from '@/components/ui/unavailable'; |
@@ -16,9 +17,11 @@ export default async function AdminRunsPage({ searchParams }: { searchParams: Pr | ||
| 16 | 17 | const limit = Math.min(500, Math.max(10, Number(sp.limit) || 100)); |
| 17 | 18 | const [runs, connectors] = await Promise.all([load(adminApi.runs({ connector: sp.connector, limit })), load(adminApi.connectors())]); |
| 18 | 19 | const names = connectors.ok ? connectors.data.items.map((c) => c.name).sort() : []; |
| 20 | + const ret = `/admin/runs${sp.connector ? `?connector=${encodeURIComponent(sp.connector)}` : ''}`; | |
| 19 | 21 | return ( |
| 20 | 22 | <> |
| 21 | − <AdminTitle title="Runs" count={runs.ok ? fmtInt(runs.data.total) : undefined} /> | |
| 23 | + <AdminTitle title="Runs" count={runs.ok ? fmtInt(runs.data.total) : undefined} lede="Rollback retracts a run's claims, closes its relations / prices / results and flags its events as backfill — only rows carrying that run_id; nothing is deleted." /> | |
| 24 | + <Notice notice={sp.notice} level={sp.level} /> | |
| 22 | 25 | <AdminFilters |
| 23 | 26 | action="/admin/runs" |
| 24 | 27 | className="mb-4" |
@@ -48,10 +51,11 @@ export default async function AdminRunsPage({ searchParams }: { searchParams: Pr | ||
| 48 | 51 | <Th num>Events</Th> |
| 49 | 52 | <Th>Error</Th> |
| 50 | 53 | <Th>Run id</Th> |
| 54 | + <Th>Rollback</Th> | |
| 51 | 55 | </tr> |
| 52 | 56 | </thead> |
| 53 | 57 | <tbody> |
| 54 | − {runs.data.items.length === 0 && <EmptyRow cols={15}>No runs match.</EmptyRow>} | |
| 58 | + {runs.data.items.length === 0 && <EmptyRow cols={16}>No runs match.</EmptyRow>} | |
| 55 | 59 | {runs.data.items.map((r) => ( |
| 56 | 60 | <tr key={r.id}> |
| 57 | 61 | <Td><Link href={`/admin/runs?connector=${encodeURIComponent(r.connector_name)}`} className="mono text-xs font-medium text-ink hover:text-accent">{r.connector_name}</Link></Td> |
@@ -69,6 +73,9 @@ export default async function AdminRunsPage({ searchParams }: { searchParams: Pr | ||
| 69 | 73 | <Td num className="tnum text-xs">{fmtInt(r.events_emitted)}</Td> |
| 70 | 74 | <Td className="text-xs text-danger"><Trunc text={r.error} max={50} /></Td> |
| 71 | 75 | <Td><Mono>{r.id}</Mono></Td> |
| 76 | + <Td> | |
| 77 | + <RollbackButton runId={r.id} connector={r.connector_name} returnTo={ret} disabled={r.status === 'running' || r.status === 'queued' || Boolean((r.meta as { rolled_back?: unknown } | null)?.rolled_back)} /> | |
| 78 | + </Td> | |
| 72 | 79 | </tr> |
| 73 | 80 | ))} |
| 74 | 81 | </tbody> |
modified
apps/web/src/app/admin/snapshots/[id]/page.tsx
+5 −1
@@ -66,7 +66,11 @@ export default async function AdminSnapshotPage({ params }: { params: Promise<{ | ||
| 66 | 66 | <p className="mb-2 text-xs"> |
| 67 | 67 | <Link href="/admin/documents" className="link">← Documents</Link> · <Link href={`/admin/documents/${encodeURIComponent(s.document_id)}`} className="link">document</Link> |
| 68 | 68 | </p> |
| 69 | − <AdminTitle title={`Snapshot ${fmtDateTime(s.observed_at)}`} lede={<Mono>{s.id}</Mono>} /> | |
| 69 | + <AdminTitle title={`Snapshot ${fmtDateTime(s.observed_at)}`} lede={<Mono>{s.id}</Mono>}> | |
| 70 | + <Link href={`/admin/extractions/${encodeURIComponent(s.id)}`} className="inline-flex h-8 items-center border border-rule px-2.5 text-xs text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 71 | + Extraction debugger → | |
| 72 | + </Link> | |
| 73 | + </AdminTitle> | |
| 70 | 74 | <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_22rem]"> |
| 71 | 75 | <div className="min-w-0 space-y-8"> |
| 72 | 76 | <section> |
added
apps/web/src/app/changes/[date]/opengraph-image.tsx
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +import { ImageResponse } from 'next/og'; | |
| 2 | +import { Wallpaper } from '@/components/brand/og'; | |
| 3 | +import { TODAY_LABELS } from '@/components/changes/today-sections'; | |
| 4 | +import { apiD3, safe } from '@/lib/api'; | |
| 5 | +import { fmtDate, fmtInt, num } from '@/lib/format'; | |
| 6 | +import { SITE_NAME } from '@/lib/site'; | |
| 7 | + | |
| 8 | +export const runtime = 'nodejs'; | |
| 9 | +export const alt = `Today in AI on ${SITE_NAME}`; | |
| 10 | +export const size = { width: 1200, height: 630 }; | |
| 11 | +export const contentType = 'image/png'; | |
| 12 | + | |
| 13 | +/** OG image for /changes/[date]: the day and its live section counts. */ | |
| 14 | +export default async function DailyOgImage({ params }: { params: Promise<{ date: string }> }) { | |
| 15 | + const { date } = await params; | |
| 16 | + const ok = /^\d{4}-\d{2}-\d{2}$/.test(date); | |
| 17 | + const d = ok ? await safe(apiD3.changesDaily(date, 1)) : null; | |
| 18 | + const sections = (d?.today ?? []).filter((s) => (num(s.total) ?? 0) > 0).slice(0, 4); | |
| 19 | + const counters: [string, string][] = d ? [['Events', fmtInt(d.total)], ...sections.map((s) => [s.label ?? TODAY_LABELS[s.key] ?? s.key, fmtInt(s.total)] as [string, string])] : []; | |
| 20 | + return new ImageResponse(<Wallpaper eyebrow="Today in AI" title={ok ? fmtDate(date) : 'Daily digest'} subtitle="Major releases · price moves · benchmark moves · model changes · research · open weights · deprecations · providers · hardware" counters={counters} footer={`www.ai-atlas.co/changes/${ok ? date : ''}`} markPx={220} />, { ...size }); | |
| 21 | +} | |
modified
apps/web/src/app/changes/[date]/page.tsx
+106 −35
@@ -1,15 +1,19 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { notFound } from 'next/navigation'; |
| 4 | −import { ChangeRow } from '@/components/changes/change-row'; | |
| 4 | +import { orderedSections, QuietLine, TODAY_FEED_HREF, TODAY_LABELS, TodayItemRow } from '@/components/changes/today-sections'; | |
| 5 | +import { DataStrip, SectionNav } from '@/components/layout/terminal'; | |
| 6 | +import { BreadcrumbLd } from '@/components/meta/breadcrumb-ld'; | |
| 7 | +import { ImportanceMark } from '@/components/ui/badges'; | |
| 5 | 8 | import { EntityLink } from '@/components/ui/entity'; |
| 6 | −import { Container, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section'; | |
| 9 | +import { Hint } from '@/components/ui/hint'; | |
| 10 | +import { Container, Note, PageHeader, Section } from '@/components/ui/section'; | |
| 7 | 11 | import { EmptyState, Unavailable } from '@/components/ui/unavailable'; |
| 8 | −import { api, safe } from '@/lib/api'; | |
| 12 | +import { apiD3, safe } from '@/lib/api'; | |
| 9 | 13 | import { fmtDate, fmtInt, num } from '@/lib/format'; |
| 10 | −import { categoryLabel, routes, SITE_NAME } from '@/lib/site'; | |
| 14 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 11 | 15 | |
| 12 | −type Params = { params: Promise<{ date: string }> }; | |
| 16 | +type Params = { params: Promise<{ date: string }>; searchParams: Promise<{ include_backfill?: string }> }; | |
| 13 | 17 | const VALID = /^\d{4}-\d{2}-\d{2}$/; |
| 14 | 18 | |
| 15 | 19 | function shift(date: string, days: number): string { |
@@ -21,69 +25,136 @@ function shift(date: string, days: number): string { | ||
| 21 | 25 | export async function generateMetadata({ params }: Params): Promise<Metadata> { |
| 22 | 26 | const { date } = await params; |
| 23 | 27 | if (!VALID.test(date)) return { title: 'Not found', robots: { index: false } }; |
| 24 | − const title = `What changed in AI on ${fmtDate(date)}`; | |
| 25 | − return { title, description: `Daily digest of AI ecosystem changes for ${fmtDate(date)}: new models, prices, benchmarks, deprecations and announcements — generated from the ${SITE_NAME} database.`, alternates: { canonical: routes.changesDay(date) } }; | |
| 28 | + const title = `Today in AI — ${fmtDate(date)}`; | |
| 29 | + const description = `What happened in AI on ${fmtDate(date)}: major releases, price moves, benchmark moves, model changes, research, open-weight releases, deprecations, provider and hardware changes — generated from the ${SITE_NAME} change log.`; | |
| 30 | + return { title, description, alternates: { canonical: routes.changesDay(date) }, openGraph: { title: `${title} | ${SITE_NAME}`, description, url: `${SITE_URL}${routes.changesDay(date)}`, type: 'article' } }; | |
| 26 | 31 | } |
| 27 | 32 | |
| 28 | −export default async function DailyPage({ params }: Params) { | |
| 33 | +export default async function DailyPage({ params, searchParams }: Params) { | |
| 29 | 34 | const { date } = await params; |
| 35 | + const sp = await searchParams; | |
| 30 | 36 | if (!VALID.test(date) || Number.isNaN(new Date(`${date}T00:00:00Z`).getTime())) notFound(); |
| 31 | 37 | const today = new Date().toISOString().slice(0, 10); |
| 32 | 38 | if (date > today) notFound(); |
| 33 | − const d = await safe(api.changesDaily(date)); | |
| 34 | − const total = d ? Object.values(d.counts ?? {}).reduce<number>((n, v) => n + (num(v) ?? 0), 0) : 0; | |
| 35 | − const cats = d ? Object.entries(d.counts ?? {}).sort((a, b) => (num(b[1]) ?? 0) - (num(a[1]) ?? 0)) : []; | |
| 39 | + const backfill = sp.include_backfill === '1'; | |
| 40 | + const d = await safe(apiD3.changesDaily(date, 30, backfill)); | |
| 41 | + const { present, quiet } = orderedSections(d?.today); | |
| 42 | + const total = num(d?.total) ?? (d ? Object.values(d.counts ?? {}).reduce<number>((n, v) => n + (num(v) ?? 0), 0) : 0); | |
| 43 | + const excluded = num(d?.backfill_excluded) ?? 0; | |
| 44 | + const prev = d?.previous_day ?? shift(date, -1); | |
| 45 | + const next = d?.next_day && d.next_day <= today ? d.next_day : date < today ? shift(date, 1) : null; | |
| 46 | + const strip = (d?.today ?? []).filter((s) => (num(s.total) ?? 0) > 0).slice(0, 8); | |
| 36 | 47 | |
| 37 | 48 | return ( |
| 38 | − <Container> | |
| 49 | + <Container wide> | |
| 50 | + <BreadcrumbLd items={[{ name: SITE_NAME, href: '/' }, { name: 'Changes', href: '/changes' }, { name: fmtDate(date), href: routes.changesDay(date) }]} /> | |
| 39 | 51 | <PageHeader |
| 40 | − eyebrow={<><Link href={routes.changes()} className="hover:text-ink">Changes</Link><span aria-hidden>/</span><span>Daily digest</span></>} | |
| 41 | − title={<>What changed in AI on <span className="text-ink-2">{fmtDate(date)}</span></>} | |
| 42 | − lede={d ? `${fmtInt(total)} events across ${cats.length} categories, generated from the database — nothing editorial.` : undefined} | |
| 52 | + eyebrow={ | |
| 53 | + <> | |
| 54 | + <Link href={routes.changes()} className="hover:text-ink"> | |
| 55 | + Changes | |
| 56 | + </Link> | |
| 57 | + <span aria-hidden>/</span> | |
| 58 | + <span>Today in AI</span> | |
| 59 | + </> | |
| 60 | + } | |
| 61 | + title={ | |
| 62 | + <> | |
| 63 | + What changed in AI on <span className="text-ink-2">{fmtDate(date)}</span> | |
| 64 | + </> | |
| 65 | + } | |
| 66 | + lede={d ? <>{fmtInt(total)} events occurred on this UTC day{d.today ? ` across ${present.length} sections` : ''} — grouped when several documents describe one release, generated from the database, nothing editorial.</> : undefined} | |
| 43 | 67 | aside={ |
| 44 | 68 | <nav className="flex items-center gap-2 text-sm" aria-label="Day navigation"> |
| 45 | − <Link href={routes.changesDay(shift(date, -1))} className="inline-flex h-10 items-center border border-rule px-3 text-ink-2 hover:text-ink" rel="prev">‹ {fmtDate(shift(date, -1))}</Link> | |
| 46 | − {date < today && <Link href={routes.changesDay(shift(date, 1))} className="inline-flex h-10 items-center border border-rule px-3 text-ink-2 hover:text-ink" rel="next">{fmtDate(shift(date, 1))} ›</Link>} | |
| 69 | + <Link href={routes.changesDay(prev)} className="inline-flex h-10 items-center border border-rule px-3 text-ink-2 hover:text-ink" rel="prev"> | |
| 70 | + ‹ {fmtDate(prev)} | |
| 71 | + </Link> | |
| 72 | + {next && ( | |
| 73 | + <Link href={routes.changesDay(next)} className="inline-flex h-10 items-center border border-rule px-3 text-ink-2 hover:text-ink" rel="next"> | |
| 74 | + {fmtDate(next)} › | |
| 75 | + </Link> | |
| 76 | + )} | |
| 77 | + {date !== today && ( | |
| 78 | + <Link href={routes.changesDay(today)} className="inline-flex h-10 items-center px-2 text-accent hover:underline"> | |
| 79 | + today | |
| 80 | + </Link> | |
| 81 | + )} | |
| 47 | 82 | </nav> |
| 48 | 83 | } |
| 49 | 84 | /> |
| 50 | 85 | <div className="pb-16"> |
| 51 | 86 | {!d ? ( |
| 52 | 87 | <Unavailable what="Daily digest" /> |
| 53 | − ) : total === 0 && !d.new_models?.length ? ( | |
| 54 | − <EmptyState title="Nothing recorded on this day">Connectors may not have observed changes, or the day predates the archive.</EmptyState> | |
| 88 | + ) : total === 0 && !d.new_models?.length && present.length === 0 ? ( | |
| 89 | + <EmptyState title="Nothing occurred on this day"> | |
| 90 | + {excluded > 0 ? ( | |
| 91 | + <> | |
| 92 | + {fmtInt(excluded)} back-filled historical events fall on this date but are not “today” news — see them on the <Link href={`/timeline?year=${date.slice(0, 4)}&include_backfill=1`} className="link">timeline with backfill</Link> or <Link href={`${routes.changesDay(date)}?include_backfill=1`} className="link">include them here</Link>. | |
| 93 | + </> | |
| 94 | + ) : ( | |
| 95 | + 'Connectors observed no material change occurring on this UTC day.' | |
| 96 | + )} | |
| 97 | + </EmptyState> | |
| 55 | 98 | ) : ( |
| 56 | 99 | <> |
| 57 | − {cats.length > 0 && ( | |
| 58 | − <StatGrid cols={cats.length >= 6 ? 6 : cats.length >= 4 ? 4 : 3}> | |
| 59 | − {cats.slice(0, 6).map(([c, n]) => ( | |
| 60 | − <Stat key={c} label={categoryLabel(c)} value={fmtInt(n)} href={`/changes?category=${encodeURIComponent(c)}&since=${date}`} /> | |
| 61 | − ))} | |
| 62 | − </StatGrid> | |
| 63 | − )} | |
| 100 | + {strip.length > 0 && <DataStrip dense items={strip.map((s) => ({ label: s.label ?? TODAY_LABELS[s.key] ?? s.key, value: fmtInt(s.total), href: `#${s.key.toLowerCase()}` }))} />} | |
| 101 | + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-3"> | |
| 102 | + <span className="inline-flex items-center gap-1"> | |
| 103 | + Dates are when events <em>occurred</em> | |
| 104 | + <Hint align="right" text="occurred_at = effective date when a source states one, else the observation time. Hover any date in the list for the observation time. `today` folds events sharing a group_key (one release seen in several documents) into one row." /> | |
| 105 | + </span> | |
| 106 | + {excluded > 0 && ( | |
| 107 | + <span data-backfill-excluded> | |
| 108 | + {fmtInt(excluded)} back-filled historical {excluded === 1 ? 'event' : 'events'} excluded ·{' '} | |
| 109 | + <Link href={`/timeline?year=${date.slice(0, 4)}&include_backfill=1`} className="link"> | |
| 110 | + see them on the timeline | |
| 111 | + </Link>{' '} | |
| 112 | + ·{' '} | |
| 113 | + <Link href={`${routes.changesDay(date)}${backfill ? '' : '?include_backfill=1'}`} className="link"> | |
| 114 | + {backfill ? 'hide backfill' : 'include here'} | |
| 115 | + </Link> | |
| 116 | + </span> | |
| 117 | + )} | |
| 118 | + {backfill && <span className="rounded-[3px] bg-surface-2 px-1.5 text-[10px] uppercase tracking-wide">including backfill</span>} | |
| 119 | + </p> | |
| 120 | + {present.length > 1 && <SectionNav items={present.map((s) => ({ id: s.key.toLowerCase(), label: s.label ?? TODAY_LABELS[s.key] ?? s.key }))} className="mt-3" />} | |
| 121 | + | |
| 64 | 122 | {d.new_models?.length > 0 && ( |
| 65 | − <Section eyebrow="New models" title={<>{fmtInt(d.new_models.length)} new model{d.new_models.length === 1 ? '' : 's'}</>} hairline={false}> | |
| 123 | + <Section eyebrow="New models" title={<>{fmtInt(d.new_models.length)} new model{d.new_models.length === 1 ? '' : 's'}</>} hairline={false} action={{ href: `/models?year_from=${date.slice(0, 4)}&sort=release`, label: 'Models by release' }}> | |
| 66 | 124 | <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3"> |
| 67 | − {d.new_models.map((m) => ( | |
| 125 | + {d.new_models.slice(0, 30).map((m) => ( | |
| 68 | 126 | <li key={m.id} className="flex items-baseline justify-between gap-3 border-b border-rule py-2 text-sm"> |
| 69 | 127 | <EntityLink e={m} className="font-medium" /> |
| 70 | 128 | <span className="shrink-0 text-xs text-ink-3">{m.organization?.name ?? ''}</span> |
| 71 | 129 | </li> |
| 72 | 130 | ))} |
| 73 | 131 | </ul> |
| 132 | + {d.new_models.length > 30 && <Note className="mt-2">First 30 of {fmtInt(d.new_models.length)}.</Note>} | |
| 74 | 133 | </Section> |
| 75 | 134 | )} |
| 76 | − {d.sections | |
| 77 | − .filter((s) => s.items.length) | |
| 78 | − .map((s) => ( | |
| 79 | − <Section key={s.category} eyebrow={categoryLabel(s.category)} title={s.label} action={{ href: `/changes?category=${encodeURIComponent(s.category)}`, label: 'All' }}> | |
| 135 | + | |
| 136 | + {present.map((s) => { | |
| 137 | + const shown = s.items.length; | |
| 138 | + const tot = num(s.total) ?? shown; | |
| 139 | + const feed = TODAY_FEED_HREF[s.key]?.(date); | |
| 140 | + return ( | |
| 141 | + <Section key={s.key} id={s.key.toLowerCase()} eyebrow={s.label ?? TODAY_LABELS[s.key] ?? s.key} title={<>{s.label ?? TODAY_LABELS[s.key] ?? s.key} <span className="tnum text-base font-normal text-ink-3">{fmtInt(tot)}</span></>} action={feed ? { href: feed, label: 'Feed' } : undefined}> | |
| 80 | 142 | <ul className="border-t border-rule"> |
| 81 | − {s.items.map((e) => ( | |
| 82 | − <ChangeRow key={e.id} e={e} live={false} /> | |
| 143 | + {s.items.map((it) => ( | |
| 144 | + <TodayItemRow key={it.id} it={it} /> | |
| 83 | 145 | ))} |
| 84 | 146 | </ul> |
| 147 | + {tot > shown && <Note className="mt-2">First {fmtInt(shown)} of {fmtInt(tot)}{feed ? <> — <Link href={feed} className="link">all in the feed →</Link></> : null}</Note>} | |
| 148 | + {s.items.some((it) => it.importance >= 3) && ( | |
| 149 | + <p className="mt-2 flex items-center gap-1.5 text-[11px] text-ink-3"> | |
| 150 | + <ImportanceMark importance={3} /> major · <ImportanceMark importance={2} /> important · <ImportanceMark importance={1} /> notable | |
| 151 | + </p> | |
| 152 | + )} | |
| 85 | 153 | </Section> |
| 86 | − ))} | |
| 154 | + ); | |
| 155 | + })} | |
| 156 | + <QuietLine quiet={quiet} labels={d.labels} /> | |
| 157 | + {d.note && <Note className="mt-4">{d.note}</Note>} | |
| 87 | 158 | </> |
| 88 | 159 | )} |
| 89 | 160 | </div> |
modified
apps/web/src/app/changes/page.tsx
+110 −60
@@ -2,97 +2,147 @@ import type { Metadata } from 'next'; | ||
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { ChangeRow, groupByDay } from '@/components/changes/change-row'; |
| 4 | 4 | import { LoadMore } from '@/components/changes/load-more'; |
| 5 | −import { ActiveFilters, FilterBar } from '@/components/listing/filters'; | |
| 5 | +import { RailFilters } from '@/components/changes/rail-filters'; | |
| 6 | +import { TerminalLayout } from '@/components/layout/terminal'; | |
| 7 | +import { ActiveFilters } from '@/components/listing/filters'; | |
| 6 | 8 | import { withParams } from '@/components/ui/pagination'; |
| 7 | −import { Container, PageHeader } from '@/components/ui/section'; | |
| 9 | +import { Container, Note, PageHeader } from '@/components/ui/section'; | |
| 8 | 10 | import { EmptyState, Unavailable } from '@/components/ui/unavailable'; |
| 9 | −import { api, safe } from '@/lib/api'; | |
| 11 | +import { api, apiD3, safe } from '@/lib/api'; | |
| 10 | 12 | import { fmtDate, fmtInt, num } from '@/lib/format'; |
| 11 | −import { categoryLabel, eventLabel, IMPORTANCE_LABELS, routes } from '@/lib/site'; | |
| 13 | +import { categoryLabel, eventLabel, IMPORTANCE_LABELS, routes, typeLabel } from '@/lib/site'; | |
| 12 | 14 | |
| 13 | 15 | export const metadata: Metadata = { |
| 14 | 16 | title: 'Changes — what changed in AI, as it happens', |
| 15 | − description: 'A live, source-attributed feed of changes in the AI ecosystem: new models, price moves, context changes, deprecations, benchmark results, announcements.', | |
| 17 | + description: 'A live, source-attributed feed of changes in the AI ecosystem keyed on when they occurred: new models, price moves, context changes, deprecations, benchmark results, announcements. Filter by category, type, entity type and importance.', | |
| 16 | 18 | alternates: { canonical: '/changes' }, |
| 17 | 19 | }; |
| 18 | 20 | export const revalidate = 60; |
| 19 | 21 | |
| 20 | 22 | type SP = Record<string, string | undefined>; |
| 21 | −const KEYS = ['category', 'type', 'entity_type', 'importance_min', 'since', 'until', 'q'] as const; | |
| 23 | +const KEYS = ['category', 'type', 'entity_type', 'importance_min', 'since', 'until', 'q', 'include_backfill', 'date_field', 'entity'] as const; | |
| 22 | 24 | const LIMIT = 50; |
| 25 | +const ENTITY_TYPES = ['model', 'company', 'paper', 'provider', 'benchmark', 'hardware', 'framework', 'dataset', 'artifact', 'model_family']; | |
| 23 | 26 | |
| 24 | 27 | export default async function ChangesPage({ searchParams }: { searchParams: Promise<SP> }) { |
| 25 | 28 | const sp = await searchParams; |
| 26 | 29 | const current: Record<string, string | undefined> = {}; |
| 27 | 30 | for (const k of KEYS) if (sp[k]) current[k] = sp[k]; |
| 28 | − const [page, cats] = await Promise.all([safe(api.changes({ ...current, limit: LIMIT })), safe(api.changesCategories(7))]); | |
| 31 | + if (current.include_backfill !== '1') delete current.include_backfill; | |
| 32 | + if (current.date_field !== 'observed') delete current.date_field; | |
| 33 | + const dateField: 'occurred' | 'observed' = current.date_field === 'observed' ? 'observed' : 'occurred'; | |
| 34 | + const [page, cats, meth] = await Promise.all([safe(apiD3.changes({ ...current, limit: LIMIT })), safe(api.changesCategories(7)), safe(apiD3.methodology())]); | |
| 29 | 35 | const href = (patch: Record<string, string | number | undefined | null>) => withParams('/changes', current, patch); |
| 30 | 36 | const qs = new URLSearchParams(Object.entries(current).filter(([, v]) => v) as [string, string][]).toString(); |
| 31 | − const groups = groupByDay(page?.items ?? []); | |
| 32 | − const last = page?.items[page.items.length - 1]; | |
| 33 | − const cursor = page && page.items.length >= LIMIT && last ? last.observed_at : null; | |
| 37 | + const groups = groupByDay(page?.items ?? [], dateField); | |
| 38 | + const cursor = page && page.items.length >= LIMIT ? page.next_before ?? (dateField === 'occurred' ? page.items[page.items.length - 1]?.occurred_at ?? page.items[page.items.length - 1]?.observed_at : page.items[page.items.length - 1]?.observed_at) ?? null : null; | |
| 34 | 39 | const catCounts = new Map<string, number>(); |
| 35 | 40 | const typeCounts = new Map<string, number>(); |
| 36 | 41 | for (const c of cats?.items ?? []) { |
| 37 | 42 | catCounts.set(c.category, (catCounts.get(c.category) ?? 0) + (num(c.count) ?? 0)); |
| 38 | 43 | typeCounts.set(c.event_type, (typeCounts.get(c.event_type) ?? 0) + (num(c.count) ?? 0)); |
| 39 | 44 | } |
| 40 | − const catOptions = [...catCounts.entries()].sort((a, b) => b[1] - a[1]).map(([value, n]) => ({ value, label: `${categoryLabel(value)} (${fmtInt(n)})` })); | |
| 41 | − const typeOptions = [...typeCounts.entries()].sort((a, b) => b[1] - a[1]).map(([value, n]) => ({ value, label: `${eventLabel(value)} (${fmtInt(n)})` })); | |
| 45 | + const catOptions = [...catCounts.entries()].sort((a, b) => b[1] - a[1]); | |
| 46 | + const typeOptions = [...typeCounts.entries()].sort((a, b) => b[1] - a[1]); | |
| 42 | 47 | const today = new Date().toISOString().slice(0, 10); |
| 48 | + const activeCount = Object.keys(current).filter((k) => k !== 'date_field').length; | |
| 49 | + const sem = meth?.event_semantics ?? {}; | |
| 43 | 50 | |
| 44 | − return ( | |
| 45 | − <Container> | |
| 46 | − <PageHeader eyebrow="Changes" title="What changed in AI" lede="Every material change the connectors observe becomes an event with a source. Filter by category, event type and importance; older events load with a cursor." aside={<Link href={routes.changesDay(today)} className="link text-sm">Today's digest →</Link>}> | |
| 47 | − <FilterBar | |
| 48 | − action="/changes" | |
| 49 | − className="mt-6" | |
| 50 | − resetHref={routes.changes()} | |
| 51 | − fields={[ | |
| 52 | − { kind: 'select', name: 'category', label: 'Category', value: current.category, options: catOptions }, | |
| 53 | − { kind: 'select', name: 'type', label: 'Event type', value: current.type, options: typeOptions }, | |
| 54 | − { kind: 'select', name: 'importance_min', label: 'Min importance', value: current.importance_min, options: [3, 2, 1].map((n) => ({ value: String(n), label: `${IMPORTANCE_LABELS[n]} (≥ ${n})` })) }, | |
| 55 | − { kind: 'text', name: 'since', label: 'Since', value: current.since, placeholder: 'YYYY-MM-DD' }, | |
| 56 | − { kind: 'text', name: 'q', label: 'Text', value: current.q, placeholder: 'in summary' }, | |
| 57 | − ]} | |
| 58 | − /> | |
| 59 | − <ActiveFilters current={current} labels={{ category: 'category', type: 'type', importance_min: 'importance ≥', since: 'since', until: 'until', q: 'text', entity_type: 'entity' }} makeHref={(p) => href(p)} className="mt-3" /> | |
| 60 | − {cats && catOptions.length > 0 && ( | |
| 61 | − <p className="mt-4 text-xs text-ink-3"> | |
| 62 | − Last 7 days: {[...catCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([c, n], i) => ( | |
| 63 | − <span key={c}> | |
| 64 | − {i > 0 && ' · '} | |
| 65 | − <Link href={href({ category: c })} className="hover:text-ink">{categoryLabel(c)} <span className="tnum text-ink-2">{fmtInt(n)}</span></Link> | |
| 66 | − </span> | |
| 67 | − ))} | |
| 68 | − </p> | |
| 69 | − )} | |
| 70 | − </PageHeader> | |
| 51 | + const filters = ( | |
| 52 | + <RailFilters | |
| 53 | + action="/changes" | |
| 54 | + resetHref={routes.changes()} | |
| 55 | + testId="changes" | |
| 56 | + fields={[ | |
| 57 | + { kind: 'select', name: 'category', label: 'Category', value: current.category, options: catOptions.map(([v, n]) => ({ value: v, label: `${categoryLabel(v)} (${fmtInt(n)})` })) }, | |
| 58 | + { kind: 'select', name: 'type', label: 'Event type', value: current.type, options: typeOptions.map(([v, n]) => ({ value: v, label: `${eventLabel(v)} (${fmtInt(n)})` })) }, | |
| 59 | + { kind: 'select', name: 'entity_type', label: 'Entity type', value: current.entity_type, options: ENTITY_TYPES.map((t) => ({ value: t, label: typeLabel(t, true) })) }, | |
| 60 | + { kind: 'select', name: 'importance_min', label: 'Min importance', value: current.importance_min, options: [3, 2, 1].map((n) => ({ value: String(n), label: `${IMPORTANCE_LABELS[n]} (≥ ${n})` })) }, | |
| 61 | + { kind: 'row', fields: [{ kind: 'date', name: 'since', label: 'Since', value: current.since, max: today }, { kind: 'date', name: 'until', label: 'Until', value: current.until, max: today }] }, | |
| 62 | + { kind: 'text', name: 'q', label: 'Text in summary', value: current.q, placeholder: 'e.g. context' }, | |
| 63 | + { kind: 'checkbox', name: 'include_backfill', label: 'Include historical backfill', checked: current.include_backfill === '1', hint: sem.is_backfill ?? 'History imported when a source is first crawled; never shown as “today” in feeds.' }, | |
| 64 | + { kind: 'checkbox', name: 'date_field', value: 'observed', label: 'Order by observation time', checked: dateField === 'observed', hint: 'Default order is occurred_at (effective date when a source states it). Tick to order by when AI Atlas first saw each change (v1 behaviour).' }, | |
| 65 | + ...(current.entity ? [{ kind: 'hidden' as const, name: 'entity', value: current.entity }] : []), | |
| 66 | + ]} | |
| 67 | + /> | |
| 68 | + ); | |
| 71 | 69 | |
| 72 | − <div className="pb-16"> | |
| 73 | − {!page ? ( | |
| 74 | − <Unavailable what="Change feed" /> | |
| 75 | − ) : page.items.length === 0 ? ( | |
| 76 | − <EmptyState title="No events match these filters">The change engine only emits events when a source states a material change — try widening the filters.</EmptyState> | |
| 70 | + const inspector = ( | |
| 71 | + <div className="space-y-4 text-sm"> | |
| 72 | + <div> | |
| 73 | + <p className="eyebrow mb-1.5">Last 7 days by category</p> | |
| 74 | + {catOptions.length === 0 ? ( | |
| 75 | + <p className="text-xs text-ink-3">No category counts available.</p> | |
| 77 | 76 | ) : ( |
| 78 | − <> | |
| 79 | − <p className="tnum text-xs text-ink-3">{fmtInt(page.total)} events{current.since ? ` since ${fmtDate(current.since)}` : ''}</p> | |
| 80 | − {groups.map((g) => ( | |
| 81 | − <section key={g.day} className="mt-6"> | |
| 82 | − <h2 className="eyebrow sticky top-[var(--header-h)] z-10 -mx-4 bg-canvas/95 px-4 py-2 backdrop-blur md:mx-0 md:px-0"> | |
| 83 | − <Link href={routes.changesDay(g.day)} className="hover:text-ink">{fmtDate(g.day)}</Link> <span className="tnum text-ink-3">{g.items.length}</span> | |
| 84 | − </h2> | |
| 85 | − <ul className="border-t border-rule"> | |
| 86 | − {g.items.map((e) => ( | |
| 87 | − <ChangeRow key={e.id} e={e} /> | |
| 88 | − ))} | |
| 89 | − </ul> | |
| 90 | − </section> | |
| 77 | + <ul className="space-y-0.5"> | |
| 78 | + {catOptions.slice(0, 10).map(([c, n]) => ( | |
| 79 | + <li key={c} className="flex items-center justify-between gap-2"> | |
| 80 | + <Link href={href({ category: c })} className="text-ink-2 hover:text-ink"> | |
| 81 | + {categoryLabel(c)} | |
| 82 | + </Link> | |
| 83 | + <span className="tnum text-xs text-ink-3">{fmtInt(n)}</span> | |
| 84 | + </li> | |
| 91 | 85 | ))} |
| 92 | − <LoadMore qs={qs} initialCursor={cursor} lastDay={groups[groups.length - 1]?.day ?? null} /> | |
| 93 | − </> | |
| 86 | + </ul> | |
| 94 | 87 | )} |
| 95 | 88 | </div> |
| 96 | − </Container> | |
| 89 | + <div> | |
| 90 | + <p className="eyebrow mb-1.5">Semantics</p> | |
| 91 | + <dl className="kv [&>div]:py-1 text-xs"> | |
| 92 | + {(['occurred_at', 'observed_at', 'recorded_at', 'is_backfill', 'group_key'] as const).filter((k) => sem[k]).map((k) => ( | |
| 93 | + <div key={k}> | |
| 94 | + <dt className="mono">{k}</dt> | |
| 95 | + <dd className="text-ink-2">{sem[k]}</dd> | |
| 96 | + </div> | |
| 97 | + ))} | |
| 98 | + {!Object.keys(sem).length && <div><dd className="text-ink-3">Definitions unavailable.</dd></div>} | |
| 99 | + </dl> | |
| 100 | + </div> | |
| 101 | + <p className="text-xs text-ink-3"> | |
| 102 | + <Link href={routes.changesDay(today)} className="link">Today in AI →</Link> · <Link href={routes.timeline()} className="link">Timeline</Link> · <Link href={routes.diff()} className="link">Diff two dates</Link> | |
| 103 | + </p> | |
| 104 | + </div> | |
| 105 | + ); | |
| 106 | + | |
| 107 | + return ( | |
| 108 | + <> | |
| 109 | + <Container wide> | |
| 110 | + <PageHeader eyebrow="Changes" title="What changed in AI" lede="Every material change the connectors observe becomes an event with a source. Dates are when the change occurred (effective date when known); hover a date for the observation time. Older events load with a cursor." aside={<Link href={routes.changesDay(today)} className="link text-sm">Today in AI →</Link>} className="pb-3"> | |
| 111 | + <ActiveFilters current={current} labels={{ category: 'category', type: 'type', importance_min: 'importance ≥', since: 'since', until: 'until', q: 'text', entity_type: 'entity type', include_backfill: 'backfill', entity: 'entity', date_field: 'order' }} makeHref={(p) => href(p)} className="mt-3" /> | |
| 112 | + </PageHeader> | |
| 113 | + </Container> | |
| 114 | + <div className="pb-16"> | |
| 115 | + <TerminalLayout filters={filters} inspector={inspector} filtersTitle="Filters" inspectorTitle="Context" storageKey="aia-changes-inspector" filterCount={activeCount}> | |
| 116 | + {!page ? ( | |
| 117 | + <Unavailable what="Change feed" /> | |
| 118 | + ) : page.items.length === 0 ? ( | |
| 119 | + <EmptyState title="No events match these filters">The change engine only emits events when a source states a material change — try widening the filters{current.include_backfill ? '' : ', or include historical backfill'}.</EmptyState> | |
| 120 | + ) : ( | |
| 121 | + <> | |
| 122 | + <p className="tnum text-xs text-ink-3" data-changes-total> | |
| 123 | + {fmtInt(page.total)} events{current.since ? ` since ${fmtDate(current.since)}` : ''}{current.until ? ` until ${fmtDate(current.until)}` : ''} · ordered by {dateField === 'occurred' ? 'occurrence' : 'observation'}{page.include_backfill ? ' · backfill included' : ''} | |
| 124 | + </p> | |
| 125 | + {groups.map((g) => ( | |
| 126 | + <section key={g.day} className="mt-6"> | |
| 127 | + <h2 className="eyebrow sticky top-[var(--header-h)] z-10 -mx-4 bg-canvas/95 px-4 py-2 backdrop-blur md:mx-0 md:px-0"> | |
| 128 | + <Link href={routes.changesDay(g.day)} className="hover:text-ink"> | |
| 129 | + {fmtDate(g.day)} | |
| 130 | + </Link>{' '} | |
| 131 | + <span className="tnum text-ink-3">{g.items.length}</span> | |
| 132 | + </h2> | |
| 133 | + <ul className="border-t border-rule"> | |
| 134 | + {g.items.map((e) => ( | |
| 135 | + <ChangeRow key={e.id} e={e} showDate /> | |
| 136 | + ))} | |
| 137 | + </ul> | |
| 138 | + </section> | |
| 139 | + ))} | |
| 140 | + <LoadMore qs={qs} initialCursor={cursor} lastDay={groups[groups.length - 1]?.day ?? null} dateField={dateField} /> | |
| 141 | + <Note className="mt-4">Feed keyed on <span className="mono">{dateField}_at</span>. Backfill = history imported when a source is first crawled{current.include_backfill ? ' (included)' : ' (excluded — tick the box in the rail to include it)'}. Times are UTC.</Note> | |
| 142 | + </> | |
| 143 | + )} | |
| 144 | + </TerminalLayout> | |
| 145 | + </div> | |
| 146 | + </> | |
| 97 | 147 | ); |
| 98 | 148 | } |
added
apps/web/src/app/claims/[id]/page.tsx
+271 −0
@@ -0,0 +1,271 @@ | ||
| 1 | +import { ExternalLink } from 'lucide-react'; | |
| 2 | +import type { Metadata } from 'next'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { notFound } from 'next/navigation'; | |
| 5 | +import { BreadcrumbLd, Breadcrumbs } from '@/components/meta/breadcrumb-ld'; | |
| 6 | +import { Chip, ConfidenceBadge, EntityBadge, TierBadge } from '@/components/ui/badges'; | |
| 7 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 8 | +import { Hint } from '@/components/ui/hint'; | |
| 9 | +import { Container, Note, PageHeader, Section } from '@/components/ui/section'; | |
| 10 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 11 | +import { ApiError, apiD3 } from '@/lib/api'; | |
| 12 | +import { fmtDateTime, fmtInt, fmtValue, hostOf } from '@/lib/format'; | |
| 13 | +import { propertyLabel, routes, SITE_NAME } from '@/lib/site'; | |
| 14 | +import type { ClaimDetail, ClaimRow } from '@/lib/types'; | |
| 15 | + | |
| 16 | +type Params = { params: Promise<{ id: string }> }; | |
| 17 | +const STATUS_TONE: Record<string, string> = { current: 'text-positive bg-positive-soft', superseded: 'text-ink-2 bg-surface-2', conflicting: 'text-danger bg-danger-soft', retracted: 'text-warning bg-warning-soft' }; | |
| 18 | + | |
| 19 | +async function loadClaim(id: string): Promise<ClaimDetail | null> { | |
| 20 | + try { | |
| 21 | + return await apiD3.claim(id); | |
| 22 | + } catch (e) { | |
| 23 | + if (e instanceof ApiError && e.notFound) notFound(); | |
| 24 | + return null; | |
| 25 | + } | |
| 26 | +} | |
| 27 | + | |
| 28 | +export async function generateMetadata({ params }: Params): Promise<Metadata> { | |
| 29 | + const { id } = await params; | |
| 30 | + let c: ClaimDetail | null = null; | |
| 31 | + try { | |
| 32 | + c = await apiD3.claim(id); | |
| 33 | + } catch { | |
| 34 | + c = null; | |
| 35 | + } | |
| 36 | + const title = c ? `${c.entity?.name ?? 'Entity'} · ${propertyLabel(c.property)} = ${fmtValue(c.claim.value, c.property)} — claim` : 'Claim'; | |
| 37 | + return { title, description: 'One temporal claim of the AI Atlas graph: value, source, tier, extractor, validity interval and its lifecycle (previous, superseding and conflicting claims).', alternates: { canonical: routes.claim(id) }, robots: { index: false, follow: true } }; | |
| 38 | +} | |
| 39 | + | |
| 40 | +function StatusChip({ s }: { s: string }) { | |
| 41 | + return <span className={`inline-flex items-center rounded-[3px] px-1.5 py-[1px] text-[11px] font-medium ${STATUS_TONE[s] ?? 'text-ink-2 bg-surface-2'}`}>{s}</span>; | |
| 42 | +} | |
| 43 | + | |
| 44 | +function ClaimTable({ rows, property, currentId, caption }: { rows: ClaimRow[]; property: string; currentId: string; caption: string }) { | |
| 45 | + return ( | |
| 46 | + <DataTable caption={caption} compact> | |
| 47 | + <thead> | |
| 48 | + <tr> | |
| 49 | + <Th>Value</Th> | |
| 50 | + <Th>Status</Th> | |
| 51 | + <Th>Valid from</Th> | |
| 52 | + <Th>Valid to</Th> | |
| 53 | + <Th>Source</Th> | |
| 54 | + <Th>Tier</Th> | |
| 55 | + <Th>Extractor</Th> | |
| 56 | + <Th>Claim</Th> | |
| 57 | + </tr> | |
| 58 | + </thead> | |
| 59 | + <tbody> | |
| 60 | + {rows.length === 0 && <EmptyRow cols={8}>None.</EmptyRow>} | |
| 61 | + {rows.map((c) => ( | |
| 62 | + <tr key={c.id} className={c.id === currentId ? 'bg-accent-soft/40' : undefined}> | |
| 63 | + <Td primary className="tnum">{fmtValue(c.value, property)}{c.unit ? <span className="text-ink-3"> {c.unit}</span> : null}</Td> | |
| 64 | + <Td label="Status"> | |
| 65 | + <StatusChip s={c.status} /> | |
| 66 | + </Td> | |
| 67 | + <Td label="Valid from" className="tnum text-xs text-ink-2">{fmtDateTime(c.valid_from)}</Td> | |
| 68 | + <Td label="Valid to" className="tnum text-xs text-ink-2">{c.valid_to ? fmtDateTime(c.valid_to) : <span className="text-positive">open</span>}</Td> | |
| 69 | + <Td label="Source" className="text-xs"> | |
| 70 | + {c.source_url ? ( | |
| 71 | + <a href={c.source_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-ink-2 hover:text-accent"> | |
| 72 | + {c.source_name ?? hostOf(c.source_url) ?? 'source'} <ExternalLink className="size-3" aria-hidden /> | |
| 73 | + </a> | |
| 74 | + ) : ( | |
| 75 | + <span className="text-ink-3">—</span> | |
| 76 | + )} | |
| 77 | + </Td> | |
| 78 | + <Td label="Tier"> | |
| 79 | + <TierBadge tier={c.tier} /> | |
| 80 | + </Td> | |
| 81 | + <Td label="Extractor" className="mono text-xs text-ink-2">{c.extractor}</Td> | |
| 82 | + <Td label="Claim"> | |
| 83 | + {c.id === currentId ? <span className="mono text-[11px] text-ink-3">this claim</span> : <Link href={routes.claim(c.id)} className="mono text-[11px] text-accent hover:underline">{c.id}</Link>} | |
| 84 | + </Td> | |
| 85 | + </tr> | |
| 86 | + ))} | |
| 87 | + </tbody> | |
| 88 | + </DataTable> | |
| 89 | + ); | |
| 90 | +} | |
| 91 | + | |
| 92 | +export default async function ClaimPage({ params }: Params) { | |
| 93 | + const { id } = await params; | |
| 94 | + const c = await loadClaim(id); | |
| 95 | + if (!c) { | |
| 96 | + return ( | |
| 97 | + <Container> | |
| 98 | + <div className="py-16"> | |
| 99 | + <Unavailable what="Claim" /> | |
| 100 | + </div> | |
| 101 | + </Container> | |
| 102 | + ); | |
| 103 | + } | |
| 104 | + const cl = c.claim; | |
| 105 | + const e = c.entity; | |
| 106 | + const crumbs = [{ name: SITE_NAME, href: '/' }, ...(e ? [{ name: e.name, href: routes.entity(e) }, { name: 'History', href: routes.entityHistory(e, c.property) }] : []), { name: `Claim · ${propertyLabel(c.property)}`, href: routes.claim(id) }]; | |
| 107 | + const chain = c.chain ?? { previous: [], superseding: [], conflicting: [], history_count: 0 }; | |
| 108 | + const lifecycle: ClaimRow[] = [...chain.previous, cl, ...chain.superseding].filter((x, i, arr) => arr.findIndex((y) => y.id === x.id) === i).sort((a, b) => (a.valid_from < b.valid_from ? -1 : 1)); | |
| 109 | + | |
| 110 | + return ( | |
| 111 | + <Container wide> | |
| 112 | + <BreadcrumbLd items={crumbs} /> | |
| 113 | + <Breadcrumbs items={crumbs} /> | |
| 114 | + <PageHeader | |
| 115 | + eyebrow={ | |
| 116 | + <> | |
| 117 | + Claim {e && <EntityBadge type={e.entity_type} small />} <StatusChip s={cl.status} /> | |
| 118 | + </> | |
| 119 | + } | |
| 120 | + title={ | |
| 121 | + <> | |
| 122 | + {e ? ( | |
| 123 | + <Link href={routes.entity(e)} className="hover:text-accent"> | |
| 124 | + {e.name} | |
| 125 | + </Link> | |
| 126 | + ) : ( | |
| 127 | + 'Entity' | |
| 128 | + )}{' '} | |
| 129 | + · <span className="text-ink-2">{propertyLabel(c.property)}</span> = <span className="tnum">{fmtValue(cl.value, c.property)}</span> | |
| 130 | + {cl.unit && <span className="text-ink-3"> {cl.unit}</span>} | |
| 131 | + </> | |
| 132 | + } | |
| 133 | + lede="A temporal claim: who said it, when it was observed, how it was extracted, how long it has been current, and what came before and after it. Claims are never overwritten — a better source supersedes, a worse one conflicts." | |
| 134 | + aside={ | |
| 135 | + <p className="mono text-xs text-ink-3"> | |
| 136 | + {cl.id} | |
| 137 | + </p> | |
| 138 | + } | |
| 139 | + /> | |
| 140 | + | |
| 141 | + <div className="grid gap-10 pb-16 lg:grid-cols-[minmax(0,1fr)_24rem]"> | |
| 142 | + <div className="min-w-0"> | |
| 143 | + <Section id="lifecycle" eyebrow="Lifecycle" title={<>Lifecycle <span className="tnum text-base font-normal text-ink-3">{fmtInt(chain.history_count)} in history</span></>} lede="Previous values, this claim, and the claims that superseded it — in validity order." hairline={false} className="pt-0"> | |
| 144 | + <ClaimTable rows={lifecycle} property={c.property} currentId={cl.id} caption="Lifecycle" /> | |
| 145 | + </Section> | |
| 146 | + <Section id="conflicts" eyebrow="Conflicts" title={<>Conflicting claims <span className="tnum text-base font-normal text-ink-3">{fmtInt(chain.conflicting.length)}</span></>} lede="Values stated by other sources that disagree with the current one. Stored side by side, flagged for review, never averaged."> | |
| 147 | + <ClaimTable rows={chain.conflicting} property={c.property} currentId={cl.id} caption="Conflicting claims" /> | |
| 148 | + </Section> | |
| 149 | + {c.note && <Note className="mt-4">{c.note}</Note>} | |
| 150 | + </div> | |
| 151 | + <aside className="min-w-0 space-y-8"> | |
| 152 | + <section> | |
| 153 | + <p className="eyebrow mb-2">Source</p> | |
| 154 | + <dl className="kv [&>div]:py-1.5 text-sm"> | |
| 155 | + <div> | |
| 156 | + <dt>Name</dt> | |
| 157 | + <dd>{c.source?.name ?? <span className="text-ink-3">—</span>}</dd> | |
| 158 | + </div> | |
| 159 | + <div> | |
| 160 | + <dt>Domain</dt> | |
| 161 | + <dd className="mono text-xs">{c.source?.domain ?? '—'}</dd> | |
| 162 | + </div> | |
| 163 | + <div> | |
| 164 | + <dt>URL</dt> | |
| 165 | + <dd> | |
| 166 | + {c.source?.url ? ( | |
| 167 | + <a href={c.source.url} target="_blank" rel="noopener noreferrer" className="link break-all text-xs"> | |
| 168 | + {c.source.url.replace(/^https?:\/\//, '').slice(0, 80)} | |
| 169 | + </a> | |
| 170 | + ) : ( | |
| 171 | + '—' | |
| 172 | + )} | |
| 173 | + </dd> | |
| 174 | + </div> | |
| 175 | + <div> | |
| 176 | + <dt>Tier</dt> | |
| 177 | + <dd> | |
| 178 | + <TierBadge tier={c.source?.tier ?? cl.tier} withLabel /> | |
| 179 | + </dd> | |
| 180 | + </div> | |
| 181 | + <div> | |
| 182 | + <dt>Observed</dt> | |
| 183 | + <dd className="tnum">{fmtDateTime(c.source?.observed_at ?? cl.observed_at)}</dd> | |
| 184 | + </div> | |
| 185 | + {cl.effective_at && ( | |
| 186 | + <div> | |
| 187 | + <dt>Effective</dt> | |
| 188 | + <dd className="tnum">{fmtDateTime(cl.effective_at)}</dd> | |
| 189 | + </div> | |
| 190 | + )} | |
| 191 | + </dl> | |
| 192 | + </section> | |
| 193 | + <section> | |
| 194 | + <p className="eyebrow mb-2">Extraction</p> | |
| 195 | + <dl className="kv [&>div]:py-1.5 text-sm"> | |
| 196 | + <div> | |
| 197 | + <dt>Extractor</dt> | |
| 198 | + <dd className="mono">{c.extractor?.name ?? cl.extractor}{c.extractor?.version ? <span className="text-ink-3"> v{c.extractor.version}</span> : null}</dd> | |
| 199 | + </div> | |
| 200 | + <div> | |
| 201 | + <dt>Confidence</dt> | |
| 202 | + <dd> | |
| 203 | + <ConfidenceBadge confidence={c.extractor?.confidence ?? cl.confidence} /> | |
| 204 | + </dd> | |
| 205 | + </div> | |
| 206 | + <div> | |
| 207 | + <dt> | |
| 208 | + Run id <Hint align="right" text="Batch identifier of the connector run that wrote this claim. Admins can roll back a whole run." /> | |
| 209 | + </dt> | |
| 210 | + <dd className="mono text-xs">{c.run_id ?? cl.run_id ?? <span className="text-ink-3">— (written before run tracking)</span>}</dd> | |
| 211 | + </div> | |
| 212 | + <div> | |
| 213 | + <dt>Snapshot id</dt> | |
| 214 | + <dd className="mono text-xs">{c.evidence?.snapshot_id ?? c.source?.snapshot_id ?? '—'}</dd> | |
| 215 | + </div> | |
| 216 | + {cl.value_raw !== null && cl.value_raw !== undefined && ( | |
| 217 | + <div> | |
| 218 | + <dt>Raw value</dt> | |
| 219 | + <dd className="mono text-xs">{String(cl.value_raw)}</dd> | |
| 220 | + </div> | |
| 221 | + )} | |
| 222 | + </dl> | |
| 223 | + </section> | |
| 224 | + <section> | |
| 225 | + <p className="eyebrow mb-2">Evidence</p> | |
| 226 | + {c.evidence ? ( | |
| 227 | + <dl className="kv [&>div]:py-1.5 text-sm"> | |
| 228 | + <div> | |
| 229 | + <dt>Document</dt> | |
| 230 | + <dd> | |
| 231 | + {c.evidence.document_url ? ( | |
| 232 | + <a href={c.evidence.document_url} target="_blank" rel="noopener noreferrer" className="link break-all text-xs"> | |
| 233 | + {c.evidence.document_title ?? c.evidence.document_url.replace(/^https?:\/\//, '').slice(0, 60)} | |
| 234 | + </a> | |
| 235 | + ) : ( | |
| 236 | + '—' | |
| 237 | + )} | |
| 238 | + {c.evidence.doc_type && <span className="block text-[11px] text-ink-3">{c.evidence.doc_type}</span>} | |
| 239 | + </dd> | |
| 240 | + </div> | |
| 241 | + <div> | |
| 242 | + <dt>Snapshot observed</dt> | |
| 243 | + <dd className="tnum">{fmtDateTime(c.evidence.snapshot_observed_at)}</dd> | |
| 244 | + </div> | |
| 245 | + <div> | |
| 246 | + <dt>Archived</dt> | |
| 247 | + <dd>{c.evidence.archived ? <Chip tone="accent">yes</Chip> : <Chip>no</Chip>}</dd> | |
| 248 | + </div> | |
| 249 | + </dl> | |
| 250 | + ) : ( | |
| 251 | + <p className="text-sm text-ink-3">No evidence pointer.</p> | |
| 252 | + )} | |
| 253 | + <div className="mt-3 flex flex-wrap gap-2 text-xs"> | |
| 254 | + {e && ( | |
| 255 | + <Link href={routes.entityHistory(e, c.property)} className="inline-flex h-8 items-center border border-rule px-2 text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 256 | + Full history of {propertyLabel(c.property).toLowerCase()} → | |
| 257 | + </Link> | |
| 258 | + )} | |
| 259 | + {(c.evidence?.snapshot_id ?? c.source?.snapshot_id) && ( | |
| 260 | + <Link href={`/admin/extractions/${encodeURIComponent(c.evidence?.snapshot_id ?? c.source?.snapshot_id ?? '')}`} className="inline-flex h-8 items-center gap-1 border border-dashed border-rule px-2 text-ink-3 hover:border-rule-strong hover:text-ink" title="Administrators only: raw archived content and the extraction pipeline for this snapshot"> | |
| 261 | + View archived snapshot <span className="rounded-[3px] bg-surface-2 px-1 text-[10px] uppercase">admin</span> | |
| 262 | + </Link> | |
| 263 | + )} | |
| 264 | + </div> | |
| 265 | + <Note className="mt-2">Raw archived content is available to administrators only; the public evidence is the pointer (snapshot id, document, observation time). <Link href={routes.methodology()} className="link">Methodology →</Link></Note> | |
| 266 | + </section> | |
| 267 | + </aside> | |
| 268 | + </div> | |
| 269 | + </Container> | |
| 270 | + ); | |
| 271 | +} | |
modified
apps/web/src/app/companies/[slug]/opengraph-image.tsx
+18 −32
@@ -1,44 +1,30 @@ | ||
| 1 | 1 | import { ImageResponse } from 'next/og'; |
| 2 | −import { Eyebrow, Facts, Fallback, Frame, INK2, Title } from '@/components/brand/og'; | |
| 3 | −import { api, safe } from '@/lib/api'; | |
| 2 | +import { Fallback, Wallpaper } from '@/components/brand/og'; | |
| 3 | +import { api, apiD1, safe } from '@/lib/api'; | |
| 4 | 4 | import { fmtInt, num } from '@/lib/format'; |
| 5 | −import { routes, SITE_NAME, typeLabel } from '@/lib/site'; | |
| 6 | −import type { EntityDetail } from '@/lib/types'; | |
| 5 | +import { SITE_NAME, typeLabel } from '@/lib/site'; | |
| 7 | 6 | |
| 8 | 7 | export const runtime = 'nodejs'; |
| 9 | 8 | export const alt = `Organization on ${SITE_NAME}`; |
| 10 | 9 | export const size = { width: 1200, height: 630 }; |
| 11 | 10 | export const contentType = 'image/png'; |
| 12 | 11 | |
| 13 | −function companyFacts(d: EntityDetail): [string, string][] { | |
| 14 | − const a = d.attributes ?? {}; | |
| 15 | − const out: [string, string][] = []; | |
| 16 | − if (typeof a.country === 'string') out.push(['Country', a.country]); | |
| 17 | − if (a.founded) out.push(['Founded', String(a.founded).slice(0, 4)]); | |
| 18 | − const models = num(d.model_count) ?? num(d.models?.total) ?? (d.models?.items.length || null); | |
| 19 | − if (models !== null) out.push(['Models', fmtInt(models)]); | |
| 20 | − const papers = num(d.paper_count) ?? (d.papers?.length || null); | |
| 21 | − if (papers !== null) out.push(['Papers', fmtInt(papers)]); | |
| 22 | − if (typeof a.headquarters === 'string') out.push(['HQ', a.headquarters.slice(0, 22)]); | |
| 23 | − return out.slice(0, 3); | |
| 24 | −} | |
| 25 | − | |
| 26 | −/** Per-organization Open Graph image: brand, type eyebrow, name, three key facts, canonical URL. */ | |
| 12 | +/** Per-organization Open Graph image: wallpaper with the live footprint counters (models · families · papers · announcements). */ | |
| 27 | 13 | export default async function CompanyOgImage({ params }: { params: Promise<{ slug: string }> }) { |
| 28 | 14 | const { slug } = await params; |
| 29 | 15 | const d = await safe(api.entityOfType('companies', slug)); |
| 30 | − if (!d) return new ImageResponse(<Fallback label="Company" />, { ...size }); | |
| 31 | − const facts = companyFacts(d); | |
| 32 | − return new ImageResponse( | |
| 33 | − ( | |
| 34 | − <Frame footer={`www.ai-atlas.co${routes.entity(d)}`}> | |
| 35 | − <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> | |
| 36 | − <Eyebrow>{typeof d.attributes?.org_kind === 'string' && d.attributes.org_kind.toLowerCase() !== typeLabel(d.entity_type).toLowerCase() ? `${typeLabel(d.entity_type)} · ${d.attributes.org_kind}` : typeLabel(d.entity_type)}</Eyebrow> | |
| 37 | − <Title>{d.name}</Title> | |
| 38 | − {facts.length > 0 ? <Facts items={facts} /> : <div style={{ display: 'flex', fontSize: 24, color: INK2 }}>{d.description ? d.description.slice(0, 120) : 'Models, research, providers and sources.'}</div>} | |
| 39 | − </div> | |
| 40 | − </Frame> | |
| 41 | − ), | |
| 42 | − { ...size }, | |
| 43 | − ); | |
| 16 | + if (!d) return new ImageResponse(<Fallback label="Organization" />, { ...size }); | |
| 17 | + const [models, fams, papers] = await Promise.all([safe(api.models({ org: d.slug, limit: 1 })), safe(apiD1.families({ org: d.slug, limit: 1 })), safe(api.papers({ org: d.slug, limit: 1 }))]); | |
| 18 | + const a = d.attributes ?? {}; | |
| 19 | + const counters: [string, string][] = []; | |
| 20 | + const m = num(models?.total) ?? num(d.model_count) ?? num(d.models?.total); | |
| 21 | + if (m !== null) counters.push(['Models', fmtInt(m)]); | |
| 22 | + const f = num(fams?.total); | |
| 23 | + if (f !== null && f > 0) counters.push(['Families', fmtInt(f)]); | |
| 24 | + const p = num(papers?.total) ?? num(d.paper_count); | |
| 25 | + if (p !== null && p > 0) counters.push(['Papers', fmtInt(p)]); | |
| 26 | + if (typeof a.country === 'string') counters.push(['Country', a.country]); | |
| 27 | + if (a.founded) counters.push(['Founded', String(a.founded).slice(0, 4)]); | |
| 28 | + const eyebrow = typeof a.org_kind === 'string' && a.org_kind.toLowerCase() !== typeLabel(d.entity_type).toLowerCase() ? `${typeLabel(d.entity_type)} · ${a.org_kind}` : typeLabel(d.entity_type); | |
| 29 | + return new ImageResponse(<Wallpaper eyebrow={eyebrow} title={d.name} subtitle={d.description ? d.description.slice(0, 140) : 'Models, families, research, providers, corporate news and sources.'} counters={counters.slice(0, 5)} footer={`www.ai-atlas.co/companies/${d.slug}`} markPx={200} />, { ...size }); | |
| 44 | 30 | } |
modified
apps/web/src/app/companies/[slug]/page.tsx
+5 −7
@@ -1,22 +1,20 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import { permanentRedirect } from 'next/navigation'; |
| 3 | −import { EntityPage, type EntityPageParams } from '@/components/entity/entity-page'; | |
| 4 | 3 | import { entityMetadata, loadEntity } from '@/components/entity/load'; |
| 5 | −import { api, safe } from '@/lib/api'; | |
| 4 | +import { OrganizationPage } from '@/components/research/organization-page'; | |
| 6 | 5 | import { routes } from '@/lib/site'; |
| 7 | 6 | |
| 8 | −type Params = { params: Promise<{ slug: string }>; searchParams: Promise<EntityPageParams> }; | |
| 7 | +type Params = { params: Promise<{ slug: string }> }; | |
| 9 | 8 | |
| 10 | 9 | export async function generateMetadata({ params }: Params): Promise<Metadata> { |
| 11 | 10 | const { slug } = await params; |
| 12 | 11 | return entityMetadata('companies', slug); |
| 13 | 12 | } |
| 14 | 13 | |
| 15 | −export default async function CompanyPage({ params, searchParams }: Params) { | |
| 14 | +/** Organization page 3.0 (companies, labs, universities). */ | |
| 15 | +export default async function CompanyPage({ params }: Params) { | |
| 16 | 16 | const { slug } = await params; |
| 17 | − const sp = await searchParams; | |
| 18 | 17 | const d = await loadEntity('companies', slug); |
| 19 | 18 | if (d.slug !== slug) permanentRedirect(routes.entity(d)); |
| 20 | − const related = await safe(api.entityRelated(d.slug, 10)); | |
| 21 | − return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} asof={sp.asof} historyProperty={sp.property} />; | |
| 19 | + return <OrganizationPage d={d} canonical={routes.entity(d)} />; | |
| 22 | 20 | } |
modified
apps/web/src/app/companies/page.tsx
+47 −12
@@ -1,18 +1,21 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { ActiveFilters, type FacetGroup, Facets, FilterBar, ListingLayout } from '@/components/listing/filters'; |
| 4 | +import { BreadcrumbLd } from '@/components/meta/breadcrumb-ld'; | |
| 4 | 5 | import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; |
| 6 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 5 | 7 | import { EntityLink, QualityMark } from '@/components/ui/entity'; |
| 8 | +import { Hint } from '@/components/ui/hint'; | |
| 6 | 9 | import { Pagination, withParams } from '@/components/ui/pagination'; |
| 7 | 10 | import { Container, PageHeader } from '@/components/ui/section'; |
| 8 | 11 | import { Unavailable } from '@/components/ui/unavailable'; |
| 9 | 12 | import { api, safe } from '@/lib/api'; |
| 10 | 13 | import { fmtInt, num, titleCase } from '@/lib/format'; |
| 11 | −import { routes } from '@/lib/site'; | |
| 14 | +import { routes, SITE_NAME } from '@/lib/site'; | |
| 12 | 15 | |
| 13 | 16 | export const metadata: Metadata = { |
| 14 | − title: 'AI companies, labs and organizations', | |
| 15 | − description: 'Companies, research labs and organizations building AI: country, kind, models and papers in the atlas, with sources for every attribute.', | |
| 17 | + title: 'Organizations — companies, labs and universities building AI', | |
| 18 | + description: 'Every organization in the atlas — companies, research labs, universities — with country, kind, canonical model count and papers, each attribute backed by a source.', | |
| 16 | 19 | alternates: { canonical: '/companies' }, |
| 17 | 20 | }; |
| 18 | 21 | export const revalidate = 300; |
@@ -33,13 +36,14 @@ export default async function CompaniesPage({ searchParams }: { searchParams: Pr | ||
| 33 | 36 | for (const k of KEYS) if (sp[k]) current[k] = sp[k]; |
| 34 | 37 | const offset = Math.max(0, Number(current.offset) || 0); |
| 35 | 38 | const sort = current.sort ?? 'models'; |
| 36 | − const page = await safe(api.companies({ ...current, sort, limit: LIMIT, offset, facets: 1 })); | |
| 39 | + const [page, stats] = await Promise.all([safe(api.companies({ ...current, sort, limit: LIMIT, offset, facets: 1 })), safe(api.stats())]); | |
| 37 | 40 | const href = (patch: Record<string, string | number | undefined | null>) => withParams('/companies', current, patch); |
| 38 | 41 | const facetGroups: FacetGroup[] = [ |
| 39 | 42 | { key: 'country', label: 'Country', items: (page?.facets?.countries ?? []).map((x) => ({ value: x.value, count: x.count })) }, |
| 40 | 43 | { key: 'kind', label: 'Kind', items: (page?.facets?.kinds ?? []).map((x) => ({ value: x.value, label: titleCase(x.value), count: x.count })) }, |
| 41 | 44 | ]; |
| 42 | 45 | const sortHref = (s: string) => href({ sort: s, order: sort === s && current.order !== 'asc' ? 'asc' : undefined, offset: undefined }); |
| 46 | + const definition = stats?.definitions?.organizations_total ?? 'Organizations = companies, labs and universities (entity types company, organization, lab, university); merged duplicates excluded.'; | |
| 43 | 47 | const SortTh = ({ s, children, num: n }: { s: string; children: React.ReactNode; num?: boolean }) => ( |
| 44 | 48 | <Th num={n} aria-sort={sort === s ? (current.order === 'asc' ? 'ascending' : 'descending') : undefined}> |
| 45 | 49 | <Link href={sortHref(s)} className={sort === s ? 'text-ink' : 'hover:text-ink'}> |
@@ -51,7 +55,20 @@ export default async function CompaniesPage({ searchParams }: { searchParams: Pr | ||
| 51 | 55 | |
| 52 | 56 | return ( |
| 53 | 57 | <Container wide> |
| 54 | − <PageHeader eyebrow="Companies" title="Companies, labs & organizations" lede="Who builds, serves and studies AI. Model and paper counts are live from the graph." aside={page ? <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} organizations</p> : undefined}> | |
| 58 | + <BreadcrumbLd items={[{ name: SITE_NAME, href: '/' }, { name: 'Organizations', href: '/companies' }]} /> | |
| 59 | + <PageHeader | |
| 60 | + eyebrow="Organizations" | |
| 61 | + title="Companies, labs & universities" | |
| 62 | + lede="Who builds, serves and studies AI. Model counts are canonical releases; paper counts are stated publisher relations. Every organization page separates corporate news from model events." | |
| 63 | + aside={ | |
| 64 | + page ? ( | |
| 65 | + <p className="tnum flex items-center gap-1 text-sm text-ink-3"> | |
| 66 | + {fmtInt(page.total)} organizations <span className="text-ink-3">(companies, labs, universities)</span> | |
| 67 | + <Hint text={definition} align="right" /> | |
| 68 | + </p> | |
| 69 | + ) : undefined | |
| 70 | + } | |
| 71 | + > | |
| 55 | 72 | <FilterBar |
| 56 | 73 | action="/companies" |
| 57 | 74 | className="mt-6" |
@@ -68,23 +85,28 @@ export default async function CompaniesPage({ searchParams }: { searchParams: Pr | ||
| 68 | 85 | <div className="pb-16"> |
| 69 | 86 | <ListingLayout facets={<Facets groups={facetGroups} current={current} makeHref={(p) => href(p)} />}> |
| 70 | 87 | {!page ? ( |
| 71 | − <Unavailable what="Companies" /> | |
| 88 | + <Unavailable what="Organizations" /> | |
| 72 | 89 | ) : ( |
| 73 | 90 | <> |
| 74 | − <DataTable caption="Companies"> | |
| 91 | + <DataTable caption="Organizations"> | |
| 75 | 92 | <thead> |
| 76 | 93 | <tr> |
| 77 | 94 | <SortTh s="name">Organization</SortTh> |
| 95 | + <Th>Type</Th> | |
| 78 | 96 | <Th>Country</Th> |
| 79 | 97 | <Th>Kind</Th> |
| 80 | 98 | <Th>Founded</Th> |
| 81 | − <SortTh s="models" num>Models</SortTh> | |
| 99 | + <SortTh s="models" num> | |
| 100 | + Models | |
| 101 | + </SortTh> | |
| 82 | 102 | <Th num>Papers</Th> |
| 83 | − <SortTh s="quality" num>Quality</SortTh> | |
| 103 | + <SortTh s="quality" num> | |
| 104 | + Quality | |
| 105 | + </SortTh> | |
| 84 | 106 | </tr> |
| 85 | 107 | </thead> |
| 86 | 108 | <tbody> |
| 87 | − {page.items.length === 0 && <EmptyRow cols={7}>No organizations match these filters.</EmptyRow>} | |
| 109 | + {page.items.length === 0 && <EmptyRow cols={8}>No organizations match these filters.</EmptyRow>} | |
| 88 | 110 | {page.items.map((c) => { |
| 89 | 111 | const a = c.attributes ?? {}; |
| 90 | 112 | return ( |
@@ -93,12 +115,25 @@ export default async function CompaniesPage({ searchParams }: { searchParams: Pr | ||
| 93 | 115 | <EntityLink e={c} /> |
| 94 | 116 | {c.description && <span className="block max-w-md truncate text-xs text-ink-3">{c.description}</span>} |
| 95 | 117 | </Td> |
| 118 | + <Td label="Type"> | |
| 119 | + <EntityBadge type={c.entity_type} small /> | |
| 120 | + </Td> | |
| 96 | 121 | <Td label="Country" className="mono text-ink-2">{typeof a.country === 'string' ? a.country : <span className="text-ink-3">—</span>}</Td> |
| 97 | 122 | <Td label="Kind" className="text-ink-2">{typeof a.org_kind === 'string' ? titleCase(a.org_kind) : <span className="text-ink-3">—</span>}</Td> |
| 98 | 123 | <Td label="Founded" className="tnum text-ink-2">{a.founded ? String(a.founded).slice(0, 4) : <span className="text-ink-3">—</span>}</Td> |
| 99 | − <Td num label="Models" className="tnum">{num(c.model_count) ? <Link href={`/models?org=${encodeURIComponent(c.slug)}`} className="hover:text-accent">{fmtInt(c.model_count)}</Link> : <span className="text-ink-3">0</span>}</Td> | |
| 124 | + <Td num label="Models" className="tnum"> | |
| 125 | + {num(c.model_count) ? ( | |
| 126 | + <Link href={`/models?org=${encodeURIComponent(c.slug)}`} className="hover:text-accent"> | |
| 127 | + {fmtInt(c.model_count)} | |
| 128 | + </Link> | |
| 129 | + ) : ( | |
| 130 | + <span className="text-ink-3">0</span> | |
| 131 | + )} | |
| 132 | + </Td> | |
| 100 | 133 | <Td num label="Papers" className="tnum">{num(c.paper_count) ? fmtInt(c.paper_count) : <span className="text-ink-3">0</span>}</Td> |
| 101 | − <Td num label="Quality"><QualityMark q={c.quality?.score} /></Td> | |
| 134 | + <Td num label="Quality"> | |
| 135 | + <QualityMark q={c.quality?.score} /> | |
| 136 | + </Td> | |
| 102 | 137 | </tr> |
| 103 | 138 | ); |
| 104 | 139 | })} |
modified
apps/web/src/app/developers/page.tsx
+79 −130
@@ -1,85 +1,22 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | +import { BreadcrumbLd } from '@/components/meta/breadcrumb-ld'; | |
| 3 | 4 | import { DataTable, Td, Th } from '@/components/ui/data-table'; |
| 4 | 5 | import { Container, Note, PageHeader, Section } from '@/components/ui/section'; |
| 5 | 6 | import { Unavailable } from '@/components/ui/unavailable'; |
| 6 | 7 | import { api, safe } from '@/lib/api'; |
| 7 | −import { PUBLIC_API_BASE, routes } from '@/lib/site'; | |
| 8 | −import type { EntityDetail, Stats } from '@/lib/types'; | |
| 8 | +import { PUBLIC_API_BASE, routes, SITE_NAME } from '@/lib/site'; | |
| 9 | +import type { Stats } from '@/lib/types'; | |
| 10 | +import { RequestBuilder } from './request-builder'; | |
| 11 | +import { ROUTES } from './routes'; | |
| 9 | 12 | |
| 10 | −export const metadata: Metadata = { title: 'Developers — public API', description: 'The AI Atlas public API: JSON over HTTPS, no key required for public routes, OpenAPI docs, live example responses and curl recipes.', alternates: { canonical: '/developers' } }; | |
| 13 | +export const metadata: Metadata = { title: 'Developers — public API 1.1, request builder, provenance and history recipes', description: 'The AI Atlas public API: JSON over HTTPS, no key for public routes, OpenAPI docs, an interactive request builder with curl / Python / JavaScript snippets, and recipes for pagination, provenance and historical queries.', alternates: { canonical: '/developers' } }; | |
| 11 | 14 | export const revalidate = 3600; |
| 12 | 15 | |
| 13 | −const ENDPOINTS: { path: string; returns: string }[] = [ | |
| 14 | − { path: 'GET /health', returns: 'Service status (db, redis, llm) and version' }, | |
| 15 | − { path: 'GET /stats', returns: 'Live counters: entities per type, sources, documents, claims, events, prices, archive size — always computed from the database' }, | |
| 16 | − { path: 'GET /stats/history?days=90', returns: 'Daily counts' }, | |
| 17 | − { path: 'GET /search?q=&type=&limit=&offset=', returns: 'Natural-language or keyword search → { query: compiled filters, items: ranked entities (+rank), total }' }, | |
| 18 | − { path: 'GET /search/suggest?q=', returns: '≤ 8 prefix suggestions { id, entity_type, slug, name, organization_name }' }, | |
| 19 | − { path: 'GET /entities/{slug}', returns: 'Full entity detail: attributes, provenance per attribute, aliases, identifiers, relations (grouped), sources, timeline and type-specific blocks (prices, price_history, results, lineage, hardware_fit, models, papers, repositories)' }, | |
| 20 | − { path: 'GET /models/{slug} · /companies/{slug} · /papers/{slug} · /providers/{slug} · /benchmarks/{slug} · /hardware/{slug} · /frameworks/{slug} · /datasets/{slug} · /tools/{slug}', returns: 'Same detail, type-checked (404 if the slug is another type)' }, | |
| 21 | − { path: 'GET /entities/{slug}/timeline?limit=&before=', returns: '{ items: ChangeEvent[], next_before } — the entity’s own events (and, for companies, those of the models it develops)' }, | |
| 22 | − { path: 'GET /entities/{slug}/history?property=', returns: '{ items: Claim[] } — every claim ever recorded, newest first, with status current | superseded | conflicting | retracted, valid_from → valid_to, source and tier' }, | |
| 23 | − { path: 'GET /entities/{slug}/asof?date=YYYY-MM-DD', returns: '{ existed, first_seen_at, date, attributes, claims } — the entity as it was known on that date' }, | |
| 24 | − { path: 'GET /entities/{slug}/graph?depth=1|2&limit=80', returns: '{ root, nodes: { id, slug, name, entity_type, organization_name }[], edges: { source, target, predicate }[] } — capped neighbourhood' }, | |
| 25 | − { path: 'GET /entities/{slug}/sources · /related?limit=', returns: 'Documents describing the entity (deduped by URL) · related entities (same org / family / shared relations)' }, | |
| 26 | − { path: 'GET /models?q=&org=&family=&openness=&modality=&status=&min_params=&max_params=&min_context=&year_from=&year_to=&license=&sort=&order=&facets=1', returns: 'Paged models (+facets: organizations, openness, modalities, families, years, licenses, status)' }, | |
| 27 | − { path: 'GET /companies?q=&country=&kind=&sort=', returns: 'Paged organizations with model_count and paper_count (+facets)' }, | |
| 28 | − { path: 'GET /papers?q=&category=&org=&since=&until=&sort=', returns: 'Paged papers' }, | |
| 29 | − { path: 'GET /providers · GET /prices?model=&provider=&sort=¤t=1 · GET /prices/history?model=|provider=', returns: 'Providers with min prices · current price rows (USD / 1M tokens) · every price row incl. closed ones, oldest first' }, | |
| 30 | − { path: 'GET /prices/index?days=180', returns: '{ days, series: { day, median_input, median_output, min_input, max_input, models, offers }[], movers: ChangeEvent[] (PRICE_CHANGED), note }' }, | |
| 31 | − { path: 'GET /benchmarks · GET /benchmarks/{slug}/results?config=&history=1&limit=&offset= · GET /benchmarks/{slug}/history?model=', returns: 'Benchmarks with result_count, model_count and current top · leaderboard sorted by score (respecting higher_is_better), current rows only unless history=1, filterable by config key=value · one model’s results over time' }, | |
| 32 | − { path: 'GET /hardware?kind=&manufacturer=&min_memory=&sort= · GET /hardware/fit?memory_gb=&quant=4bit|8bit|fp16&context=8192&openness=&limit=', returns: 'Hardware listing · ESTIMATED fit: { inputs, estimated: true, assumptions[], counts, items: { model, parameter_count, estimated_memory_gb, headroom_gb, fits, quantization, note }[] }' }, | |
| 33 | − { path: 'GET /explore/types · GET /explore/{type}?q=&org=&sort=', returns: 'Entity types with live counts; generic paged listing for any type' }, | |
| 34 | − { path: 'GET /changes?category=&type=&entity_type=&importance_min=&since=&until=&q=&limit=&before= · GET /changes/daily?date= · GET /changes/categories?days=', returns: 'Change-event feed (cursor paged, next_before) · daily digest with sections · category counts' }, | |
| 35 | − { path: 'GET /timeline?entity=&year=&category=&limit=', returns: '{ items: { month, count, events[] }[], total } — grouped by month' }, | |
| 36 | − { path: 'GET /compare?ids=a,b[,…6]', returns: '{ entity_type, dimensions: { key, label, unit, kind: number|text|list|bool|date, source }[], items: { entity, values, provenance, prices?, results? }[] } — 2–6 entities of one type; models add best prices and the benchmarks shared by all' }, | |
| 37 | − { path: 'GET /diff?a=YYYY-MM-DD&b=YYYY-MM-DD&scope=all|models|org:<slug>|family:<name>', returns: '{ a, b, scope, new_entities[], gone_entities[], property_changes[], price_changes[], benchmark_changes[], counts }' }, | |
| 38 | − { path: 'GET /sources · GET /methodology · GET /trending?days=&limit=&type= · GET /sitemap?type=&limit=&offset=', returns: 'Transparency page data, vocabularies and tiers, most viewed entities, sitemap feed' }, | |
| 39 | −]; | |
| 40 | − | |
| 41 | 16 | /** Compact, honest projection of /stats for the example block (the real payload has more counters). */ |
| 42 | 17 | function statsExample(s: Stats) { |
| 43 | − return { | |
| 44 | − entities: s.entities, | |
| 45 | − entities_total: s.entities_total, | |
| 46 | − sources: s.sources, | |
| 47 | − connectors: s.connectors, | |
| 48 | − claims_current: s.claims_current, | |
| 49 | − change_events_24h: s.change_events_24h, | |
| 50 | − benchmark_results: s.benchmark_results, | |
| 51 | − prices_current: s.prices_current, | |
| 52 | − last_event_at: s.last_event_at, | |
| 53 | − computed_at: s.computed_at, | |
| 54 | − '…': `${Object.keys(s).length} keys in total`, | |
| 55 | − }; | |
| 56 | −} | |
| 57 | − | |
| 58 | −function modelExample(d: EntityDetail) { | |
| 59 | − const attrKeys = Object.keys(d.attributes ?? {}).slice(0, 8); | |
| 60 | − const attributes: Record<string, unknown> = {}; | |
| 61 | − for (const k of attrKeys) attributes[k] = d.attributes[k]; | |
| 62 | − const provKey = attrKeys.find((k) => d.provenance?.[k]) ?? Object.keys(d.provenance ?? {})[0]; | |
| 63 | − const rest = Object.keys(d.attributes ?? {}).length - attrKeys.length; | |
| 64 | − return { | |
| 65 | − id: d.id, | |
| 66 | − entity_type: d.entity_type, | |
| 67 | − slug: d.slug, | |
| 68 | − name: d.name, | |
| 69 | − organization: d.organization, | |
| 70 | − status: d.status, | |
| 71 | − attributes: rest > 0 ? { ...attributes, '…': `${rest} more` } : attributes, | |
| 72 | − provenance: provKey ? { [provKey]: d.provenance[provKey], '…': `one entry per attribute` } : {}, | |
| 73 | − quality: d.quality, | |
| 74 | − counts: d.counts, | |
| 75 | − relations: `${d.relations?.length ?? 0} groups`, | |
| 76 | − prices: `${d.prices?.length ?? 0} current rows`, | |
| 77 | − results: `${d.results?.length ?? 0} benchmark results`, | |
| 78 | − timeline: `${d.timeline?.length ?? 0} events`, | |
| 79 | − updated_at: d.updated_at, | |
| 80 | − }; | |
| 18 | + return { entities: s.entities, entities_total: s.entities_total, organizations_total: s.organizations_total, change_events_live_24h: s.change_events_live_24h, claims_current: s.claims_current, prices_current: s.prices_current, last_event_at: s.last_event_at, definitions: s.definitions ? `${Object.keys(s.definitions).length} counter definitions` : undefined, computed_at: s.computed_at, '…': `${Object.keys(s).length} keys in total` }; | |
| 81 | 19 | } |
| 82 | − | |
| 83 | 20 | function Code({ children }: { children: string }) { |
| 84 | 21 | return ( |
| 85 | 22 | <pre className="scrollbar-thin mt-3 overflow-x-auto border border-rule bg-surface p-4 text-[12.5px] leading-relaxed text-ink"> |
@@ -89,104 +26,116 @@ function Code({ children }: { children: string }) { | ||
| 89 | 26 | } |
| 90 | 27 | |
| 91 | 28 | export default async function DevelopersPage() { |
| 92 | − const [health, stats, top] = await Promise.all([safe(api.health()), safe(api.stats()), safe(api.models({ limit: 2, sort: 'quality' }))]); | |
| 93 | − const slugA = top?.items[0]?.slug; | |
| 94 | − const slugB = top?.items[1]?.slug; | |
| 95 | − const model = slugA ? await safe(api.entityOfType('models', slugA)) : null; | |
| 96 | − const exA = slugA ?? '<model-slug>'; | |
| 97 | − const exB = slugB ?? '<other-slug>'; | |
| 29 | + const [health, stats, top] = await Promise.all([safe(api.health()), safe(api.stats()), safe(api.models({ limit: 1, sort: 'quality' }))]); | |
| 30 | + const exA = top?.items[0]?.slug ?? '<model-slug>'; | |
| 98 | 31 | const today = new Date().toISOString().slice(0, 10); |
| 99 | 32 | const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10); |
| 100 | 33 | |
| 101 | 34 | return ( |
| 102 | − <Container> | |
| 103 | − <PageHeader eyebrow="Developers" title="Public API" lede="Everything on this site comes from the same JSON API. Public routes need no key; responses are cached for 1–10 minutes and search is rate-limited per IP." aside={health ? <p className="text-sm text-ink-3">API <span className={health.status === 'ok' ? 'text-positive' : 'text-warning'}>{health.status}</span> · v{health.version}</p> : <p className="text-sm text-ink-3">API status unavailable</p>} /> | |
| 35 | + <Container wide> | |
| 36 | + <BreadcrumbLd items={[{ name: SITE_NAME, href: '/' }, { name: 'Developers', href: '/developers' }]} /> | |
| 37 | + <PageHeader eyebrow="Developers" title="Public API 1.1" lede="Everything on this site comes from the same JSON API. Public routes need no key; responses carry a weak ETag and are cached for 1–10 minutes; search is rate-limited per IP. 1.1 is additive over v1 — no route, field or type was removed." aside={health ? <p className="text-sm text-ink-3">API <span className={health.status === 'ok' ? 'text-positive' : 'text-warning'}>{health.status}</span> · v{health.version}{(health as { api_version?: string }).api_version ? ` · contract ${(health as { api_version?: string }).api_version}` : ''}</p> : <p className="text-sm text-ink-3">API status unavailable</p>} /> | |
| 104 | 38 | |
| 105 | 39 | <Section eyebrow="Base URL" title={<span className="mono text-lg md:text-xl">{PUBLIC_API_BASE}</span>} hairline={false}> |
| 106 | 40 | <p className="text-sm text-ink-2"> |
| 107 | − Interactive OpenAPI documentation: <a href="/api/v1/docs" className="link mono">/api/v1/docs</a>. JSON, UTF-8, ISO-8601 UTC timestamps. Errors are <span className="mono">{'{ "detail": "…" }'}</span> with 400 / 404 / 429 / 503 (422 for parameters rejected by validation). | |
| 41 | + Interactive OpenAPI documentation: <a href="/api/v1/docs" className="link mono">/api/v1/docs</a>. JSON, UTF-8, ISO-8601 UTC timestamps. Errors are <span className="mono">{'{ "detail": "…" }'}</span> with 400 / 404 / 409 / 429 / 501 / 503 (422 with <span className="mono">errors</span> for parameters rejected by validation). Every response carries <span className="mono">x-api-version: 1.1</span>. | |
| 108 | 42 | </p> |
| 109 | − <Code>{`# Live counters | |
| 110 | −curl -s ${PUBLIC_API_BASE}/stats | jq '.entities' | |
| 111 | − | |
| 112 | −# Natural-language search → compiled filters + ranked results | |
| 113 | −curl -s "${PUBLIC_API_BASE}/search?q=open+models+over+100B+released+in+2026" | jq '.query, .items[0:3]' | |
| 114 | − | |
| 115 | −# A model with provenance, prices, results, lineage | |
| 116 | −curl -s ${PUBLIC_API_BASE}/models/${exA} | jq '{name, attributes, provenance: (.provenance | keys)}' | |
| 117 | − | |
| 118 | −# Compare two models on every shared dimension (+ shared benchmarks, best prices) | |
| 119 | −curl -s "${PUBLIC_API_BASE}/compare?ids=${exA},${exB}" | jq '.dimensions[].key, .items[].values' | |
| 120 | − | |
| 121 | −# What changed between two dates (scope: all | models | org:<slug> | family:<name>) | |
| 122 | −curl -s "${PUBLIC_API_BASE}/diff?a=${weekAgo}&b=${today}&scope=models" | jq '.counts, .new_entities[0:3]' | |
| 123 | − | |
| 124 | −# Claim history and the state of a record as of a date | |
| 125 | −curl -s "${PUBLIC_API_BASE}/entities/${exA}/history?property=context_length" | jq '.items[] | {value, status, valid_from, valid_to, source_url}' | |
| 126 | −curl -s "${PUBLIC_API_BASE}/entities/${exA}/asof?date=${weekAgo}" | jq '{existed, attributes}' | |
| 127 | − | |
| 128 | −# Neighbourhood graph, price index, leaderboard by config, estimated hardware fit | |
| 129 | −curl -s "${PUBLIC_API_BASE}/entities/${exA}/graph?depth=1&limit=80" | jq '{nodes: (.nodes|length), edges: (.edges|length)}' | |
| 130 | −curl -s "${PUBLIC_API_BASE}/prices/index?days=90" | jq '.series[-1], (.movers|length)' | |
| 131 | −curl -s "${PUBLIC_API_BASE}/benchmarks/<benchmark-slug>/results?config=variant=v2.1&limit=20" | jq '.items[] | {model: .model.name, score, config}' | |
| 132 | −curl -s "${PUBLIC_API_BASE}/hardware/fit?memory_gb=64&quant=4bit&context=32768&limit=20" | jq '.assumptions, .items[0]' | |
| 43 | + </Section> | |
| 133 | 44 | |
| 134 | −# What changed today | |
| 135 | −curl -s "${PUBLIC_API_BASE}/changes?importance_min=2&limit=20" | jq '.items[] | {event_type, summary, source_url}'`}</Code> | |
| 45 | + <Section id="builder" eyebrow="Request builder" title="Build a request, try it, copy the snippet" lede="Routes and parameters come from the 1.1 contract; “Try” calls the API from your browser through this site's same-origin proxy."> | |
| 46 | + <RequestBuilder /> | |
| 136 | 47 | </Section> |
| 137 | 48 | |
| 138 | − <Section eyebrow="Live examples" title="Example responses" lede="Fetched from the API when this page was rendered (revalidated hourly) and trimmed for display — not sample data."> | |
| 49 | + <Section id="recipes" eyebrow="Recipes" title="Pagination, provenance, history"> | |
| 139 | 50 | <div className="grid gap-8 lg:grid-cols-2"> |
| 140 | 51 | <div className="min-w-0"> |
| 141 | − <p className="mono text-sm text-ink">GET /stats</p> | |
| 142 | − {stats ? <Code>{JSON.stringify(statsExample(stats), null, 2)}</Code> : <Unavailable what="Live /stats example" className="mt-3" />} | |
| 52 | + <p className="text-sm font-medium text-ink">Cursor pagination on feeds</p> | |
| 53 | + <Code>{`# /changes and /entities/{slug}/timeline return next_before; pass it back as before= | |
| 54 | +curl -s "${PUBLIC_API_BASE}/changes?importance_min=2&limit=50" | jq '{next_before, n: (.items|length)}' | |
| 55 | +curl -s "${PUBLIC_API_BASE}/changes?importance_min=2&limit=50&before=<next_before>" | |
| 56 | + | |
| 57 | +# Listings use limit/offset (≤ 200; /sitemap ≤ 5 000). 1.1 feeds are keyed on occurred_at and exclude backfill: | |
| 58 | +curl -s "${PUBLIC_API_BASE}/changes?include_backfill=1&date_field=observed&limit=20" # v1 behaviour`}</Code> | |
| 59 | + <p className="mt-6 text-sm font-medium text-ink">Field-level provenance</p> | |
| 60 | + <Code>{`# Every displayed value has a claim behind it | |
| 61 | +curl -s "${PUBLIC_API_BASE}/entities/${exA}/provenance/context_length" | jq '{value, source: .source.name, tier, extractor, observed_at, valid_since, claim_id, conflicts: (.conflicts|length)}' | |
| 62 | + | |
| 63 | +# One claim and its lifecycle (previous, superseding, conflicting) | |
| 64 | +curl -s "${PUBLIC_API_BASE}/claims/<claim_id>" | jq '{property, claim: .claim.value, chain: (.chain | map_values(if type=="array" then length else . end))}' | |
| 65 | + | |
| 66 | +# All current claims of an entity | |
| 67 | +curl -s "${PUBLIC_API_BASE}/entities/${exA}/claims?status=current&limit=50" | jq '.items[] | {property, value, tier, source_name}'`}</Code> | |
| 143 | 68 | </div> |
| 144 | 69 | <div className="min-w-0"> |
| 145 | − <p className="mono text-sm text-ink">GET /models/{exA}</p> | |
| 146 | − {model ? <Code>{JSON.stringify(modelExample(model), null, 2)}</Code> : <Unavailable what="Live model example" className="mt-3" />} | |
| 147 | − {model && ( | |
| 148 | − <Note className="mt-2"> | |
| 149 | − Trimmed: the full record has every attribute, a provenance entry per attribute, grouped relations, sources and the latest 30 events — <Link href={routes.entity(model)} className="link">see it rendered</Link> or <a href={`/api/v1/models/${encodeURIComponent(model.slug)}`} className="link">fetch the JSON</a>. | |
| 150 | − </Note> | |
| 151 | − )} | |
| 70 | + <p className="text-sm font-medium text-ink">Historical queries</p> | |
| 71 | + <Code>{`# The atlas as of a date (reconstructed before the observation history) | |
| 72 | +curl -s "${PUBLIC_API_BASE}/time-machine?date=2025-06-01&scope=models&limit=20" | jq '{reconstructed, first_entity_at, total: .models.total}' | |
| 73 | + | |
| 74 | +# One entity as of a date, and the full claim history of a property | |
| 75 | +curl -s "${PUBLIC_API_BASE}/entities/${exA}/asof?date=${weekAgo}" | jq '{existed, attributes}' | |
| 76 | +curl -s "${PUBLIC_API_BASE}/entities/${exA}/history?property=context_length" | jq '.items[] | {value, status, valid_from, valid_to}' | |
| 77 | + | |
| 78 | +# What changed between two dates (scope: all | models | org:<slug> | family:<slug>) | |
| 79 | +curl -s "${PUBLIC_API_BASE}/diff?a=${weekAgo}&b=${today}&scope=models" | jq '.counts' | |
| 80 | + | |
| 81 | +# Today in AI 2.0 — grouped sections, backfill excluded | |
| 82 | +curl -s "${PUBLIC_API_BASE}/changes/daily?date=${today}" | jq '{total, backfill_excluded, sections: [.today[] | {key, total}]}'`}</Code> | |
| 83 | + <p className="mt-6 text-sm font-medium text-ink">Graph</p> | |
| 84 | + <Code>{`# Typed neighbourhood explorer — never more than limit nodes, truncated says when the API cut | |
| 85 | +curl -s "${PUBLIC_API_BASE}/graph/explore?node=anthropic&mode=company&depth=1&limit=150" | jq '{truncated, counts, predicates}'`}</Code> | |
| 152 | 86 | </div> |
| 153 | 87 | </div> |
| 154 | 88 | </Section> |
| 155 | 89 | |
| 156 | − <Section eyebrow="Endpoints" title="Public routes"> | |
| 90 | + <Section eyebrow="Live example" title="GET /stats" lede="Fetched from the API when this page was rendered (revalidated hourly) and trimmed for display — not sample data."> | |
| 91 | + {stats ? <Code>{JSON.stringify(statsExample(stats), null, 2)}</Code> : <Unavailable what="Live /stats example" className="mt-3" />} | |
| 92 | + </Section> | |
| 93 | + | |
| 94 | + <Section eyebrow="Endpoints" title="Public routes (1.1)"> | |
| 157 | 95 | <DataTable caption="Public endpoints" compact> |
| 158 | − <thead><tr><Th>Route</Th><Th>Returns</Th></tr></thead> | |
| 96 | + <thead> | |
| 97 | + <tr> | |
| 98 | + <Th>Route</Th> | |
| 99 | + <Th>Group</Th> | |
| 100 | + <Th>Returns</Th> | |
| 101 | + </tr> | |
| 102 | + </thead> | |
| 159 | 103 | <tbody> |
| 160 | − {ENDPOINTS.map((e) => ( | |
| 161 | − <tr key={e.path}> | |
| 162 | − <Td primary className="mono text-[12.5px] break-words">{e.path}</Td> | |
| 163 | − <Td label="Returns" wide className="text-ink-2">{e.returns}</Td> | |
| 104 | + {ROUTES.map((r) => ( | |
| 105 | + <tr key={r.id}> | |
| 106 | + <Td primary className="mono text-[12.5px] break-words"> | |
| 107 | + {r.method} {r.path} | |
| 108 | + {r.params.length > 0 && <span className="block text-[11px] text-ink-3">{r.params.map((p) => p.name).join(' · ')}</span>} | |
| 109 | + </Td> | |
| 110 | + <Td label="Group" className="text-xs text-ink-2">{r.group}</Td> | |
| 111 | + <Td label="Returns" wide className="text-ink-2">{r.returns}</Td> | |
| 164 | 112 | </tr> |
| 165 | 113 | ))} |
| 166 | 114 | </tbody> |
| 167 | 115 | </DataTable> |
| 168 | − <Note className="mt-3">Shapes are documented in the OpenAPI schema. Admin routes (<span className="mono">/admin/*</span>) require the <span className="mono">x-aia-admin-token</span> header and are not public.</Note> | |
| 116 | + <Note className="mt-3"> | |
| 117 | + Shapes are documented in the OpenAPI schema and in <span className="mono">docs/API.md</span>. Admin routes (<span className="mono">/admin/*</span>) require the <span className="mono">x-aia-admin-token</span> header and are not public. Also: <span className="mono">/benchmarks/{'{slug}'}/frontier</span>, <span className="mono">/pareto</span>, <span className="mono">/cost/context</span>, <span className="mono">/hardware/{'{slug}'}/fit</span>, <span className="mono">/licenses/{'{key}'}</span>, <span className="mono">/compare</span>, <span className="mono">/explore/types</span>, <span className="mono">/explore/{'{type}'}</span>, <span className="mono">POST /views</span>. | |
| 118 | + </Note> | |
| 169 | 119 | </Section> |
| 170 | 120 | |
| 171 | 121 | <Section eyebrow="Conventions" title="Reading responses"> |
| 172 | 122 | <ul className="max-w-3xl list-disc space-y-1.5 pl-5 text-sm leading-relaxed text-ink-2"> |
| 173 | 123 | <li>Numeric aggregates may arrive as <span className="mono">string</span> (Postgres decimals): treat every numeric field as <span className="mono">number | string | null</span> and coerce.</li> |
| 174 | 124 | <li><span className="mono">null</span> means the sources did not state it. Never read a missing field as zero, and never average two conflicting claims — the API returns both, flagged.</li> |
| 175 | − <li>Timestamps are ISO-8601 UTC. Dates without time (<span className="mono">release_date</span>, <span className="mono">knowledge_cutoff</span>) may be <span className="mono">YYYY</span>, <span className="mono">YYYY-MM</span> or <span className="mono">YYYY-MM-DD</span>, exactly as published.</li> | |
| 176 | − <li>Units: tokens, USD per 1M tokens (<span className="mono">*_per_mtok</span>), GB, GB/s, W. Openness vocabulary: <span className="mono">open-weights | open-source | proprietary | restricted</span>.</li> | |
| 177 | − <li><span className="mono">provenance</span> is an object keyed by attribute: <span className="mono">{'{ source_id, url, observed_at, tier (1 official → 4 unverified), confidence, extractor: deterministic | llm, unit? }'}</span>.</li> | |
| 178 | − <li>Cursor feeds (<span className="mono">/changes</span>, <span className="mono">/entities/{'{slug}'}/timeline</span>) return <span className="mono">next_before</span>; pass it as <span className="mono">before=</span> for the next page (<span className="mono">null</span> at the end). Listings otherwise use <span className="mono">limit</span>/<span className="mono">offset</span>, capped at 200 (sitemap: 5 000).</li> | |
| 179 | − <li>Parameters rejected by validation (bad type or bounds) return <span className="mono">422</span> with <span className="mono">{'{ detail, errors }'}</span>; semantic errors (unknown sort, bad date, wrong scope, ids count) return <span className="mono">400</span>.</li> | |
| 180 | − <li>Anything marked <span className="mono">estimated</span> (hardware fit) is derived from published specs by a stated formula — the <span className="mono">assumptions</span> array is part of the response.</li> | |
| 125 | + <li>Events: <span className="mono">occurred_at = coalesce(effective_at, observed_at)</span>; <span className="mono">is_backfill</span> marks imported history; <span className="mono">group_key</span> folds one release seen in several documents.</li> | |
| 126 | + <li>Universe: <span className="mono">/models</span> lists canonical model releases; artifacts and folded variants are excluded unless <span className="mono">include=artifacts</span>. Old slugs keep resolving (<span className="mono">redirected_from</span>).</li> | |
| 127 | + <li>Benchmarks: results live in comparability groups (metric × <span className="mono">config_key</span>); leaderboards are one row per canonical model with a <span className="mono">trust_level</span>.</li> | |
| 128 | + <li>Anything marked <span className="mono">estimated: true</span> (hardware fit, memory-derived parameter bounds) is derived by a stated formula — the <span className="mono">assumptions</span> array is part of the response.</li> | |
| 129 | + <li>Caching: weak <span className="mono">ETag</span> + <span className="mono">Cache-Control: public, max-age=60, stale-while-revalidate=300</span>; send <span className="mono">If-None-Match</span> for a 304.</li> | |
| 181 | 130 | </ul> |
| 182 | 131 | </Section> |
| 183 | 132 | |
| 184 | 133 | <Section eyebrow="Terms" title="Fair use"> |
| 185 | 134 | <ul className="max-w-3xl list-disc space-y-1.5 pl-5 text-sm leading-relaxed text-ink-2"> |
| 186 | 135 | <li>Public routes are free to use without a key. Please cache responses and identify your client with a User-Agent that includes a contact address.</li> |
| 187 | − <li>Search is rate-limited per IP (HTTP 429 when exceeded). Higher limits and developer keys (<span className="mono">x-api-key</span>) will be available on request — see <Link href={routes.about()} className="link">contact</Link>.</li> | |
| 136 | + <li>Search is rate-limited per IP (HTTP 429 when exceeded). Higher limits and developer keys (<span className="mono">x-api-key</span>) are available on request — see <Link href={routes.about()} className="link">contact</Link>.</li> | |
| 188 | 137 | <li>Attribution: “Data: AI Atlas (www.ai-atlas.co)” with a link. Every record carries its own upstream sources; please keep them when you redistribute.</li> |
| 189 | − <li>Never treat a missing field as zero. <span className="mono">null</span> means the sources did not state it.</li> | |
| 138 | + <li>Agent-readable access (MCP server, bulk exports) is planned, not available yet — the routes above are the only supported surface today.</li> | |
| 190 | 139 | </ul> |
| 191 | 140 | </Section> |
| 192 | 141 | </Container> |
added
apps/web/src/app/developers/request-builder.tsx
+155 −0
@@ -0,0 +1,155 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { Copy, Play } from 'lucide-react'; | |
| 3 | +import { useMemo, useState } from 'react'; | |
| 4 | +import { cn } from '@/lib/cn'; | |
| 5 | +import { clientTry } from '@/lib/client-api'; | |
| 6 | +import { PUBLIC_API_BASE } from '@/lib/site'; | |
| 7 | +import { buildPath, ROUTES, type RouteDef } from './routes'; | |
| 8 | + | |
| 9 | +/* | |
| 10 | + Interactive request builder (client): route picker (static catalogue of the 1.1 routes), parameter fields with | |
| 11 | + examples, "Try" against the same origin `/api/v1` (rewritten to the API), copyable curl / Python / JavaScript snippets | |
| 12 | + and the response-shape excerpt from docs/API.md. Nothing is fetched until the user asks. | |
| 13 | +*/ | |
| 14 | + | |
| 15 | +type Lang = 'curl' | 'python' | 'js'; | |
| 16 | +const MAX_BODY = 6000; | |
| 17 | + | |
| 18 | +function snippet(lang: Lang, url: string): string { | |
| 19 | + if (lang === 'curl') return `curl -s "${url}" | jq .`; | |
| 20 | + if (lang === 'python') | |
| 21 | + return `import requests\n\nr = requests.get("${url}", headers={"User-Agent": "my-app/1.0 (contact@example.com)"}, timeout=30)\nr.raise_for_status()\ndata = r.json() # numeric aggregates may be strings — coerce before arithmetic\nprint(data)`; | |
| 22 | + return `const res = await fetch("${url}", { headers: { accept: "application/json" } });\nif (!res.ok) throw new Error(\`API \${res.status}\`);\nconst data = await res.json(); // null means "not stated by any source"\nconsole.log(data);`; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export function RequestBuilder({ initialRoute = 'search' }: { initialRoute?: string }) { | |
| 26 | + const [routeId, setRouteId] = useState(initialRoute); | |
| 27 | + const route: RouteDef = ROUTES.find((r) => r.id === routeId) ?? ROUTES[0]!; | |
| 28 | + const [values, setValues] = useState<Record<string, string>>({}); | |
| 29 | + const [lang, setLang] = useState<Lang>('curl'); | |
| 30 | + const [result, setResult] = useState<{ status: number; ms: number; body: unknown; headers: Record<string, string> } | null>(null); | |
| 31 | + const [busy, setBusy] = useState(false); | |
| 32 | + const [error, setError] = useState<string | null>(null); | |
| 33 | + const [copied, setCopied] = useState(false); | |
| 34 | + const path = useMemo(() => buildPath(route, values), [route, values]); | |
| 35 | + const url = `${PUBLIC_API_BASE}${path}`; | |
| 36 | + const groups = useMemo(() => [...new Set(ROUTES.map((r) => r.group))], []); | |
| 37 | + | |
| 38 | + const pick = (id: string) => { | |
| 39 | + setRouteId(id); | |
| 40 | + setValues({}); | |
| 41 | + setResult(null); | |
| 42 | + setError(null); | |
| 43 | + }; | |
| 44 | + const tryIt = async () => { | |
| 45 | + setBusy(true); | |
| 46 | + setError(null); | |
| 47 | + try { | |
| 48 | + setResult(await clientTry(path)); | |
| 49 | + } catch (e) { | |
| 50 | + setError((e as Error).message); | |
| 51 | + } finally { | |
| 52 | + setBusy(false); | |
| 53 | + } | |
| 54 | + }; | |
| 55 | + const copy = async () => { | |
| 56 | + try { | |
| 57 | + await navigator.clipboard.writeText(snippet(lang, url)); | |
| 58 | + setCopied(true); | |
| 59 | + setTimeout(() => setCopied(false), 1500); | |
| 60 | + } catch { | |
| 61 | + /* ignore */ | |
| 62 | + } | |
| 63 | + }; | |
| 64 | + const bodyText = result ? (typeof result.body === 'string' ? result.body : JSON.stringify(result.body, null, 2)) : ''; | |
| 65 | + const cls = 'h-10 w-full border border-rule bg-surface px-2 text-sm text-ink focus:border-accent focus:outline-none'; | |
| 66 | + | |
| 67 | + return ( | |
| 68 | + <div className="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]" data-request-builder> | |
| 69 | + <div className="min-w-0"> | |
| 70 | + <label className="block"> | |
| 71 | + <span className="eyebrow block pb-1">Route</span> | |
| 72 | + <select value={route.id} onChange={(e) => pick(e.target.value)} className={cls} data-route-picker> | |
| 73 | + {groups.map((g) => ( | |
| 74 | + <optgroup key={g} label={g}> | |
| 75 | + {ROUTES.filter((r) => r.group === g).map((r) => ( | |
| 76 | + <option key={r.id} value={r.id}> | |
| 77 | + {r.path} — {r.summary} | |
| 78 | + </option> | |
| 79 | + ))} | |
| 80 | + </optgroup> | |
| 81 | + ))} | |
| 82 | + </select> | |
| 83 | + </label> | |
| 84 | + <p className="mono mt-2 break-all text-xs text-ink-2"> | |
| 85 | + {route.method} {route.path} | |
| 86 | + </p> | |
| 87 | + <p className="mt-1 text-xs text-ink-3">{route.summary}</p> | |
| 88 | + {route.params.length > 0 && ( | |
| 89 | + <div className="mt-4 grid grid-cols-2 gap-2 lg:grid-cols-1"> | |
| 90 | + {route.params.map((p) => ( | |
| 91 | + <label key={p.name} className="block min-w-0"> | |
| 92 | + <span className="eyebrow block pb-1"> | |
| 93 | + {p.name} | |
| 94 | + {p.path && <span className="normal-case tracking-normal text-ink-3"> · path</span>} | |
| 95 | + </span> | |
| 96 | + <input value={values[p.name] ?? ''} onChange={(e) => setValues((v) => ({ ...v, [p.name]: e.target.value }))} placeholder={p.example ?? p.hint ?? ''} className={cn(cls, 'mono text-xs')} aria-label={`${p.name} parameter`} /> | |
| 97 | + {p.hint && p.example && <span className="mt-0.5 block text-[11px] text-ink-3">{p.hint}</span>} | |
| 98 | + </label> | |
| 99 | + ))} | |
| 100 | + </div> | |
| 101 | + )} | |
| 102 | + </div> | |
| 103 | + <div className="min-w-0 space-y-4"> | |
| 104 | + <div className="flex flex-wrap items-center gap-2"> | |
| 105 | + <code className="mono min-w-0 flex-1 truncate border border-rule bg-surface px-2 py-2 text-xs text-ink" title={url} data-request-url> | |
| 106 | + {url} | |
| 107 | + </code> | |
| 108 | + <button type="button" onClick={tryIt} disabled={busy} className="inline-flex h-10 items-center gap-1.5 bg-ink px-3 text-sm font-medium text-canvas hover:opacity-90 disabled:opacity-60" data-try> | |
| 109 | + <Play className="size-4" aria-hidden /> {busy ? 'Requesting…' : 'Try'} | |
| 110 | + </button> | |
| 111 | + </div> | |
| 112 | + <div> | |
| 113 | + <div className="flex items-center gap-1 border-b border-rule" role="tablist" aria-label="Snippet language"> | |
| 114 | + {(['curl', 'python', 'js'] as Lang[]).map((l) => ( | |
| 115 | + <button key={l} type="button" role="tab" aria-selected={lang === l} onClick={() => setLang(l)} className={cn('h-9 border-b-2 px-3 text-sm', lang === l ? 'border-ink font-medium text-ink' : 'border-transparent text-ink-2 hover:text-ink')}> | |
| 116 | + {l === 'js' ? 'JavaScript' : l === 'python' ? 'Python' : 'curl'} | |
| 117 | + </button> | |
| 118 | + ))} | |
| 119 | + <button type="button" onClick={copy} className="ml-auto inline-flex h-9 items-center gap-1 px-2 text-xs text-ink-3 hover:text-ink"> | |
| 120 | + <Copy className="size-3.5" aria-hidden /> {copied ? 'Copied' : 'Copy'} | |
| 121 | + </button> | |
| 122 | + </div> | |
| 123 | + <pre className="scrollbar-thin mt-2 overflow-x-auto border border-rule bg-surface p-3 text-[12.5px] leading-relaxed text-ink"> | |
| 124 | + <code>{snippet(lang, url)}</code> | |
| 125 | + </pre> | |
| 126 | + </div> | |
| 127 | + <div> | |
| 128 | + <p className="eyebrow mb-1">Response shape (docs/API.md)</p> | |
| 129 | + <pre className="scrollbar-thin overflow-x-auto whitespace-pre-wrap border border-dashed border-rule p-3 text-[12px] leading-relaxed text-ink-2"> | |
| 130 | + <code>{route.returns}</code> | |
| 131 | + </pre> | |
| 132 | + </div> | |
| 133 | + <div aria-live="polite" data-try-result> | |
| 134 | + {error && <p className="text-sm text-danger">Request failed: {error}</p>} | |
| 135 | + {result && ( | |
| 136 | + <> | |
| 137 | + <p className="tnum flex flex-wrap items-center gap-x-3 text-xs text-ink-3"> | |
| 138 | + <span className={result.status < 400 ? 'text-positive' : 'text-danger'}>HTTP {result.status}</span> | |
| 139 | + <span>{result.ms} ms</span> | |
| 140 | + {Object.entries(result.headers).map(([k, v]) => ( | |
| 141 | + <span key={k} className="mono"> | |
| 142 | + {k}: {v.length > 40 ? `${v.slice(0, 40)}…` : v} | |
| 143 | + </span> | |
| 144 | + ))} | |
| 145 | + </p> | |
| 146 | + <pre className="scrollbar-thin mt-2 max-h-[28rem] overflow-auto border border-rule bg-surface p-3 text-[12px] leading-relaxed text-ink"> | |
| 147 | + <code>{bodyText.length > MAX_BODY ? `${bodyText.slice(0, MAX_BODY)}\n… (${bodyText.length - MAX_BODY} more characters — open the URL for the full body)` : bodyText}</code> | |
| 148 | + </pre> | |
| 149 | + </> | |
| 150 | + )} | |
| 151 | + </div> | |
| 152 | + </div> | |
| 153 | + </div> | |
| 154 | + ); | |
| 155 | +} | |
added
apps/web/src/app/developers/routes.ts
+60 −0
@@ -0,0 +1,60 @@ | ||
| 1 | +/** Static catalogue of the public API 1.1 routes (docs/API.md) for the request builder. No endpoint here is invented. */ | |
| 2 | +export type RouteParam = { name: string; example?: string; hint?: string; path?: boolean }; | |
| 3 | +export type RouteDef = { id: string; method: 'GET'; path: string; group: string; summary: string; params: RouteParam[]; returns: string }; | |
| 4 | + | |
| 5 | +export const ROUTES: RouteDef[] = [ | |
| 6 | + { id: 'health', method: 'GET', path: '/health', group: 'Meta', summary: 'Service status', params: [], returns: '{ status: "ok"|"degraded", version, api_version: "1.1", db, redis, llm: { available, reachable? }, time }' }, | |
| 7 | + { id: 'stats', method: 'GET', path: '/stats', group: 'Meta', summary: 'Live counters with definitions', params: [], returns: '{ entities: Record<type, n>, entities_total, organizations_total, artifacts, model_families, change_events_live_24h, definitions: Record<key, string>, … computed_at }' }, | |
| 8 | + { id: 'search', method: 'GET', path: '/search', group: 'Search', summary: 'Compiler v2 + ranked results', params: [{ name: 'q', example: 'open reasoning models over 30B released in 2026' }, { name: 'type', hint: 'entity type' }, { name: 'limit', example: '10' }, { name: 'offset' }], returns: '{ query: { compiled: { filter, label, value, source_span }[], sort, residual, unrecognised, semantic, version: 2 }, items: (EntitySummary & { rank })[], total }' }, | |
| 9 | + { id: 'suggest', method: 'GET', path: '/search/suggest', group: 'Search', summary: '≤ 8 prefix suggestions', params: [{ name: 'q', example: 'clau' }], returns: '{ items: { id, entity_type, slug, name, organization_name }[] }' }, | |
| 10 | + { id: 'entity', method: 'GET', path: '/entities/{slug}', group: 'Entities', summary: 'Full detail with provenance', params: [{ name: 'slug', example: 'claude-opus-5', path: true }], returns: 'EntityDetail: attributes, provenance, aliases, identifiers, relations[], sources[], timeline[] + type blocks (prices, results, lineage, family, artifacts, deployments, identity, licence, openness, version_history, benchmarks…)' }, | |
| 11 | + { id: 'models', method: 'GET', path: '/models', group: 'Entities', summary: 'Canonical model universe', params: [{ name: 'q' }, { name: 'org', example: 'anthropic' }, { name: 'family' }, { name: 'openness', example: 'open-weights' }, { name: 'modality' }, { name: 'status' }, { name: 'min_params', example: '30000000000' }, { name: 'max_params' }, { name: 'min_context' }, { name: 'year_from', example: '2026' }, { name: 'year_to' }, { name: 'license' }, { name: 'reasoning', hint: '0|1' }, { name: 'trust', hint: 'high,medium,low' }, { name: 'include', hint: 'artifacts' }, { name: 'sort', hint: 'release|params|name|quality|cheapest' }, { name: 'order' }, { name: 'limit', example: '5' }, { name: 'offset' }, { name: 'facets', example: '1' }], returns: 'Page<EntitySummary & { identity_confidence, family?, canonical?, artifact_kind? }> & { universe, facets? }' }, | |
| 12 | + { id: 'model-diff', method: 'GET', path: '/models/{a}/diff/{b}', group: 'Entities', summary: 'Differing dimensions of two models', params: [{ name: 'a', example: 'claude-opus-5', path: true }, { name: 'b', example: 'claude-sonnet-5', path: true }], returns: '{ a, b, dimensions: (Dimension & { a, b, delta })[], comparability, note }' }, | |
| 13 | + { id: 'companies', method: 'GET', path: '/companies', group: 'Entities', summary: 'Organizations (companies, labs, universities)', params: [{ name: 'q' }, { name: 'country' }, { name: 'kind' }, { name: 'sort', example: 'models' }, { name: 'limit', example: '5' }, { name: 'facets', example: '1' }], returns: 'Page<EntitySummary & { model_count, paper_count }>; total = /stats.organizations_total' }, | |
| 14 | + { id: 'papers', method: 'GET', path: '/papers', group: 'Entities', summary: 'Papers', params: [{ name: 'q' }, { name: 'category', example: 'cs.CL' }, { name: 'org' }, { name: 'since' }, { name: 'until' }, { name: 'sort', example: 'published' }, { name: 'limit', example: '5' }], returns: 'Page<EntitySummary>' }, | |
| 15 | + { id: 'families', method: 'GET', path: '/families', group: 'Entities', summary: 'Model families', params: [{ name: 'q' }, { name: 'org', example: 'anthropic' }, { name: 'sort', hint: 'models|name|last_release' }, { name: 'limit', example: '5' }], returns: 'Page<{ id, slug, name, canonical, organization, model_count, first_release, last_release, param_range, modalities, licenses, benchmark_best }> & { note }' }, | |
| 16 | + { id: 'family', method: 'GET', path: '/families/{slug}', group: 'Entities', summary: 'Family detail with members and lineage', params: [{ name: 'slug', example: 'claude', path: true }, { name: 'limit' }], returns: '{ …aggregates, members: { model, key_facts, benchmark_ranks }[], artifacts_count, providers, lineage, timeline, note }' }, | |
| 17 | + { id: 'licenses', method: 'GET', path: '/licenses', group: 'Entities', summary: 'Licence ontology', params: [], returns: '{ items: (LicenseInfo & { aliases, models })[], total, categories, unclassified, note }' }, | |
| 18 | + { id: 'timeline-entity', method: 'GET', path: '/entities/{slug}/timeline', group: 'Temporal', summary: 'Entity events (occurred_at order)', params: [{ name: 'slug', example: 'anthropic', path: true }, { name: 'limit', example: '20' }, { name: 'before', hint: 'cursor' }, { name: 'include_backfill', hint: '0|1' }, { name: 'date_field', hint: 'occurred|observed' }], returns: '{ items: ChangeEvent[], next_before, date_field, include_backfill }' }, | |
| 19 | + { id: 'history', method: 'GET', path: '/entities/{slug}/history', group: 'Temporal', summary: 'Claim history of one property', params: [{ name: 'slug', example: 'claude-opus-5', path: true }, { name: 'property', example: 'context_length' }], returns: '{ items: Claim[] } — all statuses, newest first' }, | |
| 20 | + { id: 'asof', method: 'GET', path: '/entities/{slug}/asof', group: 'Temporal', summary: 'Entity as of a date', params: [{ name: 'slug', example: 'claude-opus-5', path: true }, { name: 'date', example: '2026-06-01' }], returns: '{ existed, first_seen_at, date, attributes, claims }' }, | |
| 21 | + { id: 'claims', method: 'GET', path: '/entities/{slug}/claims', group: 'Provenance', summary: 'Claims of an entity', params: [{ name: 'slug', example: 'claude-opus-5', path: true }, { name: 'property' }, { name: 'status', hint: 'current|all|superseded|conflicting|retracted' }, { name: 'limit', example: '10' }], returns: '{ entity, items: (Claim & { snapshot_id, run_id, value_raw })[], total, limit, offset, status }' }, | |
| 22 | + { id: 'provenance', method: 'GET', path: '/entities/{slug}/provenance/{property}', group: 'Provenance', summary: 'Evidence behind one value', params: [{ name: 'slug', example: 'claude-opus-5', path: true }, { name: 'property', example: 'context_length', path: true }], returns: '{ entity, property, value, source: { id, name, domain, url }, tier, confidence, extractor, observed_at, valid_since, claim_id, run_id, snapshot: { id, observed_at, document_url, title, archived }, conflicts, history_count }' }, | |
| 23 | + { id: 'claim', method: 'GET', path: '/claims/{id}', group: 'Provenance', summary: 'One claim with its lifecycle', params: [{ name: 'id', example: 'claim_…', path: true }], returns: '{ claim, entity, property, chain: { previous, superseding, conflicting, history_count }, source, extractor, run_id, evidence, note }' }, | |
| 24 | + { id: 'graph', method: 'GET', path: '/entities/{slug}/graph', group: 'Graph', summary: 'Capped neighbourhood (v1)', params: [{ name: 'slug', example: 'claude-opus-5', path: true }, { name: 'depth', example: '1' }, { name: 'limit', example: '80' }], returns: '{ root, nodes: { id, slug, name, entity_type, organization_name }[], edges: { source, target, predicate }[] }' }, | |
| 25 | + { id: 'graph-explore', method: 'GET', path: '/graph/explore', group: 'Graph', summary: 'Typed explorer, 7 modes', params: [{ name: 'node', example: 'anthropic' }, { name: 'mode', example: 'company', hint: 'lineage|research|company|benchmark|dataset|provider|hardware' }, { name: 'depth', example: '1' }, { name: 'limit', example: '150' }], returns: '{ root, mode, depth, predicates, nodes: { id, slug, name, entity_type, org, org_slug, level, artifact_kind, attributes }[], edges: { source, target, predicate, attributes, tier }[], truncated, counts }' }, | |
| 26 | + { id: 'changes', method: 'GET', path: '/changes', group: 'Temporal', summary: 'Change feed (cursor)', params: [{ name: 'category' }, { name: 'type', example: 'PRICE_CHANGED' }, { name: 'entity_type' }, { name: 'importance_min', example: '2' }, { name: 'since' }, { name: 'until' }, { name: 'q' }, { name: 'entity' }, { name: 'limit', example: '10' }, { name: 'before', hint: 'cursor' }, { name: 'include_backfill', hint: '0|1' }, { name: 'date_field', hint: 'occurred|observed' }], returns: 'Page<ChangeEvent & { occurred_at, is_backfill, group_key }> & { next_before, date_field, include_backfill }' }, | |
| 27 | + { id: 'daily', method: 'GET', path: '/changes/daily', group: 'Temporal', summary: 'Today in AI 2.0', params: [{ name: 'date', example: new Date().toISOString().slice(0, 10) }, { name: 'per_section', example: '5' }, { name: 'include_backfill', hint: '0|1' }], returns: '{ date, counts, total, sections, today: { key, label, items: (ChangeEvent & { sources, documents, grouped_events })[], total }[], new_models, labels, backfill_excluded, previous_day, next_day, note }' }, | |
| 28 | + { id: 'timeline', method: 'GET', path: '/timeline', group: 'Temporal', summary: 'Events grouped by month', params: [{ name: 'entity' }, { name: 'year', example: '2026' }, { name: 'category' }, { name: 'importance_min' }, { name: 'limit', example: '50' }, { name: 'include_backfill', hint: '0|1' }], returns: '{ items: { month, count, events }[], total, date_field, include_backfill }' }, | |
| 29 | + { id: 'diff', method: 'GET', path: '/diff', group: 'Temporal', summary: 'Diff two dates', params: [{ name: 'a', example: new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10) }, { name: 'b', example: new Date().toISOString().slice(0, 10) }, { name: 'scope', example: 'all', hint: 'all|models|org:<slug>|family:<slug>' }, { name: 'limit', example: '5' }, { name: 'include_backfill', hint: '0|1' }], returns: '{ a, b, scope, new_entities, gone_entities, property_changes, price_changes, benchmark_changes, new_benchmark_leaders, provider_changes, hardware_changes, context_changes, retired_models, counts, note }' }, | |
| 30 | + { id: 'time-machine', method: 'GET', path: '/time-machine', group: 'Temporal', summary: 'The atlas as of a date', params: [{ name: 'date', example: '2025-06-01' }, { name: 'scope', example: 'models', hint: 'models|prices|benchmarks|hardware|all' }, { name: 'limit', example: '5' }], returns: '{ date, scope, first_entity_at, reconstructed, note, models?: { items: { model, attributes_as_of, observed_then, reconstructed }[], total }, prices?, benchmarks?: { leaders }, hardware? }' }, | |
| 31 | + { id: 'pulse', method: 'GET', path: '/pulse', group: 'Intelligence', summary: 'Live activity counters', params: [{ name: 'days', example: '7' }], returns: '{ days, since, until, counters: Record<key, { value, definition, items? }>, note }' }, | |
| 32 | + { id: 'frontier', method: 'GET', path: '/frontier', group: 'Intelligence', summary: 'Who leads on what', params: [{ name: 'limit', example: '5' }], returns: '{ latest_major_models, benchmark_frontier, price_frontier, context_frontier, open_weight_frontier, efficiency_frontier, agentic_frontier, multimodal_frontier, recent_frontier_movements, generated_at, methodology }' }, | |
| 33 | + { id: 'trending', method: 'GET', path: '/trending', group: 'Intelligence', summary: 'Most viewed / most changed', params: [{ name: 'kind', example: 'most_changed', hint: 'views|most_changed|new_listings|new_results' }, { name: 'days', example: '7' }, { name: 'limit', example: '5' }, { name: 'type' }], returns: '{ days, kind, items, definition }' }, | |
| 34 | + { id: 'benchmarks', method: 'GET', path: '/benchmarks', group: 'Benchmarks', summary: 'Benchmarks with leaders', params: [{ name: 'category' }], returns: '{ items: { slug, name, category, metric, result_count, model_count, leader, groups, trust_mix }[], total, note }' }, | |
| 35 | + { id: 'leaderboard', method: 'GET', path: '/benchmarks/{slug}/leaderboard', group: 'Benchmarks', summary: 'One row per canonical model', params: [{ name: 'slug', example: 'gpqa-diamond', path: true }, { name: 'metric' }, { name: 'config_key' }, { name: 'trust' }, { name: 'org' }, { name: 'comparable_only', hint: '0|1' }, { name: 'limit', example: '10' }], returns: '{ benchmark, group, groups, items: LeaderboardRow[], total, comparable_only, filters, history_available, methodology }' }, | |
| 36 | + { id: 'matrix', method: 'GET', path: '/benchmarks/matrix', group: 'Benchmarks', summary: 'Models × benchmarks', params: [{ name: 'benchmarks' }, { name: 'models' }, { name: 'org' }, { name: 'family' }, { name: 'limit', example: '10' }, { name: 'min_cells', example: '3' }], returns: '{ columns, rows: { model, cells, n_cells, mean_rank }[], total_rows, methodology }' }, | |
| 37 | + { id: 'prices', method: 'GET', path: '/prices', group: 'Prices', summary: 'Current offers', params: [{ name: 'model' }, { name: 'provider' }, { name: 'org' }, { name: 'family' }, { name: 'sort', hint: 'cheapest_frontier…' }, { name: 'current', example: '1' }, { name: 'limit', example: '5' }], returns: 'Page<Price>' }, | |
| 38 | + { id: 'price-index', method: 'GET', path: '/prices/index', group: 'Prices', summary: 'AI Price Index', params: [{ name: 'days', example: '90' }], returns: '{ days, series[], movers, cheapest_frontier, distribution, new_listings_30d, delistings_30d, price_changes_30d, frontier, methodology, note }' }, | |
| 39 | + { id: 'deployments', method: 'GET', path: '/deployments', group: 'Prices', summary: 'Model × provider offers', params: [{ name: 'model' }, { name: 'provider' }, { name: 'org' }, { name: 'current', example: '1' }, { name: 'sort' }, { name: 'limit', example: '5' }], returns: 'Page<Deployment> & { next_before, current }' }, | |
| 40 | + { id: 'cost', method: 'GET', path: '/cost', group: 'Prices', summary: 'Workload cost across providers', params: [{ name: 'model', example: 'claude-opus-5' }, { name: 'input_tokens', example: '1000' }, { name: 'output_tokens', example: '500' }, { name: 'requests_per_day', example: '1000' }, { name: 'cached_share' }, { name: 'batch', hint: '0|1' }], returns: '{ model, inputs, items: { deployment, cost: { per_request, daily, monthly, annual, … } }[], total, currency, methodology, note }' }, | |
| 41 | + { id: 'fit', method: 'GET', path: '/hardware/fit', group: 'Hardware', summary: 'ESTIMATED fit', params: [{ name: 'memory_gb', example: '64' }, { name: 'quant', example: '4bit' }, { name: 'context', example: '8192' }, { name: 'limit', example: '5' }], returns: '{ inputs, estimated: true, assumptions[], counts, items[] }' }, | |
| 42 | + { id: 'run-locally', method: 'GET', path: '/run-locally', group: 'Hardware', summary: 'What fits your machine', params: [{ name: 'memory_gb', example: '32' }, { name: 'quant', example: '4bit' }, { name: 'context', example: '8192' }, { name: 'platform', hint: 'apple|nvidia|amd|any' }, { name: 'limit', example: '5' }], returns: '{ inputs, estimated: true, assumptions, counts, items: { model, fit, artifacts, artifact_count }[], note }' }, | |
| 43 | + { id: 'open', method: 'GET', path: '/open', group: 'Intelligence', summary: 'Open-weight universe', params: [{ name: 'sort', hint: 'release|params|context|rank|name|downloads' }, { name: 'days', example: '30' }, { name: 'limit', example: '5' }], returns: 'Page<{ model, licence, dimensions, best_results, best_rank, hardware_fit, providers, cheapest_output_per_mtok }> & { summary, note }' }, | |
| 44 | + { id: 'find', method: 'GET', path: '/find-a-model', group: 'Intelligence', summary: 'Deterministic finder', params: [{ name: 'use_case', example: 'coding', hint: 'coding|reasoning|agentic|long_context|vision|low_cost|local|embeddings|chat' }, { name: 'deployment', hint: 'local|api|any' }, { name: 'memory_gb' }, { name: 'license', hint: 'commercial|any' }, { name: 'limit', example: '5' }], returns: '{ matches: { model, why[], observed, estimated_fit?, deployments? }[], total, filters_applied, rules, note }' }, | |
| 45 | + { id: 'sources', method: 'GET', path: '/sources', group: 'Meta', summary: 'Sources and connector health', params: [], returns: '{ items: { key, name, domain, tier, kind, category, organization, enabled, documents, snapshots, claims, last_crawled_at, connectors[] }[], total, tiers }' }, | |
| 46 | + { id: 'methodology', method: 'GET', path: '/methodology', group: 'Meta', summary: 'Every vocabulary and rule', params: [], returns: '{ metrics, tiers, confidence_levels, event_types, extractors, openness, trust_levels, comparability, counters, anomaly_checks, event_semantics, hardware_fit, frontier, find_a_model, licence_categories, principles }' }, | |
| 47 | + { id: 'sitemap', method: 'GET', path: '/sitemap', group: 'Meta', summary: 'Sitemap feed', params: [{ name: 'type', example: 'model_family' }, { name: 'limit', example: '5' }, { name: 'offset' }], returns: '{ items: { slug, entity_type, updated_at }[], total }' }, | |
| 48 | +]; | |
| 49 | + | |
| 50 | +export function buildPath(r: RouteDef, values: Record<string, string>): string { | |
| 51 | + let path = r.path; | |
| 52 | + const qs = new URLSearchParams(); | |
| 53 | + for (const p of r.params) { | |
| 54 | + const v = (values[p.name] ?? '').trim(); | |
| 55 | + if (p.path) path = path.replace(`{${p.name}}`, encodeURIComponent(v || p.example || '')); | |
| 56 | + else if (v) qs.set(p.name, v); | |
| 57 | + } | |
| 58 | + const s = qs.toString(); | |
| 59 | + return `${path}${s ? `?${s}` : ''}`; | |
| 60 | +} | |
added
apps/web/src/app/diff/og/route.tsx
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +import { ImageResponse } from 'next/og'; | |
| 2 | +import type { NextRequest } from 'next/server'; | |
| 3 | +import { Wallpaper } from '@/components/brand/og'; | |
| 4 | +import { daysBefore, ISO_DAY, todayUtc } from '@/components/temporal/dates'; | |
| 5 | +import { apiD3, safe } from '@/lib/api'; | |
| 6 | +import { fmtDate, fmtInt, num } from '@/lib/format'; | |
| 7 | + | |
| 8 | +export const runtime = 'nodejs'; | |
| 9 | +export const revalidate = 1800; | |
| 10 | + | |
| 11 | +/** Dynamic OG image for /diff?a=&b=&scope= — both dates and the live counts. */ | |
| 12 | +export async function GET(req: NextRequest): Promise<Response> { | |
| 13 | + const sp = req.nextUrl.searchParams; | |
| 14 | + const b = ISO_DAY.test(sp.get('b') ?? '') ? (sp.get('b') as string) : todayUtc(); | |
| 15 | + const a = ISO_DAY.test(sp.get('a') ?? '') ? (sp.get('a') as string) : daysBefore(b, 7); | |
| 16 | + const scope = sp.get('scope') || 'all'; | |
| 17 | + const d = a < b ? await safe(apiD3.diff(a, b, scope, 1)) : null; | |
| 18 | + const c = d?.counts ?? {}; | |
| 19 | + const n = (k: string) => fmtInt(num(c[k])); | |
| 20 | + const counters: [string, string][] = d ? [['New entities', n('new_entities')], ['Price changes', n('price_changes')], ['Context', n('context_changes')], ['New leaders', n('new_benchmark_leaders')], ['Events', n('events')]] : []; | |
| 21 | + return new ImageResponse(<Wallpaper eyebrow={`Diff the AI world${scope !== 'all' ? ` · ${scope}` : ''}`} title={`${fmtDate(a)} → ${fmtDate(b)}`} subtitle="New and retired models, price and context changes, new benchmark leaders, papers, providers, hardware — from the change log, nothing inferred." counters={counters} footer={`www.ai-atlas.co/diff?a=${a}&b=${b}`} markPx={200} />, { width: 1200, height: 630 }); | |
| 22 | +} | |
modified
apps/web/src/app/diff/page.tsx
+139 −102
@@ -1,125 +1,127 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | −import { ChangeRow } from '@/components/changes/change-row'; | |
| 4 | −import { EntityRow } from '@/components/ui/entity'; | |
| 5 | −import { Container, Note, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section'; | |
| 6 | −import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 7 | −import { api, ApiError, safe } from '@/lib/api'; | |
| 3 | +import { DataStrip, SectionNav } from '@/components/layout/terminal'; | |
| 4 | +import { BreadcrumbLd } from '@/components/meta/breadcrumb-ld'; | |
| 5 | +import { daysBefore, firstOfMonth, firstOfQuarter, ISO_DAY, monthsBefore, todayUtc } from '@/components/temporal/dates'; | |
| 6 | +import { ContextChangesSection, EntitiesSection, EventsSection, LeadersSection, NewEntitiesSection, PriceChangesSection } from '@/components/temporal/diff-sections'; | |
| 7 | +import { Container, Note, PageHeader } from '@/components/ui/section'; | |
| 8 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 9 | +import { api, ApiError, apiD1, apiD3, safe } from '@/lib/api'; | |
| 8 | 10 | import { fmtDate, fmtInt, num } from '@/lib/format'; |
| 9 | −import { routes } from '@/lib/site'; | |
| 10 | −import type { ChangeEvent, DiffPayload, EntitySummary } from '@/lib/types'; | |
| 11 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 12 | +import type { ChangeEvent, DiffPayload11, EntitySummary } from '@/lib/types'; | |
| 11 | 13 | |
| 12 | −type SP = { a?: string; b?: string; scope?: string; scope_custom?: string }; | |
| 13 | −const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/; | |
| 14 | +type SP = { a?: string; b?: string; scope?: string; scope_custom?: string; include_backfill?: string }; | |
| 15 | +const LIMIT = 200; | |
| 14 | 16 | |
| 15 | −function todayUtc(): string { | |
| 16 | − return new Date().toISOString().slice(0, 10); | |
| 17 | −} | |
| 18 | −function daysBefore(iso: string, days: number): string { | |
| 19 | − const d = new Date(`${iso}T00:00:00Z`); | |
| 20 | − d.setUTCDate(d.getUTCDate() - days); | |
| 21 | − return d.toISOString().slice(0, 10); | |
| 22 | −} | |
| 23 | −function resolve(sp: SP): { a: string; b: string; scope: string; valid: boolean } { | |
| 17 | +function resolve(sp: SP): { a: string; b: string; scope: string; valid: boolean; backfill: boolean } { | |
| 24 | 18 | const b = sp.b && ISO_DAY.test(sp.b) ? sp.b : todayUtc(); |
| 25 | 19 | const a = sp.a && ISO_DAY.test(sp.a) ? sp.a : daysBefore(b, 7); |
| 26 | 20 | const scope = (sp.scope_custom?.trim() || sp.scope || 'all').trim() || 'all'; |
| 27 | 21 | const valid = (!sp.a || ISO_DAY.test(sp.a)) && (!sp.b || ISO_DAY.test(sp.b)) && a < b; |
| 28 | − return { a, b, scope, valid }; | |
| 22 | + return { a, b, scope, valid, backfill: sp.include_backfill === '1' }; | |
| 29 | 23 | } |
| 30 | − | |
| 31 | −export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 32 | − const { a, b, scope } = resolve(await searchParams); | |
| 33 | − const title = `What changed between ${fmtDate(a)} and ${fmtDate(b)}${scope !== 'all' ? ` (${scope})` : ''}`; | |
| 34 | − return { title, description: `New, gone and changed entities, prices and benchmark results in the AI ecosystem between ${a} and ${b}, straight from the AI Atlas change log.`, alternates: { canonical: routes.diff({ a, b, scope }) }, robots: { index: false } }; | |
| 24 | +function href(a: string, b: string, scope: string, backfill = false): string { | |
| 25 | + const p = new URLSearchParams({ a, b }); | |
| 26 | + if (scope !== 'all') p.set('scope', scope); | |
| 27 | + if (backfill) p.set('include_backfill', '1'); | |
| 28 | + return `/diff?${p.toString()}`; | |
| 35 | 29 | } |
| 36 | − | |
| 37 | −function describeScope(s: DiffPayload['scope'], fallback: string): string { | |
| 30 | +function describeScope(s: DiffPayload11['scope'], fallback: string): string { | |
| 38 | 31 | if (!s) return fallback === 'all' ? 'the whole atlas' : fallback; |
| 39 | 32 | if (typeof s === 'string') return s; |
| 40 | 33 | const kind = typeof s.kind === 'string' ? s.kind : fallback; |
| 41 | − const parts = Object.entries(s) | |
| 42 | − .filter(([k, v]) => k !== 'kind' && v !== null && v !== undefined && v !== '') | |
| 43 | − .map(([k, v]) => `${k} ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`); | |
| 44 | 34 | if (kind === 'all') return 'the whole atlas'; |
| 45 | − return parts.length ? `${kind} · ${parts.join(' · ')}` : kind; | |
| 46 | −} | |
| 47 | − | |
| 48 | −function Capped({ shown, total, what }: { shown: number; total: number | null; what: string }) { | |
| 49 | − if (total === null || total <= shown) return null; | |
| 50 | − return <Note className="mt-2">Showing the first {fmtInt(shown)} of {fmtInt(total)} {what}. Narrow the scope or the dates to see everything.</Note>; | |
| 35 | + const org = s.organization as { name?: string } | undefined; | |
| 36 | + if (kind === 'org' && org?.name) return `organization · ${org.name}`; | |
| 37 | + if (kind === 'family' && typeof s.family === 'string') return `family · ${s.family}`; | |
| 38 | + if (kind === 'models') return 'models only'; | |
| 39 | + return kind; | |
| 51 | 40 | } |
| 52 | 41 | |
| 53 | −function EventsSection({ id, title, items, total, empty }: { id: string; title: string; items: ChangeEvent[]; total: number | null; empty: string }) { | |
| 54 | − return ( | |
| 55 | − <Section id={id} eyebrow={title} title={<>{title} <span className="tnum text-base font-normal text-ink-3">{fmtInt(total ?? items.length)}</span></>}> | |
| 56 | − {items.length === 0 ? ( | |
| 57 | − <EmptyState title={empty} /> | |
| 58 | − ) : ( | |
| 59 | − <> | |
| 60 | − <ul className="border-t border-rule"> | |
| 61 | − {items.map((e) => ( | |
| 62 | − <ChangeRow key={e.id} e={e} showDate live={false} /> | |
| 63 | − ))} | |
| 64 | − </ul> | |
| 65 | − <Capped shown={items.length} total={total} what="events" /> | |
| 66 | − </> | |
| 67 | − )} | |
| 68 | − </Section> | |
| 69 | − ); | |
| 70 | −} | |
| 71 | − | |
| 72 | −function EntitiesSection({ id, title, items, total, empty }: { id: string; title: string; items: EntitySummary[]; total: number | null; empty: string }) { | |
| 73 | − return ( | |
| 74 | − <Section id={id} eyebrow={title} title={<>{title} <span className="tnum text-base font-normal text-ink-3">{fmtInt(total ?? items.length)}</span></>}> | |
| 75 | − {items.length === 0 ? ( | |
| 76 | − <EmptyState title={empty} /> | |
| 77 | − ) : ( | |
| 78 | − <> | |
| 79 | − <ul className="border-t border-rule"> | |
| 80 | − {items.map((e) => ( | |
| 81 | − <EntityRow key={e.id} e={e} showType showDescription={false} /> | |
| 82 | − ))} | |
| 83 | − </ul> | |
| 84 | − <Capped shown={items.length} total={total} what="entities" /> | |
| 85 | − </> | |
| 86 | − )} | |
| 87 | − </Section> | |
| 88 | − ); | |
| 42 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 43 | + const { a, b, scope } = resolve(await searchParams); | |
| 44 | + const title = `Diff the AI world — ${fmtDate(a)} → ${fmtDate(b)}${scope !== 'all' ? ` (${scope})` : ''}`; | |
| 45 | + const description = `Every recorded change in the AI ecosystem between ${a} and ${b}: new and retired models, price and context changes, new benchmark leaders, papers, provider and hardware changes — from the change log, nothing inferred.`; | |
| 46 | + const og = `${SITE_URL}/diff/og?a=${a}&b=${b}&scope=${encodeURIComponent(scope)}`; | |
| 47 | + return { title, description, alternates: { canonical: href(a, b, scope) }, openGraph: { title: `${title} | ${SITE_NAME}`, description, url: `${SITE_URL}${href(a, b, scope)}`, images: [{ url: og, width: 1200, height: 630 }] }, twitter: { card: 'summary_large_image', images: [og] } }; | |
| 89 | 48 | } |
| 90 | 49 | |
| 91 | 50 | export default async function DiffPage({ searchParams }: { searchParams: Promise<SP> }) { |
| 92 | 51 | const sp = await searchParams; |
| 93 | − const { a, b, scope, valid } = resolve(sp); | |
| 94 | − const orgs = await safe(api.companies({ limit: 30, sort: 'models' })); | |
| 95 | − let payload: DiffPayload | null = null; | |
| 52 | + const { a, b, scope, valid, backfill } = resolve(sp); | |
| 53 | + const today = todayUtc(); | |
| 54 | + const [orgs, fams] = await Promise.all([safe(api.companies({ limit: 40, sort: 'models' })), safe(apiD1.families({ limit: 40, sort: 'models' }))]); | |
| 55 | + let payload: DiffPayload11 | null = null; | |
| 96 | 56 | let error: string | null = null; |
| 97 | 57 | if (!valid) error = 'Dates must be YYYY-MM-DD and the first date must come before the second.'; |
| 98 | 58 | else { |
| 99 | 59 | try { |
| 100 | − payload = await api.diff(a, b, scope); | |
| 60 | + payload = await apiD3.diff(a, b, scope, LIMIT, backfill); | |
| 101 | 61 | } catch (e) { |
| 102 | 62 | error = e instanceof ApiError && e.status === 400 ? e.detail ?? 'The API rejected these parameters.' : null; |
| 103 | 63 | } |
| 104 | 64 | } |
| 105 | 65 | const cls = 'h-11 w-full border border-rule bg-surface px-2.5 text-sm text-ink focus:border-accent focus:outline-none'; |
| 106 | 66 | const orgOptions = (orgs?.items ?? []).map((o) => ({ value: `org:${o.slug}`, label: `${o.name} (${fmtInt(o.model_count)} models)` })); |
| 107 | − const knownScopes = new Set(['all', 'models', ...orgOptions.map((o) => o.value)]); | |
| 67 | + const famOptions = (fams?.items ?? []).filter((f) => f.canonical).map((f) => ({ value: `family:${f.slug}`, label: `${f.name} (${fmtInt(f.model_count)})` })); | |
| 68 | + const knownScopes = new Set(['all', 'models', ...orgOptions.map((o) => o.value), ...famOptions.map((o) => o.value)]); | |
| 108 | 69 | const custom = knownScopes.has(scope) ? '' : scope; |
| 109 | 70 | const c = payload?.counts ?? {}; |
| 110 | 71 | const cnt = (k: string): number | null => num(c[k]); |
| 111 | 72 | |
| 73 | + // Split new entities by type (the API's new_entities is every type for scope=all). | |
| 74 | + const newAll: EntitySummary[] = payload?.new_entities ?? []; | |
| 75 | + const newModels = newAll.filter((e) => e.entity_type === 'model'); | |
| 76 | + const newPapers = newAll.filter((e) => e.entity_type === 'paper'); | |
| 77 | + const newOther = newAll.filter((e) => e.entity_type !== 'model' && e.entity_type !== 'paper'); | |
| 78 | + const newTotal = cnt('new_entities'); | |
| 79 | + const listCapped = newTotal !== null && newTotal > newAll.length; | |
| 80 | + const retired: EntitySummary[] = (payload?.retired_models ?? []).map((r) => ('entity' in (r as ChangeEvent) && (r as ChangeEvent).entity ? ((r as ChangeEvent).entity as EntitySummary) : (r as EntitySummary))).filter((e) => e && e.slug); | |
| 81 | + | |
| 82 | + const presets: { label: string; a: string; b: string }[] = [ | |
| 83 | + { label: 'Last 7 days', a: daysBefore(today, 7), b: today }, | |
| 84 | + { label: 'Since the 1st', a: firstOfMonth(today), b: today }, | |
| 85 | + { label: 'Last 30 days', a: monthsBefore(today, 1), b: today }, | |
| 86 | + { label: 'This quarter', a: firstOfQuarter(today), b: today }, | |
| 87 | + { label: 'Last 90 days', a: daysBefore(today, 90), b: today }, | |
| 88 | + ]; | |
| 89 | + const sections = payload | |
| 90 | + ? [ | |
| 91 | + { id: 'new-models', label: 'New models' }, | |
| 92 | + { id: 'retired', label: 'Retired' }, | |
| 93 | + { id: 'prices', label: 'Prices' }, | |
| 94 | + { id: 'context', label: 'Context' }, | |
| 95 | + { id: 'leaders', label: 'Leaders' }, | |
| 96 | + { id: 'papers', label: 'Papers' }, | |
| 97 | + { id: 'providers', label: 'Providers' }, | |
| 98 | + { id: 'hardware', label: 'Hardware' }, | |
| 99 | + { id: 'properties', label: 'Properties' }, | |
| 100 | + { id: 'gone', label: 'Gone' }, | |
| 101 | + ] | |
| 102 | + : []; | |
| 103 | + | |
| 112 | 104 | return ( |
| 113 | − <Container> | |
| 114 | − <PageHeader eyebrow="Diff" title={<>What changed between <span className="tnum">{fmtDate(a)}</span> and <span className="tnum">{fmtDate(b)}</span></>} lede="Two dates, one scope: the entities that appeared or disappeared, and every property, price and benchmark change recorded in between. Built from the change log — nothing is inferred." aside={payload ? <p className="text-sm text-ink-3">Scope: <span className="text-ink-2">{describeScope(payload.scope, scope)}</span></p> : undefined}> | |
| 115 | − <form action="/diff" method="get" className="mt-6 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5"> | |
| 105 | + <Container wide> | |
| 106 | + <BreadcrumbLd items={[{ name: SITE_NAME, href: '/' }, { name: 'Diff', href: '/diff' }, { name: `${a} → ${b}`, href: href(a, b, scope) }]} /> | |
| 107 | + <PageHeader | |
| 108 | + eyebrow="Diff the AI world" | |
| 109 | + title={ | |
| 110 | + <> | |
| 111 | + What changed between <span className="tnum">{fmtDate(a)}</span> and <span className="tnum">{fmtDate(b)}</span> | |
| 112 | + </> | |
| 113 | + } | |
| 114 | + lede="Two dates, one scope: new and retired models, price and context changes, new benchmark leaders, new papers, provider and hardware changes. Built from the change log keyed on when things occurred — nothing is inferred, and the URL is the report." | |
| 115 | + aside={payload ? <p className="text-sm text-ink-3">Scope: <span className="text-ink-2">{describeScope(payload.scope, scope)}</span></p> : undefined} | |
| 116 | + > | |
| 117 | + <form action="/diff" method="get" className="mt-6 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6" data-diff-form> | |
| 116 | 118 | <label className="block min-w-0"> |
| 117 | 119 | <span className="eyebrow block pb-1">From (UTC)</span> |
| 118 | − <input type="date" name="a" defaultValue={a} max={todayUtc()} className={cls} required /> | |
| 120 | + <input type="date" name="a" defaultValue={a} max={today} className={cls} required /> | |
| 119 | 121 | </label> |
| 120 | 122 | <label className="block min-w-0"> |
| 121 | 123 | <span className="eyebrow block pb-1">To (UTC)</span> |
| 122 | − <input type="date" name="b" defaultValue={b} max={todayUtc()} className={cls} required /> | |
| 124 | + <input type="date" name="b" defaultValue={b} max={today} className={cls} required /> | |
| 123 | 125 | </label> |
| 124 | 126 | <label className="block min-w-0"> |
| 125 | 127 | <span className="eyebrow block pb-1">Scope</span> |
@@ -135,12 +137,26 @@ export default async function DiffPage({ searchParams }: { searchParams: Promise | ||
| 135 | 137 | ))} |
| 136 | 138 | </optgroup> |
| 137 | 139 | )} |
| 140 | + {famOptions.length > 0 && ( | |
| 141 | + <optgroup label="Family"> | |
| 142 | + {famOptions.map((o) => ( | |
| 143 | + <option key={o.value} value={o.value}> | |
| 144 | + {o.label} | |
| 145 | + </option> | |
| 146 | + ))} | |
| 147 | + </optgroup> | |
| 148 | + )} | |
| 138 | 149 | {custom && <option value="">Custom (below)</option>} |
| 139 | 150 | </select> |
| 140 | 151 | </label> |
| 141 | 152 | <label className="block min-w-0"> |
| 142 | 153 | <span className="eyebrow block pb-1">Custom scope</span> |
| 143 | − <input name="scope_custom" defaultValue={custom} placeholder="family:Claude · org:<slug>" className={cls} /> | |
| 154 | + <input name="scope_custom" defaultValue={custom} placeholder="org:<slug> · family:<slug>" className={cls} /> | |
| 155 | + </label> | |
| 156 | + <label className="flex min-w-0 items-end"> | |
| 157 | + <span className="flex h-11 items-center gap-2 text-sm text-ink-2"> | |
| 158 | + <input type="checkbox" name="include_backfill" value="1" defaultChecked={backfill} className="size-4 accent-[var(--accent)]" /> include backfill | |
| 159 | + </span> | |
| 144 | 160 | </label> |
| 145 | 161 | <div className="flex items-end gap-2"> |
| 146 | 162 | <button type="submit" className="inline-flex h-11 flex-1 items-center justify-center bg-ink px-3 text-sm font-medium text-canvas hover:opacity-90"> |
@@ -151,9 +167,18 @@ export default async function DiffPage({ searchParams }: { searchParams: Promise | ||
| 151 | 167 | </Link> |
| 152 | 168 | </div> |
| 153 | 169 | </form> |
| 154 | − <Note className="mt-2"> | |
| 155 | − Custom scope accepts <span className="mono">org:<slug></span> or <span className="mono">family:<name></span>; when filled it overrides the select. Quick links: <Link href={routes.diff({ a: daysBefore(todayUtc(), 1), b: todayUtc() })} className="link">yesterday → today</Link> · <Link href={routes.diff({ a: daysBefore(todayUtc(), 7), b: todayUtc(), scope: 'models' })} className="link">last 7 days, models</Link> · <Link href={routes.diff({ a: daysBefore(todayUtc(), 30), b: todayUtc() })} className="link">last 30 days</Link> | |
| 156 | − </Note> | |
| 170 | + <ul className="no-scrollbar -mx-4 mt-3 flex gap-1 overflow-x-auto px-4 md:mx-0 md:px-0" aria-label="Presets"> | |
| 171 | + {presets.map((p) => { | |
| 172 | + const on = p.a === a && p.b === b; | |
| 173 | + return ( | |
| 174 | + <li key={p.label} className="shrink-0"> | |
| 175 | + <Link href={href(p.a, p.b, scope, backfill)} className={`inline-flex h-9 items-center border px-2.5 text-sm ${on ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink'}`} aria-current={on ? 'true' : undefined}> | |
| 176 | + {p.label} | |
| 177 | + </Link> | |
| 178 | + </li> | |
| 179 | + ); | |
| 180 | + })} | |
| 181 | + </ul> | |
| 157 | 182 | </PageHeader> |
| 158 | 183 | |
| 159 | 184 | <div className="pb-16"> |
@@ -163,27 +188,39 @@ export default async function DiffPage({ searchParams }: { searchParams: Promise | ||
| 163 | 188 | <Unavailable what="Diff" reason="The change log could not be read right now." /> |
| 164 | 189 | ) : ( |
| 165 | 190 | <> |
| 166 | − <StatGrid cols={8}> | |
| 167 | − <Stat label="New entities" value={fmtInt(cnt('new_entities') ?? payload.new_entities.length)} href="#new" /> | |
| 168 | − <Stat label="Gone" value={fmtInt(cnt('gone_entities') ?? payload.gone_entities.length)} href="#gone" /> | |
| 169 | − <Stat label="Events" value={fmtInt(cnt('events'))} hint="all types" /> | |
| 170 | − <Stat label="Price changes" value={fmtInt(cnt('price_changes') ?? payload.price_changes.length)} href="#prices" accent /> | |
| 171 | − <Stat label="Benchmark changes" value={fmtInt(cnt('benchmark_changes') ?? payload.benchmark_changes.length)} href="#benchmarks" /> | |
| 172 | − <Stat label="Claims superseded" value={fmtInt(cnt('claims_superseded'))} /> | |
| 173 | − <Stat label={`Entities at ${fmtDate(a)}`} value={fmtInt(cnt('entities_at_a'))} /> | |
| 174 | − <Stat label={`Entities at ${fmtDate(b)}`} value={fmtInt(cnt('entities_at_b'))} /> | |
| 175 | − </StatGrid> | |
| 191 | + <DataStrip | |
| 192 | + dense | |
| 193 | + items={[ | |
| 194 | + { label: 'New models', value: fmtInt(listCapped ? null : newModels.length), hint: listCapped ? `${fmtInt(newTotal)} new entities in total` : undefined, definition: 'Canonical models first seen between the two dates (artifacts excluded unless include=artifacts).', href: '#new-models' }, | |
| 195 | + { label: 'Retired models', value: fmtInt(cnt('retired_models') ?? retired.length), definition: 'Models whose status became retired/deprecated in the window.', href: '#retired' }, | |
| 196 | + { label: 'Price changes', value: fmtInt(cnt('price_changes') ?? payload.price_changes.length), definition: 'PRICE_CHANGED events that occurred in the window.', href: '#prices' }, | |
| 197 | + { label: 'Context changes', value: fmtInt(cnt('context_changes') ?? payload.context_changes?.length), definition: 'CONTEXT_CHANGED events in the window.', href: '#context' }, | |
| 198 | + { label: 'New leaders', value: fmtInt(cnt('new_benchmark_leaders') ?? payload.new_benchmark_leaders?.length), definition: 'Benchmarks whose primary-group leader differs between the two dates.', href: '#leaders' }, | |
| 199 | + { label: 'New papers', value: fmtInt(listCapped ? null : newPapers.length), definition: 'Papers first seen in the window (from the new-entities list).', href: '#papers' }, | |
| 200 | + { label: 'Provider changes', value: fmtInt(cnt('provider_changes') ?? payload.provider_changes?.length), definition: 'Listings and delistings by providers.', href: '#providers' }, | |
| 201 | + { label: 'Hardware changes', value: fmtInt(cnt('hardware_changes') ?? payload.hardware_changes?.length), definition: 'Hardware entities and spec changes.', href: '#hardware' }, | |
| 202 | + ]} | |
| 203 | + /> | |
| 176 | 204 | <Note className="mt-2"> |
| 177 | − Price rows opened {fmtInt(cnt('price_rows_opened'))} · closed {fmtInt(cnt('price_rows_closed'))}. Counts are totals over the period; the lists below are capped by the API. | |
| 205 | + {fmtInt(cnt('events'))} events in the window · {fmtInt(cnt('claims_superseded'))} claims superseded · price rows opened {fmtInt(cnt('price_rows_opened'))} / closed {fmtInt(cnt('price_rows_closed'))} · entities at {fmtDate(a)}: {fmtInt(cnt('entities_at_a'))} → at {fmtDate(b)}: {fmtInt(cnt('entities_at_b'))}. | |
| 206 | + {listCapped && <> The entity lists are capped at {fmtInt(LIMIT)} by the API — per-type counts below are “of the first {fmtInt(LIMIT)}”.</>} | |
| 207 | + {payload.note && <> {payload.note}</>} | |
| 178 | 208 | </Note> |
| 209 | + <SectionNav items={sections} className="mt-4" /> | |
| 179 | 210 | |
| 180 | − <EntitiesSection id="new" title="New entities" items={payload.new_entities} total={cnt('new_entities')} empty="No new entities between these dates" /> | |
| 181 | − <EntitiesSection id="gone" title="Gone entities" items={payload.gone_entities} total={cnt('gone_entities')} empty="No entities disappeared between these dates" /> | |
| 182 | − <EventsSection id="properties" title="Property changes" items={payload.property_changes} total={cnt('property_changes')} empty="No property changes between these dates" /> | |
| 183 | − <EventsSection id="prices" title="Price changes" items={payload.price_changes} total={cnt('price_changes')} empty="No price changes between these dates" /> | |
| 184 | − <EventsSection id="benchmarks" title="Benchmark changes" items={payload.benchmark_changes} total={cnt('benchmark_changes')} empty="No benchmark changes between these dates" /> | |
| 211 | + <NewEntitiesSection id="new-models" title="New models" items={newModels} total={listCapped ? null : newModels.length} empty="No new canonical model between these dates" type="model" /> | |
| 212 | + <EntitiesSection id="retired" title="Retired models" items={retired} total={cnt('retired_models')} empty="No model was retired between these dates" /> | |
| 213 | + <PriceChangesSection id="prices" title="Price changes" items={payload.price_changes} total={cnt('price_changes')} empty="No price change between these dates" /> | |
| 214 | + <ContextChangesSection id="context" title="Context changes" items={payload.context_changes ?? []} total={cnt('context_changes')} empty="No context-window change between these dates" /> | |
| 215 | + <LeadersSection id="leaders" items={payload.new_benchmark_leaders ?? []} a={a} b={b} /> | |
| 216 | + <NewEntitiesSection id="papers" title="New papers" items={newPapers} total={listCapped ? null : newPapers.length} empty="No new paper between these dates" type="paper" /> | |
| 217 | + <PriceChangesSection id="providers" title="Provider changes" items={payload.provider_changes ?? []} total={cnt('provider_changes')} empty="No provider listing or delisting between these dates" /> | |
| 218 | + <EventsSection id="hardware" title="Hardware changes" items={payload.hardware_changes ?? []} total={cnt('hardware_changes')} empty="No hardware change between these dates" /> | |
| 219 | + <EventsSection id="properties" title="Other property changes" items={payload.property_changes} total={cnt('property_changes')} empty="No other property change between these dates" lede="Openness, licence, status, parameters, release-date and other material property changes (context changes are listed above)." /> | |
| 220 | + {newOther.length > 0 && <NewEntitiesSection id="new-other" title="Other new entities" items={newOther} total={listCapped ? null : newOther.length} empty="" type="other" />} | |
| 221 | + <EntitiesSection id="gone" title="Gone entities" items={payload.gone_entities} total={cnt('gone_entities')} empty="No entity disappeared between these dates" /> | |
| 185 | 222 | <Note className="mt-6"> |
| 186 | − “Gone” means an entity present at the first date is no longer current at the second (merged or retired) — its record and history are kept. Per-entity history: open any entity's History tab. <Link href="/methodology" className="link">Methodology →</Link> | |
| 223 | + “Gone” means an entity present at the first date is no longer current at the second (merged or retired) — its record and history are kept. Events are keyed on <span className="mono">occurred_at</span>{backfill ? ' and include back-filled history' : '; back-filled history is excluded (tick “include backfill” to add it)'}. Per-entity history: any entity's History tab. <Link href={routes.timeMachine(a)} className="link">Atlas as of {fmtDate(a)}</Link> · <Link href={routes.methodology()} className="link">Methodology →</Link> | |
| 187 | 224 | </Note> |
| 188 | 225 | </> |
| 189 | 226 | )} |
modified
apps/web/src/app/explore/[type]/[slug]/page.tsx
+8 −2
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'; | ||
| 2 | 2 | import { permanentRedirect } from 'next/navigation'; |
| 3 | 3 | import { EntityPage, type EntityPageParams } from '@/components/entity/entity-page'; |
| 4 | 4 | import { buildMetadata, loadAnyEntity } from '@/components/entity/load'; |
| 5 | +import { ResearcherPage } from '@/components/research/researcher-page'; | |
| 5 | 6 | import { api, safe } from '@/lib/api'; |
| 6 | 7 | import { routes, TYPE_PATH } from '@/lib/site'; |
| 7 | 8 | |
@@ -12,7 +13,10 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> { | ||
| 12 | 13 | const { type, slug } = await params; |
| 13 | 14 | const d = await safe(api.entity(slug)); |
| 14 | 15 | if (!d || d.entity_type !== type) return { title: 'Not found', robots: { index: false } }; |
| 15 | − return buildMetadata(d); | |
| 16 | + const m = buildMetadata(d); | |
| 17 | + // Name-only researcher rows (no identifier) are thin pages: keep them out of the index, follow their links. | |
| 18 | + if (d.entity_type === 'researcher' && !d.identifiers?.length) return { ...m, robots: { index: false, follow: true } }; | |
| 19 | + return m; | |
| 16 | 20 | } |
| 17 | 21 | |
| 18 | 22 | export default async function ExploreEntityPage({ params, searchParams }: Params) { |
@@ -20,6 +24,8 @@ export default async function ExploreEntityPage({ params, searchParams }: Params | ||
| 20 | 24 | const sp = await searchParams; |
| 21 | 25 | const d = await loadAnyEntity(type, slug); |
| 22 | 26 | if (TYPE_PATH[d.entity_type]) permanentRedirect(routes.entity(d)); |
| 27 | + const canonical = routes.entity(d); | |
| 28 | + if (d.entity_type === 'researcher') return <ResearcherPage d={d} canonical={canonical} />; | |
| 23 | 29 | const related = await safe(api.entityRelated(d.slug, 10)); |
| 24 | − return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} asof={sp.asof} historyProperty={sp.property} />; | |
| 30 | + return <EntityPage d={d} canonical={canonical} related={related?.items} asof={sp.asof} historyProperty={sp.property} />; | |
| 25 | 31 | } |
added
apps/web/src/app/explore/builder.tsx
+404 −0
@@ -0,0 +1,404 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { Bookmark, Copy, Play, Trash2 } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useRouter } from 'next/navigation'; | |
| 5 | +import { useEffect, useMemo, useState } from 'react'; | |
| 6 | +import { Hint } from '@/components/ui/hint'; | |
| 7 | +import { cn } from '@/lib/cn'; | |
| 8 | +import { fmtInt } from '@/lib/format'; | |
| 9 | +import { OPENNESS_LABELS, STATUS_LABELS, typeLabel } from '@/lib/site'; | |
| 10 | + | |
| 11 | +/* | |
| 12 | + Structured query builder (client). Type selector → type-specific form → a `/models?…` (or `/explore/<type>?…`) URL that | |
| 13 | + updates as you type. The builder's own URL (`/explore?type=model&min_params=…`) is shareable; saved queries live in | |
| 14 | + localStorage['aia-saved-queries'] (name + URL). Filters the API cannot express are shown disabled with the reason. | |
| 15 | +*/ | |
| 16 | + | |
| 17 | +export type BuilderOptions = { | |
| 18 | + types: { entity_type: string; count: number; label: string }[]; | |
| 19 | + organizations: { slug: string; name: string; count: number }[]; | |
| 20 | + families: { value: string; label: string; count: number }[]; | |
| 21 | + licenses: { value: string; label: string; count: number }[]; | |
| 22 | + openness: { value: string; count: number }[]; | |
| 23 | + modalities: { value: string; count: number }[]; | |
| 24 | + status: { value: string; count: number }[]; | |
| 25 | + benchmarks: { slug: string; name: string }[]; | |
| 26 | +}; | |
| 27 | +type Saved = { name: string; url: string; created_at: string }; | |
| 28 | +const KEY = 'aia-saved-queries'; | |
| 29 | +const MODEL_KEYS = ['org', 'family', 'min_params', 'max_params', 'min_context', 'license', 'openness', 'modality', 'year_from', 'year_to', 'status', 'reasoning', 'sort', 'order', 'q', 'trust', 'include'] as const; | |
| 30 | +const GENERIC_KEYS = ['q', 'org', 'sort'] as const; | |
| 31 | +const PARAM_PRESETS: { label: string; value: string }[] = [ | |
| 32 | + { label: 'any', value: '' }, | |
| 33 | + { label: '1B', value: '1000000000' }, | |
| 34 | + { label: '7B', value: '7000000000' }, | |
| 35 | + { label: '30B', value: '30000000000' }, | |
| 36 | + { label: '70B', value: '70000000000' }, | |
| 37 | + { label: '100B', value: '100000000000' }, | |
| 38 | + { label: '400B', value: '400000000000' }, | |
| 39 | + { label: '1T', value: '1000000000000' }, | |
| 40 | +]; | |
| 41 | +const CONTEXT_PRESETS = [ | |
| 42 | + { label: 'any', value: '' }, | |
| 43 | + { label: '8K', value: '8192' }, | |
| 44 | + { label: '32K', value: '32768' }, | |
| 45 | + { label: '128K', value: '131072' }, | |
| 46 | + { label: '200K', value: '200000' }, | |
| 47 | + { label: '1M', value: '1000000' }, | |
| 48 | +]; | |
| 49 | +const MODEL_SORTS = [ | |
| 50 | + { value: '', label: 'Default (recently updated)' }, | |
| 51 | + { value: 'release', label: 'Release date' }, | |
| 52 | + { value: 'params', label: 'Parameters' }, | |
| 53 | + { value: 'name', label: 'Name' }, | |
| 54 | + { value: 'quality', label: 'Data quality' }, | |
| 55 | + { value: 'cheapest', label: 'Cheapest output price' }, | |
| 56 | +]; | |
| 57 | + | |
| 58 | +function readSaved(): Saved[] { | |
| 59 | + try { | |
| 60 | + const v = JSON.parse(localStorage.getItem(KEY) ?? '[]'); | |
| 61 | + return Array.isArray(v) ? v.filter((x) => x && typeof x.url === 'string') : []; | |
| 62 | + } catch { | |
| 63 | + return []; | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +export function QueryBuilder({ options, initial }: { options: BuilderOptions; initial: Record<string, string> }) { | |
| 68 | + const router = useRouter(); | |
| 69 | + const [type, setType] = useState(initial.type && options.types.some((t) => t.entity_type === initial.type) ? initial.type : 'model'); | |
| 70 | + const [f, setF] = useState<Record<string, string>>(() => { | |
| 71 | + const out: Record<string, string> = {}; | |
| 72 | + for (const k of [...MODEL_KEYS, ...GENERIC_KEYS]) if (initial[k]) out[k] = initial[k]!; | |
| 73 | + return out; | |
| 74 | + }); | |
| 75 | + const [saved, setSaved] = useState<Saved[]>([]); | |
| 76 | + const [ready, setReady] = useState(false); | |
| 77 | + const [name, setName] = useState(''); | |
| 78 | + const [copied, setCopied] = useState(false); | |
| 79 | + useEffect(() => { | |
| 80 | + setSaved(readSaved()); | |
| 81 | + setReady(true); | |
| 82 | + }, []); | |
| 83 | + const set = (k: string, v: string) => setF((cur) => { | |
| 84 | + const next = { ...cur }; | |
| 85 | + if (v === '') delete next[k]; | |
| 86 | + else next[k] = v; | |
| 87 | + return next; | |
| 88 | + }); | |
| 89 | + const isModel = type === 'model'; | |
| 90 | + const target = useMemo(() => { | |
| 91 | + const p = new URLSearchParams(); | |
| 92 | + const keys = isModel ? MODEL_KEYS : GENERIC_KEYS; | |
| 93 | + for (const k of keys) if (f[k]) p.set(k, f[k]!); | |
| 94 | + const s = p.toString(); | |
| 95 | + return isModel ? `/models${s ? `?${s}` : ''}` : `/explore/${encodeURIComponent(type)}${s ? `?${s}` : ''}`; | |
| 96 | + }, [f, isModel, type]); | |
| 97 | + const shareUrl = useMemo(() => { | |
| 98 | + const p = new URLSearchParams({ type }); | |
| 99 | + const keys = isModel ? MODEL_KEYS : GENERIC_KEYS; | |
| 100 | + for (const k of keys) if (f[k]) p.set(k, f[k]!); | |
| 101 | + return `/explore?${p.toString()}`; | |
| 102 | + }, [f, isModel, type]); | |
| 103 | + // mirror the builder state into the URL (shareable) without a navigation | |
| 104 | + useEffect(() => { | |
| 105 | + if (!ready) return; | |
| 106 | + window.history.replaceState(null, '', shareUrl); | |
| 107 | + }, [shareUrl, ready]); | |
| 108 | + const persist = (list: Saved[]) => { | |
| 109 | + setSaved(list); | |
| 110 | + try { | |
| 111 | + localStorage.setItem(KEY, JSON.stringify(list)); | |
| 112 | + } catch { | |
| 113 | + /* ignore */ | |
| 114 | + } | |
| 115 | + }; | |
| 116 | + const save = () => { | |
| 117 | + const n = name.trim() || target.replace(/^\//, '').slice(0, 60); | |
| 118 | + persist([{ name: n, url: target, created_at: new Date().toISOString() }, ...saved.filter((s) => s.url !== target)].slice(0, 30)); | |
| 119 | + setName(''); | |
| 120 | + }; | |
| 121 | + const copy = async () => { | |
| 122 | + try { | |
| 123 | + await navigator.clipboard.writeText(`${location.origin}${shareUrl}`); | |
| 124 | + setCopied(true); | |
| 125 | + setTimeout(() => setCopied(false), 1500); | |
| 126 | + } catch { | |
| 127 | + /* ignore */ | |
| 128 | + } | |
| 129 | + }; | |
| 130 | + const active = Object.keys(f).length; | |
| 131 | + const cls = 'h-10 w-full border border-rule bg-surface px-2 text-sm text-ink focus:border-accent focus:outline-none'; | |
| 132 | + const label = 'eyebrow block pb-1'; | |
| 133 | + | |
| 134 | + return ( | |
| 135 | + <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]" data-query-builder> | |
| 136 | + <div className="min-w-0 space-y-6"> | |
| 137 | + <div> | |
| 138 | + <p className="eyebrow mb-1.5">Entity type</p> | |
| 139 | + <ul className="no-scrollbar -mx-4 flex gap-1 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0" role="radiogroup" aria-label="Entity type"> | |
| 140 | + {options.types.map((t) => ( | |
| 141 | + <li key={t.entity_type} className="shrink-0"> | |
| 142 | + <button type="button" role="radio" aria-checked={type === t.entity_type} onClick={() => setType(t.entity_type)} className={cn('inline-flex h-9 items-center gap-1.5 border px-2.5 text-sm', type === t.entity_type ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}> | |
| 143 | + {t.label || typeLabel(t.entity_type, true)} <span className="tnum text-[11px] opacity-70">{fmtInt(t.count)}</span> | |
| 144 | + </button> | |
| 145 | + </li> | |
| 146 | + ))} | |
| 147 | + </ul> | |
| 148 | + </div> | |
| 149 | + | |
| 150 | + {isModel ? ( | |
| 151 | + <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4"> | |
| 152 | + <label className="block"> | |
| 153 | + <span className={label}>Organization</span> | |
| 154 | + <select value={f.org ?? ''} onChange={(e) => set('org', e.target.value)} className={cls}> | |
| 155 | + <option value="">Any</option> | |
| 156 | + {options.organizations.map((o) => ( | |
| 157 | + <option key={o.slug} value={o.slug}> | |
| 158 | + {o.name} ({fmtInt(o.count)}) | |
| 159 | + </option> | |
| 160 | + ))} | |
| 161 | + </select> | |
| 162 | + </label> | |
| 163 | + <label className="block"> | |
| 164 | + <span className={label}>Family</span> | |
| 165 | + <select value={f.family ?? ''} onChange={(e) => set('family', e.target.value)} className={cls}> | |
| 166 | + <option value="">Any</option> | |
| 167 | + {options.families.map((o) => ( | |
| 168 | + <option key={o.value} value={o.value}> | |
| 169 | + {o.label} ({fmtInt(o.count)}) | |
| 170 | + </option> | |
| 171 | + ))} | |
| 172 | + </select> | |
| 173 | + </label> | |
| 174 | + <label className="block"> | |
| 175 | + <span className={label}>Parameters ≥</span> | |
| 176 | + <select value={f.min_params ?? ''} onChange={(e) => set('min_params', e.target.value)} className={cls}> | |
| 177 | + {PARAM_PRESETS.map((p) => ( | |
| 178 | + <option key={p.value} value={p.value}> | |
| 179 | + {p.label} | |
| 180 | + </option> | |
| 181 | + ))} | |
| 182 | + </select> | |
| 183 | + </label> | |
| 184 | + <label className="block"> | |
| 185 | + <span className={label}>Parameters ≤</span> | |
| 186 | + <select value={f.max_params ?? ''} onChange={(e) => set('max_params', e.target.value)} className={cls}> | |
| 187 | + {PARAM_PRESETS.map((p) => ( | |
| 188 | + <option key={p.value} value={p.value}> | |
| 189 | + {p.label} | |
| 190 | + </option> | |
| 191 | + ))} | |
| 192 | + </select> | |
| 193 | + </label> | |
| 194 | + <label className="block opacity-60" title="Not a /models filter yet"> | |
| 195 | + <span className={label}> | |
| 196 | + Active parameters <Hint align="right" text="Mixture-of-experts active parameters are shown on model pages but /models has no min/max filter for them (API gap)." /> | |
| 197 | + </span> | |
| 198 | + <input disabled placeholder="not filterable" className={cls} /> | |
| 199 | + </label> | |
| 200 | + <label className="block opacity-60" title="Not a /models filter yet"> | |
| 201 | + <span className={label}> | |
| 202 | + Architecture <Hint align="right" text="Architecture (dense, MoE, SSM…) is an attribute without a listing filter (API gap)." /> | |
| 203 | + </span> | |
| 204 | + <input disabled placeholder="not filterable" className={cls} /> | |
| 205 | + </label> | |
| 206 | + <label className="block"> | |
| 207 | + <span className={label}>Context ≥</span> | |
| 208 | + <select value={f.min_context ?? ''} onChange={(e) => set('min_context', e.target.value)} className={cls}> | |
| 209 | + {CONTEXT_PRESETS.map((p) => ( | |
| 210 | + <option key={p.value} value={p.value}> | |
| 211 | + {p.label} | |
| 212 | + </option> | |
| 213 | + ))} | |
| 214 | + </select> | |
| 215 | + </label> | |
| 216 | + <label className="block"> | |
| 217 | + <span className={label}>License</span> | |
| 218 | + <select value={f.license ?? ''} onChange={(e) => set('license', e.target.value)} className={cls}> | |
| 219 | + <option value="">Any</option> | |
| 220 | + {options.licenses.map((o) => ( | |
| 221 | + <option key={o.value} value={o.value}> | |
| 222 | + {o.label} ({fmtInt(o.count)}) | |
| 223 | + </option> | |
| 224 | + ))} | |
| 225 | + </select> | |
| 226 | + </label> | |
| 227 | + <label className="block"> | |
| 228 | + <span className={label}>Openness</span> | |
| 229 | + <select value={f.openness ?? ''} onChange={(e) => set('openness', e.target.value)} className={cls}> | |
| 230 | + <option value="">Any</option> | |
| 231 | + <option value="open">Open (weights or source)</option> | |
| 232 | + {options.openness.map((o) => ( | |
| 233 | + <option key={o.value} value={o.value}> | |
| 234 | + {OPENNESS_LABELS[o.value] ?? o.value} ({fmtInt(o.count)}) | |
| 235 | + </option> | |
| 236 | + ))} | |
| 237 | + </select> | |
| 238 | + </label> | |
| 239 | + <label className="block"> | |
| 240 | + <span className={label}>Modality</span> | |
| 241 | + <select value={f.modality ?? ''} onChange={(e) => set('modality', e.target.value)} className={cls}> | |
| 242 | + <option value="">Any</option> | |
| 243 | + {options.modalities.map((o) => ( | |
| 244 | + <option key={o.value} value={o.value}> | |
| 245 | + {o.value} ({fmtInt(o.count)}) | |
| 246 | + </option> | |
| 247 | + ))} | |
| 248 | + </select> | |
| 249 | + <span className="mt-1 block text-[11px] text-ink-3">Vision = image · Audio = audio</span> | |
| 250 | + </label> | |
| 251 | + <label className="block"> | |
| 252 | + <span className={label}>Released from</span> | |
| 253 | + <input type="number" inputMode="numeric" min={2015} max={2100} placeholder="YYYY" value={f.year_from ?? ''} onChange={(e) => set('year_from', e.target.value)} className={cls} /> | |
| 254 | + </label> | |
| 255 | + <label className="block"> | |
| 256 | + <span className={label}>Released to</span> | |
| 257 | + <input type="number" inputMode="numeric" min={2015} max={2100} placeholder="YYYY" value={f.year_to ?? ''} onChange={(e) => set('year_to', e.target.value)} className={cls} /> | |
| 258 | + </label> | |
| 259 | + <label className="block"> | |
| 260 | + <span className={label}>Status</span> | |
| 261 | + <select value={f.status ?? ''} onChange={(e) => set('status', e.target.value)} className={cls}> | |
| 262 | + <option value="">Any</option> | |
| 263 | + {options.status.map((o) => ( | |
| 264 | + <option key={o.value} value={o.value}> | |
| 265 | + {STATUS_LABELS[o.value] ?? o.value} ({fmtInt(o.count)}) | |
| 266 | + </option> | |
| 267 | + ))} | |
| 268 | + </select> | |
| 269 | + </label> | |
| 270 | + <label className="block"> | |
| 271 | + <span className={label}>Reasoning</span> | |
| 272 | + <select value={f.reasoning ?? ''} onChange={(e) => set('reasoning', e.target.value)} className={cls}> | |
| 273 | + <option value="">Any</option> | |
| 274 | + <option value="1">Reasoning / thinking</option> | |
| 275 | + <option value="0">Non-reasoning</option> | |
| 276 | + </select> | |
| 277 | + </label> | |
| 278 | + <label className="block opacity-60" title="Not a /models filter"> | |
| 279 | + <span className={label}> | |
| 280 | + Price ≤ <Hint align="right" text="Prices live on deployments, not models: /models sorts by cheapest output but has no price bound. Use Prices or the Calculator (API gap for a bound)." /> | |
| 281 | + </span> | |
| 282 | + <input disabled placeholder="see Prices" className={cls} /> | |
| 283 | + </label> | |
| 284 | + <label className="block opacity-60" title="Not a /models filter"> | |
| 285 | + <span className={label}> | |
| 286 | + Benchmark ≥ <Hint align="right" text="Score thresholds are not a /models parameter (API gap). Pick a benchmark to open its leaderboard instead." /> | |
| 287 | + </span> | |
| 288 | + <select className={cls} defaultValue="" onChange={(e) => e.target.value && router.push(`/benchmarks/${encodeURIComponent(e.target.value)}`)}> | |
| 289 | + <option value="">Open a leaderboard…</option> | |
| 290 | + {options.benchmarks.map((b) => ( | |
| 291 | + <option key={b.slug} value={b.slug}> | |
| 292 | + {b.name} | |
| 293 | + </option> | |
| 294 | + ))} | |
| 295 | + </select> | |
| 296 | + </label> | |
| 297 | + <label className="block"> | |
| 298 | + <span className={label}>Sort</span> | |
| 299 | + <select value={f.sort ?? ''} onChange={(e) => set('sort', e.target.value)} className={cls}> | |
| 300 | + {MODEL_SORTS.map((s) => ( | |
| 301 | + <option key={s.value} value={s.value}> | |
| 302 | + {s.label} | |
| 303 | + </option> | |
| 304 | + ))} | |
| 305 | + </select> | |
| 306 | + </label> | |
| 307 | + <label className="block"> | |
| 308 | + <span className={label}>Order</span> | |
| 309 | + <select value={f.order ?? ''} onChange={(e) => set('order', e.target.value)} className={cls}> | |
| 310 | + <option value="">Descending</option> | |
| 311 | + <option value="asc">Ascending</option> | |
| 312 | + </select> | |
| 313 | + </label> | |
| 314 | + <label className="block col-span-2"> | |
| 315 | + <span className={label}>Name contains</span> | |
| 316 | + <input value={f.q ?? ''} onChange={(e) => set('q', e.target.value)} placeholder="e.g. coder" className={cls} /> | |
| 317 | + </label> | |
| 318 | + <label className="flex min-h-10 items-center gap-2 pt-5 text-sm text-ink-2"> | |
| 319 | + <input type="checkbox" checked={f.include === 'artifacts'} onChange={(e) => set('include', e.target.checked ? 'artifacts' : '')} className="size-4 accent-[var(--accent)]" /> Include artifacts | |
| 320 | + <Hint align="right" text="By default /models lists canonical model releases only; artifacts (checkpoints, quantisations, conversions) and folded variants are excluded." /> | |
| 321 | + </label> | |
| 322 | + </div> | |
| 323 | + ) : ( | |
| 324 | + <div className="grid grid-cols-2 gap-3 md:grid-cols-3"> | |
| 325 | + <label className="block"> | |
| 326 | + <span className={label}>Name contains</span> | |
| 327 | + <input value={f.q ?? ''} onChange={(e) => set('q', e.target.value)} className={cls} placeholder="Search by name" /> | |
| 328 | + </label> | |
| 329 | + <label className="block"> | |
| 330 | + <span className={label}>Organization</span> | |
| 331 | + <select value={f.org ?? ''} onChange={(e) => set('org', e.target.value)} className={cls}> | |
| 332 | + <option value="">Any</option> | |
| 333 | + {options.organizations.map((o) => ( | |
| 334 | + <option key={o.slug} value={o.slug}> | |
| 335 | + {o.name} | |
| 336 | + </option> | |
| 337 | + ))} | |
| 338 | + </select> | |
| 339 | + </label> | |
| 340 | + <label className="block"> | |
| 341 | + <span className={label}>Sort</span> | |
| 342 | + <select value={f.sort ?? ''} onChange={(e) => set('sort', e.target.value)} className={cls}> | |
| 343 | + <option value="">Recently updated</option> | |
| 344 | + <option value="name">Name</option> | |
| 345 | + <option value="quality">Data quality</option> | |
| 346 | + <option value="first_seen">First seen</option> | |
| 347 | + </select> | |
| 348 | + </label> | |
| 349 | + <p className="col-span-full text-xs text-ink-3"> | |
| 350 | + {typeLabel(type, true)} use the generic listing filters (name, organization, sort). Dedicated listings have more: models, benchmarks, prices, hardware. | |
| 351 | + </p> | |
| 352 | + </div> | |
| 353 | + )} | |
| 354 | + | |
| 355 | + <div className="flex flex-wrap items-center gap-2 border-t border-rule pt-4"> | |
| 356 | + <Link href={target} className="inline-flex h-11 items-center gap-1.5 bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90" data-run-query> | |
| 357 | + <Play className="size-4" aria-hidden /> Run query | |
| 358 | + </Link> | |
| 359 | + <code className="mono min-w-0 flex-1 truncate border border-rule bg-surface px-2 py-2 text-xs text-ink-2" title={target} data-target-url> | |
| 360 | + {target} | |
| 361 | + </code> | |
| 362 | + <button type="button" onClick={copy} className="inline-flex h-11 items-center gap-1.5 border border-rule px-3 text-sm text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 363 | + <Copy className="size-4" aria-hidden /> {copied ? 'Copied' : 'Copy share link'} | |
| 364 | + </button> | |
| 365 | + <span className="tnum text-xs text-ink-3">{active} filter{active === 1 ? '' : 's'}</span> | |
| 366 | + </div> | |
| 367 | + </div> | |
| 368 | + | |
| 369 | + <aside className="min-w-0 space-y-4"> | |
| 370 | + <div> | |
| 371 | + <p className="eyebrow mb-1.5">Save this query</p> | |
| 372 | + <div className="flex gap-2"> | |
| 373 | + <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name (optional)" className={cls} aria-label="Saved query name" /> | |
| 374 | + <button type="button" onClick={save} className="inline-flex h-10 shrink-0 items-center gap-1.5 border border-rule px-3 text-sm text-ink-2 hover:border-rule-strong hover:text-ink" data-save-query> | |
| 375 | + <Bookmark className="size-4" aria-hidden /> Save | |
| 376 | + </button> | |
| 377 | + </div> | |
| 378 | + <p className="mt-1 text-[11px] text-ink-3">Stored in this browser only (localStorage). Share the link instead to send it to someone.</p> | |
| 379 | + </div> | |
| 380 | + <div> | |
| 381 | + <p className="eyebrow mb-1.5"> | |
| 382 | + Saved queries <span className="tnum normal-case tracking-normal text-ink-3">{ready ? saved.length : ''}</span> | |
| 383 | + </p> | |
| 384 | + {!ready ? null : saved.length === 0 ? ( | |
| 385 | + <p className="text-xs text-ink-3">Nothing saved yet.</p> | |
| 386 | + ) : ( | |
| 387 | + <ul className="divide-y divide-rule border-y border-rule" data-saved-queries> | |
| 388 | + {saved.map((s) => ( | |
| 389 | + <li key={s.url} className="flex items-center gap-2 py-2 text-sm"> | |
| 390 | + <Link href={s.url} className="min-w-0 flex-1 truncate text-ink hover:text-accent" title={s.url}> | |
| 391 | + {s.name} | |
| 392 | + </Link> | |
| 393 | + <button type="button" onClick={() => persist(saved.filter((x) => x.url !== s.url))} className="flex size-9 items-center justify-center text-ink-3 hover:text-danger" aria-label={`Remove ${s.name}`}> | |
| 394 | + <Trash2 className="size-4" aria-hidden /> | |
| 395 | + </button> | |
| 396 | + </li> | |
| 397 | + ))} | |
| 398 | + </ul> | |
| 399 | + )} | |
| 400 | + </div> | |
| 401 | + </aside> | |
| 402 | + </div> | |
| 403 | + ); | |
| 404 | +} | |
modified
apps/web/src/app/explore/page.tsx
+42 −18
@@ -1,57 +1,81 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { EntityBadge } from '@/components/ui/badges'; |
| 4 | −import { Container, PageHeader } from '@/components/ui/section'; | |
| 4 | +import { Container, Note, PageHeader, Section } from '@/components/ui/section'; | |
| 5 | 5 | import { Unavailable } from '@/components/ui/unavailable'; |
| 6 | 6 | import { api, safe } from '@/lib/api'; |
| 7 | 7 | import { fmtInt, num } from '@/lib/format'; |
| 8 | 8 | import { routes, typeLabel } from '@/lib/site'; |
| 9 | +import { type BuilderOptions, QueryBuilder } from './builder'; | |
| 9 | 10 | |
| 10 | −export const metadata: Metadata = { title: 'Explore — every entity type with live counts', description: 'Browse the AI Atlas graph by entity type: models, companies, papers, providers, benchmarks, hardware, frameworks, datasets, tools and more.', alternates: { canonical: '/explore' } }; | |
| 11 | +export const metadata: Metadata = { title: 'Explore — structured query builder and every entity type', description: 'Build a structured query over the AI Atlas graph (organization, family, parameters, context, licence, openness, modality, release year, status, reasoning) and browse every entity type with live counts.', alternates: { canonical: '/explore' } }; | |
| 11 | 12 | export const revalidate = 300; |
| 12 | 13 | |
| 13 | 14 | const BLURB: Record<string, string> = { |
| 14 | − model: 'Foundation and fine-tuned models: parameters, context, openness, prices, benchmarks, lineage.', | |
| 15 | + model: 'Canonical model releases: parameters, context, openness, prices, benchmarks, lineage.', | |
| 16 | + artifact: 'Checkpoints, quantisations, conversions and packagings of canonical models.', | |
| 17 | + model_family: 'Families grouping canonical models (Llama 4, Qwen3, Claude…).', | |
| 15 | 18 | company: 'Labs and companies that develop, serve or study AI.', |
| 19 | + organization: 'Organizations, labs and universities.', | |
| 16 | 20 | paper: 'Research papers linked to the models and benchmarks they describe.', |
| 21 | + researcher: 'Authors of the papers in the atlas (name-only rows for now).', | |
| 17 | 22 | provider: 'Inference providers and their published prices per 1M tokens.', |
| 18 | 23 | benchmark: 'Evaluation suites with results and configurations.', |
| 19 | 24 | hardware: 'GPUs, accelerators and devices with memory and bandwidth specs.', |
| 20 | 25 | framework: 'Training, inference and agent frameworks.', |
| 26 | + library: 'Libraries and SDKs.', | |
| 21 | 27 | dataset: 'Training and evaluation datasets.', |
| 28 | + license: 'Licences from the ontology with their permissions.', | |
| 22 | 29 | tool: 'Developer tools, agents and MCP servers.', |
| 23 | 30 | repository: 'Code repositories linked to models and frameworks.', |
| 24 | − regulation: 'Laws, standards and policy instruments affecting AI.', | |
| 25 | − incident: 'Reported incidents and safety events.', | |
| 26 | 31 | }; |
| 27 | 32 | |
| 28 | −export default async function ExplorePage() { | |
| 29 | − const res = await safe(api.exploreTypes()); | |
| 30 | − const items = (res?.items ?? []).slice().sort((a, b) => (num(b.count) ?? 0) - (num(a.count) ?? 0)); | |
| 33 | +export default async function ExplorePage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 34 | + const sp = await searchParams; | |
| 35 | + const [types, models, benches] = await Promise.all([safe(api.exploreTypes()), safe(api.models({ limit: 1, facets: 1 })), safe(api.benchmarks())]); | |
| 36 | + const items = (types?.items ?? []).slice().sort((a, b) => (num(b.count) ?? 0) - (num(a.count) ?? 0)); | |
| 31 | 37 | const total = items.reduce((n, t) => n + (num(t.count) ?? 0), 0); |
| 38 | + const f = models?.facets ?? {}; | |
| 39 | + const options: BuilderOptions = { | |
| 40 | + types: items.map((t) => ({ entity_type: t.entity_type, count: num(t.count) ?? 0, label: t.label })), | |
| 41 | + organizations: (f.organizations ?? []).map((o) => ({ slug: o.slug, name: o.name, count: num(o.count) ?? 0 })), | |
| 42 | + families: ((f.families ?? []) as { value: string; label?: string; count: unknown }[]).map((x) => ({ value: x.value, label: x.label ?? x.value, count: num(x.count) ?? 0 })), | |
| 43 | + licenses: ((f.licenses ?? []) as { value: string; label?: string; count: unknown }[]).map((x) => ({ value: x.value, label: x.label ?? x.value, count: num(x.count) ?? 0 })), | |
| 44 | + openness: (f.openness ?? []).map((x) => ({ value: x.value, count: num(x.count) ?? 0 })), | |
| 45 | + modalities: (f.modalities ?? []).map((x) => ({ value: x.value, count: num(x.count) ?? 0 })), | |
| 46 | + status: (f.status ?? []).map((x) => ({ value: x.value, count: num(x.count) ?? 0 })), | |
| 47 | + benchmarks: (benches?.items ?? []).map((b) => ({ slug: b.slug, name: b.name })), | |
| 48 | + }; | |
| 49 | + const initial: Record<string, string> = {}; | |
| 50 | + for (const [k, v] of Object.entries(sp)) if (typeof v === 'string' && v) initial[k] = v; | |
| 51 | + | |
| 32 | 52 | return ( |
| 33 | − <Container> | |
| 34 | − <PageHeader eyebrow="Explore" title="The atlas by entity type" lede="Counts are live from the graph. Types without a dedicated section open a generic listing." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(total)} entities</p> : undefined} /> | |
| 35 | − <div className="pb-16"> | |
| 36 | − {!res ? ( | |
| 53 | + <Container wide> | |
| 54 | + <PageHeader eyebrow="Explore" title="Query builder" lede="Pick a type, set the filters the API understands, run — or save the query and share its link. Option counts are live facets from the graph." aside={types ? <p className="tnum text-sm text-ink-3">{fmtInt(total)} entities</p> : undefined} /> | |
| 55 | + <div className="pb-6">{!types ? <Unavailable what="Entity types" /> : <QueryBuilder options={options} initial={initial} />}</div> | |
| 56 | + <Section eyebrow="By type" title="The atlas by entity type" lede="Counts are live from the graph. Types without a dedicated section open a generic listing."> | |
| 57 | + {!types ? ( | |
| 37 | 58 | <Unavailable what="Entity types" /> |
| 38 | 59 | ) : ( |
| 39 | − <ul className="grid border-l border-t border-rule sm:grid-cols-2 lg:grid-cols-3"> | |
| 60 | + <ul className="grid border-l border-t border-rule sm:grid-cols-2 lg:grid-cols-4"> | |
| 40 | 61 | {items.map((t) => ( |
| 41 | 62 | <li key={t.entity_type} className="border-b border-r border-rule"> |
| 42 | − <Link href={routes.listing(t.entity_type)} className="flex h-full flex-col px-5 py-5 hover:bg-surface-2"> | |
| 63 | + <Link href={routes.listing(t.entity_type)} className="flex h-full flex-col px-4 py-4 hover:bg-surface-2"> | |
| 43 | 64 | <div className="flex items-center justify-between"> |
| 44 | 65 | <EntityBadge type={t.entity_type} /> |
| 45 | − <span className="tnum text-2xl font-semibold tracking-tight">{fmtInt(t.count)}</span> | |
| 66 | + <span className="tnum text-xl font-semibold tracking-tight">{fmtInt(t.count)}</span> | |
| 46 | 67 | </div> |
| 47 | − <p className="mt-3 font-medium">{t.label || typeLabel(t.entity_type, true)}</p> | |
| 48 | − <p className="mt-1 text-sm text-ink-2">{BLURB[t.entity_type] ?? `All ${typeLabel(t.entity_type, true).toLowerCase()} in the graph.`}</p> | |
| 68 | + <p className="mt-2 text-sm font-medium">{t.label || typeLabel(t.entity_type, true)}</p> | |
| 69 | + <p className="mt-0.5 text-xs text-ink-2">{BLURB[t.entity_type] ?? `All ${typeLabel(t.entity_type, true).toLowerCase()} in the graph.`}</p> | |
| 49 | 70 | </Link> |
| 50 | 71 | </li> |
| 51 | 72 | ))} |
| 52 | 73 | </ul> |
| 53 | 74 | )} |
| 54 | − </div> | |
| 75 | + <Note className="mt-3"> | |
| 76 | + Prefer words? <Link href={routes.search('')} className="link">Search</Link> compiles plain English into these same filters and shows them back as chips. | |
| 77 | + </Note> | |
| 78 | + </Section> | |
| 55 | 79 | </Container> |
| 56 | 80 | ); |
| 57 | 81 | } |
modified
apps/web/src/app/graph/[slug]/page.tsx
+104 −113
@@ -1,49 +1,66 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { notFound } from 'next/navigation'; |
| 4 | −import { GraphExplorer } from '@/components/graph/graph-explorer'; | |
| 4 | +import { GraphWorkbench } from '@/components/graph/graph-workbench'; | |
| 5 | +import { defaultModeFor, GRAPH_MODES, graphSlugHref, isGraphMode } from '@/components/graph/modes'; | |
| 6 | +import { BreadcrumbLd, Breadcrumbs } from '@/components/meta/breadcrumb-ld'; | |
| 5 | 7 | import { EntityBadge } from '@/components/ui/badges'; |
| 6 | 8 | import { EntityLink } from '@/components/ui/entity'; |
| 7 | 9 | import { Container, Note, PageHeader } from '@/components/ui/section'; |
| 8 | 10 | import { EmptyState, Unavailable } from '@/components/ui/unavailable'; |
| 9 | −import { api, ApiError, safe } from '@/lib/api'; | |
| 11 | +import { api, ApiError, apiD3, safe } from '@/lib/api'; | |
| 10 | 12 | import { fmtInt } from '@/lib/format'; |
| 11 | −import { predicateLabel, routes, SITE_NAME, TYPE_COLOR_KEY, typeLabel } from '@/lib/site'; | |
| 12 | −import type { GraphNode, GraphPayload } from '@/lib/types'; | |
| 13 | +import { predicateLabel, routes, SITE_NAME, SITE_URL, typeLabel } from '@/lib/site'; | |
| 14 | +import type { ExploreNode, GraphExploreMode, GraphExplorePayload } from '@/lib/types'; | |
| 13 | 15 | |
| 14 | −type Params = { params: Promise<{ slug: string }>; searchParams: Promise<{ depth?: string }> }; | |
| 15 | −const CAP = 80; | |
| 16 | +type Params = { params: Promise<{ slug: string }>; searchParams: Promise<{ depth?: string; mode?: string }> }; | |
| 17 | +const LIMIT = 150; | |
| 18 | + | |
| 19 | +const hrefFor = graphSlugHref; | |
| 16 | 20 | |
| 17 | 21 | /** 404 → notFound(); any other failure → null (the page renders an Unavailable state). */ |
| 18 | −async function loadGraph(slug: string, depth: 1 | 2): Promise<GraphPayload | null> { | |
| 22 | +async function loadGraph(slug: string, mode: GraphExploreMode, depth: 1 | 2): Promise<GraphExplorePayload | null> { | |
| 19 | 23 | try { |
| 20 | − return await api.entityGraph(slug, depth, CAP); | |
| 24 | + return await apiD3.graphExplore(slug, mode, depth, LIMIT); | |
| 21 | 25 | } catch (e) { |
| 22 | 26 | if (e instanceof ApiError && e.notFound) notFound(); |
| 23 | 27 | return null; |
| 24 | 28 | } |
| 25 | 29 | } |
| 26 | 30 | |
| 27 | −export async function generateMetadata({ params }: Params): Promise<Metadata> { | |
| 31 | +export async function generateMetadata({ params, searchParams }: Params): Promise<Metadata> { | |
| 28 | 32 | const { slug } = await params; |
| 33 | + const sp = await searchParams; | |
| 29 | 34 | const d = await safe(api.entity(slug)); |
| 30 | 35 | if (!d) return { title: 'Graph', robots: { index: false } }; |
| 31 | − const title = `${d.name} — relationship graph`; | |
| 32 | − return { title, description: `Everything AI Atlas links to ${d.name} (${typeLabel(d.entity_type).toLowerCase()}): organizations, models, benchmarks, papers and providers, with the predicate of each relation.`, alternates: { canonical: routes.graph(d.slug) }, openGraph: { title: `${title} | ${SITE_NAME}`, type: 'article' } }; | |
| 36 | + const mode = isGraphMode(sp.mode) ? sp.mode : defaultModeFor(d.entity_type); | |
| 37 | + const depth: 1 | 2 = sp.depth === '2' ? 2 : 1; | |
| 38 | + const modeLabel = GRAPH_MODES.find((x) => x.mode === mode)?.label ?? 'Graph'; | |
| 39 | + const title = `${d.name} — ${modeLabel.toLowerCase()} graph`; | |
| 40 | + const og = `${SITE_URL}/graph/og?node=${encodeURIComponent(d.slug)}&mode=${mode}&depth=${depth}`; | |
| 41 | + return { title, description: `Everything AI Atlas links to ${d.name} (${typeLabel(d.entity_type).toLowerCase()}) in ${modeLabel.toLowerCase()} mode, with the predicate of each relation.`, alternates: { canonical: hrefFor(d.slug, mode, depth) }, openGraph: { title: `${title} | ${SITE_NAME}`, type: 'article', images: [{ url: og, width: 1200, height: 630 }] }, twitter: { card: 'summary_large_image', images: [og] } }; | |
| 33 | 42 | } |
| 34 | 43 | |
| 35 | 44 | export default async function GraphPage({ params, searchParams }: Params) { |
| 36 | 45 | const { slug } = await params; |
| 37 | 46 | const sp = await searchParams; |
| 47 | + const d = await safe(api.entity(slug)); | |
| 48 | + if (!d) { | |
| 49 | + // distinguish "unknown slug" (404) from "API down" | |
| 50 | + try { | |
| 51 | + await api.entity(slug); | |
| 52 | + } catch (e) { | |
| 53 | + if (e instanceof ApiError && e.notFound) notFound(); | |
| 54 | + } | |
| 55 | + } | |
| 56 | + const mode = isGraphMode(sp.mode) ? sp.mode : defaultModeFor(d?.entity_type); | |
| 38 | 57 | const depth: 1 | 2 = sp.depth === '2' ? 2 : 1; |
| 39 | − const [graph, d] = await Promise.all([loadGraph(slug, depth), safe(api.entity(slug))]); | |
| 58 | + const graph = await loadGraph(slug, mode, depth); | |
| 40 | 59 | const rootId = graph?.root ?? d?.id ?? ''; |
| 41 | 60 | const nodes = graph?.nodes ?? []; |
| 42 | 61 | const edges = graph?.edges ?? []; |
| 43 | − const capped = nodes.length >= CAP; | |
| 44 | 62 | const byId = new Map(nodes.map((n) => [n.id, n])); |
| 45 | − // Text fallback grouped by predicate (root's own edges first, then the rest). | |
| 46 | − const groups = new Map<string, { node: GraphNode; direction: 'out' | 'in' }[]>(); | |
| 63 | + const groups = new Map<string, { node: ExploreNode; direction: 'out' | 'in' }[]>(); | |
| 47 | 64 | for (const e of edges) { |
| 48 | 65 | const isOut = e.source === rootId; |
| 49 | 66 | const isIn = e.target === rootId; |
@@ -53,111 +70,85 @@ export default async function GraphPage({ params, searchParams }: Params) { | ||
| 53 | 70 | const key = `${e.predicate}|${isOut ? 'out' : 'in'}`; |
| 54 | 71 | (groups.get(key) ?? groups.set(key, []).get(key)!).push({ node: other, direction: isOut ? 'out' : 'in' }); |
| 55 | 72 | } |
| 56 | − const secondOrder = edges.filter((e) => e.source !== rootId && e.target !== rootId).length; | |
| 57 | − const predicateCounts = [...edges.reduce((m, e) => m.set(e.predicate, (m.get(e.predicate) ?? 0) + 1), new Map<string, number>()).entries()].sort((a, b) => b[1] - a[1]); | |
| 58 | − const typeCounts = [...nodes.reduce((m, n) => m.set(n.entity_type, (m.get(n.entity_type) ?? 0) + 1), new Map<string, number>()).entries()].sort((a, b) => b[1] - a[1]); | |
| 59 | − const depthLink = (n: 1 | 2) => (n === depth ? 'bg-ink text-canvas' : 'text-ink-2 hover:text-ink'); | |
| 73 | + const modeDef = GRAPH_MODES.find((x) => x.mode === mode)!; | |
| 74 | + const crumbs = [{ name: SITE_NAME, href: '/' }, { name: 'Knowledge graph', href: '/graph' }, ...(d ? [{ name: typeLabel(d.entity_type, true), href: routes.listing(d.entity_type) }, { name: d.name, href: routes.entity(d) }] : []), { name: modeDef.label, href: hrefFor(slug, mode, depth) }]; | |
| 60 | 75 | |
| 61 | 76 | return ( |
| 62 | − <Container wide> | |
| 63 | − <nav aria-label="Breadcrumb" className="pt-5 text-xs text-ink-3"> | |
| 64 | − <ol className="flex flex-wrap items-center gap-1.5"> | |
| 65 | − <li><Link href="/" className="hover:text-ink">AI Atlas</Link></li> | |
| 66 | − {d && ( | |
| 67 | − <> | |
| 68 | − <li aria-hidden>/</li> | |
| 69 | − <li><Link href={routes.listing(d.entity_type)} className="hover:text-ink">{typeLabel(d.entity_type, true)}</Link></li> | |
| 70 | − <li aria-hidden>/</li> | |
| 71 | − <li><Link href={routes.entity(d)} className="hover:text-ink">{d.name}</Link></li> | |
| 72 | − </> | |
| 73 | − )} | |
| 74 | − <li aria-hidden>/</li> | |
| 75 | − <li className="text-ink-2">Graph</li> | |
| 76 | − </ol> | |
| 77 | − </nav> | |
| 78 | − <PageHeader | |
| 79 | − eyebrow={<>Graph explorer {d && <EntityBadge type={d.entity_type} small />}</>} | |
| 80 | − title={d ? <>Around <Link href={routes.entity(d)} className="hover:text-accent">{d.name}</Link></> : `Around ${slug}`} | |
| 81 | − lede={d ? `Every recorded relation of ${d.name}${depth === 2 ? ' and of its neighbours' : ''}, coloured by entity type. Click a node to open it.` : undefined} | |
| 82 | − aside={ | |
| 83 | − <div className="flex flex-col items-start gap-2 md:items-end"> | |
| 84 | − <div className="inline-flex border border-rule text-sm" role="group" aria-label="Depth"> | |
| 85 | − <Link href={routes.graph(slug, 1)} className={`inline-flex h-11 items-center px-3 ${depthLink(1)}`} aria-current={depth === 1 ? 'true' : undefined}>Depth 1</Link> | |
| 86 | − <Link href={routes.graph(slug, 2)} className={`inline-flex h-11 items-center border-l border-rule px-3 ${depthLink(2)}`} aria-current={depth === 2 ? 'true' : undefined}>Depth 2</Link> | |
| 87 | − </div> | |
| 88 | − {graph && <p className="tnum text-xs text-ink-3">{fmtInt(nodes.length)} nodes · {fmtInt(edges.length)} edges{capped ? ` · capped at ${CAP}` : ''}</p>} | |
| 89 | − </div> | |
| 90 | − } | |
| 91 | − /> | |
| 77 | + <> | |
| 78 | + <BreadcrumbLd items={crumbs} /> | |
| 79 | + <Container wide> | |
| 80 | + <Breadcrumbs items={crumbs} /> | |
| 81 | + <PageHeader | |
| 82 | + eyebrow={<>Graph explorer {d && <EntityBadge type={d.entity_type} small />}</>} | |
| 83 | + title={d ? <>{modeDef.label} around <Link href={routes.entity(d)} className="hover:text-accent">{d.name}</Link></> : `Around ${slug}`} | |
| 84 | + lede={d ? `${modeDef.hint[0]?.toUpperCase()}${modeDef.hint.slice(1)}${depth === 2 ? ' — two hops' : ''}. Click a node to inspect it, double-click to expand, drag to pan, wheel to zoom.` : undefined} | |
| 85 | + aside={ | |
| 86 | + graph ? ( | |
| 87 | + <p className="tnum text-xs text-ink-3"> | |
| 88 | + {fmtInt(graph.counts?.nodes ?? nodes.length)} nodes · {fmtInt(graph.counts?.edges ?? edges.length)} edges{graph.truncated ? <span className="text-warning"> · truncated at {LIMIT}</span> : ''} | |
| 89 | + </p> | |
| 90 | + ) : undefined | |
| 91 | + } | |
| 92 | + className="pb-3" | |
| 93 | + > | |
| 94 | + <ul className="no-scrollbar -mx-4 mt-4 flex gap-1 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0" aria-label="Mode"> | |
| 95 | + {GRAPH_MODES.map((m) => ( | |
| 96 | + <li key={m.mode} className="shrink-0"> | |
| 97 | + <Link href={hrefFor(slug, m.mode, depth)} className={`inline-flex h-9 items-center border px-2.5 text-[12px] uppercase tracking-wide ${m.mode === mode ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink'}`} aria-current={m.mode === mode ? 'true' : undefined} title={m.hint}> | |
| 98 | + {m.label} | |
| 99 | + </Link> | |
| 100 | + </li> | |
| 101 | + ))} | |
| 102 | + </ul> | |
| 103 | + </PageHeader> | |
| 104 | + </Container> | |
| 92 | 105 | <div className="pb-16"> |
| 93 | − {!graph ? ( | |
| 94 | − <Unavailable what="Graph" /> | |
| 106 | + {!graph || !d ? ( | |
| 107 | + <Container> | |
| 108 | + <Unavailable what="Graph" /> | |
| 109 | + </Container> | |
| 95 | 110 | ) : nodes.length <= 1 || edges.length === 0 ? ( |
| 96 | − <EmptyState title="No relations recorded yet"> | |
| 97 | − Relations are written only when a source states them (developer, provider, benchmark, paper…). {d && <Link href={routes.entity(d)} className="link">Back to {d.name} →</Link>} | |
| 98 | − </EmptyState> | |
| 111 | + <Container> | |
| 112 | + <EmptyState title={`No ${modeDef.label.toLowerCase()} relations recorded for ${d.name}`}> | |
| 113 | + Relations are written only when a source states them. Try another mode above, or <Link href={routes.entity(d)} className="link">go back to {d.name} →</Link> | |
| 114 | + </EmptyState> | |
| 115 | + </Container> | |
| 99 | 116 | ) : ( |
| 100 | − <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_18rem]"> | |
| 101 | − <div className="min-w-0"> | |
| 102 | − <GraphExplorer nodes={nodes} edges={edges} rootId={rootId} /> | |
| 103 | − {capped && <Note className="mt-2">The neighbourhood is larger than {CAP} nodes; only the first {CAP} are drawn (+ more not shown). Depth 1 shows direct relations only; use the entity's Relations block for full lists.</Note>} | |
| 104 | − {depth === 2 && <Note className="mt-1">{fmtInt(secondOrder)} of the edges connect neighbours to each other (second order).</Note>} | |
| 105 | − </div> | |
| 106 | − <aside className="min-w-0 space-y-6"> | |
| 107 | − <section> | |
| 108 | − <p className="eyebrow mb-2">Node types</p> | |
| 109 | − <ul className="space-y-1 text-sm"> | |
| 110 | − {typeCounts.map(([t, n]) => ( | |
| 111 | − <li key={t} className="flex items-center justify-between gap-2"> | |
| 112 | − <span className="flex items-center gap-2"> | |
| 113 | − <span className="inline-block size-2.5 rounded-full" style={{ background: `var(--type-${TYPE_COLOR_KEY[t] ?? 'tool'})` }} aria-hidden /> | |
| 114 | − {typeLabel(t, n !== 1)} | |
| 115 | − </span> | |
| 116 | − <span className="tnum text-xs text-ink-3">{fmtInt(n)}</span> | |
| 117 | − </li> | |
| 118 | − ))} | |
| 119 | − </ul> | |
| 120 | − </section> | |
| 121 | − <section> | |
| 122 | − <p className="eyebrow mb-2">Predicates</p> | |
| 123 | − <ul className="space-y-1 text-sm"> | |
| 124 | − {predicateCounts.map(([p, n]) => ( | |
| 125 | − <li key={p} className="flex items-center justify-between gap-2"> | |
| 126 | − <span className="text-ink-2">{predicateLabel(p, 'out')} <span className="mono text-[11px] text-ink-3">{p}</span></span> | |
| 127 | − <span className="tnum text-xs text-ink-3">{fmtInt(n)}</span> | |
| 128 | − </li> | |
| 129 | − ))} | |
| 130 | − </ul> | |
| 117 | + <> | |
| 118 | + <GraphWorkbench key={`${slug}:${mode}:${depth}`} initial={graph} root={{ slug: d.slug, name: d.name, entity_type: d.entity_type }} mode={mode} depth={depth} urlStyle="path" embedded /> | |
| 119 | + <Container wide> | |
| 120 | + <section className="mt-8 min-w-0"> | |
| 121 | + <p className="eyebrow mb-2">Direct relations of {d.name}, as a list</p> | |
| 122 | + {groups.size === 0 ? ( | |
| 123 | + <p className="text-sm text-ink-3">No direct relations for the root node in this graph.</p> | |
| 124 | + ) : ( | |
| 125 | + <dl className="kv"> | |
| 126 | + {[...groups.entries()].map(([key, items]) => { | |
| 127 | + const [pred, dir] = key.split('|') as [string, 'out' | 'in']; | |
| 128 | + return ( | |
| 129 | + <div key={key}> | |
| 130 | + <dt> | |
| 131 | + {predicateLabel(pred, dir)} <span className="tnum text-ink-3">{fmtInt(items.length)}</span> | |
| 132 | + </dt> | |
| 133 | + <dd className="flex flex-wrap gap-x-3 gap-y-1"> | |
| 134 | + {items.map(({ node }) => ( | |
| 135 | + <span key={node.id} className="inline-flex items-center gap-1.5"> | |
| 136 | + <EntityBadge type={node.entity_type} small /> | |
| 137 | + <EntityLink e={node} /> | |
| 138 | + </span> | |
| 139 | + ))} | |
| 140 | + </dd> | |
| 141 | + </div> | |
| 142 | + ); | |
| 143 | + })} | |
| 144 | + </dl> | |
| 145 | + )} | |
| 131 | 146 | </section> |
| 132 | − </aside> | |
| 133 | − <section className="min-w-0 lg:col-span-2"> | |
| 134 | − <p className="eyebrow mb-2">Direct relations, as a list</p> | |
| 135 | − {groups.size === 0 ? ( | |
| 136 | − <p className="text-sm text-ink-3">No direct relations for the root node in this graph.</p> | |
| 137 | − ) : ( | |
| 138 | − <dl className="kv"> | |
| 139 | − {[...groups.entries()].map(([key, items]) => { | |
| 140 | − const [pred, dir] = key.split('|') as [string, 'out' | 'in']; | |
| 141 | − return ( | |
| 142 | − <div key={key}> | |
| 143 | − <dt>{predicateLabel(pred, dir)} <span className="tnum text-ink-3">{fmtInt(items.length)}</span></dt> | |
| 144 | − <dd className="flex flex-wrap gap-x-3 gap-y-1"> | |
| 145 | − {items.map(({ node }) => ( | |
| 146 | − <span key={node.id} className="inline-flex items-center gap-1.5"> | |
| 147 | − <EntityBadge type={node.entity_type} small /> | |
| 148 | − <EntityLink e={node} /> | |
| 149 | − </span> | |
| 150 | − ))} | |
| 151 | − </dd> | |
| 152 | − </div> | |
| 153 | − ); | |
| 154 | − })} | |
| 155 | − </dl> | |
| 156 | − )} | |
| 157 | − </section> | |
| 158 | − </div> | |
| 147 | + {graph.truncated && <Note className="mt-3">The neighbourhood is larger than {LIMIT} nodes; the API returned the first {LIMIT} and flagged the cut. Expand individual nodes, or use the entity's Relations block for full lists.</Note>} | |
| 148 | + </Container> | |
| 149 | + </> | |
| 159 | 150 | )} |
| 160 | 151 | </div> |
| 161 | − </Container> | |
| 152 | + </> | |
| 162 | 153 | ); |
| 163 | 154 | } |
added
apps/web/src/app/graph/og/route.tsx
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +import { ImageResponse } from 'next/og'; | |
| 2 | +import type { NextRequest } from 'next/server'; | |
| 3 | +import { Wallpaper } from '@/components/brand/og'; | |
| 4 | +import { defaultModeFor, GRAPH_MODES, isGraphMode } from '@/components/graph/modes'; | |
| 5 | +import { api, apiD3, safe } from '@/lib/api'; | |
| 6 | +import { fmtInt } from '@/lib/format'; | |
| 7 | +import { typeLabel } from '@/lib/site'; | |
| 8 | + | |
| 9 | +export const runtime = 'nodejs'; | |
| 10 | +export const revalidate = 3600; | |
| 11 | + | |
| 12 | +/** Dynamic Open Graph image for /graph?node=&mode=&depth= (query routes cannot use the opengraph-image convention). */ | |
| 13 | +export async function GET(req: NextRequest): Promise<Response> { | |
| 14 | + const sp = req.nextUrl.searchParams; | |
| 15 | + const node = sp.get('node'); | |
| 16 | + const size = { width: 1200, height: 630 }; | |
| 17 | + const d = node ? await safe(api.entity(node)) : null; | |
| 18 | + if (!d) return new ImageResponse(<Wallpaper eyebrow="Knowledge graph" title="Every entity, every relation" subtitle="Model lineage · research networks · company ecosystems · benchmark, dataset, provider and hardware graphs" />, size); | |
| 19 | + const mode = isGraphMode(sp.get('mode')) ? (sp.get('mode') as ReturnType<typeof defaultModeFor>) : defaultModeFor(d.entity_type); | |
| 20 | + const depth: 1 | 2 = sp.get('depth') === '2' ? 2 : 1; | |
| 21 | + const g = await safe(apiD3.graphExplore(d.slug, mode, depth, 150)); | |
| 22 | + const label = GRAPH_MODES.find((m) => m.mode === mode)?.label ?? 'Graph'; | |
| 23 | + const counters: [string, string][] = g ? [['Nodes', fmtInt(g.counts?.nodes ?? g.nodes.length)], ['Edges', fmtInt(g.counts?.edges ?? g.edges.length)], ['Depth', String(g.depth)], ...(g.truncated ? ([['Truncated', 'yes']] as [string, string][]) : [])] : []; | |
| 24 | + return new ImageResponse(<Wallpaper eyebrow={`${label} · ${typeLabel(d.entity_type)}`} title={d.name} subtitle={`${label} around ${d.name}${d.organization ? ` (${d.organization.name})` : ''} — every edge is a stated relation with a source.`} counters={counters} footer={`www.ai-atlas.co/graph?node=${d.slug}`} markPx={220} />, size); | |
| 25 | +} | |
added
apps/web/src/app/graph/page.tsx
+141 −0
@@ -0,0 +1,141 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { GraphWorkbench } from '@/components/graph/graph-workbench'; | |
| 4 | +import { defaultModeFor, GRAPH_MODES, graphHref, isGraphMode } from '@/components/graph/modes'; | |
| 5 | +import { NodeSearch } from '@/components/graph/node-search-link'; | |
| 6 | +import { BreadcrumbLd } from '@/components/meta/breadcrumb-ld'; | |
| 7 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 8 | +import { Container, Note, PageHeader } from '@/components/ui/section'; | |
| 9 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 10 | +import { api, apiD3, safe } from '@/lib/api'; | |
| 11 | +import { fmtInt } from '@/lib/format'; | |
| 12 | +import { routes, SITE_NAME, SITE_URL, typeLabel } from '@/lib/site'; | |
| 13 | +import type { GraphExploreMode } from '@/lib/types'; | |
| 14 | + | |
| 15 | +type SP = { node?: string; mode?: string; depth?: string }; | |
| 16 | +export const revalidate = 600; | |
| 17 | + | |
| 18 | +function resolve(sp: SP): { node: string | null; mode: GraphExploreMode | null; depth: 1 | 2 } { | |
| 19 | + return { node: sp.node?.trim() || null, mode: isGraphMode(sp.mode) ? sp.mode : null, depth: sp.depth === '2' ? 2 : 1 }; | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** Default root when none is asked: the organization with the most canonical models (company ecosystem — the richest landing view), else the most viewed model this week. Never hardcoded. */ | |
| 23 | +async function defaultRoot(): Promise<string | null> { | |
| 24 | + const o = await safe(api.companies({ limit: 1, sort: 'models' })); | |
| 25 | + if (o?.items?.[0]?.slug) return o.items[0].slug; | |
| 26 | + const t = await safe(apiD3.trending('views', { type: 'model', limit: 1, days: 7 })); | |
| 27 | + if (t?.items?.[0]?.slug) return t.items[0].slug; | |
| 28 | + const m = await safe(api.models({ limit: 1, sort: 'quality' })); | |
| 29 | + return m?.items?.[0]?.slug ?? null; | |
| 30 | +} | |
| 31 | + | |
| 32 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 33 | + const { node, mode, depth } = resolve(await searchParams); | |
| 34 | + const d = node ? await safe(api.entity(node)) : null; | |
| 35 | + const m = mode ?? defaultModeFor(d?.entity_type); | |
| 36 | + const modeLabel = GRAPH_MODES.find((x) => x.mode === m)?.label ?? 'Graph'; | |
| 37 | + const title = d ? `${d.name} — ${modeLabel.toLowerCase()} graph` : 'Knowledge graph — every entity, every relation'; | |
| 38 | + const description = d ? `${modeLabel} around ${d.name} (${typeLabel(d.entity_type).toLowerCase()}) on ${SITE_NAME}: ${depth}-hop neighbourhood with the predicate of every stated relation.` : 'Explore the AI ecosystem as a graph: model lineage, research networks, company ecosystems, benchmark, dataset, provider and hardware graphs — every edge is a stated relation with a source.'; | |
| 39 | + const canonical = d ? graphHref(d.slug, m, depth) : '/graph'; | |
| 40 | + const og = `${SITE_URL}/graph/og${d ? `?node=${encodeURIComponent(d.slug)}&mode=${m}&depth=${depth}` : ''}`; | |
| 41 | + return { title, description, alternates: { canonical }, openGraph: { title: `${title} | ${SITE_NAME}`, description, url: `${SITE_URL}${canonical}`, images: [{ url: og, width: 1200, height: 630 }] }, twitter: { card: 'summary_large_image', images: [og] }, robots: node && !d ? { index: false } : undefined }; | |
| 42 | +} | |
| 43 | + | |
| 44 | +export default async function GraphIndexPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 45 | + const sp = await searchParams; | |
| 46 | + const { node: asked, mode: askedMode, depth } = resolve(sp); | |
| 47 | + const node = asked ?? (await defaultRoot()); | |
| 48 | + const d = node ? await safe(api.entity(node)) : null; | |
| 49 | + const mode = askedMode ?? defaultModeFor(d?.entity_type); | |
| 50 | + const graph = d ? await safe(apiD3.graphExplore(d.slug, mode, depth, 150)) : null; | |
| 51 | + const modeDef = GRAPH_MODES.find((x) => x.mode === mode)!; | |
| 52 | + | |
| 53 | + return ( | |
| 54 | + <> | |
| 55 | + <BreadcrumbLd items={[{ name: SITE_NAME, href: '/' }, { name: 'Knowledge graph', href: '/graph' }, ...(d ? [{ name: d.name, href: graphHref(d.slug, mode, depth) }] : [])]} /> | |
| 56 | + <Container wide> | |
| 57 | + <PageHeader | |
| 58 | + eyebrow={ | |
| 59 | + <> | |
| 60 | + Knowledge graph {d && <EntityBadge type={d.entity_type} small />} | |
| 61 | + </> | |
| 62 | + } | |
| 63 | + title={ | |
| 64 | + d ? ( | |
| 65 | + <> | |
| 66 | + {modeDef.label} around{' '} | |
| 67 | + <Link href={routes.entity(d)} className="hover:text-accent"> | |
| 68 | + {d.name} | |
| 69 | + </Link> | |
| 70 | + </> | |
| 71 | + ) : ( | |
| 72 | + 'Every entity, every relation' | |
| 73 | + ) | |
| 74 | + } | |
| 75 | + lede={d ? `${modeDef.hint[0]?.toUpperCase()}${modeDef.hint.slice(1)}. Nodes are coloured by type; every edge is a relation stated by a source. Click a node for its facts, double-click to expand its neighbourhood, pick another root or mode in the rail.` : 'Pick a root entity and a mode to draw its neighbourhood.'} | |
| 76 | + aside={ | |
| 77 | + graph ? ( | |
| 78 | + <p className="tnum text-xs text-ink-3"> | |
| 79 | + {fmtInt(graph.counts?.nodes ?? graph.nodes.length)} nodes · {fmtInt(graph.counts?.edges ?? graph.edges.length)} edges · depth {graph.depth} | |
| 80 | + {graph.truncated ? <span className="text-warning"> · truncated</span> : null} | |
| 81 | + </p> | |
| 82 | + ) : undefined | |
| 83 | + } | |
| 84 | + className="pb-3 md:pb-4" | |
| 85 | + /> | |
| 86 | + </Container> | |
| 87 | + {!node ? ( | |
| 88 | + <Container> | |
| 89 | + <div className="pb-16"> | |
| 90 | + <Unavailable what="Graph" reason="No root could be chosen (the API did not answer)." /> | |
| 91 | + </div> | |
| 92 | + </Container> | |
| 93 | + ) : !d ? ( | |
| 94 | + <Container> | |
| 95 | + <div className="pb-16"> | |
| 96 | + <EmptyState title={`No entity called “${node}”`}> | |
| 97 | + Pick a root from the atlas: | |
| 98 | + <div className="mx-auto mt-3 max-w-md text-left"> | |
| 99 | + <NodeSearch mode={mode} depth={depth} /> | |
| 100 | + </div> | |
| 101 | + </EmptyState> | |
| 102 | + </div> | |
| 103 | + </Container> | |
| 104 | + ) : !graph ? ( | |
| 105 | + <Container> | |
| 106 | + <div className="pb-16"> | |
| 107 | + <Unavailable what="Graph" reason="The graph service did not answer." /> | |
| 108 | + </div> | |
| 109 | + </Container> | |
| 110 | + ) : graph.nodes.length <= 1 ? ( | |
| 111 | + <Container> | |
| 112 | + <div className="pb-16"> | |
| 113 | + <EmptyState title={`No ${modeDef.label.toLowerCase()} relations recorded for ${d.name}`}> | |
| 114 | + Relations are written only when a source states them. Try another mode:{' '} | |
| 115 | + {GRAPH_MODES.filter((m) => m.mode !== mode) | |
| 116 | + .slice(0, 4) | |
| 117 | + .map((m, i) => ( | |
| 118 | + <span key={m.mode}> | |
| 119 | + {i > 0 && ' · '} | |
| 120 | + <Link href={graphHref(d.slug, m.mode, depth)} className="link"> | |
| 121 | + {m.label} | |
| 122 | + </Link> | |
| 123 | + </span> | |
| 124 | + ))} | |
| 125 | + , or <Link href={routes.entity(d)} className="link">open {d.name}</Link>. | |
| 126 | + </EmptyState> | |
| 127 | + </div> | |
| 128 | + </Container> | |
| 129 | + ) : ( | |
| 130 | + <div className="pb-12"> | |
| 131 | + <GraphWorkbench key={`${d.slug}:${mode}:${depth}`} initial={graph} root={{ slug: d.slug, name: d.name, entity_type: d.entity_type }} mode={mode} depth={depth} /> | |
| 132 | + <Container wide> | |
| 133 | + <Note className="mt-4"> | |
| 134 | + Modes map to <span className="mono">/graph/explore?mode=</span>: {GRAPH_MODES.map((m) => m.mode).join(' · ')}. The API never returns more than 150 nodes and says so (<span className="mono">truncated</span>). Nothing is inferred: an edge exists only when a source stated the relation. <Link href={routes.methodology()} className="link">Methodology →</Link> | |
| 135 | + </Note> | |
| 136 | + </Container> | |
| 137 | + </div> | |
| 138 | + )} | |
| 139 | + </> | |
| 140 | + ); | |
| 141 | +} | |
modified
apps/web/src/app/methodology/page.tsx
+295 −54
@@ -1,15 +1,17 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | −import { TierBadge } from '@/components/ui/badges'; | |
| 3 | +import { SectionNav } from '@/components/layout/terminal'; | |
| 4 | +import { BreadcrumbLd } from '@/components/meta/breadcrumb-ld'; | |
| 5 | +import { Chip, Estimated, TierBadge } from '@/components/ui/badges'; | |
| 4 | 6 | import { DataTable, Td, Th } from '@/components/ui/data-table'; |
| 5 | −import { Container, PageHeader, Section } from '@/components/ui/section'; | |
| 7 | +import { Container, Note, PageHeader, Section } from '@/components/ui/section'; | |
| 6 | 8 | import { Unavailable } from '@/components/ui/unavailable'; |
| 7 | −import { api, safe } from '@/lib/api'; | |
| 9 | +import { apiD3, safe } from '@/lib/api'; | |
| 8 | 10 | import { fmtAgo, fmtInt, humanize } from '@/lib/format'; |
| 9 | −import { eventLabel, routes, TIER_LABELS } from '@/lib/site'; | |
| 10 | −import type { Methodology } from '@/lib/types'; | |
| 11 | +import { eventLabel, routes, SITE_NAME, TIER_LABELS } from '@/lib/site'; | |
| 12 | +import type { MethodologyD3 } from '@/lib/types'; | |
| 11 | 13 | |
| 12 | −export const metadata: Metadata = { title: 'Methodology — provenance, tiers, confidence, quality score, events', description: 'How AI Atlas records facts: source tiers, confidence levels, temporal claims, conflict handling, the data-quality score, change events and estimates.', alternates: { canonical: '/methodology' } }; | |
| 14 | +export const metadata: Metadata = { title: 'Methodology — provenance, tiers, openness, trust, comparability, counters, events, anomalies, estimates', description: 'How AI Atlas records facts: source tiers, confidence, temporal claims and conflicts, openness definitions, benchmark trust levels and comparability rules, counter definitions, event semantics (occurred / observed / recorded), anomaly checks, hardware-fit assumptions and frontier composition.', alternates: { canonical: '/methodology' } }; | |
| 13 | 15 | export const revalidate = 3600; |
| 14 | 16 | |
| 15 | 17 | /** The API may return dicts or lists for these vocabularies; normalise to rows. */ |
@@ -19,41 +21,104 @@ function rows(v: unknown, keyName: string): { key: string; label?: string; descr | ||
| 19 | 21 | if (typeof v === 'object') return Object.entries(v as Record<string, unknown>).map(([k, d]) => (typeof d === 'string' ? { key: k, description: d } : { key: k, ...(d as Record<string, unknown>), extra: d as Record<string, unknown> })); |
| 20 | 22 | return []; |
| 21 | 23 | } |
| 24 | +function Defs({ obj, mono = true }: { obj: Record<string, unknown> | undefined | null; mono?: boolean }) { | |
| 25 | + const entries = Object.entries(obj ?? {}).filter(([, v]) => typeof v === 'string'); | |
| 26 | + if (!entries.length) return <Unavailable what="Definitions" compact />; | |
| 27 | + return ( | |
| 28 | + <dl className="kv max-w-4xl"> | |
| 29 | + {entries.map(([k, v]) => ( | |
| 30 | + <div key={k}> | |
| 31 | + <dt className={mono ? 'mono' : undefined}>{k}</dt> | |
| 32 | + <dd className="text-ink-2">{String(v)}</dd> | |
| 33 | + </div> | |
| 34 | + ))} | |
| 35 | + </dl> | |
| 36 | + ); | |
| 37 | +} | |
| 38 | +function Keys({ items }: { items: unknown }) { | |
| 39 | + if (!Array.isArray(items) || !items.length) return <span className="text-ink-3">—</span>; | |
| 40 | + return ( | |
| 41 | + <span className="flex flex-wrap gap-1"> | |
| 42 | + {items.map((k) => ( | |
| 43 | + <Chip key={String(k)} className="mono"> | |
| 44 | + {String(k)} | |
| 45 | + </Chip> | |
| 46 | + ))} | |
| 47 | + </span> | |
| 48 | + ); | |
| 49 | +} | |
| 50 | + | |
| 51 | +const NAV = [ | |
| 52 | + { id: 'principles', label: 'Principles' }, | |
| 53 | + { id: 'tiers', label: 'Tiers' }, | |
| 54 | + { id: 'confidence', label: 'Confidence' }, | |
| 55 | + { id: 'openness', label: 'Openness' }, | |
| 56 | + { id: 'trust', label: 'Trust levels' }, | |
| 57 | + { id: 'comparability', label: 'Comparability' }, | |
| 58 | + { id: 'counters', label: 'Counters' }, | |
| 59 | + { id: 'events', label: 'Events' }, | |
| 60 | + { id: 'anomalies', label: 'Anomalies' }, | |
| 61 | + { id: 'estimates', label: 'Hardware fit' }, | |
| 62 | + { id: 'frontier', label: 'Frontier' }, | |
| 63 | + { id: 'quality', label: 'Quality' }, | |
| 64 | + { id: 'extractors', label: 'Extraction' }, | |
| 65 | +]; | |
| 22 | 66 | |
| 23 | 67 | export default async function MethodologyPage() { |
| 24 | − const m: Methodology | null = await safe(api.methodology()); | |
| 68 | + const m: MethodologyD3 | null = await safe(apiD3.methodology()); | |
| 25 | 69 | const tiers = rows(m?.tiers, 'tier'); |
| 26 | 70 | const conf = rows(m?.confidence_levels, 'key'); |
| 27 | 71 | const events = rows(m?.event_types, 'event_type'); |
| 28 | 72 | const extractors = rows(m?.extractors, 'key'); |
| 73 | + const comp = (m?.comparability ?? {}) as Record<string, unknown>; | |
| 74 | + const hf = (m?.hardware_fit ?? {}) as { assumptions?: string[]; bytes_per_param?: Record<string, number>; reserved_gb?: number }; | |
| 29 | 75 | return ( |
| 30 | 76 | <Container> |
| 31 | − <PageHeader eyebrow="Methodology" title="How AI Atlas records facts" lede="The dataset is the product. These are the rules every connector, extractor and page follows — and the vocabularies the API exposes." /> | |
| 32 | − <div className="prose-atlas max-w-3xl text-[15px] leading-relaxed text-ink-2"> | |
| 33 | − <p>AI Atlas is built from first-party connectors that read public documents directly: official documentation, pricing pages, model cards, release notes, papers, feeds, sitemaps and repositories. Nothing depends on a third-party data API. Every document is snapshotted and archived; every fact points back to a snapshot.</p> | |
| 34 | − <p>Facts are <strong className="text-ink">temporal claims</strong>: a property, a value, a source, a tier, a confidence, an extractor and a validity interval. When a better-or-equal source states a new value, the old claim is superseded (its interval closes) and the new one becomes current. When a worse source disagrees, the value is stored as <em>conflicting</em> and flagged for review — it is never averaged or silently overwritten. Missing means missing: the site shows “Unavailable” rather than a guess.</p> | |
| 35 | − </div> | |
| 77 | + <BreadcrumbLd items={[{ name: SITE_NAME, href: '/' }, { name: 'Methodology', href: '/methodology' }]} /> | |
| 78 | + <PageHeader eyebrow="Methodology 2.0" title="How AI Atlas records facts" lede="The dataset is the product. These are the rules every connector, extractor and page follows — and the vocabularies the API exposes. Everything on this page is read from GET /methodology; nothing is hardcoded." aside={m?.quality_version ? <p className="mono text-xs text-ink-3">quality v{m.quality_version}</p> : undefined} /> | |
| 79 | + <SectionNav items={NAV} /> | |
| 80 | + {!m && <Unavailable what="Methodology" className="my-8" />} | |
| 81 | + | |
| 82 | + <Section id="principles" eyebrow="Principles" title="Non-negotiables" hairline={false}> | |
| 83 | + {m?.principles?.length ? ( | |
| 84 | + <ol className="max-w-3xl list-decimal space-y-1.5 pl-5 text-[15px] leading-relaxed text-ink-2"> | |
| 85 | + {m.principles.map((p) => ( | |
| 86 | + <li key={p}>{p}</li> | |
| 87 | + ))} | |
| 88 | + </ol> | |
| 89 | + ) : ( | |
| 90 | + <div className="prose-atlas max-w-3xl text-[15px] leading-relaxed text-ink-2"> | |
| 91 | + <p>AI Atlas is built from first-party connectors that read public documents directly. Every document is snapshotted and archived; every fact points back to a snapshot. Facts are temporal claims — a property, a value, a source, a tier, a confidence, an extractor and a validity interval. Missing means missing.</p> | |
| 92 | + </div> | |
| 93 | + )} | |
| 94 | + </Section> | |
| 36 | 95 | |
| 37 | 96 | <Section id="tiers" eyebrow="Source tiers" title="Primary sources first"> |
| 38 | 97 | {m && tiers.length ? ( |
| 39 | 98 | <DataTable caption="Source tiers"> |
| 40 | − <thead><tr><Th>Tier</Th><Th>Meaning</Th><Th>Description</Th></tr></thead> | |
| 99 | + <thead> | |
| 100 | + <tr> | |
| 101 | + <Th>Tier</Th> | |
| 102 | + <Th>Meaning</Th> | |
| 103 | + <Th>Description</Th> | |
| 104 | + </tr> | |
| 105 | + </thead> | |
| 41 | 106 | <tbody> |
| 42 | 107 | {tiers.map((t, i) => ( |
| 43 | 108 | <tr key={`${t.key}-${i}`}> |
| 44 | − <Td primary><TierBadge tier={Number(t.key)} /></Td> | |
| 109 | + <Td primary> | |
| 110 | + <TierBadge tier={Number(t.key)} /> | |
| 111 | + </Td> | |
| 45 | 112 | <Td label="Meaning" className="text-ink">{t.label ?? TIER_LABELS[Number(t.key)] ?? humanize(t.key)}</Td> |
| 46 | 113 | <Td label="Description" wide className="text-ink-2">{t.description ?? '—'}</Td> |
| 47 | 114 | </tr> |
| 48 | 115 | ))} |
| 49 | 116 | </tbody> |
| 50 | 117 | </DataTable> |
| 51 | − ) : m ? ( | |
| 52 | − <ul className="space-y-1 text-sm text-ink-2">{[1, 2, 3, 4].map((t) => <li key={t} className="flex items-center gap-2"><TierBadge tier={t} /> {TIER_LABELS[t]}</li>)}</ul> | |
| 53 | 118 | ) : ( |
| 54 | 119 | <Unavailable what="Tier vocabulary" compact /> |
| 55 | 120 | )} |
| 56 | − <p className="mt-3 max-w-3xl text-sm text-ink-2">A tier 1 source is the entity's own publisher (a lab's documentation, a provider's pricing page, an arXiv listing for a paper). Tier 2 are quality secondary sources, tier 3 community sources, tier 4 unverified. A higher tier can supersede a lower one; the reverse produces a flagged conflict.</p> | |
| 121 | + <p className="mt-3 max-w-3xl text-sm text-ink-2">A higher tier can supersede a lower one; the reverse produces a flagged conflict. Both claims are kept.</p> | |
| 57 | 122 | </Section> |
| 58 | 123 | |
| 59 | 124 | <Section id="confidence" eyebrow="Confidence" title="Confidence levels"> |
@@ -69,41 +134,126 @@ export default async function MethodologyPage() { | ||
| 69 | 134 | ) : ( |
| 70 | 135 | <Unavailable what="Confidence vocabulary" compact /> |
| 71 | 136 | )} |
| 72 | − <p className="mt-3 max-w-3xl text-sm text-ink-2">Default confidence follows the tier (tier 1 → high, tier 2 → medium, tiers 3–4 → low). A claim becomes <em>conflicted</em> when a current value is contradicted by another source. Extractors can raise confidence to <em>verified</em> when a value is confirmed by several independent tier 1–2 sources.</p> | |
| 137 | + {m?.status_vocabulary?.length ? ( | |
| 138 | + <p className="mt-3 text-sm text-ink-2"> | |
| 139 | + Status vocabulary: <Keys items={m.status_vocabulary} /> | |
| 140 | + </p> | |
| 141 | + ) : null} | |
| 73 | 142 | </Section> |
| 74 | 143 | |
| 75 | − <Section id="quality" eyebrow="Data quality" title="The quality score measures our knowledge, not the entity"> | |
| 76 | − <p className="max-w-3xl text-sm leading-relaxed text-ink-2"> | |
| 77 | − <span className="mono text-ink">quality.score = 100 × (0.25 completeness + 0.25 primary-source ratio + 0.20 freshness + 0.15 agreement + 0.15 source diversity)</span>. It says how well AI Atlas knows an entity — how many expected attributes are filled, how much comes from tier 1, how recently it was re-observed, how few conflicts remain and how many independent sources agree. It says nothing about whether a model is good. | |
| 78 | − </p> | |
| 79 | − {m && m.metrics?.length > 0 && ( | |
| 80 | − <DataTable caption="Metric definitions" className="mt-5"> | |
| 81 | − <thead><tr><Th>Metric</Th><Th>Definition</Th><Th>Version</Th></tr></thead> | |
| 82 | − <tbody> | |
| 83 | − {m.metrics.map((x, i) => ( | |
| 84 | − <tr key={String(x.key ?? x.name ?? i)}> | |
| 85 | − <Td primary className="mono text-xs">{String(x.key ?? x.name ?? '—')}</Td> | |
| 86 | − <Td label="Definition" wide className="text-ink-2">{String(x.description ?? x.formula ?? x.label ?? '—')}{x.unit ? <span className="text-ink-3"> · {String(x.unit)}</span> : null}</Td> | |
| 87 | − <Td label="Version" className="mono text-xs text-ink-3">{x.version !== undefined ? String(x.version) : '—'}</Td> | |
| 144 | + <Section id="openness" eyebrow="Openness" title="Openness is derived from measurable dimensions" lede={m?.openness?.note}> | |
| 145 | + {m?.openness ? ( | |
| 146 | + <> | |
| 147 | + <DataTable caption="Openness categories" compact> | |
| 148 | + <thead> | |
| 149 | + <tr> | |
| 150 | + <Th>Category</Th> | |
| 151 | + <Th>Label</Th> | |
| 152 | + <Th>Definition</Th> | |
| 88 | 153 | </tr> |
| 154 | + </thead> | |
| 155 | + <tbody> | |
| 156 | + {m.openness.categories.map((c) => ( | |
| 157 | + <tr key={c}> | |
| 158 | + <Td primary className="mono text-xs">{c}</Td> | |
| 159 | + <Td label="Label" className="text-ink">{m.openness?.labels?.[c] ?? humanize(c)}</Td> | |
| 160 | + <Td label="Definition" wide className="text-ink-2">{m.openness?.definitions?.[c] ?? '—'}</Td> | |
| 161 | + </tr> | |
| 162 | + ))} | |
| 163 | + </tbody> | |
| 164 | + </DataTable> | |
| 165 | + <p className="mt-3 text-sm text-ink-2"> | |
| 166 | + Dimensions: <Keys items={m.openness.dimensions} /> | |
| 167 | + </p> | |
| 168 | + {m.licence_categories?.length ? ( | |
| 169 | + <p className="mt-2 text-sm text-ink-2"> | |
| 170 | + Licence categories: <Keys items={m.licence_categories} /> | |
| 171 | + </p> | |
| 172 | + ) : null} | |
| 173 | + </> | |
| 174 | + ) : ( | |
| 175 | + <Unavailable what="Openness definitions" compact /> | |
| 176 | + )} | |
| 177 | + </Section> | |
| 178 | + | |
| 179 | + <Section id="trust" eyebrow="Benchmarks" title="Trust levels of a result"> | |
| 180 | + {m?.trust_levels?.length ? ( | |
| 181 | + <dl className="kv max-w-3xl"> | |
| 182 | + {(m.trust_levels as { key: string; label: string; description?: string }[]).map((t) => ( | |
| 183 | + <div key={t.key}> | |
| 184 | + <dt className="mono">{t.key}</dt> | |
| 185 | + <dd className="text-ink-2">{t.label}{t.description ? ` — ${t.description}` : ''}</dd> | |
| 186 | + </div> | |
| 187 | + ))} | |
| 188 | + </dl> | |
| 189 | + ) : ( | |
| 190 | + <Unavailable what="Trust levels" compact /> | |
| 191 | + )} | |
| 192 | + </Section> | |
| 193 | + | |
| 194 | + <Section id="comparability" eyebrow="Benchmarks" title="Results are never compared blindly" lede={typeof comp.group === 'string' ? comp.group : undefined}> | |
| 195 | + {Object.keys(comp).length ? ( | |
| 196 | + <> | |
| 197 | + <dl className="kv max-w-4xl"> | |
| 198 | + {(['comparable', 'partially-comparable', 'not-comparable'] as const).filter((k) => typeof comp[k] === 'string').map((k) => ( | |
| 199 | + <div key={k}> | |
| 200 | + <dt className="mono">{k}</dt> | |
| 201 | + <dd className="text-ink-2">{String(comp[k])}</dd> | |
| 202 | + </div> | |
| 89 | 203 | ))} |
| 90 | − </tbody> | |
| 91 | − </DataTable> | |
| 204 | + {typeof comp.leaderboard === 'string' && ( | |
| 205 | + <div> | |
| 206 | + <dt>Leaderboards</dt> | |
| 207 | + <dd className="text-ink-2">{comp.leaderboard}</dd> | |
| 208 | + </div> | |
| 209 | + )} | |
| 210 | + </dl> | |
| 211 | + <div className="mt-4 grid gap-4 md:grid-cols-3"> | |
| 212 | + <div> | |
| 213 | + <p className="eyebrow mb-1.5">Task-defining keys</p> | |
| 214 | + <Keys items={comp.task_keys} /> | |
| 215 | + </div> | |
| 216 | + <div> | |
| 217 | + <p className="eyebrow mb-1.5">Condition keys</p> | |
| 218 | + <Keys items={comp.condition_keys} /> | |
| 219 | + </div> | |
| 220 | + <div> | |
| 221 | + <p className="eyebrow mb-1.5">Ignored keys</p> | |
| 222 | + <Keys items={comp.ignored_keys} /> | |
| 223 | + </div> | |
| 224 | + </div> | |
| 225 | + </> | |
| 226 | + ) : ( | |
| 227 | + <Unavailable what="Comparability rules" compact /> | |
| 92 | 228 | )} |
| 93 | 229 | </Section> |
| 94 | 230 | |
| 95 | − <Section id="events" eyebrow="History" title="Change events"> | |
| 96 | − <p className="max-w-3xl text-sm leading-relaxed text-ink-2">Material properties — context length, status, license, openness, parameters, release date, deprecation and retirement dates, versions, prices — emit events when they change. Noisy metrics (downloads, likes, stars) are stored as time series and never generate events. Descriptions and other soft text follow their own source without events. Each event has a category and an importance from 0 (minor) to 3 (major), and links to the source document that triggered it.</p> | |
| 231 | + <Section id="counters" eyebrow="Counters" title="How every number is counted"> | |
| 232 | + <Defs obj={m?.counters} /> | |
| 233 | + </Section> | |
| 234 | + | |
| 235 | + <Section id="events" eyebrow="History" title="Change events: occurred, observed, recorded"> | |
| 236 | + <Defs obj={m?.event_semantics} /> | |
| 97 | 237 | {m && events.length ? ( |
| 98 | 238 | <DataTable caption="Event types" className="mt-5" compact> |
| 99 | − <thead><tr><Th>Event type</Th><Th>Label</Th><Th>Category</Th><Th num>Recorded</Th><Th>Last seen</Th></tr></thead> | |
| 239 | + <thead> | |
| 240 | + <tr> | |
| 241 | + <Th>Event type</Th> | |
| 242 | + <Th>Label</Th> | |
| 243 | + <Th>Category</Th> | |
| 244 | + <Th num>Importance</Th> | |
| 245 | + <Th num>Recorded</Th> | |
| 246 | + <Th>Last seen</Th> | |
| 247 | + </tr> | |
| 248 | + </thead> | |
| 100 | 249 | <tbody> |
| 101 | 250 | {events.map((e, i) => ( |
| 102 | 251 | <tr key={`${e.key}-${String(e.extra?.category ?? '')}-${i}`}> |
| 103 | 252 | <Td primary className="mono text-xs">{e.key}</Td> |
| 104 | 253 | <Td label="Label" className="text-ink-2">{e.label ?? e.description ?? eventLabel(e.key)}</Td> |
| 105 | 254 | <Td label="Category" className="text-ink-2">{String(e.extra?.category ?? '—')}</Td> |
| 106 | − <Td num label="Recorded" className="tnum text-ink-2">{e.extra?.count !== undefined ? fmtInt(e.extra.count) : e.extra?.importance !== undefined ? `importance ${String(e.extra.importance)}` : '—'}</Td> | |
| 255 | + <Td num label="Importance" className="tnum text-ink-2">{e.extra?.importance !== undefined ? String(e.extra.importance) : '—'}</Td> | |
| 256 | + <Td num label="Recorded" className="tnum text-ink-2">{e.extra?.count !== undefined ? fmtInt(e.extra.count) : '—'}</Td> | |
| 107 | 257 | <Td label="Last seen" className="text-ink-2">{typeof e.extra?.last_seen_at === 'string' ? fmtAgo(e.extra.last_seen_at) : '—'}</Td> |
| 108 | 258 | </tr> |
| 109 | 259 | ))} |
@@ -114,10 +264,110 @@ export default async function MethodologyPage() { | ||
| 114 | 264 | )} |
| 115 | 265 | </Section> |
| 116 | 266 | |
| 267 | + <Section id="anomalies" eyebrow="Data health" title="Anomaly checks" lede="Impossible or suspicious values are flagged, never deleted; each flag points at a review action."> | |
| 268 | + {m?.anomaly_checks?.length ? ( | |
| 269 | + <DataTable caption="Anomaly checks" compact> | |
| 270 | + <thead> | |
| 271 | + <tr> | |
| 272 | + <Th>Check</Th> | |
| 273 | + <Th>Severity</Th> | |
| 274 | + <Th>Description</Th> | |
| 275 | + </tr> | |
| 276 | + </thead> | |
| 277 | + <tbody> | |
| 278 | + {m.anomaly_checks.map((c) => ( | |
| 279 | + <tr key={c.check}> | |
| 280 | + <Td primary className="mono text-xs">{c.check}</Td> | |
| 281 | + <Td label="Severity"> | |
| 282 | + <span className={c.severity === 'critical' ? 'text-danger' : c.severity === 'warning' ? 'text-warning' : 'text-ink-2'}>{c.severity}</span> | |
| 283 | + </Td> | |
| 284 | + <Td label="Description" wide className="text-ink-2">{c.description}</Td> | |
| 285 | + </tr> | |
| 286 | + ))} | |
| 287 | + </tbody> | |
| 288 | + </DataTable> | |
| 289 | + ) : ( | |
| 290 | + <Unavailable what="Anomaly checks" compact /> | |
| 291 | + )} | |
| 292 | + </Section> | |
| 293 | + | |
| 294 | + <Section id="estimates" eyebrow="Estimates" title={<>Hardware fit is an estimate <Estimated className="ml-2 align-middle" /></>}> | |
| 295 | + {hf.assumptions?.length ? ( | |
| 296 | + <ul className="max-w-3xl list-disc space-y-1 pl-5 text-sm leading-relaxed text-ink-2"> | |
| 297 | + {hf.assumptions.map((a) => ( | |
| 298 | + <li key={a}>{a}</li> | |
| 299 | + ))} | |
| 300 | + </ul> | |
| 301 | + ) : ( | |
| 302 | + <Unavailable what="Hardware-fit assumptions" compact /> | |
| 303 | + )} | |
| 304 | + {hf.bytes_per_param && ( | |
| 305 | + <p className="mt-3 text-sm text-ink-2"> | |
| 306 | + Bytes per parameter:{' '} | |
| 307 | + {Object.entries(hf.bytes_per_param).map(([k, v], i) => ( | |
| 308 | + <span key={k} className="tnum"> | |
| 309 | + {i > 0 && ' · '} | |
| 310 | + <span className="mono">{k}</span> {v} | |
| 311 | + </span> | |
| 312 | + ))} | |
| 313 | + {hf.reserved_gb !== undefined && <> · reserved {hf.reserved_gb} GB</>} | |
| 314 | + </p> | |
| 315 | + )} | |
| 316 | + </Section> | |
| 317 | + | |
| 318 | + <Section id="frontier" eyebrow="Frontier" title="Frontier composition"> | |
| 319 | + {typeof m?.frontier === 'string' ? <p className="max-w-3xl text-sm leading-relaxed text-ink-2">{m.frontier}</p> : m?.frontier ? <Defs obj={m.frontier as Record<string, unknown>} mono={false} /> : <Unavailable what="Frontier definition" compact />} | |
| 320 | + {m?.find_a_model && ( | |
| 321 | + <> | |
| 322 | + <p className="eyebrow mb-1.5 mt-5">Find-a-model rules</p> | |
| 323 | + <Defs obj={m.find_a_model} /> | |
| 324 | + </> | |
| 325 | + )} | |
| 326 | + </Section> | |
| 327 | + | |
| 328 | + <Section id="quality" eyebrow="Data quality" title="The quality score measures our knowledge, not the entity"> | |
| 329 | + {m && m.metrics?.length > 0 ? ( | |
| 330 | + <DataTable caption="Metric definitions"> | |
| 331 | + <thead> | |
| 332 | + <tr> | |
| 333 | + <Th>Metric</Th> | |
| 334 | + <Th>Definition</Th> | |
| 335 | + <Th>Version</Th> | |
| 336 | + </tr> | |
| 337 | + </thead> | |
| 338 | + <tbody> | |
| 339 | + {m.metrics.map((x, i) => ( | |
| 340 | + <tr key={String(x.key ?? x.name ?? i)}> | |
| 341 | + <Td primary className="mono text-xs">{String(x.key ?? x.name ?? '—')}</Td> | |
| 342 | + <Td label="Definition" wide className="text-ink-2">{String(x.description ?? x.formula ?? x.label ?? '—')}{x.unit ? <span className="text-ink-3"> · {String(x.unit)}</span> : null}</Td> | |
| 343 | + <Td label="Version" className="mono text-xs text-ink-3">{x.version !== undefined ? String(x.version) : '—'}</Td> | |
| 344 | + </tr> | |
| 345 | + ))} | |
| 346 | + </tbody> | |
| 347 | + </DataTable> | |
| 348 | + ) : ( | |
| 349 | + <Unavailable what="Metric definitions" compact /> | |
| 350 | + )} | |
| 351 | + {m?.expected_fields && ( | |
| 352 | + <> | |
| 353 | + <p className="eyebrow mb-1.5 mt-5">Expected fields per type (completeness)</p> | |
| 354 | + <dl className="kv max-w-4xl"> | |
| 355 | + {Object.entries(m.expected_fields).map(([t, fields]) => ( | |
| 356 | + <div key={t}> | |
| 357 | + <dt className="mono">{t}</dt> | |
| 358 | + <dd> | |
| 359 | + <Keys items={fields} /> | |
| 360 | + </dd> | |
| 361 | + </div> | |
| 362 | + ))} | |
| 363 | + </dl> | |
| 364 | + </> | |
| 365 | + )} | |
| 366 | + </Section> | |
| 367 | + | |
| 117 | 368 | <Section id="extractors" eyebrow="Extraction" title="Deterministic before LLM"> |
| 118 | − <p className="max-w-3xl text-sm leading-relaxed text-ink-2">Stage 1 runs on every document: DOM selectors, JSON-LD, tables, Markdown, feeds. Only documents flagged as needing it go to the local LLM factory (MacLustr, OpenAI-compatible), through versioned schemas with full token accounting. LLM-extracted values are marked as such in provenance. Improving a parser bumps its version and reprocesses archived snapshots — never a re-crawl.</p> | |
| 119 | − {m && extractors.length > 0 && ( | |
| 120 | − <dl className="kv mt-5 max-w-3xl"> | |
| 369 | + {m && extractors.length > 0 ? ( | |
| 370 | + <dl className="kv max-w-3xl"> | |
| 121 | 371 | {extractors.map((x, i) => ( |
| 122 | 372 | <div key={`${x.key}-${i}`}> |
| 123 | 373 | <dt className="mono">{x.key}</dt> |
@@ -125,21 +375,12 @@ export default async function MethodologyPage() { | ||
| 125 | 375 | </div> |
| 126 | 376 | ))} |
| 127 | 377 | </dl> |
| 378 | + ) : ( | |
| 379 | + <Unavailable what="Extractor vocabulary" compact /> | |
| 128 | 380 | )} |
| 129 | − </Section> | |
| 130 | − | |
| 131 | − <Section id="benchmarks" eyebrow="Benchmarks" title="Results are never compared blindly"> | |
| 132 | − <p className="max-w-3xl text-sm leading-relaxed text-ink-2">Benchmark results are append-only and carry their configuration (harness, prompting, number of shots, judge). Leaderboards rank current rows under the benchmark's own direction; results with different configurations are shown with their config so the reader can judge comparability. We do not compute composite indices.</p> | |
| 133 | − </Section> | |
| 134 | − | |
| 135 | − <Section id="estimates" eyebrow="Estimates" title="Hardware fit is an estimate"> | |
| 136 | − <p className="max-w-3xl text-sm leading-relaxed text-ink-2">The only derived figures on AI Atlas are hardware-fit estimates: memory need ≈ parameters × bytes per parameter (4-bit 0.5 × 1.15 overhead, 8-bit 1.0, fp16 2.0) plus a KV-cache allowance for the chosen context. They are labelled <span className="border border-dashed border-warning/60 px-1 text-[11px] uppercase text-warning">Estimated</span> everywhere and never mixed with observed facts.</p> | |
| 137 | − </Section> | |
| 138 | − | |
| 139 | − <Section eyebrow="Crawling" title="Respectful by design"> | |
| 140 | − <p className="max-w-3xl text-sm leading-relaxed text-ink-2"> | |
| 141 | − Connectors honour robots.txt, use per-domain rate limits and conditional requests, identify as <Link href={routes.bot()} className="link mono">AIAtlasBot</Link>, never bypass access controls and never collect private data. The list of sources and connector health is public on <Link href={routes.sources()} className="link">/sources</Link>. | |
| 142 | − </p> | |
| 381 | + <Note className="mt-4"> | |
| 382 | + Connectors honour robots.txt, use per-domain rate limits and conditional requests, identify as <Link href={routes.bot()} className="link mono">AIAtlasBot</Link>, never bypass access controls and never collect private data. Sources and connector health: <Link href={routes.sources()} className="link">/sources</Link>. | |
| 383 | + </Note> | |
| 143 | 384 | </Section> |
| 144 | 385 | </Container> |
| 145 | 386 | ); |
modified
apps/web/src/app/papers/page.tsx
+215 −96
@@ -1,107 +1,226 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | −import { Dash, list, str, TypedListing } from '@/components/listing/typed-listing'; | |
| 4 | −import { Chip } from '@/components/ui/badges'; | |
| 5 | −import { EntityLink, QualityMark } from '@/components/ui/entity'; | |
| 6 | −import { api } from '@/lib/api'; | |
| 7 | −import { fmtDate } from '@/lib/format'; | |
| 8 | −import { routes } from '@/lib/site'; | |
| 3 | +import { RailFilters } from '@/components/changes/rail-filters'; | |
| 4 | +import { TerminalLayout } from '@/components/layout/terminal'; | |
| 5 | +import { ActiveFilters } from '@/components/listing/filters'; | |
| 6 | +import { BreadcrumbLd } from '@/components/meta/breadcrumb-ld'; | |
| 7 | +import { Chip, EntityBadge } from '@/components/ui/badges'; | |
| 8 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 9 | +import { EntityLink } from '@/components/ui/entity'; | |
| 10 | +import { Hint } from '@/components/ui/hint'; | |
| 11 | +import { Pagination, withParams } from '@/components/ui/pagination'; | |
| 12 | +import { Container, Note, PageHeader } from '@/components/ui/section'; | |
| 13 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 14 | +import { api, safe } from '@/lib/api'; | |
| 15 | +import { fmtDate, fmtInt } from '@/lib/format'; | |
| 16 | +import { routes, SITE_NAME } from '@/lib/site'; | |
| 17 | +import type { EntityDetail, EntitySummary } from '@/lib/types'; | |
| 9 | 18 | |
| 10 | −export const metadata: Metadata = { title: 'AI research papers — authors, dates, categories', description: 'Research papers in the atlas, linked to the models, organizations and benchmarks they describe. Authors, arXiv categories and publication dates as stated by their sources.', alternates: { canonical: '/papers' } }; | |
| 19 | +export const metadata: Metadata = { title: 'AI research papers — authors, models introduced, datasets, benchmarks', description: 'Research papers in the atlas with their authors, publication dates, arXiv categories and the models, datasets and benchmarks they are linked to — as stated by model cards and paper metadata.', alternates: { canonical: '/papers' } }; | |
| 11 | 20 | export const revalidate = 300; |
| 12 | 21 | |
| 13 | −export default async function PapersPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 22 | +type SP = Record<string, string | undefined>; | |
| 23 | +const LIMIT = 25; | |
| 24 | +const KEYS = ['q', 'category', 'org', 'since', 'until', 'sort', 'offset'] as const; | |
| 25 | +const SORTS = [ | |
| 26 | + { value: 'published', label: 'Recently published' }, | |
| 27 | + { value: 'updated', label: 'Recently updated' }, | |
| 28 | + { value: 'name', label: 'Title' }, | |
| 29 | +]; | |
| 30 | + | |
| 31 | +function str(v: unknown): string | null { | |
| 32 | + return typeof v === 'string' && v.trim() ? v : null; | |
| 33 | +} | |
| 34 | +function list(v: unknown): string[] { | |
| 35 | + return Array.isArray(v) ? v.filter((x) => x !== null && x !== undefined).map(String) : []; | |
| 36 | +} | |
| 37 | +type Linked = { models: EntitySummary[]; datasets: EntitySummary[]; benchmarks: EntitySummary[]; code: EntitySummary[] }; | |
| 38 | +function linked(d: EntityDetail | null): Linked { | |
| 39 | + const out: Linked = { models: [], datasets: [], benchmarks: [], code: [] }; | |
| 40 | + if (!d) return out; | |
| 41 | + const seen = new Set<string>(); | |
| 42 | + for (const g of d.relations ?? []) | |
| 43 | + for (const it of g.items) { | |
| 44 | + if (seen.has(it.id)) continue; | |
| 45 | + seen.add(it.id); | |
| 46 | + if (it.entity_type === 'model' || it.entity_type === 'artifact') out.models.push(it); | |
| 47 | + else if (it.entity_type === 'dataset') out.datasets.push(it); | |
| 48 | + else if (it.entity_type === 'benchmark') out.benchmarks.push(it); | |
| 49 | + else if (['repository', 'framework', 'library'].includes(it.entity_type)) out.code.push(it); | |
| 50 | + } | |
| 51 | + return out; | |
| 52 | +} | |
| 53 | + | |
| 54 | +export default async function PapersPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 14 | 55 | const sp = await searchParams; |
| 15 | − return ( | |
| 16 | − <TypedListing | |
| 17 | − title="Papers" | |
| 18 | − eyebrow="Research" | |
| 19 | − lede="Publications linked to models, labs and benchmarks. Authors, venues and abstracts come from arXiv and publisher pages." | |
| 20 | − basePath="/papers" | |
| 21 | − searchParams={sp} | |
| 22 | − fetch={(q) => api.papers(q)} | |
| 23 | − sorts={[ | |
| 24 | − { value: 'published', label: 'Recently published' }, | |
| 25 | − { value: 'updated', label: 'Recently updated' }, | |
| 26 | − ]} | |
| 27 | − filters={[ | |
| 28 | − { kind: 'text', name: 'category', label: 'arXiv category', value: sp.category, placeholder: 'cs.CL, cs.LG…' }, | |
| 29 | − { kind: 'text', name: 'since', label: 'Published since', value: sp.since, placeholder: 'YYYY-MM-DD' }, | |
| 30 | − ...(sp.org ? [{ kind: 'hidden' as const, name: 'org', value: sp.org }] : []), | |
| 56 | + const current: Record<string, string | undefined> = {}; | |
| 57 | + for (const k of KEYS) if (sp[k]) current[k] = sp[k]; | |
| 58 | + const offset = Math.max(0, Number(current.offset) || 0); | |
| 59 | + const sort = current.sort ?? 'published'; | |
| 60 | + const [page, orgs] = await Promise.all([safe(api.papers({ ...current, sort, limit: LIMIT, offset })), safe(api.companies({ limit: 40, sort: 'models' }))]); | |
| 61 | + // "Introduces" needs each paper's relations: fetch the page's details in parallel (local API, ISR-cached). | |
| 62 | + const details = page ? await Promise.all(page.items.map((p) => safe(api.entity(p.slug)))) : []; | |
| 63 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/papers', current, patch); | |
| 64 | + const cats = new Map<string, number>(); | |
| 65 | + for (const p of page?.items ?? []) for (const c of list(p.attributes?.categories)) cats.set(c, (cats.get(c) ?? 0) + 1); | |
| 66 | + const activeCount = Object.keys(current).filter((k) => !['sort', 'offset'].includes(k)).length; | |
| 67 | + | |
| 68 | + const filters = ( | |
| 69 | + <RailFilters | |
| 70 | + action="/papers" | |
| 71 | + resetHref={routes.papers()} | |
| 72 | + testId="papers" | |
| 73 | + fields={[ | |
| 74 | + { kind: 'text', name: 'q', label: 'Title contains', value: current.q, placeholder: 'e.g. mixture of experts' }, | |
| 75 | + { kind: 'text', name: 'category', label: 'arXiv category', value: current.category, placeholder: 'cs.CL, cs.LG…', list: [...cats.keys()].sort() }, | |
| 76 | + { kind: 'select', name: 'org', label: 'Organization', value: current.org, remote: 'companies', options: (orgs?.items ?? []).filter((o) => o.slug === current.org).map((o) => ({ value: o.slug, label: o.name })), note: 'Stated publisher only — arXiv metadata carries no affiliation.' }, | |
| 77 | + { kind: 'row', fields: [{ kind: 'date', name: 'since', label: 'Since', value: current.since }, { kind: 'date', name: 'until', label: 'Until', value: current.until }] }, | |
| 78 | + { kind: 'select', name: 'sort', label: 'Sort', value: sort, any: SORTS[0]!.label, options: SORTS.slice(1) }, | |
| 31 | 79 | ]} |
| 32 | − columns={[ | |
| 33 | − { | |
| 34 | − key: 'title', | |
| 35 | − label: 'Title', | |
| 36 | − primary: true, | |
| 37 | − render: (e) => ( | |
| 80 | + /> | |
| 81 | + ); | |
| 82 | + const inspector = ( | |
| 83 | + <div className="space-y-4 text-sm"> | |
| 84 | + <div> | |
| 85 | + <p className="eyebrow mb-1.5">Categories on this page</p> | |
| 86 | + {cats.size === 0 ? ( | |
| 87 | + <p className="text-xs text-ink-3">—</p> | |
| 88 | + ) : ( | |
| 89 | + <ul className="flex flex-wrap gap-1"> | |
| 90 | + {[...cats.entries()] | |
| 91 | + .sort((a, b) => b[1] - a[1]) | |
| 92 | + .slice(0, 16) | |
| 93 | + .map(([c, n]) => ( | |
| 94 | + <li key={c}> | |
| 95 | + <Link href={href({ category: c, offset: undefined })} className="inline-flex h-7 items-center gap-1 border border-rule px-1.5 text-xs text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 96 | + <span className="mono">{c}</span> <span className="tnum text-ink-3">{n}</span> | |
| 97 | + </Link> | |
| 98 | + </li> | |
| 99 | + ))} | |
| 100 | + </ul> | |
| 101 | + )} | |
| 102 | + </div> | |
| 103 | + <p className="text-xs text-ink-3"> | |
| 104 | + “Introduces” lists the models whose cards cite the paper (<span className="mono">described_by</span>). Author names link to researcher pages when a record exists. <Link href={`/graph?mode=research`} className="link">Research graph →</Link> | |
| 105 | + </p> | |
| 106 | + </div> | |
| 107 | + ); | |
| 108 | + | |
| 109 | + return ( | |
| 110 | + <> | |
| 111 | + <Container wide> | |
| 112 | + <BreadcrumbLd items={[{ name: SITE_NAME, href: '/' }, { name: 'Research', href: '/papers' }]} /> | |
| 113 | + <PageHeader eyebrow="Research" title="Papers" lede="Publications linked to models, labs and benchmarks. Authors, venues and abstracts come from arXiv and publisher pages; model links come from model cards citing the paper." aside={page ? <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} papers</p> : undefined} className="pb-3"> | |
| 114 | + <ActiveFilters current={current} labels={{ q: 'title', category: 'category', org: 'organization', since: 'since', until: 'until' }} makeHref={(p) => href(p)} className="mt-3" /> | |
| 115 | + </PageHeader> | |
| 116 | + </Container> | |
| 117 | + <div className="pb-16"> | |
| 118 | + <TerminalLayout filters={filters} inspector={inspector} filtersTitle="Filters" inspectorTitle="Context" storageKey="aia-papers-inspector" filterCount={activeCount}> | |
| 119 | + {!page ? ( | |
| 120 | + <Unavailable what="Papers" /> | |
| 121 | + ) : page.items.length === 0 ? ( | |
| 122 | + <EmptyState title="No papers match">Try another title, category or organization.</EmptyState> | |
| 123 | + ) : ( | |
| 38 | 124 | <> |
| 39 | − <EntityLink e={e} /> | |
| 40 | − {str(e.attributes?.arxiv_id) && <span className="mono ml-2 text-[11px] text-ink-3">arXiv:{str(e.attributes.arxiv_id)}</span>} | |
| 125 | + <DataTable caption="Papers" compact scroll> | |
| 126 | + <thead> | |
| 127 | + <tr> | |
| 128 | + <Th>Title</Th> | |
| 129 | + <Th>Authors</Th> | |
| 130 | + <Th>Organization</Th> | |
| 131 | + <Th>Published</Th> | |
| 132 | + <Th> | |
| 133 | + Introduces <Hint text="Models (and artifacts) whose model card or documentation cites this paper — inbound described_by relations." /> | |
| 134 | + </Th> | |
| 135 | + <Th>Datasets</Th> | |
| 136 | + <Th>Benchmarks</Th> | |
| 137 | + <Th>Code</Th> | |
| 138 | + </tr> | |
| 139 | + </thead> | |
| 140 | + <tbody> | |
| 141 | + {page.items.length === 0 && <EmptyRow cols={8}>No rows.</EmptyRow>} | |
| 142 | + {page.items.map((e, i) => { | |
| 143 | + const a = e.attributes ?? {}; | |
| 144 | + const authors = list(a.authors); | |
| 145 | + const l = linked(details[i] ?? null); | |
| 146 | + const code = str(a.code_url); | |
| 147 | + const primary = str(a.primary_category); | |
| 148 | + return ( | |
| 149 | + <tr key={e.id}> | |
| 150 | + <Td primary> | |
| 151 | + <EntityLink e={e} /> | |
| 152 | + <span className="mt-0.5 flex flex-wrap items-center gap-1"> | |
| 153 | + {str(a.arxiv_id) && <span className="mono text-[11px] text-ink-3">arXiv:{str(a.arxiv_id)}</span>} | |
| 154 | + {primary && ( | |
| 155 | + <Link href={href({ category: primary, offset: undefined })}> | |
| 156 | + <Chip tone="accent" className="mono">{primary}</Chip> | |
| 157 | + </Link> | |
| 158 | + )} | |
| 159 | + </span> | |
| 160 | + </Td> | |
| 161 | + <Td label="Authors" className="max-w-[18rem] text-sm text-ink-2"> | |
| 162 | + {authors.length ? ( | |
| 163 | + <> | |
| 164 | + {authors.slice(0, 3).join(', ')} | |
| 165 | + {authors.length > 3 && <span className="tnum text-ink-3"> +{authors.length - 3}</span>} | |
| 166 | + </> | |
| 167 | + ) : ( | |
| 168 | + <span className="text-ink-3">—</span> | |
| 169 | + )} | |
| 170 | + </Td> | |
| 171 | + <Td label="Organization" className="text-ink-2"> | |
| 172 | + {e.organization ? ( | |
| 173 | + <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent"> | |
| 174 | + {e.organization.name} | |
| 175 | + </Link> | |
| 176 | + ) : str(a.venue) ? ( | |
| 177 | + <span className="text-xs">{str(a.venue)}</span> | |
| 178 | + ) : ( | |
| 179 | + <span className="text-ink-3">—</span> | |
| 180 | + )} | |
| 181 | + </Td> | |
| 182 | + <Td label="Published" className="tnum whitespace-nowrap text-ink-2">{str(a.published_at) ? fmtDate(str(a.published_at)) : <span className="text-ink-3">—</span>}</Td> | |
| 183 | + <Td label="Introduces"> | |
| 184 | + {l.models.length ? ( | |
| 185 | + <span className="flex flex-wrap gap-x-2 gap-y-0.5 text-sm"> | |
| 186 | + {l.models.slice(0, 3).map((m) => ( | |
| 187 | + <span key={m.id} className="inline-flex items-center gap-1"> | |
| 188 | + <EntityBadge type={m.entity_type} small /> | |
| 189 | + <EntityLink e={m} /> | |
| 190 | + </span> | |
| 191 | + ))} | |
| 192 | + {l.models.length > 3 && <span className="tnum text-xs text-ink-3">+{l.models.length - 3}</span>} | |
| 193 | + </span> | |
| 194 | + ) : details[i] === null ? ( | |
| 195 | + <span className="text-ink-3">unavailable</span> | |
| 196 | + ) : ( | |
| 197 | + <span className="text-ink-3">—</span> | |
| 198 | + )} | |
| 199 | + </Td> | |
| 200 | + <Td label="Datasets" className="text-sm">{l.datasets.length ? l.datasets.slice(0, 2).map((x) => <EntityLink key={x.id} e={x} className="mr-2" />) : <span className="text-ink-3">—</span>}</Td> | |
| 201 | + <Td label="Benchmarks" className="text-sm">{l.benchmarks.length ? l.benchmarks.slice(0, 2).map((x) => <EntityLink key={x.id} e={x} className="mr-2" />) : <span className="text-ink-3">—</span>}</Td> | |
| 202 | + <Td label="Code" className="text-sm"> | |
| 203 | + {l.code.length ? ( | |
| 204 | + l.code.slice(0, 2).map((x) => <EntityLink key={x.id} e={x} className="mr-2" />) | |
| 205 | + ) : code ? ( | |
| 206 | + <a href={code} target="_blank" rel="noopener noreferrer" className="link text-xs"> | |
| 207 | + {code.replace(/^https?:\/\/(www\.)?/, '').slice(0, 32)} | |
| 208 | + </a> | |
| 209 | + ) : ( | |
| 210 | + <span className="text-ink-3">—</span> | |
| 211 | + )} | |
| 212 | + </Td> | |
| 213 | + </tr> | |
| 214 | + ); | |
| 215 | + })} | |
| 216 | + </tbody> | |
| 217 | + </DataTable> | |
| 218 | + <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" /> | |
| 219 | + <Note className="mt-3">Author lists and categories are copied from the paper's own metadata (arXiv, publisher). Linked models, datasets, benchmarks and code come from stated relations only; a dash means no source stated one.</Note> | |
| 41 | 220 | </> |
| 42 | − ), | |
| 43 | − }, | |
| 44 | − { | |
| 45 | − key: 'authors', | |
| 46 | − label: 'Authors', | |
| 47 | − className: 'max-w-[22rem] text-ink-2', | |
| 48 | − render: (e) => { | |
| 49 | − const a = list(e.attributes?.authors); | |
| 50 | − if (!a.length) return <Dash />; | |
| 51 | − return ( | |
| 52 | − <span className="text-sm"> | |
| 53 | − {a.slice(0, 3).join(', ')} | |
| 54 | − {a.length > 3 && <span className="tnum text-ink-3"> +{a.length - 3}</span>} | |
| 55 | − </span> | |
| 56 | − ); | |
| 57 | − }, | |
| 58 | − }, | |
| 59 | − { key: 'published', label: 'Published', className: 'tnum text-ink-2 whitespace-nowrap', render: (e) => (str(e.attributes?.published_at) ? fmtDate(str(e.attributes.published_at)) : <Dash />) }, | |
| 60 | − { | |
| 61 | − key: 'categories', | |
| 62 | − label: 'Categories', | |
| 63 | − render: (e) => { | |
| 64 | − const cats = list(e.attributes?.categories); | |
| 65 | − const primary = str(e.attributes?.primary_category); | |
| 66 | − const all = [...new Set([...(primary ? [primary] : []), ...cats])].slice(0, 4); | |
| 67 | − if (!all.length) return <Dash />; | |
| 68 | − return ( | |
| 69 | − <span className="flex flex-wrap gap-1"> | |
| 70 | − {all.map((c) => ( | |
| 71 | − <Link key={c} href={`/papers?category=${encodeURIComponent(c)}`} className="hover:opacity-80"> | |
| 72 | − <Chip tone={c === primary ? 'accent' : 'neutral'} className="mono">{c}</Chip> | |
| 73 | − </Link> | |
| 74 | − ))} | |
| 75 | − </span> | |
| 76 | − ); | |
| 77 | − }, | |
| 78 | − }, | |
| 79 | − { | |
| 80 | − key: 'org', | |
| 81 | − label: 'Organization / venue', | |
| 82 | − className: 'text-ink-2', | |
| 83 | − render: (e) => { | |
| 84 | − const venue = str(e.attributes?.venue); | |
| 85 | − if (!e.organization && !venue) return <Dash />; | |
| 86 | − return ( | |
| 87 | − <> | |
| 88 | − {e.organization && ( | |
| 89 | − <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent"> | |
| 90 | − {e.organization.name} | |
| 91 | − </Link> | |
| 92 | − )} | |
| 93 | − {venue && <span className="block text-xs text-ink-3">{venue}</span>} | |
| 94 | − </> | |
| 95 | − ); | |
| 96 | − }, | |
| 97 | − }, | |
| 98 | − { key: 'quality', label: 'Quality', num: true, render: (e) => <QualityMark q={e.quality?.score} /> }, | |
| 99 | − ]} | |
| 100 | − note={ | |
| 101 | − <> | |
| 102 | − Author lists and categories are copied from the paper's own metadata (arXiv, publisher). Each paper page shows the abstract, related models and every source snapshot. | |
| 103 | − </> | |
| 104 | − } | |
| 105 | − /> | |
| 221 | + )} | |
| 222 | + </TerminalLayout> | |
| 223 | + </div> | |
| 224 | + </> | |
| 106 | 225 | ); |
| 107 | 226 | } |
added
apps/web/src/app/search/compiled.ts
+144 −0
@@ -0,0 +1,144 @@ | ||
| 1 | +import type { CompiledFilter, CompiledQuery2 } from '@/lib/types'; | |
| 2 | + | |
| 3 | +/* | |
| 4 | + Search compiler v2 helpers (server-safe, pure). Two ways to "drop a chip": | |
| 5 | + - re-query: remove the chip's `source_span` from the text and search again (works for every filter); | |
| 6 | + - structured: map the remaining compiled filters to `/models?…` parameters when the compiled type is `model`. | |
| 7 | + Filters the listing API cannot express are reported, never silently dropped. | |
| 8 | +*/ | |
| 9 | + | |
| 10 | +export function withoutSpan(text: string, span: string | null | undefined): string { | |
| 11 | + if (!span) return text; | |
| 12 | + const i = text.toLowerCase().indexOf(span.toLowerCase()); | |
| 13 | + if (i < 0) return text; | |
| 14 | + return `${text.slice(0, i)} ${text.slice(i + span.length)}`.replace(/\s{2,}/g, ' ').trim(); | |
| 15 | +} | |
| 16 | + | |
| 17 | +export type Mapped = { params: URLSearchParams; unmapped: { filter: CompiledFilter; why: string }[]; links: { label: string; href: string }[] }; | |
| 18 | + | |
| 19 | +const SORT_MAP: Record<string, { sort: string; order?: string }> = { newest: { sort: 'release' }, largest: { sort: 'params' }, smallest: { sort: 'params', order: 'asc' }, cheapest: { sort: 'cheapest' }, best: { sort: 'quality' } }; | |
| 20 | + | |
| 21 | +/** Map compiled filters to `/models` query parameters (docs/API.md `/models`). */ | |
| 22 | +export function toModelsParams(compiled: CompiledFilter[], residual?: string | null): Mapped { | |
| 23 | + const p = new URLSearchParams(); | |
| 24 | + const unmapped: Mapped['unmapped'] = []; | |
| 25 | + const links: Mapped['links'] = []; | |
| 26 | + const slugOf = (v: unknown) => (v && typeof v === 'object' && 'slug' in (v as Record<string, unknown>) ? String((v as { slug: unknown }).slug) : typeof v === 'string' ? v : null); | |
| 27 | + for (const f of compiled) { | |
| 28 | + const v = f.value; | |
| 29 | + switch (f.filter) { | |
| 30 | + case 'entity_type': | |
| 31 | + break; | |
| 32 | + case 'params_min': | |
| 33 | + p.set('min_params', String(v)); | |
| 34 | + break; | |
| 35 | + case 'params_max': | |
| 36 | + p.set('max_params', String(v)); | |
| 37 | + break; | |
| 38 | + case 'params_range': | |
| 39 | + if (Array.isArray(v) && v.length === 2) { | |
| 40 | + p.set('min_params', String(v[0])); | |
| 41 | + p.set('max_params', String(v[1])); | |
| 42 | + } | |
| 43 | + break; | |
| 44 | + case 'context_min': | |
| 45 | + p.set('min_context', String(v)); | |
| 46 | + break; | |
| 47 | + case 'year': | |
| 48 | + p.set('year_from', String(v)); | |
| 49 | + p.set('year_to', String(v)); | |
| 50 | + break; | |
| 51 | + case 'year_from': | |
| 52 | + p.set('year_from', String(v)); | |
| 53 | + break; | |
| 54 | + case 'year_to': | |
| 55 | + p.set('year_to', String(v)); | |
| 56 | + break; | |
| 57 | + case 'license': | |
| 58 | + p.set('license', String(v)); | |
| 59 | + break; | |
| 60 | + case 'modality': | |
| 61 | + p.set('modality', String(v)); | |
| 62 | + break; | |
| 63 | + case 'openness': | |
| 64 | + p.set('openness', String(v)); | |
| 65 | + break; | |
| 66 | + case 'organization': { | |
| 67 | + const s = slugOf(v); | |
| 68 | + if (s) p.set('org', s); | |
| 69 | + else unmapped.push({ filter: f, why: 'organization not resolved to a slug' }); | |
| 70 | + break; | |
| 71 | + } | |
| 72 | + case 'family': { | |
| 73 | + const s = slugOf(v); | |
| 74 | + if (s) p.set('family', s); | |
| 75 | + break; | |
| 76 | + } | |
| 77 | + case 'reasoning': | |
| 78 | + p.set('reasoning', v ? '1' : '0'); | |
| 79 | + break; | |
| 80 | + case 'status': | |
| 81 | + p.set('status', String(v)); | |
| 82 | + break; | |
| 83 | + case 'sort': { | |
| 84 | + const m = SORT_MAP[String(v)]; | |
| 85 | + if (m) { | |
| 86 | + p.set('sort', m.sort); | |
| 87 | + if (m.order) p.set('order', m.order); | |
| 88 | + } else unmapped.push({ filter: f, why: `sort “${String(v)}” has no /models equivalent` }); | |
| 89 | + break; | |
| 90 | + } | |
| 91 | + case 'benchmark': { | |
| 92 | + const s = slugOf(v); | |
| 93 | + if (s) links.push({ label: `${f.label} → leaderboard`, href: `/benchmarks/${encodeURIComponent(s)}` }); | |
| 94 | + break; | |
| 95 | + } | |
| 96 | + case 'provider': { | |
| 97 | + const s = slugOf(v); | |
| 98 | + if (s) links.push({ label: `${f.label} → provider page`, href: `/providers/${encodeURIComponent(s)}` }); | |
| 99 | + break; | |
| 100 | + } | |
| 101 | + case 'max_input_price': | |
| 102 | + case 'max_output_price': | |
| 103 | + unmapped.push({ filter: f, why: '/models has no price filter — use Prices sorted cheapest' }); | |
| 104 | + links.push({ label: 'Prices, cheapest first', href: `/prices?sort=${f.filter === 'max_input_price' ? 'input' : 'output'}` }); | |
| 105 | + break; | |
| 106 | + case 'memory_gb': | |
| 107 | + unmapped.push({ filter: f, why: 'an ESTIMATED parameter bound — use Run locally for a real fit' }); | |
| 108 | + links.push({ label: `Run locally with ${String(v)} GB`, href: `/run-locally?memory_gb=${encodeURIComponent(String(v))}` }); | |
| 109 | + break; | |
| 110 | + case 'days_back': | |
| 111 | + unmapped.push({ filter: f, why: '/models filters by release year, not by a rolling window' }); | |
| 112 | + break; | |
| 113 | + case 'commercial_use': | |
| 114 | + unmapped.push({ filter: f, why: 'licence permissions are not a /models filter — see Licenses' }); | |
| 115 | + links.push({ label: 'Licenses (commercial use)', href: '/licenses' }); | |
| 116 | + break; | |
| 117 | + default: | |
| 118 | + unmapped.push({ filter: f, why: 'no /models equivalent' }); | |
| 119 | + } | |
| 120 | + } | |
| 121 | + if (residual && residual.trim()) p.set('q', residual.trim()); | |
| 122 | + return { params: p, unmapped, links }; | |
| 123 | +} | |
| 124 | + | |
| 125 | +/** Which model columns the query implies (always Model · Org; the rest depends on the compiled filters). */ | |
| 126 | +export function impliedColumns(compiled: CompiledFilter[], sort?: string | null): Set<string> { | |
| 127 | + const cols = new Set<string>(['params', 'context', 'openness', 'released']); | |
| 128 | + for (const f of compiled) { | |
| 129 | + if (/params|memory_gb/.test(f.filter)) cols.add('params'); | |
| 130 | + if (/context/.test(f.filter)) cols.add('context'); | |
| 131 | + if (/price/.test(f.filter)) cols.add('price'); | |
| 132 | + if (/openness|license|commercial/.test(f.filter)) cols.add('openness'); | |
| 133 | + if (/year|days_back/.test(f.filter)) cols.add('released'); | |
| 134 | + if (f.filter === 'reasoning') cols.add('reasoning'); | |
| 135 | + if (f.filter === 'modality') cols.add('modalities'); | |
| 136 | + } | |
| 137 | + if (sort === 'cheapest') cols.add('price'); | |
| 138 | + return cols; | |
| 139 | +} | |
| 140 | + | |
| 141 | +export function compiledType(q: CompiledQuery2 | undefined): string | null { | |
| 142 | + const t = (q?.entity_type ?? q?.type) as string | null | undefined; | |
| 143 | + return t ?? null; | |
| 144 | +} | |
modified
apps/web/src/app/search/page.tsx
+228 −75
@@ -1,50 +1,25 @@ | ||
| 1 | −import { Search } from 'lucide-react'; | |
| 1 | +import { Search, X } from 'lucide-react'; | |
| 2 | 2 | import type { Metadata } from 'next'; |
| 3 | 3 | import Link from 'next/link'; |
| 4 | −import { EntityBadge } from '@/components/ui/badges'; | |
| 5 | −import { EntityRow } from '@/components/ui/entity'; | |
| 4 | +import { Chip, EntityBadge, OpennessBadge } from '@/components/ui/badges'; | |
| 5 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 6 | +import { cheapestPrice, EntityLink, EntityRow, QualityMark } from '@/components/ui/entity'; | |
| 7 | +import { Hint } from '@/components/ui/hint'; | |
| 6 | 8 | import { Pagination, withParams } from '@/components/ui/pagination'; |
| 7 | −import { Container, PageHeader } from '@/components/ui/section'; | |
| 9 | +import { Container, Note, PageHeader } from '@/components/ui/section'; | |
| 8 | 10 | import { EmptyState, Unavailable } from '@/components/ui/unavailable'; |
| 9 | −import { api, safe } from '@/lib/api'; | |
| 10 | −import { fmtInt, fmtParams, fmtTokens, num } from '@/lib/format'; | |
| 11 | −import { EXAMPLE_QUERIES, exploreNav, propertyLabel, routes, typeLabel } from '@/lib/site'; | |
| 12 | −import type { CompiledQuery } from '@/lib/types'; | |
| 11 | +import { apiD3, safe } from '@/lib/api'; | |
| 12 | +import { fmtDate, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format'; | |
| 13 | +import { EXAMPLE_QUERIES, exploreNav, PALETTE_PREFIXES, routes, typeLabel } from '@/lib/site'; | |
| 14 | +import type { EntitySummary } from '@/lib/types'; | |
| 15 | +import { compiledType, impliedColumns, toModelsParams, withoutSpan } from './compiled'; | |
| 13 | 16 | |
| 14 | 17 | type SP = { q?: string; type?: string; offset?: string }; |
| 15 | 18 | const LIMIT = 30; |
| 16 | 19 | |
| 17 | 20 | export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { |
| 18 | 21 | const { q } = await searchParams; |
| 19 | − return { title: q ? `“${q}” — search` : 'Search', robots: { index: false, follow: true }, alternates: { canonical: '/search' } }; | |
| 20 | −} | |
| 21 | − | |
| 22 | −/** Human rendering of compile_query output: "models · open weights · released ≥ 2026 · > 100B params". */ | |
| 23 | −function understood(c: CompiledQuery | undefined): string[] { | |
| 24 | − if (!c) return []; | |
| 25 | − const parts: string[] = []; | |
| 26 | − const type = (c.entity_type ?? c.type) as string | null | undefined; | |
| 27 | − if (type) parts.push(typeLabel(type, true).toLowerCase()); | |
| 28 | − const f = (c.filters ?? {}) as Record<string, unknown>; | |
| 29 | − const flat: Record<string, unknown> = { ...f }; | |
| 30 | − const SKIP = new Set(['text', 'q', 'entity_type', 'type', 'filters', 'embedding', 'vector', 'limit', 'offset', 'terms', 'tokens', 'semantic', 'residual', 'sort', 'order']); | |
| 31 | − for (const [k, v] of Object.entries(c)) if (!SKIP.has(k) && v !== null && v !== undefined && v !== '' && typeof v !== 'object') flat[k] = v; | |
| 32 | − for (const [k, v] of Object.entries(flat)) { | |
| 33 | − if (SKIP.has(k) || v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length)) continue; | |
| 34 | − const key = k.replace(/^(min|max)_/, '').replace(/_(min|max)$/, ''); | |
| 35 | − const isMin = k.startsWith('min_') || k.endsWith('_min') || k.endsWith('_from') || k === 'since'; | |
| 36 | − const isMax = k.startsWith('max_') || k.endsWith('_max') || k.endsWith('_to') || k === 'until'; | |
| 37 | − const sign = isMin ? '≥ ' : isMax ? '≤ ' : ''; | |
| 38 | − let val: string; | |
| 39 | − if (/params|parameter/.test(key) && num(v) !== null) val = fmtParams(v); | |
| 40 | − else if (/context|tokens/.test(key) && num(v) !== null) val = fmtTokens(v); | |
| 41 | − else if (Array.isArray(v)) val = v.map(String).join(', '); | |
| 42 | − else if (typeof v === 'boolean') val = v ? 'yes' : 'no'; | |
| 43 | − else val = String(v); | |
| 44 | − const label = /^(year|release_year)/.test(key) ? 'released' : propertyLabel(key.replace(/_from$|_to$/, '')).toLowerCase(); | |
| 45 | − parts.push(`${label} ${sign}${val}`.trim()); | |
| 46 | − } | |
| 47 | − return parts; | |
| 22 | + return { title: q ? `“${q}” — search` : 'Search', description: 'Plain-English search over the AI Atlas graph: the compiler turns your words into filters (organization, parameters, context, openness, licence, dates, price) and shows them back as removable chips.', robots: { index: false, follow: true }, alternates: { canonical: '/search' } }; | |
| 48 | 23 | } |
| 49 | 24 | |
| 50 | 25 | export default async function SearchPage({ searchParams }: { searchParams: Promise<SP> }) { |
@@ -52,26 +27,119 @@ export default async function SearchPage({ searchParams }: { searchParams: Promi | ||
| 52 | 27 | const q = (sp.q ?? '').trim(); |
| 53 | 28 | const type = sp.type ?? ''; |
| 54 | 29 | const offset = Math.max(0, Number(sp.offset) || 0); |
| 55 | − const res = q ? await safe(api.search(q, { type: type || undefined, limit: LIMIT, offset })) : null; | |
| 56 | − const chips = understood(res?.query); | |
| 30 | + const res = q ? await safe(apiD3.search(q, { type: type || undefined, limit: LIMIT, offset })) : null; | |
| 31 | + const query = res?.query; | |
| 32 | + const compiled = query?.compiled ?? []; | |
| 33 | + const cType = compiledType(query); | |
| 34 | + const isModelTable = (type || cType) === 'model' && compiled.length > 0; | |
| 35 | + const mapped = isModelTable ? toModelsParams(compiled, query?.residual) : null; | |
| 36 | + const cols = impliedColumns(compiled, query?.sort); | |
| 57 | 37 | const current = { q, type }; |
| 38 | + const unrec = query?.unrecognised ?? []; | |
| 39 | + const exploreHref = mapped ? `/explore?type=model&${mapped.params.toString()}` : `/explore${cType ? `?type=${encodeURIComponent(cType)}` : ''}`; | |
| 58 | 40 | |
| 59 | 41 | return ( |
| 60 | − <Container> | |
| 61 | − <PageHeader eyebrow="Search" title={q ? <>Results for <span className="text-ink-2">“{q}”</span></> : 'Search the atlas'} lede={!q ? 'Names, ids, providers, benchmarks — or a plain-English question. Filters are compiled from your words and shown back to you.' : undefined}> | |
| 62 | − <form action="/search" method="get" role="search" className="mt-5 flex max-w-2xl items-stretch border border-rule-strong bg-surface focus-within:border-accent"> | |
| 63 | − <span className="flex items-center pl-3 text-ink-3"><Search className="size-5" aria-hidden /></span> | |
| 64 | − <input name="q" type="search" defaultValue={q} placeholder="Search models, companies, papers, benchmarks…" className="h-12 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" autoComplete="off" aria-label="Search query" /> | |
| 42 | + <Container wide> | |
| 43 | + <PageHeader eyebrow="Search 3.0" title={q ? <>Results for <span className="text-ink-2">“{q}”</span></> : 'Search the atlas'} lede={!q ? 'Names, ids, providers, benchmarks — or a plain-English question. The compiler turns your words into filters and shows them back as chips you can remove.' : undefined}> | |
| 44 | + <form action="/search" method="get" role="search" className="mt-5 flex max-w-3xl items-stretch border border-rule-strong bg-surface focus-within:border-accent"> | |
| 45 | + <span className="flex items-center pl-3 text-ink-3"> | |
| 46 | + <Search className="size-5" aria-hidden /> | |
| 47 | + </span> | |
| 48 | + <input name="q" type="search" defaultValue={q} placeholder="open reasoning models over 30B released in 2026…" className="h-12 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" autoComplete="off" aria-label="Search query" /> | |
| 65 | 49 | {type && <input type="hidden" name="type" value={type} />} |
| 66 | − <button type="submit" className="bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90">Search</button> | |
| 50 | + <button type="submit" className="bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90"> | |
| 51 | + Search | |
| 52 | + </button> | |
| 67 | 53 | </form> |
| 68 | − {q && ( | |
| 69 | − <div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm"> | |
| 70 | − <p className="text-ink-3"> | |
| 71 | − Understood as: {chips.length ? <span className="text-ink-2">{chips.join(' · ')}</span> : <span className="text-ink-2">full-text “{q}”</span>} | |
| 72 | − </p> | |
| 73 | − </div> | |
| 54 | + | |
| 55 | + {q && res && ( | |
| 56 | + <section className="mt-4 border-y border-rule py-3" aria-label="Compiled query" data-compiled> | |
| 57 | + <div className="flex flex-wrap items-start gap-x-6 gap-y-2"> | |
| 58 | + <div className="min-w-0 flex-1"> | |
| 59 | + <p className="eyebrow mb-1.5 flex items-center gap-1"> | |
| 60 | + Compiled as | |
| 61 | + <Hint align="right" text="The search compiler (v2) recognises organizations, parameter and context bounds, openness, licences, dates, prices, modalities and sort hints. Each chip shows the words it came from; removing a chip re-runs the search without those words." /> | |
| 62 | + {query?.semantic && <span className="ml-2 normal-case tracking-normal text-ink-3">· semantic ranking on</span>} | |
| 63 | + </p> | |
| 64 | + {compiled.length === 0 ? ( | |
| 65 | + <p className="text-sm text-ink-2"> | |
| 66 | + No structured filter recognised — full-text search for “{q}”{cType ? ` among ${typeLabel(cType, true).toLowerCase()}` : ''}. | |
| 67 | + </p> | |
| 68 | + ) : ( | |
| 69 | + <ul className="flex flex-wrap gap-1.5" data-compiled-chips> | |
| 70 | + {compiled.map((f, i) => { | |
| 71 | + const nextText = withoutSpan(q, f.source_span); | |
| 72 | + const removable = !!f.source_span && nextText !== q; | |
| 73 | + return ( | |
| 74 | + <li key={`${f.filter}-${i}`} className="inline-flex h-8 items-stretch border border-rule bg-surface text-xs" data-compiled-chip={f.filter}> | |
| 75 | + <span className="flex items-center gap-1.5 px-2 text-ink" title={f.source_span ? `from “${f.source_span}”` : undefined}> | |
| 76 | + <span className="mono text-[10px] uppercase text-ink-3">{f.filter}</span> | |
| 77 | + {f.label} | |
| 78 | + </span> | |
| 79 | + {removable ? ( | |
| 80 | + <Link href={nextText ? withParams('/search', current, { q: nextText, offset: undefined }) : routes.search('')} className="flex items-center border-l border-rule px-1.5 text-ink-3 hover:bg-surface-2 hover:text-danger" aria-label={`Remove ${f.label}`} title="Remove this filter (re-runs the search without its words)"> | |
| 81 | + <X className="size-3" aria-hidden /> | |
| 82 | + </Link> | |
| 83 | + ) : ( | |
| 84 | + <span className="flex items-center border-l border-rule px-1.5 text-ink-3" title="Implicit — not tied to words in your query"> | |
| 85 | + · | |
| 86 | + </span> | |
| 87 | + )} | |
| 88 | + </li> | |
| 89 | + ); | |
| 90 | + })} | |
| 91 | + </ul> | |
| 92 | + )} | |
| 93 | + <p className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-3"> | |
| 94 | + {query?.sort && ( | |
| 95 | + <span> | |
| 96 | + Sort: <span className="text-ink-2">{query.sort}</span> | |
| 97 | + </span> | |
| 98 | + )} | |
| 99 | + {query?.residual?.trim() && ( | |
| 100 | + <span> | |
| 101 | + Free text: <span className="text-ink-2">“{query.residual.trim()}”</span> | |
| 102 | + </span> | |
| 103 | + )} | |
| 104 | + {unrec.length > 0 && ( | |
| 105 | + <span data-unrecognised> | |
| 106 | + Not understood: <span className="text-warning">{unrec.join(' · ')}</span> | |
| 107 | + <Hint align="right" text="Words the compiler could not turn into a filter (an organization name it does not know, a benchmark alias…) stay as free text and are matched against names and descriptions." /> | |
| 108 | + </span> | |
| 109 | + )} | |
| 110 | + {query?.note && <span className="text-warning">{query.note}</span>} | |
| 111 | + </p> | |
| 112 | + </div> | |
| 113 | + <div className="flex flex-wrap items-center gap-2 text-xs"> | |
| 114 | + {mapped && ( | |
| 115 | + <Link href={`/models?${mapped.params.toString()}`} className="inline-flex h-9 items-center border border-rule px-2.5 text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 116 | + Open in Models → | |
| 117 | + </Link> | |
| 118 | + )} | |
| 119 | + <Link href={exploreHref} className="inline-flex h-9 items-center border border-rule px-2.5 text-ink-2 hover:border-rule-strong hover:text-ink" data-open-builder> | |
| 120 | + Open in Explore builder → | |
| 121 | + </Link> | |
| 122 | + {mapped?.links.map((l) => ( | |
| 123 | + <Link key={l.href} href={l.href} className="link"> | |
| 124 | + {l.label} | |
| 125 | + </Link> | |
| 126 | + ))} | |
| 127 | + </div> | |
| 128 | + </div> | |
| 129 | + {mapped && mapped.unmapped.length > 0 && ( | |
| 130 | + <Note className="mt-2"> | |
| 131 | + Not expressible in /models: {mapped.unmapped.map((u, i) => ( | |
| 132 | + <span key={i}> | |
| 133 | + {i > 0 && ' · '} | |
| 134 | + <span className="text-ink-2">{u.filter.label}</span> ({u.why}) | |
| 135 | + </span> | |
| 136 | + ))} | |
| 137 | + . | |
| 138 | + </Note> | |
| 139 | + )} | |
| 140 | + </section> | |
| 74 | 141 | )} |
| 142 | + | |
| 75 | 143 | {q && ( |
| 76 | 144 | <ul className="no-scrollbar -mx-4 mt-4 flex gap-1 overflow-x-auto px-4 md:mx-0 md:px-0" aria-label="Filter by type"> |
| 77 | 145 | {[{ label: 'All', type: '' }, ...exploreNav.map((n) => ({ label: n.label.replace(' & Pricing', ''), type: n.type }))].map((t) => { |
@@ -89,40 +157,125 @@ export default async function SearchPage({ searchParams }: { searchParams: Promi | ||
| 89 | 157 | </PageHeader> |
| 90 | 158 | |
| 91 | 159 | {!q ? ( |
| 92 | − <div className="pb-16"> | |
| 93 | − <p className="eyebrow mb-2">Try</p> | |
| 94 | − <ul className="flex flex-wrap gap-2"> | |
| 95 | − {EXAMPLE_QUERIES.map((ex) => ( | |
| 96 | − <li key={ex}> | |
| 97 | − <Link href={routes.search(ex)} className="inline-block border border-rule px-2.5 py-1.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">{ex}</Link> | |
| 98 | − </li> | |
| 99 | − ))} | |
| 100 | − </ul> | |
| 160 | + <div className="grid gap-10 pb-16 md:grid-cols-2"> | |
| 161 | + <div> | |
| 162 | + <p className="eyebrow mb-2">Try</p> | |
| 163 | + <ul className="flex flex-wrap gap-2"> | |
| 164 | + {[...EXAMPLE_QUERIES, 'open reasoning models over 30B released in 2026', 'cheapest models under $1/M output with 1M context', 'papers by DeepSeek'].map((ex) => ( | |
| 165 | + <li key={ex}> | |
| 166 | + <Link href={routes.search(ex)} className="inline-block border border-rule px-2.5 py-1.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 167 | + {ex} | |
| 168 | + </Link> | |
| 169 | + </li> | |
| 170 | + ))} | |
| 171 | + </ul> | |
| 172 | + </div> | |
| 173 | + <div> | |
| 174 | + <p className="eyebrow mb-2">Prefixes (also in the ⌘K palette)</p> | |
| 175 | + <ul className="space-y-1 text-sm"> | |
| 176 | + {Object.entries(PALETTE_PREFIXES).map(([k, v]) => ( | |
| 177 | + <li key={k} className="flex items-center gap-3"> | |
| 178 | + <span className="mono w-8 text-ink">{k}:</span> | |
| 179 | + <Link href={withParams('/search', {}, { type: v.types[0] })} className="text-ink-2 hover:text-ink"> | |
| 180 | + {v.label} | |
| 181 | + </Link> | |
| 182 | + </li> | |
| 183 | + ))} | |
| 184 | + </ul> | |
| 185 | + <p className="mt-3 text-xs text-ink-3"> | |
| 186 | + Prefer a form? <Link href={routes.explore()} className="link">Open the Explore builder →</Link> | |
| 187 | + </p> | |
| 188 | + </div> | |
| 101 | 189 | </div> |
| 102 | 190 | ) : !res ? ( |
| 103 | − <Unavailable what="Search" className="mb-16" reason="The search service did not answer." /> | |
| 191 | + <Unavailable what="Search" className="mb-16" reason="The search service did not answer (rate limit or outage)." /> | |
| 104 | 192 | ) : res.items.length === 0 ? ( |
| 105 | 193 | <EmptyState title={`Nothing matched “${q}”`} className="mb-16"> |
| 106 | − Try fewer words, a provider's API id, or browse{' '} | |
| 107 | − <Link href={routes.explore()} className="link">by type</Link>. Suggestions:{' '} | |
| 108 | − {EXAMPLE_QUERIES.slice(0, 3).map((ex, i) => ( | |
| 109 | − <span key={ex}> | |
| 110 | − {i > 0 && ' · '} | |
| 111 | − <Link href={routes.search(ex)} className="link">{ex}</Link> | |
| 112 | − </span> | |
| 113 | − ))} | |
| 194 | + Try fewer words, a provider's API id, or <Link href={routes.explore()} className="link">the builder</Link>. | |
| 195 | + {compiled.length > 0 && ( | |
| 196 | + <> | |
| 197 | + {' '} | |
| 198 | + Remove a chip above to widen the search. | |
| 199 | + </> | |
| 200 | + )} | |
| 114 | 201 | </EmptyState> |
| 115 | 202 | ) : ( |
| 116 | 203 | <div className="pb-16"> |
| 117 | − <p className="tnum mb-2 text-xs text-ink-3">{fmtInt(res.total)} result{res.total === 1 ? '' : 's'}</p> | |
| 118 | − <ul className="border-t border-rule"> | |
| 119 | − {res.items.map((e) => ( | |
| 120 | − <EntityRow key={e.id} e={e} trailing={<EntityBadge type={e.entity_type} small className="md:hidden" />} /> | |
| 121 | − ))} | |
| 122 | − </ul> | |
| 204 | + <p className="tnum mb-2 text-xs text-ink-3"> | |
| 205 | + {fmtInt(res.total)} result{res.total === 1 ? '' : 's'} | |
| 206 | + {isModelTable ? ' · dense model table (columns implied by the query)' : ''} | |
| 207 | + </p> | |
| 208 | + {isModelTable ? <ModelResults items={res.items} cols={cols} /> : ( | |
| 209 | + <ul className="border-t border-rule"> | |
| 210 | + {res.items.map((e) => ( | |
| 211 | + <EntityRow key={e.id} e={e} trailing={<EntityBadge type={e.entity_type} small className="md:hidden" />} /> | |
| 212 | + ))} | |
| 213 | + </ul> | |
| 214 | + )} | |
| 123 | 215 | <Pagination total={res.total} limit={LIMIT} offset={offset} makeHref={(o) => withParams('/search', current, { offset: o || undefined })} className="mt-4" /> |
| 124 | 216 | </div> |
| 125 | 217 | )} |
| 126 | 218 | </Container> |
| 127 | 219 | ); |
| 128 | 220 | } |
| 221 | + | |
| 222 | +function ModelResults({ items, cols }: { items: (EntitySummary & { rank: number })[]; cols: Set<string> }) { | |
| 223 | + return ( | |
| 224 | + <DataTable caption="Models matching the compiled query" compact scroll> | |
| 225 | + <thead> | |
| 226 | + <tr> | |
| 227 | + <Th>Model</Th> | |
| 228 | + <Th>Organization</Th> | |
| 229 | + {cols.has('params') && <Th num>Params</Th>} | |
| 230 | + {cols.has('context') && <Th num>Context</Th>} | |
| 231 | + {cols.has('price') && <Th num>Cheapest input</Th>} | |
| 232 | + {cols.has('openness') && <Th>Openness · licence</Th>} | |
| 233 | + {cols.has('reasoning') && <Th>Reasoning</Th>} | |
| 234 | + {cols.has('modalities') && <Th>Modalities</Th>} | |
| 235 | + {cols.has('released') && <Th>Released</Th>} | |
| 236 | + <Th num>Quality</Th> | |
| 237 | + </tr> | |
| 238 | + </thead> | |
| 239 | + <tbody> | |
| 240 | + {items.length === 0 && <EmptyRow cols={10}>No rows.</EmptyRow>} | |
| 241 | + {items.map((e) => { | |
| 242 | + const a = e.attributes ?? {}; | |
| 243 | + const ap = num(a.active_parameter_count); | |
| 244 | + const p = num(a.parameter_count); | |
| 245 | + return ( | |
| 246 | + <tr key={e.id}> | |
| 247 | + <Td primary> | |
| 248 | + <EntityLink e={e} /> | |
| 249 | + {e.entity_type !== 'model' && <EntityBadge type={e.entity_type} small className="ml-2" />} | |
| 250 | + </Td> | |
| 251 | + <Td label="Organization" className="text-ink-2"> | |
| 252 | + {e.organization ? <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent">{e.organization.name}</Link> : <span className="text-ink-3">—</span>} | |
| 253 | + </Td> | |
| 254 | + {cols.has('params') && ( | |
| 255 | + <Td num label="Params" className="tnum"> | |
| 256 | + {p === null ? <span className="text-ink-3">—</span> : ap !== null && ap !== p ? `${fmtParams(p)} · ${fmtParams(ap)} active` : fmtParams(p)} | |
| 257 | + </Td> | |
| 258 | + )} | |
| 259 | + {cols.has('context') && <Td num label="Context" className="tnum">{num(a.context_length) === null ? <span className="text-ink-3">—</span> : fmtTokens(a.context_length)}</Td>} | |
| 260 | + {cols.has('price') && <Td num label="Cheapest input" className="tnum text-accent-2">{cheapestPrice(e) ?? <span className="text-ink-3">—</span>}</Td>} | |
| 261 | + {cols.has('openness') && ( | |
| 262 | + <Td label="Openness"> | |
| 263 | + <span className="inline-flex flex-wrap items-center gap-1"> | |
| 264 | + {typeof a.openness === 'string' ? <OpennessBadge openness={a.openness} /> : <span className="text-ink-3">—</span>} | |
| 265 | + {typeof a.license === 'string' && <Chip>{a.license}</Chip>} | |
| 266 | + </span> | |
| 267 | + </Td> | |
| 268 | + )} | |
| 269 | + {cols.has('reasoning') && <Td label="Reasoning" className="text-ink-2">{a.reasoning === true ? 'yes' : a.reasoning === false ? 'no' : <span className="text-ink-3">—</span>}</Td>} | |
| 270 | + {cols.has('modalities') && <Td label="Modalities" className="text-xs text-ink-2">{Array.isArray(a.modalities) && a.modalities.length ? (a.modalities as unknown[]).map(String).join(', ') : <span className="text-ink-3">—</span>}</Td>} | |
| 271 | + {cols.has('released') && <Td label="Released" className="tnum text-ink-2">{typeof a.release_date === 'string' ? fmtDate(a.release_date) : <span className="text-ink-3">—</span>}</Td>} | |
| 272 | + <Td num label="Quality"> | |
| 273 | + <QualityMark q={e.quality?.score} /> | |
| 274 | + </Td> | |
| 275 | + </tr> | |
| 276 | + ); | |
| 277 | + })} | |
| 278 | + </tbody> | |
| 279 | + </DataTable> | |
| 280 | + ); | |
| 281 | +} | |
modified
apps/web/src/app/sources/page.tsx
+159 −50
@@ -1,87 +1,196 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | +import { DataStrip } from '@/components/layout/terminal'; | |
| 4 | +import { BreadcrumbLd } from '@/components/meta/breadcrumb-ld'; | |
| 3 | 5 | import { TierBadge } from '@/components/ui/badges'; |
| 4 | 6 | import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; |
| 5 | −import { Container, Note, PageHeader, Stat, StatGrid } from '@/components/ui/section'; | |
| 7 | +import { Hint } from '@/components/ui/hint'; | |
| 8 | +import { Container, Note, PageHeader } from '@/components/ui/section'; | |
| 6 | 9 | import { Unavailable } from '@/components/ui/unavailable'; |
| 7 | −import { api, safe } from '@/lib/api'; | |
| 10 | +import { adminApi } from '@/lib/admin/admin-api'; | |
| 11 | +import { getAdminToken } from '@/lib/admin/session'; | |
| 12 | +import type { AdminConnector } from '@/lib/admin/types'; | |
| 13 | +import { apiD3, safe } from '@/lib/api'; | |
| 8 | 14 | import { cn } from '@/lib/cn'; |
| 9 | −import { fmtAgo, fmtDuration, fmtInt, num, titleCase } from '@/lib/format'; | |
| 10 | −import { routes } from '@/lib/site'; | |
| 15 | +import { fmtAgo, fmtDateTime, fmtDuration, fmtInt, fmtPct, num, titleCase } from '@/lib/format'; | |
| 16 | +import { routes, SITE_NAME } from '@/lib/site'; | |
| 17 | +import type { SourceRow11 } from '@/lib/types'; | |
| 11 | 18 | |
| 12 | −export const metadata: Metadata = { title: 'Sources — every site AI Atlas reads, with tier and connector health', description: 'Transparency page: the sources AI Atlas crawls, their tier, document counts, last crawl and connector health.', alternates: { canonical: '/sources' } }; | |
| 13 | −export const revalidate = 300; | |
| 19 | +export const metadata: Metadata = { title: 'Sources — health center: every site AI Atlas reads, tier, freshness and connector status', description: 'Transparency page: the sources AI Atlas crawls, their tier, last successful observation, documents and connector health. Operators see request-level health when signed in.', alternates: { canonical: '/sources' } }; | |
| 20 | +/** Reads the admin cookie to decide whether to show operator columns → dynamic. API fetches stay ISR-cached. */ | |
| 21 | +export const dynamic = 'force-dynamic'; | |
| 14 | 22 | |
| 15 | 23 | const HEALTH: Record<string, string> = { ok: 'text-positive', healthy: 'text-positive', degraded: 'text-warning', failing: 'text-danger', broken: 'text-danger', disabled: 'text-ink-3', unknown: 'text-ink-3' }; |
| 24 | +const DOT: Record<string, string> = { ok: 'bg-positive', healthy: 'bg-positive', degraded: 'bg-warning', disabled: 'bg-ink-3', unknown: 'bg-ink-3' }; | |
| 25 | + | |
| 26 | +function statusOf(s: SourceRow11): string { | |
| 27 | + if (!s.enabled) return 'disabled'; | |
| 28 | + const hs = (s.connectors ?? []).map((c) => c.health); | |
| 29 | + if (!hs.length) return 'no connector'; | |
| 30 | + if (hs.some((h) => h === 'failing' || h === 'broken')) return 'failing'; | |
| 31 | + if (hs.some((h) => h === 'degraded')) return 'degraded'; | |
| 32 | + if (hs.every((h) => h === 'ok' || h === 'healthy')) return 'ok'; | |
| 33 | + return hs[0] ?? 'unknown'; | |
| 34 | +} | |
| 35 | +function lastSuccess(s: SourceRow11): string | null { | |
| 36 | + const ts = (s.connectors ?? []).map((c) => c.last_success_at).filter((x): x is string => !!x); | |
| 37 | + return ts.length ? ts.sort().at(-1)! : s.last_crawled_at; | |
| 38 | +} | |
| 16 | 39 | |
| 17 | 40 | export default async function SourcesPage() { |
| 18 | − const res = await safe(api.sources()); | |
| 19 | − const items = (res?.items ?? []).slice().sort((a, b) => a.tier - b.tier || (num(b.documents) ?? 0) - (num(a.documents) ?? 0)); | |
| 41 | + const token = await getAdminToken(); | |
| 42 | + const [res, admin] = await Promise.all([ | |
| 43 | + safe(apiD3.sources()), | |
| 44 | + token | |
| 45 | + ? adminApi.connectors().catch(() => null) | |
| 46 | + : Promise.resolve(null), | |
| 47 | + ]); | |
| 48 | + const items = ((res?.items ?? []) as SourceRow11[]).slice().sort((a, b) => a.tier - b.tier || (num(b.documents) ?? 0) - (num(a.documents) ?? 0)); | |
| 49 | + const byConnector = new Map<string, AdminConnector>((admin?.items ?? []).map((c) => [c.name, c])); | |
| 50 | + const isAdmin = !!admin; | |
| 20 | 51 | const connectors = items.flatMap((s) => s.connectors ?? []); |
| 21 | − const health = (h: string) => connectors.filter((c) => c.health === h).length; | |
| 52 | + const count = (pred: (h: string) => boolean) => connectors.filter((c) => pred(c.health)).length; | |
| 22 | 53 | const docs = items.reduce((n, s) => n + (num(s.documents) ?? 0), 0); |
| 54 | + const snaps = items.reduce((n, s) => n + (num(s.snapshots) ?? 0), 0); | |
| 55 | + const claims = items.reduce((n, s) => n + (num(s.claims) ?? 0), 0); | |
| 56 | + const stale = items.filter((s) => { | |
| 57 | + const t = lastSuccess(s); | |
| 58 | + return s.enabled && t && Date.now() - new Date(t).getTime() > 3 * 86400000; | |
| 59 | + }).length; | |
| 60 | + | |
| 23 | 61 | return ( |
| 24 | 62 | <Container wide> |
| 25 | − <PageHeader eyebrow="Sources" title="What AI Atlas reads" lede="Every source is crawled directly and archived. Tier grades reliability (1 official → 4 unverified); connector health is live." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(items.length)} sources · {fmtInt(connectors.length)} connectors</p> : undefined} /> | |
| 63 | + <BreadcrumbLd items={[{ name: SITE_NAME, href: '/' }, { name: 'Sources', href: '/sources' }]} /> | |
| 64 | + <PageHeader eyebrow="Sources · health center" title="What AI Atlas reads" lede="Every source is crawled directly and archived. Tier grades reliability (1 official → 4 unverified); status is the live health of the connectors reading it. Signed-in operators see request-level health." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(res.total ?? items.length)} sources · {fmtInt(connectors.length)} connectors</p> : undefined} /> | |
| 26 | 65 | <div className="pb-16"> |
| 27 | 66 | {!res ? ( |
| 28 | 67 | <Unavailable what="Sources" /> |
| 29 | 68 | ) : ( |
| 30 | 69 | <> |
| 31 | − <StatGrid cols={5} className="mb-8"> | |
| 32 | − <Stat label="Sources" value={fmtInt(items.length)} /> | |
| 33 | − <Stat label="Documents" value={fmtInt(docs)} /> | |
| 34 | − <Stat label="Connectors OK" value={fmtInt(health('ok') + health('healthy'))} /> | |
| 35 | − <Stat label="Degraded" value={fmtInt(health('degraded'))} accent={health('degraded') > 0} /> | |
| 36 | − <Stat label="Failing" value={fmtInt(health('failing') + health('broken'))} /> | |
| 37 | − </StatGrid> | |
| 38 | − <DataTable caption="Sources"> | |
| 70 | + <DataStrip | |
| 71 | + dense | |
| 72 | + items={[ | |
| 73 | + { label: 'Sources', value: fmtInt(res.total ?? items.length), definition: 'Registered sources (registry/sources.yaml), enabled or not.' }, | |
| 74 | + { label: 'Documents', value: fmtInt(docs), definition: 'Distinct documents discovered across all sources.' }, | |
| 75 | + { label: 'Snapshots', value: snaps ? fmtInt(snaps) : '—', definition: 'Archived fetches (raw + text) across all sources.' }, | |
| 76 | + { label: 'Claims', value: claims ? fmtInt(claims) : '—', definition: 'Claims whose source is this registry entry.' }, | |
| 77 | + { label: 'Connectors OK', value: fmtInt(count((h) => h === 'ok' || h === 'healthy')), definition: 'Connectors whose last run succeeded within their interval.' }, | |
| 78 | + { label: 'Degraded', value: fmtInt(count((h) => h === 'degraded')), definition: 'Connectors with recent failures or a breakage suspicion.', delta: count((h) => h === 'degraded') ? { value: 'attention', tone: 'neutral' } : undefined }, | |
| 79 | + { label: 'Failing', value: fmtInt(count((h) => h === 'failing' || h === 'broken')), definition: 'Connectors whose circuit breaker is open or whose last runs failed.' }, | |
| 80 | + { label: 'Stale > 3 d', value: fmtInt(stale), definition: 'Enabled sources with no successful observation in the last 3 days.' }, | |
| 81 | + ]} | |
| 82 | + /> | |
| 83 | + <div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-ink-3"> | |
| 84 | + <span> | |
| 85 | + Tiers:{' '} | |
| 86 | + {Object.entries(res.tiers ?? {}).map(([t, l], i) => ( | |
| 87 | + <span key={t}> | |
| 88 | + {i > 0 && ' · '} | |
| 89 | + <TierBadge tier={Number(t)} /> {l} | |
| 90 | + </span> | |
| 91 | + ))} | |
| 92 | + </span> | |
| 93 | + {isAdmin && <span className="rounded-[3px] bg-accent-soft px-1.5 text-[10px] uppercase tracking-wide text-accent">operator columns on</span>} | |
| 94 | + </div> | |
| 95 | + <DataTable caption="Sources" scroll compact className="mt-4"> | |
| 39 | 96 | <thead> |
| 40 | 97 | <tr> |
| 41 | 98 | <Th>Source</Th> |
| 42 | − <Th>Tier</Th> | |
| 43 | − <Th>Kind</Th> | |
| 44 | 99 | <Th>Category</Th> |
| 100 | + <Th>Tier</Th> | |
| 101 | + <Th>Last successful observation</Th> | |
| 45 | 102 | <Th num>Documents</Th> |
| 46 | − <Th>Last crawled</Th> | |
| 103 | + <Th>Status</Th> | |
| 47 | 104 | <Th>Connectors</Th> |
| 105 | + {isAdmin && ( | |
| 106 | + <> | |
| 107 | + <Th>Last request</Th> | |
| 108 | + <Th> | |
| 109 | + HTTP <Hint text="Not exposed per connector by /admin/connectors (documents carry last_status individually) — shown as —." /> | |
| 110 | + </Th> | |
| 111 | + <Th num> | |
| 112 | + Unchanged share <Hint text="docs_unchanged / docs_fetched of the last run — a proxy for the conditional-request hit rate, which the API does not report directly." /> | |
| 113 | + </Th> | |
| 114 | + <Th num>Docs changed</Th> | |
| 115 | + <Th num>Claims</Th> | |
| 116 | + <Th num>Errors 7 d</Th> | |
| 117 | + <Th>Parser</Th> | |
| 118 | + <Th> | |
| 119 | + LLM <Hint text="needs_llm flag from the connector registry; the LLM fallback *rate* is not exposed by the API." /> | |
| 120 | + </Th> | |
| 121 | + <Th num>Latency</Th> | |
| 122 | + </> | |
| 123 | + )} | |
| 48 | 124 | </tr> |
| 49 | 125 | </thead> |
| 50 | 126 | <tbody> |
| 51 | − {items.length === 0 && <EmptyRow cols={7}>No sources registered.</EmptyRow>} | |
| 52 | − {items.map((s) => ( | |
| 53 | − <tr key={s.key} className={!s.enabled ? 'opacity-60' : undefined}> | |
| 54 | − <Td primary> | |
| 55 | − <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer" className="hover:text-accent">{s.name}</a> | |
| 56 | − <span className="mono block text-[11px] text-ink-3">{s.domain}{s.organization ? ` · ${s.organization.name}` : ''}</span> | |
| 57 | − </Td> | |
| 58 | − <Td label="Tier"><TierBadge tier={s.tier} withLabel /></Td> | |
| 59 | − <Td label="Kind" className="text-ink-2">{titleCase(s.kind)}</Td> | |
| 60 | − <Td label="Category" className="text-ink-2">{titleCase(s.category)}</Td> | |
| 61 | − <Td num label="Documents" className="tnum">{fmtInt(s.documents)}</Td> | |
| 62 | − <Td label="Last crawled" className="text-ink-2" title={s.last_crawled_at ?? undefined}>{fmtAgo(s.last_crawled_at)}</Td> | |
| 63 | − <Td label="Connectors" wide> | |
| 64 | − {s.connectors?.length ? ( | |
| 65 | − <ul className="space-y-0.5 text-xs"> | |
| 66 | − {s.connectors.map((c) => ( | |
| 67 | − <li key={c.name} className="flex flex-wrap items-center gap-x-2"> | |
| 68 | − <span className={cn('inline-block size-1.5 rounded-full', c.health === 'ok' || c.health === 'healthy' ? 'bg-positive' : c.health === 'degraded' ? 'bg-warning' : c.health === 'disabled' ? 'bg-ink-3' : 'bg-danger')} aria-hidden /> | |
| 69 | − <span className="mono text-ink-2">{c.name}</span> | |
| 70 | − <span className={HEALTH[c.health] ?? 'text-ink-3'}>{c.health}</span> | |
| 71 | − <span className="text-ink-3">every {fmtDuration(c.interval_seconds)} · last success {fmtAgo(c.last_success_at)}</span> | |
| 72 | − </li> | |
| 73 | − ))} | |
| 74 | − </ul> | |
| 75 | − ) : ( | |
| 76 | − <span className="text-ink-3">—</span> | |
| 127 | + {items.length === 0 && <EmptyRow cols={isAdmin ? 16 : 7}>No sources registered.</EmptyRow>} | |
| 128 | + {items.map((s) => { | |
| 129 | + const st = statusOf(s); | |
| 130 | + const ls = lastSuccess(s); | |
| 131 | + const conns = (s.connectors ?? []).map((c) => byConnector.get(c.name)).filter((c): c is AdminConnector => !!c); | |
| 132 | + const run = conns.map((c) => c.last_run).find((r) => r) ?? null; | |
| 133 | + const fetched = num(run?.docs_fetched); | |
| 134 | + const unchanged = num(run?.docs_unchanged); | |
| 135 | + return ( | |
| 136 | + <tr key={s.key} className={!s.enabled ? 'opacity-60' : undefined}> | |
| 137 | + <Td primary> | |
| 138 | + <a href={s.base_url ?? `https://${s.domain}`} target="_blank" rel="noopener noreferrer" className="hover:text-accent"> | |
| 139 | + {s.name} | |
| 140 | + </a> | |
| 141 | + <span className="mono block text-[11px] text-ink-3"> | |
| 142 | + {s.domain} | |
| 143 | + {s.organization ? ` · ${s.organization.name}` : ''} | |
| 144 | + </span> | |
| 145 | + </Td> | |
| 146 | + <Td label="Category" className="text-ink-2">{titleCase(s.category)}<span className="block text-[11px] text-ink-3">{titleCase(s.kind)}</span></Td> | |
| 147 | + <Td label="Tier"> | |
| 148 | + <TierBadge tier={s.tier} /> | |
| 149 | + </Td> | |
| 150 | + <Td label="Last observation" className="text-ink-2" title={ls ? fmtDateTime(ls) : undefined}>{ls ? fmtAgo(ls) : <span className="text-ink-3">never</span>}</Td> | |
| 151 | + <Td num label="Documents" className="tnum">{fmtInt(s.documents)}</Td> | |
| 152 | + <Td label="Status"> | |
| 153 | + <span className={cn('inline-flex items-center gap-1.5 text-xs', HEALTH[st] ?? 'text-ink-3')}> | |
| 154 | + <span className={cn('inline-block size-1.5 rounded-full', DOT[st] ?? (st === 'no connector' ? 'bg-ink-3' : 'bg-danger'))} aria-hidden /> | |
| 155 | + {st} | |
| 156 | + </span> | |
| 157 | + </Td> | |
| 158 | + <Td label="Connectors"> | |
| 159 | + {s.connectors?.length ? ( | |
| 160 | + <ul className="space-y-0.5 text-xs"> | |
| 161 | + {s.connectors.map((c) => ( | |
| 162 | + <li key={c.name} className="flex flex-wrap items-center gap-x-2"> | |
| 163 | + <span className="mono text-ink-2">{c.name}</span> | |
| 164 | + <span className={HEALTH[c.health] ?? 'text-ink-3'}>{c.health}</span> | |
| 165 | + <span className="text-ink-3">every {fmtDuration(c.interval_seconds)}</span> | |
| 166 | + </li> | |
| 167 | + ))} | |
| 168 | + </ul> | |
| 169 | + ) : ( | |
| 170 | + <span className="text-ink-3">—</span> | |
| 171 | + )} | |
| 172 | + </Td> | |
| 173 | + {isAdmin && ( | |
| 174 | + <> | |
| 175 | + <Td label="Last request" className="text-xs text-ink-2">{conns[0]?.last_attempt_at ? fmtAgo(conns[0].last_attempt_at) : '—'}</Td> | |
| 176 | + <Td label="HTTP" className="text-xs text-ink-3">—</Td> | |
| 177 | + <Td num label="Unchanged share" className="tnum text-xs">{fetched && unchanged !== null ? fmtPct((unchanged / fetched) * 100, 0) : '—'}</Td> | |
| 178 | + <Td num label="Docs changed" className="tnum text-xs">{run ? fmtInt(run.docs_changed) : '—'}</Td> | |
| 179 | + <Td num label="Claims" className="tnum text-xs">{run ? fmtInt(run.claims_written) : '—'}</Td> | |
| 180 | + <Td num label="Errors 7 d" className={cn('tnum text-xs', conns.some((c) => (num((c as unknown as { errors_7d?: unknown }).errors_7d) ?? 0) > 0) && 'text-danger')}>{conns.length ? fmtInt(conns.reduce((n, c) => n + (num((c as unknown as { errors_7d?: unknown }).errors_7d) ?? 0), 0)) : '—'}</Td> | |
| 181 | + <Td label="Parser" className="mono text-xs text-ink-2">{conns[0]?.parser_version ?? '—'}</Td> | |
| 182 | + <Td label="LLM" className="text-xs text-ink-2">{conns.length ? (conns.some((c) => (c.meta as { needs_llm?: boolean } | undefined)?.needs_llm) ? 'fallback on' : 'deterministic') : '—'}</Td> | |
| 183 | + <Td num label="Latency" className="tnum text-xs">{num(run?.duration_ms) === null ? '—' : fmtDuration(Math.round((num(run?.duration_ms) ?? 0) / 1000))}</Td> | |
| 184 | + </> | |
| 77 | 185 | )} |
| 78 | − </Td> | |
| 79 | − </tr> | |
| 80 | − ))} | |
| 186 | + </tr> | |
| 187 | + ); | |
| 188 | + })} | |
| 81 | 189 | </tbody> |
| 82 | 190 | </DataTable> |
| 83 | 191 | <Note className="mt-4"> |
| 84 | 192 | Crawling policy: robots.txt honoured, per-domain rate limits, conditional requests, identified user agent (<Link href={routes.bot()} className="link mono">AIAtlasBot</Link>). Tiers and confidence are explained in the <Link href={routes.methodology()} className="link">methodology</Link>. |
| 193 | + {isAdmin ? <> Operator columns come from <span className="mono">/admin/connectors</span> (last run); HTTP status, conditional-hit rate and LLM fallback rate are not exposed per connector by the API and show “—”. Full console: <Link href={routes.admin('connectors')} className="link">Connectors →</Link></> : token ? ' Operator columns unavailable (admin API did not answer).' : null} | |
| 85 | 194 | </Note> |
| 86 | 195 | </> |
| 87 | 196 | )} |
added
apps/web/src/app/time-machine/og/route.tsx
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +import { ImageResponse } from 'next/og'; | |
| 2 | +import type { NextRequest } from 'next/server'; | |
| 3 | +import { Wallpaper } from '@/components/brand/og'; | |
| 4 | +import { todayUtc, validDay } from '@/components/temporal/dates'; | |
| 5 | +import { apiD3, safe } from '@/lib/api'; | |
| 6 | +import { fmtDate, fmtInt } from '@/lib/format'; | |
| 7 | + | |
| 8 | +export const runtime = 'nodejs'; | |
| 9 | +export const revalidate = 3600; | |
| 10 | + | |
| 11 | +/** Dynamic OG image for /time-machine?date= — the date and the live as-of counters. */ | |
| 12 | +export async function GET(req: NextRequest): Promise<Response> { | |
| 13 | + const sp = req.nextUrl.searchParams; | |
| 14 | + const date = validDay(sp.get('date')) ?? todayUtc(); | |
| 15 | + const tm = await safe(apiD3.timeMachine(date, 'all', 1)); | |
| 16 | + const counters: [string, string][] = tm ? [['Models', fmtInt(tm.models?.total)], ['Offers valid', fmtInt(tm.prices?.total)], ['Leaders', fmtInt(tm.benchmarks?.leaders?.length)], ['Basis', tm.reconstructed ? 'reconstructed' : 'observed']] : []; | |
| 17 | + return new ImageResponse(<Wallpaper eyebrow="Time machine" title={`As of ${fmtDate(date)}`} subtitle={tm?.reconstructed ? 'Reconstructed from dated claims and release dates — before the observation history began.' : 'Models, prices, benchmark leaders and hardware as they were known that day.'} counters={counters} footer={`www.ai-atlas.co/time-machine?date=${date}`} markPx={220} />, { width: 1200, height: 630 }); | |
| 18 | +} | |
added
apps/web/src/app/time-machine/page.tsx
+179 −0
@@ -0,0 +1,179 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { DataStrip } from '@/components/layout/terminal'; | |
| 4 | +import { BreadcrumbLd } from '@/components/meta/breadcrumb-ld'; | |
| 5 | +import { daysBefore, monthsBefore, todayUtc, validDay } from '@/components/temporal/dates'; | |
| 6 | +import { HardwareAsOf, LeadersAsOf, ModelsAsOf, PricesAsOf } from '@/components/temporal/time-machine-tables'; | |
| 7 | +import { Chip } from '@/components/ui/badges'; | |
| 8 | +import { Container, Note, PageHeader, Section } from '@/components/ui/section'; | |
| 9 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 10 | +import { apiD3, safe } from '@/lib/api'; | |
| 11 | +import { fmtDate, fmtDateTime, fmtInt, num } from '@/lib/format'; | |
| 12 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 13 | + | |
| 14 | +type SP = { date?: string; scope?: string }; | |
| 15 | +const SCOPES = [ | |
| 16 | + { key: 'all', label: 'Overview' }, | |
| 17 | + { key: 'models', label: 'Models' }, | |
| 18 | + { key: 'prices', label: 'Provider prices' }, | |
| 19 | + { key: 'context', label: 'Context lengths' }, | |
| 20 | + { key: 'benchmarks', label: 'Benchmark leaders' }, | |
| 21 | + { key: 'hardware', label: 'Hardware' }, | |
| 22 | +] as const; | |
| 23 | +type Scope = (typeof SCOPES)[number]['key']; | |
| 24 | +const LIMIT_FOCUS = 200; | |
| 25 | +const LIMIT_ALL = 25; | |
| 26 | + | |
| 27 | +function resolve(sp: SP): { date: string; scope: Scope; invalid: boolean } { | |
| 28 | + const today = todayUtc(); | |
| 29 | + const date = validDay(sp.date) ?? today; | |
| 30 | + const scope = (SCOPES.find((s) => s.key === sp.scope)?.key ?? 'all') as Scope; | |
| 31 | + return { date: date > today ? today : date, scope, invalid: !!sp.date && !validDay(sp.date) }; | |
| 32 | +} | |
| 33 | +function href(date: string, scope: Scope): string { | |
| 34 | + const p = new URLSearchParams({ date }); | |
| 35 | + if (scope !== 'all') p.set('scope', scope); | |
| 36 | + return `/time-machine?${p.toString()}`; | |
| 37 | +} | |
| 38 | + | |
| 39 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 40 | + const { date, scope } = resolve(await searchParams); | |
| 41 | + const title = `AI Atlas as of ${fmtDate(date)}${scope !== 'all' ? ` — ${SCOPES.find((s) => s.key === scope)?.label.toLowerCase()}` : ''}`; | |
| 42 | + const description = `The AI ecosystem as it stood on ${fmtDate(date)}: which models existed, their context lengths and status then, provider prices valid that day, benchmark leaders known by then, hardware — reconstructed from dated claims and honest about it.`; | |
| 43 | + const og = `${SITE_URL}/time-machine/og?date=${date}&scope=${scope}`; | |
| 44 | + return { title, description, alternates: { canonical: href(date, scope) }, openGraph: { title: `${title} | ${SITE_NAME}`, description, url: `${SITE_URL}${href(date, scope)}`, images: [{ url: og, width: 1200, height: 630 }] }, twitter: { card: 'summary_large_image', images: [og] } }; | |
| 45 | +} | |
| 46 | + | |
| 47 | +export default async function TimeMachinePage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 48 | + const sp = await searchParams; | |
| 49 | + const { date, scope, invalid } = resolve(sp); | |
| 50 | + const today = todayUtc(); | |
| 51 | + const apiScope = scope === 'context' ? 'models' : scope; | |
| 52 | + const [focus, overview] = await Promise.all([safe(apiD3.timeMachine(date, apiScope, scope === 'all' ? LIMIT_ALL : LIMIT_FOCUS)), scope === 'all' ? Promise.resolve(null) : safe(apiD3.timeMachine(date, 'all', 1))]); | |
| 53 | + const strip = overview ?? focus; | |
| 54 | + const presets: { label: string; date: string }[] = [ | |
| 55 | + { label: '2023-01-01', date: '2023-01-01' }, | |
| 56 | + { label: '2024-01-01', date: '2024-01-01' }, | |
| 57 | + { label: '2025-01-01', date: '2025-01-01' }, | |
| 58 | + { label: '6 months ago', date: monthsBefore(today, 6) }, | |
| 59 | + { label: '1 month ago', date: monthsBefore(today, 1) }, | |
| 60 | + { label: 'yesterday', date: daysBefore(today, 1) }, | |
| 61 | + ]; | |
| 62 | + const models = focus?.models; | |
| 63 | + const prices = focus?.prices; | |
| 64 | + const leaders = focus?.benchmarks?.leaders ?? []; | |
| 65 | + const hardware = focus?.hardware?.items ?? []; | |
| 66 | + const contextRows = (models?.items ?? []).filter((r) => r.attributes_as_of.context_length !== undefined && r.attributes_as_of.context_length !== null).sort((a, b) => (num(b.attributes_as_of.context_length) ?? 0) - (num(a.attributes_as_of.context_length) ?? 0)); | |
| 67 | + const cls = 'h-11 border border-rule bg-surface px-2.5 text-sm text-ink focus:border-accent focus:outline-none'; | |
| 68 | + | |
| 69 | + return ( | |
| 70 | + <Container wide> | |
| 71 | + <BreadcrumbLd items={[{ name: SITE_NAME, href: '/' }, { name: 'Time machine', href: '/time-machine' }, { name: fmtDate(date), href: href(date, scope) }]} /> | |
| 72 | + <PageHeader | |
| 73 | + eyebrow="Time machine" | |
| 74 | + title={ | |
| 75 | + <> | |
| 76 | + AI Atlas as of <span className="tnum text-ink-2">{fmtDate(date)}</span> | |
| 77 | + </> | |
| 78 | + } | |
| 79 | + lede="Pick any date: the models that existed, their context lengths and status at the time, the provider prices valid that day, the benchmark leaders known by then, the hardware. Every value opens the claim that establishes it, with its validity interval." | |
| 80 | + aside={ | |
| 81 | + <form action="/time-machine" method="get" className="flex items-end gap-2"> | |
| 82 | + <label className="block"> | |
| 83 | + <span className="eyebrow block pb-1">Date (UTC)</span> | |
| 84 | + <input type="date" name="date" defaultValue={date} max={today} className={cls} required data-tm-date /> | |
| 85 | + </label> | |
| 86 | + {scope !== 'all' && <input type="hidden" name="scope" value={scope} />} | |
| 87 | + <button type="submit" className="inline-flex h-11 items-center bg-ink px-3 text-sm font-medium text-canvas hover:opacity-90"> | |
| 88 | + Travel | |
| 89 | + </button> | |
| 90 | + </form> | |
| 91 | + } | |
| 92 | + > | |
| 93 | + <ul className="no-scrollbar -mx-4 mt-4 flex gap-1 overflow-x-auto px-4 md:mx-0 md:px-0" aria-label="Presets"> | |
| 94 | + {presets.map((p) => ( | |
| 95 | + <li key={p.label} className="shrink-0"> | |
| 96 | + <Link href={href(p.date, scope)} className={`inline-flex h-9 items-center border px-2.5 text-sm ${p.date === date ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink'}`} aria-current={p.date === date ? 'true' : undefined}> | |
| 97 | + {p.label} | |
| 98 | + </Link> | |
| 99 | + </li> | |
| 100 | + ))} | |
| 101 | + </ul> | |
| 102 | + {invalid && <Note className="mt-2 text-warning">The date in the URL was not YYYY-MM-DD — showing today instead.</Note>} | |
| 103 | + </PageHeader> | |
| 104 | + | |
| 105 | + <div className="pb-16"> | |
| 106 | + {!focus ? ( | |
| 107 | + <Unavailable what="Time machine" reason="The API did not answer for this date." /> | |
| 108 | + ) : ( | |
| 109 | + <> | |
| 110 | + {/* honesty banner */} | |
| 111 | + <div className={`border-l-2 px-4 py-3 text-sm ${focus.reconstructed ? 'border-warning bg-warning-soft' : 'border-accent bg-accent-soft'}`} role="note" data-tm-banner> | |
| 112 | + <p className="flex flex-wrap items-center gap-2 font-medium text-ink"> | |
| 113 | + {focus.reconstructed ? <Chip tone="estimated">Reconstructed</Chip> : <Chip tone="accent">Observed</Chip>} | |
| 114 | + {focus.reconstructed ? `This date is earlier than AI Atlas's first observation — the state is reconstructed from dated claims and release dates, not from direct observation.` : 'This date is inside the observation history: values are claims that were current that day.'} | |
| 115 | + </p> | |
| 116 | + <p className="mt-1 text-xs text-ink-2"> | |
| 117 | + Observation history starts {focus.first_entity_at ? <time dateTime={focus.first_entity_at}>{fmtDateTime(focus.first_entity_at)}</time> : 'unknown'}.{focus.note ? ` ${focus.note}` : ''} | |
| 118 | + </p> | |
| 119 | + </div> | |
| 120 | + | |
| 121 | + <DataStrip | |
| 122 | + className="mt-6" | |
| 123 | + items={[ | |
| 124 | + { label: 'Models existing', value: fmtInt(strip?.models?.total ?? models?.total), definition: 'Canonical models whose release date (or first dated claim) is on or before the date.', href: href(date, 'models') }, | |
| 125 | + { label: 'Offers valid', value: fmtInt(strip?.prices?.total ?? prices?.total), definition: 'Price rows whose validity interval covers the date (append-only history).', href: href(date, 'prices') }, | |
| 126 | + { label: 'Benchmark leaders', value: fmtInt(strip?.benchmarks?.leaders?.length ?? leaders.length), definition: 'Benchmarks with at least one result observed by the date.', href: href(date, 'benchmarks') }, | |
| 127 | + { label: 'Hardware', value: fmtInt(strip?.hardware?.total ?? strip?.hardware?.items?.length ?? hardware.length), definition: 'Hardware entities released on or before the date.', href: href(date, 'hardware') }, | |
| 128 | + { label: 'Basis', value: focus.reconstructed ? 'reconstructed' : 'observed', definition: 'Reconstructed = before the first observation; observed = inside the recorded history.' }, | |
| 129 | + ]} | |
| 130 | + /> | |
| 131 | + | |
| 132 | + <nav aria-label="Scope" className="no-scrollbar -mx-4 mt-6 flex gap-1 overflow-x-auto border-b border-rule px-4 md:mx-0 md:px-0"> | |
| 133 | + {SCOPES.map((s) => ( | |
| 134 | + <Link key={s.key} href={href(date, s.key)} className={`flex h-10 shrink-0 items-center border-b-2 px-3 text-sm ${s.key === scope ? 'border-ink font-medium text-ink' : 'border-transparent text-ink-2 hover:text-ink'}`} aria-current={s.key === scope ? 'page' : undefined}> | |
| 135 | + {s.label} | |
| 136 | + </Link> | |
| 137 | + ))} | |
| 138 | + <Link href={routes.diff({ a: date, b: today })} className="ml-auto flex h-10 shrink-0 items-center px-3 text-sm text-accent hover:underline"> | |
| 139 | + Diff → today | |
| 140 | + </Link> | |
| 141 | + </nav> | |
| 142 | + | |
| 143 | + {(scope === 'all' || scope === 'models') && ( | |
| 144 | + <Section id="models" eyebrow="Models" title={<>Models as of {fmtDate(date)} <span className="tnum text-base font-normal text-ink-3">{fmtInt(models?.total)}</span></>} action={scope === 'all' ? { href: href(date, 'models'), label: 'All' } : undefined}> | |
| 145 | + <ModelsAsOf rows={models?.items ?? []} total={num(models?.total)} date={date} limit={scope === 'all' ? LIMIT_ALL : LIMIT_FOCUS} /> | |
| 146 | + </Section> | |
| 147 | + )} | |
| 148 | + {scope === 'context' && ( | |
| 149 | + <Section id="context" eyebrow="Context lengths" title={<>Context windows as of {fmtDate(date)} <span className="tnum text-base font-normal text-ink-3">{fmtInt(contextRows.length)} with a dated value</span></>}> | |
| 150 | + <ModelsAsOf rows={contextRows} total={null} date={date} limit={LIMIT_FOCUS} /> | |
| 151 | + <Note className="mt-2">Only models with a context-length claim valid on that date are listed (sorted by context). Models without a dated claim are omitted rather than guessed.</Note> | |
| 152 | + </Section> | |
| 153 | + )} | |
| 154 | + {(scope === 'all' || scope === 'prices') && ( | |
| 155 | + <Section id="prices" eyebrow="Provider prices" title={<>Offers valid on {fmtDate(date)} <span className="tnum text-base font-normal text-ink-3">{fmtInt(prices?.total)}</span></>} action={scope === 'all' ? { href: href(date, 'prices'), label: 'All' } : undefined}> | |
| 156 | + <PricesAsOf rows={prices?.items ?? []} total={num(prices?.total)} date={date} /> | |
| 157 | + {prices?.note && <Note className="mt-1">{prices.note}</Note>} | |
| 158 | + </Section> | |
| 159 | + )} | |
| 160 | + {(scope === 'all' || scope === 'benchmarks') && ( | |
| 161 | + <Section id="benchmarks" eyebrow="Benchmark leaders" title={<>Leaders known by {fmtDate(date)} <span className="tnum text-base font-normal text-ink-3">{fmtInt(leaders.length)}</span></>}> | |
| 162 | + <LeadersAsOf rows={leaders} date={date} /> | |
| 163 | + {focus.benchmarks?.note && <Note className="mt-1">{focus.benchmarks.note}</Note>} | |
| 164 | + </Section> | |
| 165 | + )} | |
| 166 | + {(scope === 'all' || scope === 'hardware') && ( | |
| 167 | + <Section id="hardware" eyebrow="Hardware" title={<>Hardware as of {fmtDate(date)} <span className="tnum text-base font-normal text-ink-3">{fmtInt(hardware.length)}</span></>} action={scope === 'all' ? { href: href(date, 'hardware'), label: 'All' } : undefined}> | |
| 168 | + <HardwareAsOf rows={hardware} date={date} /> | |
| 169 | + </Section> | |
| 170 | + )} | |
| 171 | + <Note className="mt-6"> | |
| 172 | + Shareable: this URL reproduces the view. Per-entity: every entity page has a History tab with the same as-of reconstruction. What changed since? <Link href={routes.diff({ a: date, b: today })} className="link">Diff {fmtDate(date)} → today</Link>. <Link href={routes.methodology()} className="link">Methodology →</Link> | |
| 173 | + </Note> | |
| 174 | + </> | |
| 175 | + )} | |
| 176 | + </div> | |
| 177 | + </Container> | |
| 178 | + ); | |
| 179 | +} | |
modified
apps/web/src/app/timeline/page.tsx
+141 −113
@@ -1,162 +1,190 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | −import { ChangeRow } from '@/components/changes/change-row'; | |
| 4 | 3 | import { Bars } from '@/components/charts/charts'; |
| 5 | −import { ChipRow, type ChipItem } from '@/components/timeline/chip-row'; | |
| 4 | +import { TerminalLayout } from '@/components/layout/terminal'; | |
| 5 | +import { RailFilters } from '@/components/changes/rail-filters'; | |
| 6 | +import { type ChipItem } from '@/components/timeline/chip-row'; | |
| 7 | +import { occurredAt, TIMELINE_LANES } from '@/components/timeline/lanes'; | |
| 8 | +import { TimelineWorkbench } from '@/components/timeline/timeline-workbench'; | |
| 6 | 9 | import { EntityBadge } from '@/components/ui/badges'; |
| 10 | +import { withParams } from '@/components/ui/pagination'; | |
| 7 | 11 | import { Container, Note, PageHeader } from '@/components/ui/section'; |
| 8 | 12 | import { EmptyState, Unavailable } from '@/components/ui/unavailable'; |
| 9 | −import { api, safe } from '@/lib/api'; | |
| 10 | −import { fmtInt, fmtMonth, fmtYear } from '@/lib/format'; | |
| 11 | −import { CATEGORY_LABELS, categoryLabel, routes, SITE_NAME } from '@/lib/site'; | |
| 13 | +import { api, apiD1, apiD3, safe } from '@/lib/api'; | |
| 14 | +import { fmtInt, fmtMonth, fmtYear, num } from '@/lib/format'; | |
| 15 | +import { CATEGORY_LABELS, categoryLabel, eventLabel, IMPORTANCE_LABELS, routes, SITE_NAME } from '@/lib/site'; | |
| 16 | +import type { ChangeEvent } from '@/lib/types'; | |
| 12 | 17 | |
| 13 | 18 | export const revalidate = 600; |
| 14 | 19 | |
| 15 | 20 | type SP = Record<string, string | undefined>; |
| 16 | −const LIMIT = 400; | |
| 21 | +const LIMIT = 1000; | |
| 22 | +const KEYS = ['entity', 'year', 'category', 'type', 'importance_min', 'include_backfill', 'org', 'family'] as const; | |
| 17 | 23 | |
| 18 | −function current(sp: SP) { | |
| 19 | − const out: { entity?: string; year?: string; category?: string } = {}; | |
| 20 | − if (sp.entity) out.entity = sp.entity; | |
| 21 | − if (sp.year && /^\d{4}$/.test(sp.year)) out.year = sp.year; | |
| 22 | − if (sp.category) out.category = sp.category; | |
| 24 | +function current(sp: SP): Record<string, string | undefined> { | |
| 25 | + const out: Record<string, string | undefined> = {}; | |
| 26 | + for (const k of KEYS) if (sp[k]) out[k] = sp[k]; | |
| 27 | + if (out.year && !/^\d{4}$/.test(out.year)) delete out.year; | |
| 28 | + if (out.include_backfill !== '1') delete out.include_backfill; | |
| 23 | 29 | return out; |
| 24 | 30 | } |
| 31 | +/** `org` / `family` are shortcuts for the entity-scoped timeline (an organization's timeline includes its models' events). */ | |
| 32 | +function scopeEntity(cur: Record<string, string | undefined>): string | undefined { | |
| 33 | + return cur.entity ?? cur.org ?? cur.family; | |
| 34 | +} | |
| 25 | 35 | |
| 26 | 36 | export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { |
| 27 | 37 | const cur = current(await searchParams); |
| 28 | 38 | const bits: string[] = []; |
| 29 | − if (cur.entity) bits.push(cur.entity); | |
| 39 | + const scope = scopeEntity(cur); | |
| 40 | + if (scope) bits.push(scope); | |
| 30 | 41 | if (cur.category) bits.push(categoryLabel(cur.category).toLowerCase()); |
| 42 | + if (cur.type) bits.push(eventLabel(cur.type).toLowerCase()); | |
| 31 | 43 | if (cur.year) bits.push(cur.year); |
| 32 | − const title = bits.length ? `Timeline — ${bits.join(' · ')}` : 'Timeline — the AI ecosystem month by month'; | |
| 44 | + const title = bits.length ? `Timeline — ${bits.join(' · ')}` : 'Timeline — the AI ecosystem, lane by lane'; | |
| 33 | 45 | return { |
| 34 | 46 | title, |
| 35 | − description: 'Change events grouped by month: releases, price moves, deprecations and benchmark results across the whole atlas or for one entity, each linked to its source.', | |
| 36 | − alternates: { canonical: routes.timeline(cur) }, | |
| 47 | + description: 'Eight lanes — models, research, prices, benchmarks, providers, hardware, frameworks, companies — of source-attributed events keyed on when they occurred, brushable by range, with historical backfill on demand.', | |
| 48 | + alternates: { canonical: withParams('/timeline', cur, {}) }, | |
| 37 | 49 | openGraph: { title: `${title} | ${SITE_NAME}` }, |
| 38 | − robots: cur.entity ? { index: false, follow: true } : undefined, | |
| 50 | + robots: scope ? { index: false, follow: true } : undefined, | |
| 39 | 51 | }; |
| 40 | 52 | } |
| 41 | 53 | |
| 42 | 54 | export default async function TimelinePage({ searchParams }: { searchParams: Promise<SP> }) { |
| 43 | 55 | const sp = await searchParams; |
| 44 | 56 | const cur = current(sp); |
| 45 | − const [res, cats, stats, entity] = await Promise.all([safe(api.timeline({ ...cur, limit: LIMIT })), safe(api.changesCategories(365)), safe(api.stats()), cur.entity ? safe(api.entity(cur.entity)) : Promise.resolve(null)]); | |
| 57 | + const scope = scopeEntity(cur); | |
| 58 | + const backfill = cur.include_backfill === '1'; | |
| 59 | + const [res, cats, stats, entity, orgs, fams, meth] = await Promise.all([ | |
| 60 | + safe(apiD3.timeline({ entity: scope, year: cur.year, category: cur.category, importance_min: cur.importance_min, include_backfill: backfill ? 1 : undefined, limit: LIMIT })), | |
| 61 | + safe(api.changesCategories(365)), | |
| 62 | + safe(api.stats()), | |
| 63 | + scope ? safe(api.entity(scope)) : Promise.resolve(null), | |
| 64 | + safe(api.companies({ limit: 40, sort: 'models' })), | |
| 65 | + safe(apiD1.families({ limit: 40, sort: 'models' })), | |
| 66 | + safe(apiD3.methodology()), | |
| 67 | + ]); | |
| 46 | 68 | const months = res?.items ?? []; |
| 47 | − const shown = months.reduce((n, m) => n + m.events.length, 0); | |
| 48 | − const total = res?.total ?? shown; | |
| 69 | + const all: ChangeEvent[] = months.flatMap((m) => m.events); | |
| 70 | + const events = cur.type ? all.filter((e) => e.event_type === cur.type) : all; | |
| 71 | + const loaded = all.length; | |
| 72 | + const capped = loaded >= LIMIT; | |
| 73 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/timeline', cur, patch); | |
| 49 | 74 | |
| 50 | − // Years: current UTC year back to the earliest year the atlas knows (first entity or earliest month returned). | |
| 75 | + // Years: current UTC year back to the earliest year the atlas knows. | |
| 51 | 76 | const thisYear = new Date().getUTCFullYear(); |
| 52 | 77 | const earliestCandidates = [stats?.first_entity_at, ...months.map((m) => m.month), cur.year].filter((v): v is string => !!v).map((v) => Number(fmtYear(v))).filter((n) => Number.isFinite(n) && n > 1990); |
| 53 | 78 | const earliest = earliestCandidates.length ? Math.min(...earliestCandidates) : thisYear; |
| 54 | 79 | const years: string[] = []; |
| 55 | − for (let y = thisYear; y >= earliest; y--) years.push(String(y)); | |
| 80 | + for (let y = thisYear; y >= Math.max(earliest, thisYear - 12); y--) years.push(String(y)); | |
| 56 | 81 | |
| 57 | − // Categories: live counts over the last year, summed per category; fall back to the vocabulary when unavailable. | |
| 58 | 82 | const catCounts = new Map<string, number>(); |
| 59 | − for (const it of cats?.items ?? []) catCounts.set(it.category, (catCounts.get(it.category) ?? 0) + (Number(it.count) || 0)); | |
| 83 | + for (const it of cats?.items ?? []) catCounts.set(it.category, (catCounts.get(it.category) ?? 0) + (num(it.count) ?? 0)); | |
| 60 | 84 | const catKeys = catCounts.size ? [...catCounts.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k) : Object.keys(CATEGORY_LABELS); |
| 61 | 85 | if (cur.category && !catKeys.includes(cur.category)) catKeys.push(cur.category); |
| 86 | + const typeCounts = new Map<string, number>(); | |
| 87 | + for (const e of all) typeCounts.set(e.event_type, (typeCounts.get(e.event_type) ?? 0) + 1); | |
| 88 | + const typeKeys = [...typeCounts.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k); | |
| 62 | 89 | |
| 63 | − const yearChips: ChipItem[] = [{ href: routes.timeline({ ...cur, year: undefined }), label: 'All years', active: !cur.year }, ...years.map((y) => ({ href: routes.timeline({ ...cur, year: y }), label: y, active: cur.year === y }))]; | |
| 64 | − const catChips: ChipItem[] = [{ href: routes.timeline({ ...cur, category: undefined }), label: 'All categories', active: !cur.category }, ...catKeys.map((c) => ({ href: routes.timeline({ ...cur, category: c }), label: categoryLabel(c), count: catCounts.size ? catCounts.get(c) ?? 0 : undefined, active: cur.category === c }))]; | |
| 65 | − | |
| 66 | − // Density strip, oldest → newest (the list below is newest first). | |
| 90 | + const yearChips: ChipItem[] = [{ href: href({ year: undefined }), label: 'All years', active: !cur.year }, ...years.map((y) => ({ href: href({ year: y }), label: y, active: cur.year === y }))]; | |
| 67 | 91 | const density = [...months].reverse().map((m) => ({ label: fmtMonth(m.month), value: m.count ?? m.events.length })); |
| 92 | + const activeCount = Object.keys(cur).length; | |
| 93 | + | |
| 94 | + const filters = ( | |
| 95 | + <RailFilters | |
| 96 | + action="/timeline" | |
| 97 | + resetHref={routes.timeline()} | |
| 98 | + testId="timeline" | |
| 99 | + fields={[ | |
| 100 | + { kind: 'chips', label: 'Year', items: yearChips.map((c) => ({ href: c.href, label: c.label, active: c.active })) }, | |
| 101 | + { kind: 'select', name: 'category', label: 'Category (lane)', value: cur.category, any: 'All lanes', options: catKeys.map((c) => ({ value: c, label: `${categoryLabel(c)}${catCounts.size ? ` (${fmtInt(catCounts.get(c) ?? 0)})` : ''}` })) }, | |
| 102 | + { kind: 'select', name: 'type', label: 'Event type', value: cur.type, any: 'All types', options: [...typeKeys.map((t) => ({ value: t, label: `${eventLabel(t)} (${fmtInt(typeCounts.get(t) ?? 0)})` })), ...(cur.type && !typeKeys.includes(cur.type) ? [{ value: cur.type, label: `${eventLabel(cur.type)} (0 loaded)` }] : [])], note: 'Filters the loaded events (the API has no type filter on /timeline).' }, | |
| 103 | + { kind: 'select', name: 'org', label: 'Organization', value: cur.org, any: 'Whole atlas', remote: 'companies', options: (orgs?.items ?? []).filter((o) => o.slug === cur.org).map((o) => ({ value: o.slug, label: `${o.name} (${fmtInt(o.model_count)})` })) }, | |
| 104 | + { kind: 'select', name: 'family', label: 'Family', value: cur.family, any: 'Any family', remote: 'families', options: (fams?.items ?? []).filter((f) => f.slug === cur.family).map((f) => ({ value: f.slug, label: `${f.name} (${fmtInt(f.model_count)})` })), note: 'Organization and family scope the timeline to that entity (one at a time).' }, | |
| 105 | + { kind: 'select', name: 'importance_min', label: 'Min importance', value: cur.importance_min, options: [3, 2, 1].map((n) => ({ value: String(n), label: `${IMPORTANCE_LABELS[n]} (≥ ${n})` })) }, | |
| 106 | + { kind: 'checkbox', name: 'include_backfill', label: 'Include historical backfill', checked: backfill, hint: 'Backfill = history imported when a source is first crawled (release dates, past prices). Off by default so the timeline shows what AI Atlas observed happening; on, it shows the reconstructed history.' }, | |
| 107 | + ...(cur.year ? [{ kind: 'hidden' as const, name: 'year', value: cur.year }] : []), | |
| 108 | + ...(cur.entity ? [{ kind: 'hidden' as const, name: 'entity', value: cur.entity }] : []), | |
| 109 | + ]} | |
| 110 | + /> | |
| 111 | + ); | |
| 112 | + | |
| 113 | + const inspector = ( | |
| 114 | + <div className="space-y-4 text-sm"> | |
| 115 | + <div> | |
| 116 | + <p className="eyebrow mb-1.5">Lanes</p> | |
| 117 | + <ul className="space-y-1"> | |
| 118 | + {TIMELINE_LANES.filter((l) => l.key !== 'other' || all.some((e) => l.categories.length === 0 && !TIMELINE_LANES.some((x) => x.categories.includes(e.category)))).map((l) => { | |
| 119 | + const n = all.filter((e) => (TIMELINE_LANES.find((x) => x.categories.includes(e.category))?.key ?? 'other') === l.key).length; | |
| 120 | + return ( | |
| 121 | + <li key={l.key} className="flex items-center justify-between gap-2"> | |
| 122 | + <span className="flex items-center gap-2"> | |
| 123 | + <span className="inline-block size-2.5 rounded-full" style={{ background: l.color }} aria-hidden /> {l.label} | |
| 124 | + </span> | |
| 125 | + <span className="tnum text-xs text-ink-3">{fmtInt(n)}</span> | |
| 126 | + </li> | |
| 127 | + ); | |
| 128 | + })} | |
| 129 | + </ul> | |
| 130 | + </div> | |
| 131 | + <div> | |
| 132 | + <p className="eyebrow mb-1.5">Semantics</p> | |
| 133 | + <dl className="kv [&>div]:py-1 text-xs"> | |
| 134 | + {Object.entries(meth?.event_semantics ?? {}).slice(0, 5).map(([k, v]) => ( | |
| 135 | + <div key={k}> | |
| 136 | + <dt className="mono">{k}</dt> | |
| 137 | + <dd className="text-ink-2">{v}</dd> | |
| 138 | + </div> | |
| 139 | + ))} | |
| 140 | + {!meth?.event_semantics && <div><dd className="text-ink-3">Definitions unavailable.</dd></div>} | |
| 141 | + </dl> | |
| 142 | + </div> | |
| 143 | + <p className="text-xs text-ink-3"> | |
| 144 | + Times are UTC. Per-entity history: open any entity's History tab. Compare two dates in <Link href={routes.diff()} className="link">Diff</Link>; see the atlas as of a date in the <Link href={routes.timeMachine()} className="link">Time machine</Link>. | |
| 145 | + </p> | |
| 146 | + </div> | |
| 147 | + ); | |
| 68 | 148 | |
| 69 | 149 | return ( |
| 70 | − <Container> | |
| 71 | − <PageHeader | |
| 72 | − eyebrow={ | |
| 73 | − entity ? ( | |
| 74 | − <> | |
| 75 | − <EntityBadge type={entity.entity_type} small /> Timeline | |
| 76 | − </> | |
| 150 | + <> | |
| 151 | + <Container wide> | |
| 152 | + <PageHeader | |
| 153 | + eyebrow={entity ? <><EntityBadge type={entity.entity_type} small /> Timeline</> : 'Timeline 2.0'} | |
| 154 | + title={entity ? <>Timeline of <Link href={routes.entity(entity)} className="text-ink-2 hover:text-accent">{entity.name}</Link></> : scope ? <>Timeline of <span className="mono text-ink-2">{scope}</span></> : 'The ecosystem, lane by lane'} | |
| 155 | + lede={entity ? <>Every recorded change for this {entity.entity_type.replace(/_/g, ' ')}{entity.organization ? ` by ${entity.organization.name}` : ''}{['company', 'organization', 'lab', 'university'].includes(entity.entity_type) ? ' and for the models it develops' : ''}, keyed on when it occurred. <Link href={routes.entity(entity)} className="link">Back to the entity →</Link></> : 'Models · Research · Prices · Benchmarks · Providers · Hardware · Frameworks · Companies. Dots are events sized by importance; drag on the lanes to zoom the list to a range. Everything links to its entity and to the source that stated it.'} | |
| 156 | + aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(events.length)}{cur.type ? ` of ${fmtInt(loaded)} loaded` : ''} events{capped ? ` · first ${fmtInt(LIMIT)}` : ''}</p> : undefined} | |
| 157 | + className="pb-3" | |
| 158 | + /> | |
| 159 | + </Container> | |
| 160 | + <div className="pb-16"> | |
| 161 | + <TerminalLayout filters={filters} inspector={inspector} filtersTitle="Filters" inspectorTitle="Legend" storageKey="aia-timeline-inspector" filterCount={activeCount}> | |
| 162 | + {!res ? ( | |
| 163 | + <Unavailable what="Timeline" reason={scope ? 'The entity slug may not exist.' : undefined} /> | |
| 164 | + ) : events.length === 0 ? ( | |
| 165 | + <EmptyState title="No events for this selection"> | |
| 166 | + {backfill ? 'Try another year, lane or scope, or open ' : 'Nothing was observed live for this selection — turn on “include historical backfill” for reconstructed history, or open '} | |
| 167 | + <Link href={routes.changes()} className="link">the live feed</Link>. | |
| 168 | + </EmptyState> | |
| 77 | 169 | ) : ( |
| 78 | − 'Timeline' | |
| 79 | − ) | |
| 80 | − } | |
| 81 | − title={ | |
| 82 | − entity ? ( | |
| 83 | 170 | <> |
| 84 | − Timeline of <Link href={routes.entity(entity)} className="text-ink-2 hover:text-accent">{entity.name}</Link> | |
| 171 | + {density.length > 1 && ( | |
| 172 | + <section aria-label="Events per month" className="mb-6"> | |
| 173 | + <p className="eyebrow mb-2"> | |
| 174 | + Events per month <span className="tnum normal-case tracking-normal text-ink-3">· {fmtInt(density.length)} months loaded</span> | |
| 175 | + </p> | |
| 176 | + <Bars data={density} height={72} /> | |
| 177 | + </section> | |
| 178 | + )} | |
| 179 | + <TimelineWorkbench events={events} semantics={meth?.event_semantics ?? null} backfill={backfill} /> | |
| 180 | + <Note className="mt-4"> | |
| 181 | + Showing up to {fmtInt(LIMIT)} events{capped ? ' — the API cap; narrow by year, lane or scope to see everything' : ''}. Dates are <span className="mono">occurred_at</span> (effective date when a source states one, else observation) — hover a date for the observation time.{!backfill && ' Historical backfill is excluded; the checkbox in the rail includes it.'} Cursor-paged history: <Link href={routes.changes()} className="link">changes feed</Link>. | |
| 182 | + </Note> | |
| 183 | + {events.length > 0 && <p className="sr-only">Earliest loaded event: {occurredAt(events[events.length - 1]!)}</p>} | |
| 85 | 184 | </> |
| 86 | − ) : cur.entity ? ( | |
| 87 | − <>Timeline of <span className="mono text-ink-2">{cur.entity}</span></> | |
| 88 | − ) : ( | |
| 89 | − 'The ecosystem, month by month' | |
| 90 | − ) | |
| 91 | − } | |
| 92 | − lede={entity ? <>Every recorded change for this {entity.entity_type.replace(/_/g, ' ')}{entity.organization ? ` by ${entity.organization.name}` : ''}, newest first. <Link href={routes.entity(entity)} className="link">Back to the entity page →</Link></> : 'Events grouped by month, newest first. Every entry links to the entity and to the source that stated the change.'} | |
| 93 | − aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(shown)}{total > shown ? ` of ${fmtInt(total)}` : ''} events</p> : undefined} | |
| 94 | − > | |
| 95 | − <div className="mt-6 space-y-3"> | |
| 96 | − <div> | |
| 97 | − <p className="eyebrow mb-1.5">Year</p> | |
| 98 | − <ChipRow label="Year" items={yearChips} /> | |
| 99 | − </div> | |
| 100 | − <div> | |
| 101 | − <p className="eyebrow mb-1.5">Category {cats ? <span className="normal-case tracking-normal text-ink-3">· events in the last 365 days</span> : null}</p> | |
| 102 | − <ChipRow label="Category" items={catChips} /> | |
| 103 | − </div> | |
| 104 | − {cur.entity && ( | |
| 105 | − <p className="text-xs text-ink-3"> | |
| 106 | − Filtered to one entity. <Link href={routes.timeline({ year: cur.year, category: cur.category })} className="link">Show the whole atlas</Link> | |
| 107 | − </p> | |
| 108 | 185 | )} |
| 109 | − </div> | |
| 110 | − </PageHeader> | |
| 111 | − | |
| 112 | − <div className="pb-16"> | |
| 113 | − {!res ? ( | |
| 114 | − <Unavailable what="Timeline" reason={cur.entity ? 'The entity slug may not exist.' : undefined} /> | |
| 115 | − ) : months.length === 0 ? ( | |
| 116 | − <EmptyState title="No events for this selection"> | |
| 117 | − Try another year or category, or open <Link href={routes.changes()} className="link">the live feed</Link>. | |
| 118 | − </EmptyState> | |
| 119 | − ) : ( | |
| 120 | − <> | |
| 121 | − {density.length > 1 && ( | |
| 122 | − <section aria-label="Events per month" className="mb-8"> | |
| 123 | − <p className="eyebrow mb-2">Events per month <span className="tnum normal-case tracking-normal text-ink-3">· {fmtInt(density.length)} months shown</span></p> | |
| 124 | − <Bars data={density} height={90} /> | |
| 125 | − </section> | |
| 126 | − )} | |
| 127 | − <div className="grid gap-x-10 lg:grid-cols-[10rem_minmax(0,1fr)]"> | |
| 128 | − <nav aria-label="Months" className="no-scrollbar -mx-4 mb-4 flex gap-1 overflow-x-auto px-4 lg:sticky lg:top-[calc(var(--header-h)+1rem)] lg:mx-0 lg:mb-0 lg:block lg:self-start lg:space-y-0.5 lg:px-0"> | |
| 129 | − {months.map((m) => ( | |
| 130 | − <a key={m.month} href={`#m-${m.month}`} className="tnum flex h-9 shrink-0 items-center justify-between gap-2 border border-rule px-2 text-xs text-ink-2 hover:text-ink lg:h-8 lg:border-0 lg:px-1"> | |
| 131 | − <span>{fmtMonth(m.month)}</span> | |
| 132 | − <span className="text-ink-3">{fmtInt(m.count ?? m.events.length)}</span> | |
| 133 | − </a> | |
| 134 | − ))} | |
| 135 | − </nav> | |
| 136 | − <div className="min-w-0"> | |
| 137 | − {months.map((m) => ( | |
| 138 | − <section key={m.month} id={`m-${m.month}`} className="scroll-mt-20 pb-8"> | |
| 139 | − <h2 className="eyebrow sticky top-[var(--header-h)] z-10 -mx-4 flex items-baseline justify-between bg-canvas/95 px-4 py-2 backdrop-blur md:mx-0 md:px-0"> | |
| 140 | − <span> | |
| 141 | − {fmtMonth(m.month)} <span className="tnum text-ink-3">{fmtInt(m.count ?? m.events.length)}</span> | |
| 142 | − </span> | |
| 143 | − {(m.count ?? m.events.length) > m.events.length && <span className="tnum normal-case tracking-normal text-ink-3">{fmtInt(m.events.length)} shown</span>} | |
| 144 | − </h2> | |
| 145 | − <ul className="border-t border-rule"> | |
| 146 | − {m.events.map((e) => ( | |
| 147 | − <ChangeRow key={e.id} e={e} showDate live={false} /> | |
| 148 | − ))} | |
| 149 | − </ul> | |
| 150 | − </section> | |
| 151 | − ))} | |
| 152 | − <Note> | |
| 153 | − Showing up to {fmtInt(LIMIT)} events{total > shown ? ` of ${fmtInt(total)} matching` : ''}. Narrow by year or category, or use <Link href={routes.changes()} className="link">the changes feed</Link> for cursor-paged history. Times are UTC. | |
| 154 | − </Note> | |
| 155 | − </div> | |
| 156 | − </div> | |
| 157 | − </> | |
| 158 | − )} | |
| 186 | + </TerminalLayout> | |
| 159 | 187 | </div> |
| 160 | − </Container> | |
| 188 | + </> | |
| 161 | 189 | ); |
| 162 | 190 | } |
added
apps/web/src/components/admin/resolution-pair.tsx
+162 −0
@@ -0,0 +1,162 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { resolutionAction } from '@/lib/admin/actions'; | |
| 3 | +import type { ResolutionDecision, ResolutionItem, ResolutionSide } from '@/lib/admin/types'; | |
| 4 | +import { cn } from '@/lib/cn'; | |
| 5 | +import { fmtDate, fmtInt, fmtParams, fmtPct, fmtTokens, num } from '@/lib/format'; | |
| 6 | +import { routes } from '@/lib/site'; | |
| 7 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 8 | +import { ActionButton, KindChip, Mono } from './ui'; | |
| 9 | + | |
| 10 | +/* | |
| 11 | + One candidate pair, side by side (server component): every comparable field on one row, equal values dimmed, differing | |
| 12 | + values highlighted; signals; six decisions as server-action forms. Direction matters for merge / variant_of / | |
| 13 | + family_member: "A → B" means A is merged into (or becomes a variant / member of) B. | |
| 14 | +*/ | |
| 15 | + | |
| 16 | +type Row = { label: string; a: React.ReactNode; b: React.ReactNode; same: boolean }; | |
| 17 | + | |
| 18 | +function val(v: unknown): string { | |
| 19 | + if (v === null || v === undefined || v === '') return '—'; | |
| 20 | + if (Array.isArray(v)) return v.length ? v.map(String).join(', ') : '—'; | |
| 21 | + return String(v); | |
| 22 | +} | |
| 23 | +function rowsFor(a: ResolutionSide, b: ResolutionSide): Row[] { | |
| 24 | + const r = (label: string, fa: unknown, fb: unknown, fmt: (v: unknown) => React.ReactNode = val) => ({ label, a: fmt(fa), b: fmt(fb), same: JSON.stringify(fa ?? null) === JSON.stringify(fb ?? null) }); | |
| 25 | + const ids = (s: ResolutionSide) => s.identifiers?.map((i) => `${i.scheme}:${i.value}`) ?? []; | |
| 26 | + const na = (s: ResolutionSide) => (s.name_analysis ?? {}) as Record<string, unknown>; | |
| 27 | + return [ | |
| 28 | + r('Name', a.name, b.name), | |
| 29 | + r('Slug', a.slug, b.slug, (v) => <Mono>{val(v)}</Mono>), | |
| 30 | + r('Organization', a.organization, b.organization), | |
| 31 | + r('Family', a.family, b.family), | |
| 32 | + r('Parameters', a.parameter_count, b.parameter_count, (v) => fmtParams(v)), | |
| 33 | + r('Active params', a.active_parameter_count, b.active_parameter_count, (v) => fmtParams(v)), | |
| 34 | + r('Release date', a.release_date, b.release_date, (v) => (typeof v === 'string' ? fmtDate(v) : '—')), | |
| 35 | + r('Architecture', a.architecture ?? a.model_type, b.architecture ?? b.model_type), | |
| 36 | + r('Openness', a.openness, b.openness), | |
| 37 | + r('Context', a.context_length, b.context_length, (v) => (num(v) === null ? '—' : fmtTokens(v))), | |
| 38 | + r('HF repo', a.hf_repo, b.hf_repo, (v) => <Mono>{val(v)}</Mono>), | |
| 39 | + r('Identifiers', ids(a), ids(b), (v) => (Array.isArray(v) && v.length ? <span className="flex flex-col">{v.map((x) => <Mono key={String(x)}>{String(x)}</Mono>)}</span> : '—')), | |
| 40 | + r('Aliases', a.aliases, b.aliases, (v) => (Array.isArray(v) && v.length ? <span className="text-xs">{v.map(String).join(' · ')}</span> : '—')), | |
| 41 | + r('Variant key', a.variant_key, b.variant_key, (v) => <Mono>{val(v)}</Mono>), | |
| 42 | + r('Base key (name analysis)', na(a).base_key, na(b).base_key, (v) => <Mono>{val(v)}</Mono>), | |
| 43 | + r('Is artifact (name analysis)', na(a).is_artifact, na(b).is_artifact, (v) => (v === true ? 'yes' : v === false ? 'no' : '—')), | |
| 44 | + r('Identity confidence', a.identity_confidence, b.identity_confidence), | |
| 45 | + r('Relations · sources · claims', [a.relations, a.sources, a.claims], [b.relations, b.sources, b.claims], (v) => (Array.isArray(v) ? <span className="tnum">{v.map((x) => fmtInt(x)).join(' · ')}</span> : '—')), | |
| 46 | + r('Prices · results', [a.prices, a.results], [b.prices, b.results], (v) => (Array.isArray(v) ? <span className="tnum">{v.map((x) => fmtInt(x)).join(' · ')}</span> : '—')), | |
| 47 | + r('First seen', a.first_seen_at, b.first_seen_at, (v) => (typeof v === 'string' ? fmtDate(v) : '—')), | |
| 48 | + ]; | |
| 49 | +} | |
| 50 | + | |
| 51 | +const DECISION_LABEL: Record<ResolutionDecision, { label: string; tone: 'neutral' | 'accent' | 'danger' | 'positive'; title: string }> = { | |
| 52 | + merge: { label: 'Merge', tone: 'danger', title: 'Merge A into B: aliases, identifiers, claims, relations and events move to B; A keeps resolving to B.' }, | |
| 53 | + alias: { label: 'Alias', tone: 'accent', title: 'Record A’s name as an alias of B without merging the rows.' }, | |
| 54 | + variant_of: { label: 'Variant of', tone: 'accent', title: 'A is an evaluation / effort variant of B (canonical_id = B), folded into B in the model universe.' }, | |
| 55 | + family_member: { label: 'Family member', tone: 'accent', title: 'A belongs to family B (B must be a model_family).' }, | |
| 56 | + keep_separate: { label: 'Keep separate', tone: 'positive', title: 'Two different entities: the pair stops being proposed.' }, | |
| 57 | + defer: { label: 'Defer', tone: 'neutral', title: 'Not now — keep the pair pending.' }, | |
| 58 | +}; | |
| 59 | +const DIRECTIONAL = new Set<ResolutionDecision>(['merge', 'alias', 'variant_of', 'family_member']); | |
| 60 | + | |
| 61 | +function Side({ s, letter }: { s: ResolutionSide; letter: 'A' | 'B' }) { | |
| 62 | + return ( | |
| 63 | + <div className="min-w-0"> | |
| 64 | + <p className="eyebrow">{letter}</p> | |
| 65 | + <p className="flex flex-wrap items-center gap-1.5"> | |
| 66 | + <EntityBadge type={s.entity_type} small /> | |
| 67 | + <Link href={routes.entity(s)} className="truncate text-sm font-medium text-ink hover:text-accent hover:underline"> | |
| 68 | + {s.name} | |
| 69 | + </Link> | |
| 70 | + </p> | |
| 71 | + <Mono title={s.id}>{s.id}</Mono> | |
| 72 | + </div> | |
| 73 | + ); | |
| 74 | +} | |
| 75 | + | |
| 76 | +export function ResolutionPair({ item, decisions, returnTo }: { item: ResolutionItem; decisions: ResolutionDecision[]; returnTo: string }) { | |
| 77 | + const rows = rowsFor(item.a, item.b); | |
| 78 | + const decided = item.decision ? (typeof item.decision === 'string' ? item.decision : item.decision.decision) : null; | |
| 79 | + const sim = num(item.similarity); | |
| 80 | + return ( | |
| 81 | + <article className="border border-rule" data-resolution-pair> | |
| 82 | + <header className="flex flex-col gap-2 border-b border-rule bg-surface-2/50 px-3 py-2 md:flex-row md:items-center md:justify-between"> | |
| 83 | + <div className="flex flex-wrap items-center gap-2 text-xs"> | |
| 84 | + {item.signals.map((s, i) => ( | |
| 85 | + <KindChip key={i} value={s.kind} /> | |
| 86 | + ))} | |
| 87 | + {sim !== null && <span className="tnum text-ink-2">similarity {fmtPct(sim * 100, 0)}</span>} | |
| 88 | + {item.same_organization && <span className="text-positive">same organization</span>} | |
| 89 | + {item.same_variant_key && <span className="text-accent">same variant key</span>} | |
| 90 | + {item.hint && <span className="text-ink-3">· {item.hint}</span>} | |
| 91 | + </div> | |
| 92 | + {decided && ( | |
| 93 | + <span className="text-xs text-ink-2"> | |
| 94 | + decided: <Mono>{decided}</Mono> | |
| 95 | + </span> | |
| 96 | + )} | |
| 97 | + </header> | |
| 98 | + <div className="grid gap-4 p-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"> | |
| 99 | + <Side s={item.a} letter="A" /> | |
| 100 | + <Side s={item.b} letter="B" /> | |
| 101 | + </div> | |
| 102 | + <div className="overflow-x-auto"> | |
| 103 | + <table className="data-table compact w-full table-fixed" style={{ minWidth: '36rem' }}> | |
| 104 | + <caption className="sr-only">Side by side</caption> | |
| 105 | + <colgroup> | |
| 106 | + <col style={{ width: '11rem' }} /> | |
| 107 | + <col /> | |
| 108 | + <col /> | |
| 109 | + </colgroup> | |
| 110 | + <thead> | |
| 111 | + <tr> | |
| 112 | + <th scope="col">Field</th> | |
| 113 | + <th scope="col">A</th> | |
| 114 | + <th scope="col">B</th> | |
| 115 | + </tr> | |
| 116 | + </thead> | |
| 117 | + <tbody> | |
| 118 | + {rows.map((r) => ( | |
| 119 | + <tr key={r.label} className={cn(r.same && 'text-ink-3')}> | |
| 120 | + <td className="text-xs text-ink-2">{r.label}</td> | |
| 121 | + <td className={cn('break-words text-xs [overflow-wrap:anywhere]', !r.same && 'bg-warning-soft/40 text-ink')} style={{ whiteSpace: 'normal' }}>{r.a}</td> | |
| 122 | + <td className={cn('break-words text-xs [overflow-wrap:anywhere]', !r.same && 'bg-warning-soft/40 text-ink')} style={{ whiteSpace: 'normal' }}>{r.b}</td> | |
| 123 | + </tr> | |
| 124 | + ))} | |
| 125 | + </tbody> | |
| 126 | + </table> | |
| 127 | + </div> | |
| 128 | + {!decided && ( | |
| 129 | + <footer className="border-t border-rule px-3 py-2"> | |
| 130 | + <p className="mb-2 text-[11px] text-ink-3">Directional decisions read A → B (A merged into / variant of / member of B). Use the second row to apply them the other way round.</p> | |
| 131 | + <div className="flex flex-wrap items-center gap-1.5"> | |
| 132 | + {decisions.map((d) => ( | |
| 133 | + <form key={d} action={resolutionAction} className="inline"> | |
| 134 | + <input type="hidden" name="a" value={item.a.id} /> | |
| 135 | + <input type="hidden" name="b" value={item.b.id} /> | |
| 136 | + <input type="hidden" name="decision" value={d} /> | |
| 137 | + <input type="hidden" name="return" value={returnTo} /> | |
| 138 | + <ActionButton tone={DECISION_LABEL[d]?.tone ?? 'neutral'} title={DECISION_LABEL[d]?.title}> | |
| 139 | + {DECISION_LABEL[d]?.label ?? d} | |
| 140 | + {DIRECTIONAL.has(d) ? ' A → B' : ''} | |
| 141 | + </ActionButton> | |
| 142 | + </form> | |
| 143 | + ))} | |
| 144 | + </div> | |
| 145 | + <div className="mt-1.5 flex flex-wrap items-center gap-1.5"> | |
| 146 | + {decisions.filter((d) => DIRECTIONAL.has(d)).map((d) => ( | |
| 147 | + <form key={`${d}-rev`} action={resolutionAction} className="inline"> | |
| 148 | + <input type="hidden" name="a" value={item.b.id} /> | |
| 149 | + <input type="hidden" name="b" value={item.a.id} /> | |
| 150 | + <input type="hidden" name="decision" value={d} /> | |
| 151 | + <input type="hidden" name="return" value={returnTo} /> | |
| 152 | + <ActionButton tone="neutral" title={`${DECISION_LABEL[d]?.title} (reversed)`}> | |
| 153 | + {DECISION_LABEL[d]?.label ?? d} B → A | |
| 154 | + </ActionButton> | |
| 155 | + </form> | |
| 156 | + ))} | |
| 157 | + </div> | |
| 158 | + </footer> | |
| 159 | + )} | |
| 160 | + </article> | |
| 161 | + ); | |
| 162 | +} | |
added
apps/web/src/components/admin/rollback-button.tsx
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { useState } from 'react'; | |
| 3 | +import { rollbackAction } from '@/lib/admin/actions'; | |
| 4 | +import { ActionButton } from './ui'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Rollback with confirmation: the operator must repeat the run id (works without JS through the same form; with JS the | |
| 8 | + * field appears inline). The server action refuses when the confirmation does not match. | |
| 9 | + */ | |
| 10 | +export function RollbackButton({ runId, connector, returnTo, disabled }: { runId: string; connector: string; returnTo: string; disabled?: boolean }) { | |
| 11 | + const [open, setOpen] = useState(false); | |
| 12 | + const [typed, setTyped] = useState(''); | |
| 13 | + if (!open) | |
| 14 | + return ( | |
| 15 | + <button type="button" onClick={() => setOpen(true)} disabled={disabled} className="inline-flex h-8 items-center border border-danger/60 px-2 text-xs font-medium text-danger hover:bg-danger-soft disabled:cursor-not-allowed disabled:opacity-40" title="Retract this run's claims, close its rows, flag its events (deletes nothing)" data-rollback> | |
| 16 | + Rollback | |
| 17 | + </button> | |
| 18 | + ); | |
| 19 | + return ( | |
| 20 | + <form action={rollbackAction} className="flex flex-wrap items-center gap-1.5" data-rollback-form> | |
| 21 | + <input type="hidden" name="run_id" value={runId} /> | |
| 22 | + <input type="hidden" name="return" value={returnTo} /> | |
| 23 | + <label className="flex items-center gap-1 text-[11px] text-ink-2"> | |
| 24 | + <span className="whitespace-nowrap"> | |
| 25 | + Type the run id to confirm ({connector}): | |
| 26 | + </span> | |
| 27 | + <input name="confirm" value={typed} onChange={(e) => setTyped(e.target.value)} placeholder={runId} className="mono h-8 w-64 border border-rule bg-surface px-2 text-[11px] text-ink focus:border-danger focus:outline-none" autoFocus /> | |
| 28 | + </label> | |
| 29 | + <ActionButton tone="danger" disabled={typed !== runId} title="Confirmed rollback"> | |
| 30 | + Confirm rollback | |
| 31 | + </ActionButton> | |
| 32 | + <button type="button" onClick={() => setOpen(false)} className="h-8 px-2 text-xs text-ink-3 hover:text-ink"> | |
| 33 | + Cancel | |
| 34 | + </button> | |
| 35 | + </form> | |
| 36 | + ); | |
| 37 | +} | |
modified
apps/web/src/components/admin/shell.tsx
+6 −1
@@ -12,7 +12,12 @@ export const ADMIN_NAV = [ | ||
| 12 | 12 | { href: '/admin/jobs', label: 'Jobs' }, |
| 13 | 13 | { href: '/admin/llm-jobs', label: 'LLM jobs' }, |
| 14 | 14 | { href: '/admin/review', label: 'Review queue' }, |
| 15 | − { href: '/admin/entities/duplicates', label: 'Duplicates' }, | |
| 15 | + { href: '/admin/quality', label: 'Data quality' }, | |
| 16 | + { href: '/admin/entity-resolution', label: 'Entity resolution' }, | |
| 17 | + { href: '/admin/anomalies', label: 'Anomalies' }, | |
| 18 | + { href: '/admin/quarantine', label: 'Quarantine' }, | |
| 19 | + { href: '/admin/audit', label: 'Audit log' }, | |
| 20 | + { href: '/admin/entities/duplicates', label: 'Duplicates (v1)' }, | |
| 16 | 21 | { href: '/admin/infrastructure', label: 'Infrastructure' }, |
| 17 | 22 | { href: '/admin/cache', label: 'Cache' }, |
| 18 | 23 | ]; |
modified
apps/web/src/components/changes/change-row.tsx
+32 −14
@@ -3,7 +3,7 @@ import Link from 'next/link'; | ||
| 3 | 3 | import { EntityBadge, ImportanceMark } from '@/components/ui/badges'; |
| 4 | 4 | import { LiveAgo } from '@/components/ui/live'; |
| 5 | 5 | import { cn } from '@/lib/cn'; |
| 6 | −import { fmtDate, fmtDateTime, fmtValue } from '@/lib/format'; | |
| 6 | +import { fmtDate, fmtDateTime, fmtUsdPerM, fmtValue } from '@/lib/format'; | |
| 7 | 7 | import { eventLabel, eventTone, propertyLabel, routes } from '@/lib/site'; |
| 8 | 8 | import type { ChangeEvent } from '@/lib/types'; |
| 9 | 9 | |
@@ -24,15 +24,29 @@ function host(url: string): string { | ||
| 24 | 24 | } |
| 25 | 25 | } |
| 26 | 26 | |
| 27 | +/** Price payloads (`PRICE_CHANGED`, `PROVIDER_LISTED`) are `{ input_per_mtok, output_per_mtok }` objects — never JSON-dump them. */ | |
| 28 | +function fmtSide(v: unknown, key?: string): string { | |
| 29 | + if (v && typeof v === 'object' && !Array.isArray(v)) { | |
| 30 | + const o = v as Record<string, unknown>; | |
| 31 | + if ('input_per_mtok' in o || 'output_per_mtok' in o) return `${fmtUsdPerM(o.input_per_mtok)} in / ${fmtUsdPerM(o.output_per_mtok)} out`; | |
| 32 | + const parts = Object.entries(o) | |
| 33 | + .filter(([, x]) => x !== null && x !== undefined && typeof x !== 'object') | |
| 34 | + .slice(0, 3) | |
| 35 | + .map(([k, x]) => `${propertyLabel(k).toLowerCase()} ${fmtValue(x, k)}`); | |
| 36 | + return parts.length ? parts.join(' · ') : fmtValue(v, key); | |
| 37 | + } | |
| 38 | + return fmtValue(v, key); | |
| 39 | +} | |
| 40 | + | |
| 27 | 41 | /** Old → new rendering for property changes and prices. */ |
| 28 | 42 | export function Delta({ e }: { e: ChangeEvent }) { |
| 29 | 43 | if (e.old_value === undefined || e.old_value === null || e.new_value === undefined || e.new_value === null) return null; |
| 30 | 44 | const key = e.property ?? undefined; |
| 31 | 45 | return ( |
| 32 | − <span className="tnum inline-flex flex-wrap items-center gap-1 text-xs"> | |
| 33 | − <span className="text-ink-3 line-through decoration-ink-3/60">{fmtValue(e.old_value, key)}</span> | |
| 46 | + <span className="tnum inline-flex min-w-0 flex-wrap items-center gap-1 break-words text-xs"> | |
| 47 | + <span className="text-ink-3 line-through decoration-ink-3/60">{fmtSide(e.old_value, key)}</span> | |
| 34 | 48 | <span className="text-ink-3">→</span> |
| 35 | − <span className="font-medium text-ink">{fmtValue(e.new_value, key)}</span> | |
| 49 | + <span className="font-medium text-ink">{fmtSide(e.new_value, key)}</span> | |
| 36 | 50 | </span> |
| 37 | 51 | ); |
| 38 | 52 | } |
@@ -41,13 +55,16 @@ export function Delta({ e }: { e: ChangeEvent }) { | ||
| 41 | 55 | * One event in a feed: importance meter · time · entity badge + name · summary · property delta · source. |
| 42 | 56 | * `dense` (homepage) hides the delta and connector; `showDate` prints the date instead of relative time. |
| 43 | 57 | */ |
| 44 | −export function ChangeRow({ e, dense = false, showDate = false, className, live = true }: { e: ChangeEvent; dense?: boolean; showDate?: boolean; className?: string; live?: boolean }) { | |
| 58 | +export function ChangeRow({ e, dense = false, showDate = false, className, live = true, trailing }: { e: ChangeEvent; dense?: boolean; showDate?: boolean; className?: string; live?: boolean; /** Extra node after the summary (D3: grouped sources, evidence links). */ trailing?: React.ReactNode }) { | |
| 45 | 59 | const tone = eventTone(e.event_type); |
| 46 | − const when = e.effective_at ?? e.observed_at; | |
| 60 | + // 1.1: occurred_at = coalesce(effective_at, observed_at); the title always tells when AI Atlas observed it. | |
| 61 | + const when = e.occurred_at ?? e.effective_at ?? e.observed_at; | |
| 62 | + const observedTitle = `observed ${fmtDateTime(e.observed_at)}${e.effective_at ? ` · effective ${fmtDate(e.effective_at)}` : ''}`; | |
| 47 | 63 | return ( |
| 48 | − <li className={cn('grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 border-b border-rule py-2.5 md:grid-cols-[5.5rem_auto_minmax(0,1fr)_auto] md:items-baseline', className)}> | |
| 64 | + <li className={cn('row-y grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 border-b border-rule md:grid-cols-[5.5rem_auto_minmax(0,1fr)_auto] md:items-baseline', className)} data-event-id={e.id}> | |
| 49 | 65 | <div className="col-span-2 flex items-center gap-2 text-xs text-ink-3 md:col-span-1 md:block"> |
| 50 | − {showDate ? <time dateTime={when} title={fmtDateTime(e.observed_at)}>{fmtDate(when)}</time> : live ? <LiveAgo at={e.observed_at} /> : <time dateTime={e.observed_at} title={fmtDateTime(e.observed_at)}>{fmtDate(e.observed_at)}</time>} | |
| 66 | + {showDate ? <time dateTime={when} title={observedTitle}>{fmtDate(when)}</time> : live ? <LiveAgo at={e.observed_at} /> : <time dateTime={e.observed_at} title={observedTitle}>{fmtDate(e.observed_at)}</time>} | |
| 67 | + {e.is_backfill && <span className="ml-1 rounded-[3px] bg-surface-2 px-1 text-[10px] uppercase tracking-wide text-ink-3" title="Historical backfill: imported when the source was first crawled, not observed live">backfill</span>} | |
| 51 | 68 | <ImportanceMark importance={e.importance} className="md:hidden" /> |
| 52 | 69 | </div> |
| 53 | 70 | <div className="hidden md:flex md:items-center md:self-center"> |
@@ -59,14 +76,14 @@ export function ChangeRow({ e, dense = false, showDate = false, className, live | ||
| 59 | 76 | {e.entity && ( |
| 60 | 77 | <> |
| 61 | 78 | <EntityBadge type={e.entity.entity_type} small /> |
| 62 | − <Link href={routes.entity(e.entity)} className="truncate text-[15px] font-medium text-ink hover:text-accent hover:underline"> | |
| 79 | + <Link href={routes.entity(e.entity)} className="max-w-full truncate text-[15px] font-medium text-ink hover:text-accent hover:underline"> | |
| 63 | 80 | {e.entity.name} |
| 64 | 81 | </Link> |
| 65 | − {e.entity.organization && <span className="truncate text-xs text-ink-3">{e.entity.organization.name}</span>} | |
| 82 | + {e.entity.organization && <span className="max-w-full truncate text-xs text-ink-3">{e.entity.organization.name}</span>} | |
| 66 | 83 | </> |
| 67 | 84 | )} |
| 68 | 85 | </div> |
| 69 | − <p className={cn('mt-0.5 text-sm text-ink-2', dense && 'line-clamp-2')}>{e.summary}</p> | |
| 86 | + <p className={cn('mt-0.5 break-words text-sm text-ink-2 [overflow-wrap:anywhere]', dense && 'line-clamp-2')}>{e.summary}</p> | |
| 70 | 87 | {!dense && ( |
| 71 | 88 | <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5"> |
| 72 | 89 | {e.property && <span className="text-xs text-ink-3">{propertyLabel(e.property)}</span>} |
@@ -74,6 +91,7 @@ export function ChangeRow({ e, dense = false, showDate = false, className, live | ||
| 74 | 91 | {e.connector_name && <span className="mono text-[11px] text-ink-3">{e.connector_name}</span>} |
| 75 | 92 | </div> |
| 76 | 93 | )} |
| 94 | + {trailing} | |
| 77 | 95 | </div> |
| 78 | 96 | <div className="col-span-2 mt-1 text-xs md:col-span-1 md:mt-0 md:text-right"> |
| 79 | 97 | {e.source_url ? ( |
@@ -88,11 +106,11 @@ export function ChangeRow({ e, dense = false, showDate = false, className, live | ||
| 88 | 106 | ); |
| 89 | 107 | } |
| 90 | 108 | |
| 91 | −/** Group events by UTC day (YYYY-MM-DD of observed_at). */ | |
| 92 | −export function groupByDay(events: ChangeEvent[]): { day: string; items: ChangeEvent[] }[] { | |
| 109 | +/** Group events by UTC day (YYYY-MM-DD of `occurred_at` when present — 1.1 feeds — else observed_at). */ | |
| 110 | +export function groupByDay(events: ChangeEvent[], field: 'occurred' | 'observed' = 'occurred'): { day: string; items: ChangeEvent[] }[] { | |
| 93 | 111 | const map = new Map<string, ChangeEvent[]>(); |
| 94 | 112 | for (const e of events) { |
| 95 | − const day = e.observed_at.slice(0, 10); | |
| 113 | + const day = (field === 'occurred' ? e.occurred_at ?? e.effective_at ?? e.observed_at : e.observed_at).slice(0, 10); | |
| 96 | 114 | const arr = map.get(day); |
| 97 | 115 | if (arr) arr.push(e); |
| 98 | 116 | else map.set(day, [e]); |
modified
apps/web/src/components/changes/load-more.tsx
+10 −8
@@ -6,15 +6,16 @@ import type { ChangeEvent } from '@/lib/types'; | ||
| 6 | 6 | import { ChangeRow, groupByDay } from './change-row'; |
| 7 | 7 | |
| 8 | 8 | /** |
| 9 | − * Cursor pagination for /changes: the server renders the first page; this appends more via `before=<observed_at>`. | |
| 10 | − * `qs` carries the active filters (category, type, importance_min, since…). | |
| 9 | + * Cursor pagination for /changes: the server renders the first page; this appends more via `before=<cursor>`. | |
| 10 | + * `qs` carries the active filters (category, type, importance_min, since, include_backfill, date_field…). The cursor is | |
| 11 | + * the API's `next_before` (1.1: follows `date_field`, `occurred_at` by default) — falls back to the last row's date. | |
| 11 | 12 | */ |
| 12 | −export function LoadMore({ qs, initialCursor, lastDay }: { qs: string; initialCursor: string | null; lastDay: string | null }) { | |
| 13 | +export function LoadMore({ qs, initialCursor, lastDay, dateField = 'occurred' }: { qs: string; initialCursor: string | null; lastDay: string | null; dateField?: 'occurred' | 'observed' }) { | |
| 13 | 14 | const [items, setItems] = useState<ChangeEvent[]>([]); |
| 14 | 15 | const [cursor, setCursor] = useState<string | null>(initialCursor); |
| 15 | 16 | const [loading, setLoading] = useState(false); |
| 16 | 17 | const [error, setError] = useState(false); |
| 17 | − const groups = groupByDay(items); | |
| 18 | + const groups = groupByDay(items, dateField); | |
| 18 | 19 | |
| 19 | 20 | const more = async () => { |
| 20 | 21 | if (!cursor) return; |
@@ -24,10 +25,11 @@ export function LoadMore({ qs, initialCursor, lastDay }: { qs: string; initialCu | ||
| 24 | 25 | const p = new URLSearchParams(qs); |
| 25 | 26 | p.set('before', cursor); |
| 26 | 27 | p.set('limit', '50'); |
| 27 | − const res = await clientApi.changes(p.toString()); | |
| 28 | + const res = (await clientApi.changes(p.toString())) as Awaited<ReturnType<typeof clientApi.changes>> & { next_before?: string | null }; | |
| 28 | 29 | setItems((prev) => [...prev, ...res.items]); |
| 29 | 30 | const last = res.items[res.items.length - 1]; |
| 30 | − setCursor(res.items.length < 50 || !last ? null : last.observed_at); | |
| 31 | + const fallback = last ? (dateField === 'occurred' ? last.occurred_at ?? last.effective_at ?? last.observed_at : last.observed_at) : null; | |
| 32 | + setCursor(res.items.length < 50 || !last ? null : res.next_before ?? fallback); | |
| 31 | 33 | } catch { |
| 32 | 34 | setError(true); |
| 33 | 35 | } finally { |
@@ -46,14 +48,14 @@ export function LoadMore({ qs, initialCursor, lastDay }: { qs: string; initialCu | ||
| 46 | 48 | )} |
| 47 | 49 | <ul className="border-t border-rule"> |
| 48 | 50 | {g.items.map((e) => ( |
| 49 | − <ChangeRow key={e.id} e={e} /> | |
| 51 | + <ChangeRow key={e.id} e={e} showDate /> | |
| 50 | 52 | ))} |
| 51 | 53 | </ul> |
| 52 | 54 | </section> |
| 53 | 55 | ))} |
| 54 | 56 | <div className="mt-6 flex items-center gap-3"> |
| 55 | 57 | {cursor ? ( |
| 56 | − <button type="button" onClick={more} disabled={loading} className="h-10 border border-rule px-4 text-sm text-ink-2 hover:border-rule-strong hover:text-ink disabled:opacity-50"> | |
| 58 | + <button type="button" onClick={more} disabled={loading} className="h-10 border border-rule px-4 text-sm text-ink-2 hover:border-rule-strong hover:text-ink disabled:opacity-50" data-load-more> | |
| 57 | 59 | {loading ? 'Loading…' : 'Load older events'} |
| 58 | 60 | </button> |
| 59 | 61 | ) : ( |
added
apps/web/src/components/changes/rail-filters.tsx
+165 −0
@@ -0,0 +1,165 @@ | ||
| 1 | +'use client'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { useEffect, useState } from 'react'; | |
| 4 | +import { Hint } from '@/components/ui/hint'; | |
| 5 | +import { cn } from '@/lib/cn'; | |
| 6 | +import { fmtInt } from '@/lib/format'; | |
| 7 | + | |
| 8 | +/* | |
| 9 | + Filter rail form (client). Pages describe their fields as plain data and the form is rendered here — a GET form that | |
| 10 | + works without JS (the URL is the state). Long option lists (organizations, families) are loaded client-side from the | |
| 11 | + same-origin API (`remote`): a large `filters` prop makes React Flight outline the element into a lazy chunk, and the | |
| 12 | + dev client then re-validates it as a keyed list (false "unique key" warning) — keeping the prop small avoids it and | |
| 13 | + keeps the rail light. The currently selected value is always rendered server-side so the form is correct before JS. | |
| 14 | +*/ | |
| 15 | +export type RailOption = { value: string; label: string }; | |
| 16 | +export type RailRemote = 'companies' | 'families'; | |
| 17 | +export type RailField = | |
| 18 | + | { kind: 'select'; name: string; label: string; value?: string; options: RailOption[]; any?: string; note?: string; hint?: string; remote?: RailRemote; remoteLimit?: number } | |
| 19 | + | { kind: 'text'; name: string; label: string; value?: string; placeholder?: string; list?: string[]; note?: string } | |
| 20 | + | { kind: 'date'; name: string; label: string; value?: string; max?: string } | |
| 21 | + | { kind: 'checkbox'; name: string; label: string; value?: string; checked?: boolean; hint?: string } | |
| 22 | + | { kind: 'hidden'; name: string; value: string } | |
| 23 | + | { kind: 'chips'; label: string; items: { href: string; label: string; active?: boolean; count?: string }[] } | |
| 24 | + | { kind: 'row'; fields: RailField[] }; | |
| 25 | + | |
| 26 | +const cls = 'h-10 w-full border border-rule bg-surface px-2 text-sm text-ink focus:border-accent focus:outline-none'; | |
| 27 | + | |
| 28 | +function Field({ f }: { f: RailField }) { | |
| 29 | + switch (f.kind) { | |
| 30 | + case 'hidden': | |
| 31 | + return <input type="hidden" name={f.name} value={f.value} />; | |
| 32 | + case 'row': | |
| 33 | + return ( | |
| 34 | + <div className="grid grid-cols-2 gap-2"> | |
| 35 | + {f.fields.map((x, i) => ( | |
| 36 | + <Field key={'name' in x ? x.name : i} f={x} /> | |
| 37 | + ))} | |
| 38 | + </div> | |
| 39 | + ); | |
| 40 | + case 'chips': | |
| 41 | + return ( | |
| 42 | + <div> | |
| 43 | + <p className="eyebrow mb-1.5">{f.label}</p> | |
| 44 | + <nav aria-label={f.label} className="no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0"> | |
| 45 | + {f.items.map((it) => ( | |
| 46 | + <Link key={it.href + it.label} href={it.href} aria-current={it.active ? 'true' : undefined} className={cn('inline-flex h-9 shrink-0 items-center gap-1.5 border px-2.5 text-sm transition-colors', it.active ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}> | |
| 47 | + <span>{it.label}</span> | |
| 48 | + {it.count !== undefined && <span className={cn('tnum text-[11px]', it.active ? 'text-canvas/70' : 'text-ink-3')}>{it.count}</span>} | |
| 49 | + </Link> | |
| 50 | + ))} | |
| 51 | + </nav> | |
| 52 | + </div> | |
| 53 | + ); | |
| 54 | + case 'checkbox': | |
| 55 | + return ( | |
| 56 | + <label className="flex min-h-10 items-center gap-2 text-sm text-ink-2"> | |
| 57 | + <input type="checkbox" name={f.name} value={f.value ?? '1'} defaultChecked={f.checked} className="size-4 accent-[var(--accent)]" /> | |
| 58 | + {f.label} | |
| 59 | + {f.hint && <Hint align="right" text={f.hint} />} | |
| 60 | + </label> | |
| 61 | + ); | |
| 62 | + case 'date': | |
| 63 | + return ( | |
| 64 | + <label className="block"> | |
| 65 | + <span className="eyebrow block pb-1">{f.label}</span> | |
| 66 | + <input type="date" name={f.name} defaultValue={f.value ?? ''} max={f.max} className={cls} /> | |
| 67 | + </label> | |
| 68 | + ); | |
| 69 | + case 'text': { | |
| 70 | + const listId = f.list ? `rail-list-${f.name}` : undefined; | |
| 71 | + return ( | |
| 72 | + <label className="block"> | |
| 73 | + <span className="eyebrow block pb-1">{f.label}</span> | |
| 74 | + <input name={f.name} defaultValue={f.value ?? ''} placeholder={f.placeholder} className={cls} list={listId} /> | |
| 75 | + {f.list && listId && ( | |
| 76 | + <datalist id={listId}> | |
| 77 | + {f.list.map((v) => ( | |
| 78 | + <option key={v} value={v} /> | |
| 79 | + ))} | |
| 80 | + </datalist> | |
| 81 | + )} | |
| 82 | + {f.note && <span className="mt-1 block text-[11px] text-ink-3">{f.note}</span>} | |
| 83 | + </label> | |
| 84 | + ); | |
| 85 | + } | |
| 86 | + case 'select': | |
| 87 | + return <SelectField f={f} />; | |
| 88 | + default: | |
| 89 | + return null; | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +async function loadRemote(kind: RailRemote, limit: number): Promise<RailOption[]> { | |
| 94 | + if (kind === 'companies') { | |
| 95 | + const r = await fetch(`/api/v1/companies?limit=${limit}&sort=models`, { headers: { accept: 'application/json' } }); | |
| 96 | + if (!r.ok) throw new Error(String(r.status)); | |
| 97 | + const d = (await r.json()) as { items: { slug: string; name: string; model_count: unknown }[] }; | |
| 98 | + return d.items.map((o) => ({ value: o.slug, label: `${o.name} (${fmtInt(o.model_count)})` })); | |
| 99 | + } | |
| 100 | + const r = await fetch(`/api/v1/families?limit=${limit}&sort=models`, { headers: { accept: 'application/json' } }); | |
| 101 | + if (!r.ok) throw new Error(String(r.status)); | |
| 102 | + const d = (await r.json()) as { items: { slug: string; name: string; canonical: boolean; model_count: unknown }[] }; | |
| 103 | + return d.items.filter((f) => f.canonical).map((f) => ({ value: f.slug, label: `${f.name} (${fmtInt(f.model_count)})` })); | |
| 104 | +} | |
| 105 | + | |
| 106 | +function SelectField({ f }: { f: Extract<RailField, { kind: 'select' }> }) { | |
| 107 | + const [remote, setRemote] = useState<RailOption[] | null>(null); | |
| 108 | + const [failed, setFailed] = useState(false); | |
| 109 | + useEffect(() => { | |
| 110 | + if (!f.remote) return; | |
| 111 | + let alive = true; | |
| 112 | + loadRemote(f.remote, f.remoteLimit ?? 60) | |
| 113 | + .then((o) => alive && setRemote(o)) | |
| 114 | + .catch(() => alive && setFailed(true)); | |
| 115 | + return () => { | |
| 116 | + alive = false; | |
| 117 | + }; | |
| 118 | + }, [f.remote, f.remoteLimit]); | |
| 119 | + const options = remote ?? f.options; | |
| 120 | + const hasValue = !f.value || options.some((o) => o.value === f.value); | |
| 121 | + return ( | |
| 122 | + <label className="block"> | |
| 123 | + <span className="eyebrow block pb-1"> | |
| 124 | + {f.label} | |
| 125 | + {f.hint && <Hint align="right" text={f.hint} />} | |
| 126 | + </span> | |
| 127 | + <select name={f.name} defaultValue={f.value ?? ''} className={cls}> | |
| 128 | + <option value="">{f.any ?? 'Any'}</option> | |
| 129 | + {!hasValue && f.value && <option value={f.value}>{f.value}</option>} | |
| 130 | + {options.map((o) => ( | |
| 131 | + <option key={o.value} value={o.value}> | |
| 132 | + {o.label} | |
| 133 | + </option> | |
| 134 | + ))} | |
| 135 | + {f.remote && !remote && !failed && <option disabled>loading…</option>} | |
| 136 | + </select> | |
| 137 | + {f.note && <span className="mt-1 block text-[11px] text-ink-3">{f.note}</span>} | |
| 138 | + {failed && <span className="mt-1 block text-[11px] text-warning">Option list unavailable — type the slug in the URL.</span>} | |
| 139 | + </label> | |
| 140 | + ); | |
| 141 | +} | |
| 142 | + | |
| 143 | +/** | |
| 144 | + * `fields` may be passed as a JSON string (`fieldsJson`): React Flight outlines large prop arrays into separate chunks and, | |
| 145 | + * in development, the client then re-validates them as if they were child lists (a false "unique key" warning). A string | |
| 146 | + * of any size is never treated as children, so pages with long option lists (organizations, families) use `fieldsJson`. | |
| 147 | + */ | |
| 148 | +export function RailFilters({ action, fields, fieldsJson, resetHref, submitLabel = 'Apply', testId }: { action: string; fields?: RailField[]; fieldsJson?: string; resetHref: string; submitLabel?: string; testId?: string }) { | |
| 149 | + const list: RailField[] = fields ?? (fieldsJson ? (JSON.parse(fieldsJson) as RailField[]) : []); | |
| 150 | + return ( | |
| 151 | + <form action={action} method="get" className="space-y-3 text-sm" data-rail-filters={testId ?? action}> | |
| 152 | + {list.map((f, i) => ( | |
| 153 | + <Field key={'name' in f ? `${f.kind}-${f.name}` : `${f.kind}-${i}`} f={f} /> | |
| 154 | + ))} | |
| 155 | + <div className="flex gap-2"> | |
| 156 | + <button type="submit" className="inline-flex h-10 flex-1 items-center justify-center bg-ink px-3 text-sm font-medium text-canvas hover:opacity-90"> | |
| 157 | + {submitLabel} | |
| 158 | + </button> | |
| 159 | + <Link href={resetHref} className="inline-flex h-10 items-center border border-rule px-3 text-sm text-ink-2 hover:text-ink"> | |
| 160 | + Reset | |
| 161 | + </Link> | |
| 162 | + </div> | |
| 163 | + </form> | |
| 164 | + ); | |
| 165 | +} | |
added
apps/web/src/components/changes/today-sections.tsx
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +import { ExternalLink } from 'lucide-react'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { hostOf, fmtInt, num, plural } from '@/lib/format'; | |
| 4 | +import { routes } from '@/lib/site'; | |
| 5 | +import type { TodayItem, TodaySection } from '@/lib/types'; | |
| 6 | +import { ChangeRow } from './change-row'; | |
| 7 | + | |
| 8 | +/** Fixed order of the Today in AI 2.0 sections (API keys) — sections absent from the payload are listed as quiet. */ | |
| 9 | +export const TODAY_ORDER = ['MAJOR_RELEASES', 'PRICE_MOVES', 'BENCHMARK_MOVES', 'MODEL_CHANGES', 'RESEARCH', 'OPEN_WEIGHT_RELEASES', 'DEPRECATIONS', 'PROVIDER_CHANGES', 'HARDWARE'] as const; | |
| 10 | +export const TODAY_LABELS: Record<string, string> = { | |
| 11 | + MAJOR_RELEASES: 'Major releases', | |
| 12 | + PRICE_MOVES: 'Price moves', | |
| 13 | + BENCHMARK_MOVES: 'Benchmark moves', | |
| 14 | + MODEL_CHANGES: 'Model changes', | |
| 15 | + RESEARCH: 'Research', | |
| 16 | + OPEN_WEIGHT_RELEASES: 'Open-weight releases', | |
| 17 | + DEPRECATIONS: 'Deprecations', | |
| 18 | + PROVIDER_CHANGES: 'Provider changes', | |
| 19 | + HARDWARE: 'Hardware', | |
| 20 | +}; | |
| 21 | +/** Section key → the `/changes` filter that lists everything behind it. */ | |
| 22 | +export const TODAY_FEED_HREF: Record<string, (date: string) => string> = { | |
| 23 | + MAJOR_RELEASES: (d) => `/changes?importance_min=3&since=${d}&until=${d}`, | |
| 24 | + PRICE_MOVES: (d) => `/changes?category=price&since=${d}&until=${d}`, | |
| 25 | + BENCHMARK_MOVES: (d) => `/changes?category=benchmark&since=${d}&until=${d}`, | |
| 26 | + MODEL_CHANGES: (d) => `/changes?category=model&since=${d}&until=${d}`, | |
| 27 | + RESEARCH: (d) => `/changes?category=paper&since=${d}&until=${d}`, | |
| 28 | + OPEN_WEIGHT_RELEASES: (d) => `/changes?type=NEW_MODEL&since=${d}&until=${d}`, | |
| 29 | + DEPRECATIONS: (d) => `/changes?type=DEPRECATION_ANNOUNCED&since=${d}&until=${d}`, | |
| 30 | + PROVIDER_CHANGES: (d) => `/changes?category=provider&since=${d}&until=${d}`, | |
| 31 | + HARDWARE: (d) => `/changes?category=hardware&since=${d}&until=${d}`, | |
| 32 | +}; | |
| 33 | + | |
| 34 | +export function orderedSections(today: TodaySection[] | undefined): { present: TodaySection[]; quiet: string[] } { | |
| 35 | + const byKey = new Map((today ?? []).map((s) => [s.key, s])); | |
| 36 | + const present: TodaySection[] = []; | |
| 37 | + const quiet: string[] = []; | |
| 38 | + for (const k of TODAY_ORDER) { | |
| 39 | + const s = byKey.get(k); | |
| 40 | + if (s && s.items.length) present.push(s); | |
| 41 | + else quiet.push(k); | |
| 42 | + } | |
| 43 | + for (const s of today ?? []) if (!(TODAY_ORDER as readonly string[]).includes(s.key) && s.items.length) present.push(s); | |
| 44 | + return { present, quiet }; | |
| 45 | +} | |
| 46 | + | |
| 47 | +/** "1 release · 4 documents" + expandable source list for grouped items (events sharing a group_key). */ | |
| 48 | +export function GroupedSources({ it }: { it: TodayItem }) { | |
| 49 | + const grouped = num(it.grouped_events) ?? 1; | |
| 50 | + const docs = it.documents ?? []; | |
| 51 | + const sources = num(it.sources) ?? docs.length; | |
| 52 | + if (grouped <= 1 && docs.length <= 1) return null; | |
| 53 | + const noun = it.event_type === 'RELEASE' || it.event_type === 'NEW_MODEL' ? 'release' : it.event_type === 'NEW_PAPER' ? 'paper' : 'event'; | |
| 54 | + return ( | |
| 55 | + <details className="mt-1 text-xs text-ink-3" data-grouped> | |
| 56 | + <summary className="cursor-pointer select-none hover:text-ink"> | |
| 57 | + 1 {noun} · {fmtInt(grouped)} {plural(grouped, 'event')} · {fmtInt(docs.length || sources)} {plural(docs.length || sources, 'document')} | |
| 58 | + {sources > 1 && docs.length !== sources ? ` · ${fmtInt(sources)} sources` : ''} | |
| 59 | + </summary> | |
| 60 | + <ul className="mt-1 space-y-0.5 pl-3"> | |
| 61 | + {docs.map((u) => ( | |
| 62 | + <li key={u}> | |
| 63 | + <a href={u} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-ink-2 hover:text-accent"> | |
| 64 | + {hostOf(u) ?? u} <ExternalLink className="size-3" aria-hidden /> | |
| 65 | + <span className="truncate text-ink-3">{u.replace(/^https?:\/\/[^/]+/, '').slice(0, 60)}</span> | |
| 66 | + </a> | |
| 67 | + </li> | |
| 68 | + ))} | |
| 69 | + {docs.length === 0 && <li>Documents not listed by the API for this group.</li>} | |
| 70 | + </ul> | |
| 71 | + </details> | |
| 72 | + ); | |
| 73 | +} | |
| 74 | + | |
| 75 | +export function TodayItemRow({ it }: { it: TodayItem }) { | |
| 76 | + return <ChangeRow e={it} showDate live={false} trailing={<GroupedSources it={it} />} />; | |
| 77 | +} | |
| 78 | + | |
| 79 | +export function QuietLine({ quiet, labels }: { quiet: string[]; labels?: Record<string, string> }) { | |
| 80 | + if (!quiet.length) return null; | |
| 81 | + return ( | |
| 82 | + <p className="mt-6 text-xs text-ink-3"> | |
| 83 | + Quiet today: {quiet.map((k, i) => ( | |
| 84 | + <span key={k}> | |
| 85 | + {i > 0 && ' · '} | |
| 86 | + {labels?.[k] ?? TODAY_LABELS[k] ?? k} | |
| 87 | + </span> | |
| 88 | + ))} | |
| 89 | + . <Link href={routes.timeline()} className="link">Timeline →</Link> | |
| 90 | + </p> | |
| 91 | + ); | |
| 92 | +} | |
added
apps/web/src/components/graph/graph-canvas.tsx
+405 −0
@@ -0,0 +1,405 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, type SimulationLinkDatum, type SimulationNodeDatum } from 'd3-force'; | |
| 3 | +import { Crosshair, Minus, Plus } from 'lucide-react'; | |
| 4 | +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |
| 5 | +import { cn } from '@/lib/cn'; | |
| 6 | +import { predicateLabel, TYPE_COLOR_KEY, typeLabel } from '@/lib/site'; | |
| 7 | +import type { ExploreEdge, ExploreNode } from '@/lib/types'; | |
| 8 | + | |
| 9 | +/* | |
| 10 | + Force graph on a plain SVG (no WebGL, no d3-zoom dependency): d3-force computes the layout synchronously after mount, | |
| 11 | + positions are kept across merges (progressive expansion), pan / wheel-zoom / pinch / node drag are pointer events on the | |
| 12 | + <svg>, labels are budgeted by degree and zoom level, click selects, double-click (or Shift+Enter) expands. | |
| 13 | + Server render = the frame only, so the markup is stable between SSR and hydration. | |
| 14 | +*/ | |
| 15 | + | |
| 16 | +export type SimNode = SimulationNodeDatum & ExploreNode & { degree: number; isRoot: boolean }; | |
| 17 | +type SimLink = SimulationLinkDatum<SimNode> & { predicate: string; tier?: number | null }; | |
| 18 | +type Transform = { x: number; y: number; k: number }; | |
| 19 | + | |
| 20 | +const W = 960; | |
| 21 | +const H = 640; | |
| 22 | +const K_MIN = 0.25; | |
| 23 | +const K_MAX = 6; | |
| 24 | + | |
| 25 | +export function colorOf(type: string): string { | |
| 26 | + return `var(--type-${TYPE_COLOR_KEY[type] ?? 'tool'})`; | |
| 27 | +} | |
| 28 | +function short(s: string, n = 24): string { | |
| 29 | + return s.length > n ? `${s.slice(0, n - 1)}…` : s; | |
| 30 | +} | |
| 31 | +function radiusOf(n: { degree: number; isRoot: boolean; level?: number }): number { | |
| 32 | + if (n.isRoot) return 13; | |
| 33 | + return 4.5 + Math.min(7, Math.sqrt(n.degree) * 1.6); | |
| 34 | +} | |
| 35 | + | |
| 36 | +export function GraphCanvas({ | |
| 37 | + nodes, | |
| 38 | + edges, | |
| 39 | + rootId, | |
| 40 | + selectedId, | |
| 41 | + onSelect, | |
| 42 | + onExpand, | |
| 43 | + expanded, | |
| 44 | + loadingId, | |
| 45 | + hiddenTypes, | |
| 46 | + className, | |
| 47 | + tall = false, | |
| 48 | +}: { | |
| 49 | + nodes: ExploreNode[]; | |
| 50 | + edges: ExploreEdge[]; | |
| 51 | + rootId: string; | |
| 52 | + selectedId: string | null; | |
| 53 | + onSelect: (id: string | null) => void; | |
| 54 | + onExpand?: (id: string) => void; | |
| 55 | + expanded: Set<string>; | |
| 56 | + loadingId?: string | null; | |
| 57 | + hiddenTypes: Set<string>; | |
| 58 | + className?: string; | |
| 59 | + /** Full-height canvas (mobile full-screen / flagship page). */ | |
| 60 | + tall?: boolean; | |
| 61 | +}) { | |
| 62 | + const svgRef = useRef<SVGSVGElement>(null); | |
| 63 | + const posRef = useRef<Map<string, { x: number; y: number }>>(new Map()); | |
| 64 | + const [mounted, setMounted] = useState(false); | |
| 65 | + const [hover, setHover] = useState<string | null>(null); | |
| 66 | + const [t, setT] = useState<Transform>({ x: 0, y: 0, k: 1 }); | |
| 67 | + const [, bump] = useState(0); | |
| 68 | + useEffect(() => setMounted(true), []); | |
| 69 | + | |
| 70 | + const visible = useMemo(() => nodes.filter((n) => !hiddenTypes.has(n.entity_type) || n.id === rootId), [nodes, hiddenTypes, rootId]); | |
| 71 | + const signature = useMemo(() => `${visible.map((n) => n.id).join('|')}#${edges.length}`, [visible, edges.length]); | |
| 72 | + | |
| 73 | + const layout = useMemo(() => { | |
| 74 | + if (!mounted) return null; | |
| 75 | + const ids = new Set(visible.map((n) => n.id)); | |
| 76 | + const degree = new Map<string, number>(); | |
| 77 | + const links: SimLink[] = []; | |
| 78 | + const seen = new Set<string>(); | |
| 79 | + for (const e of edges) { | |
| 80 | + if (!ids.has(e.source) || !ids.has(e.target) || e.source === e.target) continue; | |
| 81 | + const key = `${e.source}>${e.target}:${e.predicate}`; | |
| 82 | + if (seen.has(key)) continue; | |
| 83 | + seen.add(key); | |
| 84 | + degree.set(e.source, (degree.get(e.source) ?? 0) + 1); | |
| 85 | + degree.set(e.target, (degree.get(e.target) ?? 0) + 1); | |
| 86 | + links.push({ source: e.source, target: e.target, predicate: e.predicate, tier: e.tier ?? null }); | |
| 87 | + } | |
| 88 | + const pos = posRef.current; | |
| 89 | + // neighbour lookup for seeding new nodes next to an already-placed neighbour | |
| 90 | + const nb = new Map<string, string[]>(); | |
| 91 | + for (const l of links) { | |
| 92 | + const a = l.source as string; | |
| 93 | + const b = l.target as string; | |
| 94 | + (nb.get(a) ?? nb.set(a, []).get(a)!).push(b); | |
| 95 | + (nb.get(b) ?? nb.set(b, []).get(b)!).push(a); | |
| 96 | + } | |
| 97 | + const sim: SimNode[] = visible.map((n, i) => { | |
| 98 | + const isRoot = n.id === rootId; | |
| 99 | + const known = pos.get(n.id); | |
| 100 | + let x: number; | |
| 101 | + let y: number; | |
| 102 | + if (known) ({ x, y } = known); | |
| 103 | + else if (isRoot) [x, y] = [W / 2, H / 2]; | |
| 104 | + else { | |
| 105 | + const anchor = (nb.get(n.id) ?? []).map((id) => pos.get(id)).find(Boolean); | |
| 106 | + const ang = (i / Math.max(1, visible.length)) * Math.PI * 2 + (n.level ?? 1) * 0.7; | |
| 107 | + if (anchor) [x, y] = [anchor.x + Math.cos(ang) * 60, anchor.y + Math.sin(ang) * 60]; | |
| 108 | + else [x, y] = [W / 2 + Math.cos(ang) * (150 + 90 * (n.level ?? 1)), H / 2 + Math.sin(ang) * (110 + 70 * (n.level ?? 1))]; | |
| 109 | + } | |
| 110 | + return { ...n, degree: degree.get(n.id) ?? 0, isRoot, x, y, fx: isRoot && !known ? W / 2 : undefined, fy: isRoot && !known ? H / 2 : undefined }; | |
| 111 | + }); | |
| 112 | + const s = forceSimulation<SimNode>(sim) | |
| 113 | + .force( | |
| 114 | + 'link', | |
| 115 | + forceLink<SimNode, SimLink>(links) | |
| 116 | + .id((d) => d.id) | |
| 117 | + .distance((l) => { | |
| 118 | + const a = l.source as SimNode; | |
| 119 | + const b = l.target as SimNode; | |
| 120 | + const hub = Math.max(a.degree, b.degree); | |
| 121 | + return 55 + Math.min(80, hub * 2.5) + (a.isRoot || b.isRoot ? 45 : 0); | |
| 122 | + }) | |
| 123 | + .strength(0.55), | |
| 124 | + ) | |
| 125 | + .force('charge', forceManyBody<SimNode>().strength((d) => (d.isRoot ? -700 : -160 - d.degree * 8)).distanceMax(420)) | |
| 126 | + .force('center', forceCenter(W / 2, H / 2).strength(0.04)) | |
| 127 | + .force('collide', forceCollide<SimNode>().radius((d) => radiusOf(d) + 10).iterations(2)) | |
| 128 | + .stop(); | |
| 129 | + const ticks = sim.length > 120 ? 220 : 300; | |
| 130 | + for (let i = 0; i < ticks; i++) s.tick(); | |
| 131 | + for (const n of sim) { | |
| 132 | + n.fx = undefined; | |
| 133 | + n.fy = undefined; | |
| 134 | + pos.set(n.id, { x: n.x ?? W / 2, y: n.y ?? H / 2 }); | |
| 135 | + } | |
| 136 | + const byId = new Map(sim.map((n) => [n.id, n])); | |
| 137 | + const neighbours = new Map<string, Set<string>>(); | |
| 138 | + for (const l of links) { | |
| 139 | + const a = (l.source as SimNode).id; | |
| 140 | + const b = (l.target as SimNode).id; | |
| 141 | + (neighbours.get(a) ?? neighbours.set(a, new Set()).get(a)!).add(b); | |
| 142 | + (neighbours.get(b) ?? neighbours.set(b, new Set()).get(b)!).add(a); | |
| 143 | + } | |
| 144 | + return { sim, links, byId, neighbours }; | |
| 145 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 146 | + }, [mounted, signature, rootId]); | |
| 147 | + | |
| 148 | + /* ------------------------------------------------------------------------------------------------ coordinates */ | |
| 149 | + const toSvg = useCallback((clientX: number, clientY: number): { x: number; y: number } => { | |
| 150 | + const svg = svgRef.current; | |
| 151 | + if (!svg) return { x: 0, y: 0 }; | |
| 152 | + const ctm = svg.getScreenCTM(); | |
| 153 | + if (!ctm) return { x: 0, y: 0 }; | |
| 154 | + const p = svg.createSVGPoint(); | |
| 155 | + p.x = clientX; | |
| 156 | + p.y = clientY; | |
| 157 | + const q = p.matrixTransform(ctm.inverse()); | |
| 158 | + return { x: q.x, y: q.y }; | |
| 159 | + }, []); | |
| 160 | + const zoomAt = useCallback((factor: number, cx: number, cy: number) => { | |
| 161 | + setT((cur) => { | |
| 162 | + const k = Math.max(K_MIN, Math.min(K_MAX, cur.k * factor)); | |
| 163 | + const r = k / cur.k; | |
| 164 | + return { k, x: cx - (cx - cur.x) * r, y: cy - (cy - cur.y) * r }; | |
| 165 | + }); | |
| 166 | + }, []); | |
| 167 | + const fit = useCallback(() => { | |
| 168 | + if (!layout || !layout.sim.length) return setT({ x: 0, y: 0, k: 1 }); | |
| 169 | + const xs = layout.sim.map((n) => n.x ?? 0); | |
| 170 | + const ys = layout.sim.map((n) => n.y ?? 0); | |
| 171 | + const minX = Math.min(...xs) - 40; | |
| 172 | + const maxX = Math.max(...xs) + 40; | |
| 173 | + const minY = Math.min(...ys) - 40; | |
| 174 | + const maxY = Math.max(...ys) + 40; | |
| 175 | + const k = Math.max(K_MIN, Math.min(K_MAX, Math.min(W / (maxX - minX), H / (maxY - minY)))); | |
| 176 | + setT({ k, x: (W - (minX + maxX) * k) / 2, y: (H - (minY + maxY) * k) / 2 }); | |
| 177 | + }, [layout]); | |
| 178 | + useEffect(() => { | |
| 179 | + if (layout) fit(); | |
| 180 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 181 | + }, [layout]); | |
| 182 | + | |
| 183 | + /* ------------------------------------------------------------------------------------------------ pointer events */ | |
| 184 | + const pointers = useRef<Map<number, { x: number; y: number }>>(new Map()); | |
| 185 | + const gesture = useRef<{ kind: 'pan' | 'node' | 'pinch'; id?: string; start: { x: number; y: number }; moved: boolean; t0: Transform; dist?: number; mid?: { x: number; y: number } } | null>(null); | |
| 186 | + | |
| 187 | + const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => { | |
| 188 | + const svg = svgRef.current; | |
| 189 | + if (!svg) return; | |
| 190 | + svg.setPointerCapture(e.pointerId); | |
| 191 | + const p = toSvg(e.clientX, e.clientY); | |
| 192 | + pointers.current.set(e.pointerId, p); | |
| 193 | + if (pointers.current.size === 2) { | |
| 194 | + const [a, b] = [...pointers.current.values()] as [{ x: number; y: number }, { x: number; y: number }]; | |
| 195 | + gesture.current = { kind: 'pinch', start: p, moved: true, t0: t, dist: Math.hypot(a.x - b.x, a.y - b.y), mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 } }; | |
| 196 | + return; | |
| 197 | + } | |
| 198 | + const target = (e.target as Element).closest('[data-node]') as SVGGElement | null; | |
| 199 | + if (target?.dataset.node) gesture.current = { kind: 'node', id: target.dataset.node, start: p, moved: false, t0: t }; | |
| 200 | + else gesture.current = { kind: 'pan', start: p, moved: false, t0: t }; | |
| 201 | + }; | |
| 202 | + const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => { | |
| 203 | + const g = gesture.current; | |
| 204 | + if (!g) return; | |
| 205 | + const p = toSvg(e.clientX, e.clientY); | |
| 206 | + pointers.current.set(e.pointerId, p); | |
| 207 | + if (g.kind === 'pinch' && pointers.current.size >= 2) { | |
| 208 | + const [a, b] = [...pointers.current.values()] as [{ x: number; y: number }, { x: number; y: number }]; | |
| 209 | + const dist = Math.hypot(a.x - b.x, a.y - b.y); | |
| 210 | + const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; | |
| 211 | + const factor = dist / (g.dist || dist); | |
| 212 | + const k = Math.max(K_MIN, Math.min(K_MAX, g.t0.k * factor)); | |
| 213 | + const r = k / g.t0.k; | |
| 214 | + const m0 = g.mid ?? mid; | |
| 215 | + setT({ k, x: mid.x - (m0.x - g.t0.x) * r, y: mid.y - (m0.y - g.t0.y) * r }); | |
| 216 | + return; | |
| 217 | + } | |
| 218 | + const dx = p.x - g.start.x; | |
| 219 | + const dy = p.y - g.start.y; | |
| 220 | + if (!g.moved && Math.hypot(dx, dy) > 3) g.moved = true; | |
| 221 | + if (!g.moved) return; | |
| 222 | + if (g.kind === 'pan') setT({ ...g.t0, x: g.t0.x + dx, y: g.t0.y + dy }); | |
| 223 | + else if (g.kind === 'node' && g.id && layout) { | |
| 224 | + const n = layout.byId.get(g.id); | |
| 225 | + if (n) { | |
| 226 | + n.x = (p.x - t.x) / t.k; | |
| 227 | + n.y = (p.y - t.y) / t.k; | |
| 228 | + posRef.current.set(n.id, { x: n.x, y: n.y }); | |
| 229 | + bump((v) => v + 1); | |
| 230 | + } | |
| 231 | + } | |
| 232 | + }; | |
| 233 | + const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => { | |
| 234 | + pointers.current.delete(e.pointerId); | |
| 235 | + const g = gesture.current; | |
| 236 | + if (!g) return; | |
| 237 | + if (pointers.current.size > 0 && g.kind === 'pinch') return; | |
| 238 | + gesture.current = null; | |
| 239 | + if (!g.moved) { | |
| 240 | + if (g.kind === 'node' && g.id) onSelect(g.id); | |
| 241 | + else if (g.kind === 'pan') onSelect(null); | |
| 242 | + } | |
| 243 | + }; | |
| 244 | + const onWheel = (e: React.WheelEvent<SVGSVGElement>) => { | |
| 245 | + const p = toSvg(e.clientX, e.clientY); | |
| 246 | + zoomAt(Math.exp(-e.deltaY * 0.0018), p.x, p.y); | |
| 247 | + }; | |
| 248 | + // Block page scroll while the pointer is over the graph (wheel must zoom, not scroll). | |
| 249 | + useEffect(() => { | |
| 250 | + const svg = svgRef.current; | |
| 251 | + if (!svg) return; | |
| 252 | + const stop = (ev: WheelEvent) => ev.preventDefault(); | |
| 253 | + svg.addEventListener('wheel', stop, { passive: false }); | |
| 254 | + return () => svg.removeEventListener('wheel', stop); | |
| 255 | + }, []); | |
| 256 | + | |
| 257 | + /* ------------------------------------------------------------------------------------------------ label policy */ | |
| 258 | + const labelSet = useMemo(() => { | |
| 259 | + if (!layout) return new Set<string>(); | |
| 260 | + const out = new Set<string>(); | |
| 261 | + const total = layout.sim.length; | |
| 262 | + const budget = total <= 36 ? total : Math.max(8, Math.min(total, Math.round(18 * t.k * t.k))); | |
| 263 | + [...layout.sim].sort((a, b) => b.degree - a.degree).slice(0, budget).forEach((n) => out.add(n.id)); | |
| 264 | + out.add(rootId); | |
| 265 | + const focus = hover ?? selectedId; | |
| 266 | + if (focus) { | |
| 267 | + out.add(focus); | |
| 268 | + layout.neighbours.get(focus)?.forEach((id) => out.add(id)); | |
| 269 | + } | |
| 270 | + return out; | |
| 271 | + }, [layout, t.k, hover, selectedId, rootId]); | |
| 272 | + | |
| 273 | + const focus = hover ?? selectedId; | |
| 274 | + const isOn = (id: string) => !focus || focus === id || (layout?.neighbours.get(focus)?.has(id) ?? false); | |
| 275 | + const fs = Math.max(6.5, Math.min(12, 10 / t.k)); | |
| 276 | + const sw = 1 / t.k; | |
| 277 | + const showEdgeLabels = (layout?.links.length ?? 0) <= 30 && t.k >= 0.9; | |
| 278 | + | |
| 279 | + return ( | |
| 280 | + <div className={cn('relative', className)} data-graph-canvas> | |
| 281 | + <svg | |
| 282 | + ref={svgRef} | |
| 283 | + viewBox={`0 0 ${W} ${H}`} | |
| 284 | + preserveAspectRatio="xMidYMid meet" | |
| 285 | + className={cn('block w-full touch-none select-none border border-rule bg-surface', tall ? 'h-[min(78dvh,calc(100dvh-var(--header-h)-7.5rem))] min-h-[22rem]' : 'h-[26rem] md:h-[34rem]')} | |
| 286 | + role="img" | |
| 287 | + aria-label="Knowledge graph" | |
| 288 | + onPointerDown={onPointerDown} | |
| 289 | + onPointerMove={onPointerMove} | |
| 290 | + onPointerUp={onPointerUp} | |
| 291 | + onPointerCancel={onPointerUp} | |
| 292 | + onWheel={onWheel} | |
| 293 | + onMouseLeave={() => setHover(null)} | |
| 294 | + style={{ cursor: gesture.current?.kind === 'pan' && gesture.current.moved ? 'grabbing' : 'default' }} | |
| 295 | + > | |
| 296 | + <defs> | |
| 297 | + <pattern id="graph-grid" width={40} height={40} patternUnits="userSpaceOnUse"> | |
| 298 | + <path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--rule)" strokeWidth={0.5} /> | |
| 299 | + </pattern> | |
| 300 | + </defs> | |
| 301 | + <rect width={W} height={H} fill="url(#graph-grid)" opacity={0.6} /> | |
| 302 | + {!layout && ( | |
| 303 | + <text x={W / 2} y={H / 2} textAnchor="middle" fontSize={14} fill="var(--ink-3)"> | |
| 304 | + {mounted ? `Laying out ${visible.length} nodes…` : 'Graph loads after hydration'} | |
| 305 | + </text> | |
| 306 | + )} | |
| 307 | + {layout && ( | |
| 308 | + <g transform={`translate(${t.x},${t.y}) scale(${t.k})`}> | |
| 309 | + <g> | |
| 310 | + {layout.links.map((l, i) => { | |
| 311 | + const a = l.source as SimNode; | |
| 312 | + const b = l.target as SimNode; | |
| 313 | + const active = !focus || focus === a.id || focus === b.id; | |
| 314 | + const label = (showEdgeLabels || (focus && active)) && active; | |
| 315 | + const mx = ((a.x ?? 0) + (b.x ?? 0)) / 2; | |
| 316 | + const my = ((a.y ?? 0) + (b.y ?? 0)) / 2; | |
| 317 | + return ( | |
| 318 | + <g key={i} opacity={active ? 1 : 0.1}> | |
| 319 | + <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={focus && active ? 'var(--accent)' : 'var(--rule-strong)'} strokeWidth={(focus && active ? 1.5 : 1) * sw} strokeDasharray={l.tier && l.tier >= 3 ? `${3 * sw} ${3 * sw}` : undefined} /> | |
| 320 | + {label && ( | |
| 321 | + <text x={mx} y={my - 2.5 * sw} textAnchor="middle" fontSize={fs * 0.8} fill="var(--ink-3)" className="pointer-events-none select-none" style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 * sw }}> | |
| 322 | + {predicateLabel(l.predicate, 'out')} | |
| 323 | + </text> | |
| 324 | + )} | |
| 325 | + </g> | |
| 326 | + ); | |
| 327 | + })} | |
| 328 | + </g> | |
| 329 | + <g> | |
| 330 | + {layout.sim.map((n) => { | |
| 331 | + const r = radiusOf(n); | |
| 332 | + const on = isOn(n.id); | |
| 333 | + const sel = selectedId === n.id; | |
| 334 | + const color = colorOf(n.entity_type); | |
| 335 | + const showLabel = labelSet.has(n.id); | |
| 336 | + const canExpand = !!onExpand && !n.isRoot && !expanded.has(n.id); | |
| 337 | + return ( | |
| 338 | + <g | |
| 339 | + key={n.id} | |
| 340 | + data-node={n.id} | |
| 341 | + transform={`translate(${n.x},${n.y})`} | |
| 342 | + opacity={on ? 1 : 0.18} | |
| 343 | + tabIndex={0} | |
| 344 | + role="button" | |
| 345 | + aria-pressed={sel} | |
| 346 | + aria-label={`${n.name} (${typeLabel(n.entity_type)})${canExpand ? ' — Shift+Enter expands' : ''}`} | |
| 347 | + className="cursor-pointer outline-none focus-visible:[&>circle:nth-child(2)]:stroke-accent" | |
| 348 | + onMouseEnter={() => setHover(n.id)} | |
| 349 | + onFocus={() => setHover(n.id)} | |
| 350 | + onBlur={() => setHover(null)} | |
| 351 | + onDoubleClick={(e) => { | |
| 352 | + e.stopPropagation(); | |
| 353 | + onExpand?.(n.id); | |
| 354 | + }} | |
| 355 | + onKeyDown={(e) => { | |
| 356 | + if (e.key === 'Enter' || e.key === ' ') { | |
| 357 | + e.preventDefault(); | |
| 358 | + if (e.shiftKey) onExpand?.(n.id); | |
| 359 | + else onSelect(n.id); | |
| 360 | + } | |
| 361 | + }} | |
| 362 | + > | |
| 363 | + <circle r={Math.max(14, r + 9) / Math.min(1.5, t.k)} fill="transparent" /> | |
| 364 | + {(n.isRoot || sel) && <circle r={r + 5 * sw} fill="none" stroke={sel ? 'var(--ink)' : color} strokeWidth={1.2 * sw} opacity={0.75} />} | |
| 365 | + {loadingId === n.id && <circle r={r + 9 * sw} fill="none" stroke="var(--accent)" strokeWidth={sw} strokeDasharray={`${4 * sw} ${4 * sw}`} className="animate-spin [animation-duration:2s]" style={{ transformOrigin: '0 0' }} />} | |
| 366 | + <circle r={r} fill={color} stroke="var(--canvas)" strokeWidth={1.5 * sw} /> | |
| 367 | + {n.artifact_kind && <circle r={r * 0.4} fill="var(--canvas)" />} | |
| 368 | + {expanded.has(n.id) && !n.isRoot && <circle r={r * 0.35} fill="var(--canvas)" opacity={0.9} />} | |
| 369 | + {showLabel && ( | |
| 370 | + <text x={0} y={r + fs + 1.5} textAnchor="middle" fontSize={n.isRoot ? fs * 1.2 : fs} fontWeight={n.isRoot || sel ? 600 : 400} fill={n.isRoot || sel ? 'var(--ink)' : 'var(--ink-2)'} className="pointer-events-none select-none" style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 * sw }}> | |
| 371 | + {short(n.name, n.isRoot ? 40 : 26)} | |
| 372 | + </text> | |
| 373 | + )} | |
| 374 | + </g> | |
| 375 | + ); | |
| 376 | + })} | |
| 377 | + </g> | |
| 378 | + </g> | |
| 379 | + )} | |
| 380 | + </svg> | |
| 381 | + | |
| 382 | + {/* hover readout */} | |
| 383 | + {layout && hover && layout.byId.get(hover) && ( | |
| 384 | + <p className="pointer-events-none absolute left-2 top-2 max-w-[70%] truncate bg-canvas/90 px-2 py-1 text-xs text-ink-2 backdrop-blur" role="status"> | |
| 385 | + <span className="font-medium text-ink">{layout.byId.get(hover)!.name}</span> · {typeLabel(layout.byId.get(hover)!.entity_type)} | |
| 386 | + {layout.byId.get(hover)!.org ? ` · ${layout.byId.get(hover)!.org}` : ''} · {layout.byId.get(hover)!.degree} link{layout.byId.get(hover)!.degree === 1 ? '' : 's'} | |
| 387 | + </p> | |
| 388 | + )} | |
| 389 | + | |
| 390 | + {/* zoom controls */} | |
| 391 | + <div className="absolute bottom-2 right-2 flex flex-col border border-rule bg-canvas/90 backdrop-blur" role="group" aria-label="Zoom"> | |
| 392 | + <button type="button" onClick={() => zoomAt(1.35, W / 2, H / 2)} className="flex size-11 items-center justify-center text-ink-2 hover:bg-surface-2 hover:text-ink" aria-label="Zoom in"> | |
| 393 | + <Plus className="size-4" aria-hidden /> | |
| 394 | + </button> | |
| 395 | + <button type="button" onClick={() => zoomAt(1 / 1.35, W / 2, H / 2)} className="flex size-11 items-center justify-center border-t border-rule text-ink-2 hover:bg-surface-2 hover:text-ink" aria-label="Zoom out"> | |
| 396 | + <Minus className="size-4" aria-hidden /> | |
| 397 | + </button> | |
| 398 | + <button type="button" onClick={fit} className="flex size-11 items-center justify-center border-t border-rule text-ink-2 hover:bg-surface-2 hover:text-ink" aria-label="Fit graph to view"> | |
| 399 | + <Crosshair className="size-4" aria-hidden /> | |
| 400 | + </button> | |
| 401 | + </div> | |
| 402 | + <p className="tnum pointer-events-none absolute bottom-2 left-2 bg-canvas/80 px-1.5 py-0.5 text-[10px] text-ink-3 backdrop-blur">{Math.round(t.k * 100)}% · drag to pan · wheel or pinch to zoom · double-click a node to expand</p> | |
| 403 | + </div> | |
| 404 | + ); | |
| 405 | +} | |
deleted
apps/web/src/components/graph/graph-explorer.tsx
+0 −167
@@ -1,167 +0,0 @@ | ||
| 1 | −'use client'; | |
| 2 | −import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, type SimulationLinkDatum, type SimulationNodeDatum } from 'd3-force'; | |
| 3 | −import { useRouter } from 'next/navigation'; | |
| 4 | −import { useEffect, useMemo, useState } from 'react'; | |
| 5 | −import { cn } from '@/lib/cn'; | |
| 6 | −import { predicateLabel, routes, TYPE_COLOR_KEY, typeLabel } from '@/lib/site'; | |
| 7 | −import type { GraphEdge, GraphNode } from '@/lib/types'; | |
| 8 | − | |
| 9 | −type SimNode = SimulationNodeDatum & GraphNode & { degree: number; isRoot: boolean }; | |
| 10 | −type SimLink = SimulationLinkDatum<SimNode> & { predicate: string }; | |
| 11 | − | |
| 12 | −const W = 960; | |
| 13 | −const H = 640; | |
| 14 | −const LABEL_LIMIT = 40; | |
| 15 | −const EDGE_LABEL_LIMIT = 40; | |
| 16 | − | |
| 17 | −function colorOf(type: string): string { | |
| 18 | − return `var(--type-${TYPE_COLOR_KEY[type] ?? 'tool'})`; | |
| 19 | −} | |
| 20 | −function short(s: string, n = 26): string { | |
| 21 | − return s.length > n ? `${s.slice(0, n - 1)}…` : s; | |
| 22 | −} | |
| 23 | − | |
| 24 | −/** | |
| 25 | − * Force-directed neighbourhood graph (d3-force run for a fixed number of ticks, deterministic seed, no animation). | |
| 26 | − * Server render: the SVG frame + legend only; the layout is computed on the client after mount so markup stays stable. | |
| 27 | − * Click / Enter → the entity page; hover / focus highlights the neighbourhood and reveals edge labels. | |
| 28 | − */ | |
| 29 | −export function GraphExplorer({ nodes, edges, rootId }: { nodes: GraphNode[]; edges: GraphEdge[]; rootId: string }) { | |
| 30 | − const router = useRouter(); | |
| 31 | − const [mounted, setMounted] = useState(false); | |
| 32 | − const [hover, setHover] = useState<string | null>(null); | |
| 33 | − useEffect(() => setMounted(true), []); | |
| 34 | − | |
| 35 | − const layout = useMemo(() => { | |
| 36 | − if (!mounted) return null; | |
| 37 | − const ids = new Set(nodes.map((n) => n.id)); | |
| 38 | − const degree = new Map<string, number>(); | |
| 39 | − const links: SimLink[] = []; | |
| 40 | − for (const e of edges) { | |
| 41 | − if (!ids.has(e.source) || !ids.has(e.target)) continue; | |
| 42 | − degree.set(e.source, (degree.get(e.source) ?? 0) + 1); | |
| 43 | − degree.set(e.target, (degree.get(e.target) ?? 0) + 1); | |
| 44 | − links.push({ source: e.source, target: e.target, predicate: e.predicate }); | |
| 45 | − } | |
| 46 | − // Deterministic seed: nodes on a ring ordered by type, root at the centre. | |
| 47 | − const sim: SimNode[] = nodes.map((n, i) => { | |
| 48 | − const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2; | |
| 49 | − const isRoot = n.id === rootId; | |
| 50 | − return { ...n, degree: degree.get(n.id) ?? 0, isRoot, x: isRoot ? W / 2 : W / 2 + Math.cos(ang) * 220, y: isRoot ? H / 2 : H / 2 + Math.sin(ang) * 220, fx: isRoot ? W / 2 : undefined, fy: isRoot ? H / 2 : undefined }; | |
| 51 | − }); | |
| 52 | − const byId = new Map(sim.map((n) => [n.id, n])); | |
| 53 | − const radius = (n: SimNode) => (n.isRoot ? 14 : 5 + Math.min(6, Math.sqrt(n.degree))); | |
| 54 | − const s = forceSimulation<SimNode>(sim) | |
| 55 | − .force('link', forceLink<SimNode, SimLink>(links).id((d) => d.id).distance((l) => { | |
| 56 | − const a = l.source as SimNode; | |
| 57 | − const b = l.target as SimNode; | |
| 58 | − const hub = Math.max(a.degree, b.degree); | |
| 59 | − return 70 + Math.min(90, hub * 3) + (a.isRoot || b.isRoot ? 60 : 0); | |
| 60 | − }).strength(0.6)) | |
| 61 | − .force('charge', forceManyBody<SimNode>().strength((d) => (d.isRoot ? -900 : -220))) | |
| 62 | − .force('center', forceCenter(W / 2, H / 2)) | |
| 63 | − .force('collide', forceCollide<SimNode>().radius((d) => radius(d) + 14).iterations(2)) | |
| 64 | − .stop(); | |
| 65 | − for (let i = 0; i < 300; i++) s.tick(); | |
| 66 | − // Clamp into the viewBox. | |
| 67 | − for (const n of sim) { | |
| 68 | − n.x = Math.max(24, Math.min(W - 24, n.x ?? W / 2)); | |
| 69 | − n.y = Math.max(24, Math.min(H - 24, n.y ?? H / 2)); | |
| 70 | − } | |
| 71 | − const neighbours = new Map<string, Set<string>>(); | |
| 72 | − for (const l of links) { | |
| 73 | − const a = (l.source as SimNode).id; | |
| 74 | − const b = (l.target as SimNode).id; | |
| 75 | − (neighbours.get(a) ?? neighbours.set(a, new Set()).get(a)!).add(b); | |
| 76 | − (neighbours.get(b) ?? neighbours.set(b, new Set()).get(b)!).add(a); | |
| 77 | − } | |
| 78 | − return { sim, links, byId, radius, neighbours }; | |
| 79 | − }, [mounted, nodes, edges, rootId]); | |
| 80 | − | |
| 81 | − const showAllLabels = nodes.length <= LABEL_LIMIT; | |
| 82 | − const showAllEdgeLabels = edges.length <= EDGE_LABEL_LIMIT; | |
| 83 | − | |
| 84 | − const go = (n: GraphNode) => router.push(routes.entity(n)); | |
| 85 | − const active = (id: string) => !hover || hover === id || (layout?.neighbours.get(hover)?.has(id) ?? false); | |
| 86 | − | |
| 87 | − return ( | |
| 88 | − <div className="relative"> | |
| 89 | − <svg viewBox={`0 0 ${W} ${H}`} className="block h-auto w-full border border-rule bg-surface" role="img" aria-label="Relationship graph" onMouseLeave={() => setHover(null)}> | |
| 90 | − {!layout && ( | |
| 91 | − <text x={W / 2} y={H / 2} textAnchor="middle" fontSize={14} fill="var(--ink-3)"> | |
| 92 | − Laying out {nodes.length} nodes… | |
| 93 | − </text> | |
| 94 | − )} | |
| 95 | − {layout && ( | |
| 96 | − <> | |
| 97 | − <g> | |
| 98 | − {layout.links.map((l, i) => { | |
| 99 | − const a = l.source as SimNode; | |
| 100 | − const b = l.target as SimNode; | |
| 101 | − const on = !hover || hover === a.id || hover === b.id; | |
| 102 | − const label = showAllEdgeLabels || (hover && (hover === a.id || hover === b.id)); | |
| 103 | − const mx = ((a.x ?? 0) + (b.x ?? 0)) / 2; | |
| 104 | − const my = ((a.y ?? 0) + (b.y ?? 0)) / 2; | |
| 105 | − return ( | |
| 106 | − <g key={i} opacity={on ? 1 : 0.12}> | |
| 107 | − <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={on && hover ? 'var(--accent)' : 'var(--rule-strong)'} strokeWidth={on && hover ? 1.4 : 1} /> | |
| 108 | − {label && ( | |
| 109 | − <text x={mx} y={my - 3} textAnchor="middle" fontSize={9} fill="var(--ink-3)" className="pointer-events-none select-none"> | |
| 110 | − {predicateLabel(l.predicate, 'out')} | |
| 111 | − </text> | |
| 112 | − )} | |
| 113 | − </g> | |
| 114 | − ); | |
| 115 | − })} | |
| 116 | − </g> | |
| 117 | − <g> | |
| 118 | − {layout.sim.map((n) => { | |
| 119 | − const r = layout.radius(n); | |
| 120 | − const on = active(n.id); | |
| 121 | − const label = n.isRoot || showAllLabels || (hover && (hover === n.id || layout.neighbours.get(hover)?.has(n.id))); | |
| 122 | − const color = colorOf(n.entity_type); | |
| 123 | − return ( | |
| 124 | − <g | |
| 125 | − key={n.id} | |
| 126 | − transform={`translate(${n.x},${n.y})`} | |
| 127 | − opacity={on ? 1 : 0.2} | |
| 128 | − tabIndex={0} | |
| 129 | − role="link" | |
| 130 | − aria-label={`${n.name} (${typeLabel(n.entity_type)})`} | |
| 131 | − className="cursor-pointer outline-none focus-visible:[&>circle]:stroke-accent" | |
| 132 | − onMouseEnter={() => setHover(n.id)} | |
| 133 | − onFocus={() => setHover(n.id)} | |
| 134 | − onBlur={() => setHover(null)} | |
| 135 | − onClick={() => go(n)} | |
| 136 | − onKeyDown={(e) => { | |
| 137 | − if (e.key === 'Enter' || e.key === ' ') { | |
| 138 | − e.preventDefault(); | |
| 139 | − go(n); | |
| 140 | − } | |
| 141 | − }} | |
| 142 | − > | |
| 143 | − <circle r={Math.max(16, r + 10)} fill="transparent" data-hit="" /> | |
| 144 | − {n.isRoot && <circle r={r + 6} fill="none" stroke={color} strokeWidth={1} opacity={0.5} />} | |
| 145 | − <circle r={r} fill={color} stroke="var(--canvas)" strokeWidth={1.5} /> | |
| 146 | − {label && ( | |
| 147 | − <text x={0} y={r + 11} textAnchor="middle" fontSize={n.isRoot ? 12 : 10} fontWeight={n.isRoot ? 600 : 400} fill={n.isRoot ? 'var(--ink)' : 'var(--ink-2)'} className="pointer-events-none select-none" style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 }}> | |
| 148 | − {short(n.name, n.isRoot ? 40 : 26)} | |
| 149 | − </text> | |
| 150 | − )} | |
| 151 | − </g> | |
| 152 | − ); | |
| 153 | − })} | |
| 154 | − </g> | |
| 155 | − </> | |
| 156 | − )} | |
| 157 | − </svg> | |
| 158 | − {layout && hover && layout.byId.get(hover) && ( | |
| 159 | − <p className="pointer-events-none absolute left-2 top-2 max-w-[70%] truncate bg-canvas/90 px-2 py-1 text-xs text-ink-2 backdrop-blur"> | |
| 160 | − <span className="font-medium text-ink">{layout.byId.get(hover)!.name}</span> · {typeLabel(layout.byId.get(hover)!.entity_type)} | |
| 161 | − {layout.byId.get(hover)!.organization_name ? ` · ${layout.byId.get(hover)!.organization_name}` : ''} · {layout.byId.get(hover)!.degree} link{layout.byId.get(hover)!.degree === 1 ? '' : 's'} | |
| 162 | − </p> | |
| 163 | − )} | |
| 164 | − {!showAllLabels && <p className={cn('mt-1 text-xs text-ink-3')}>Labels are shown on hover or focus beyond {LABEL_LIMIT} nodes; the list below names every node.</p>} | |
| 165 | − </div> | |
| 166 | − ); | |
| 167 | −} | |
added
apps/web/src/components/graph/graph-workbench.tsx
+365 −0
@@ -0,0 +1,365 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { ExternalLink, Focus, GitFork, Maximize2 } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useRouter } from 'next/navigation'; | |
| 5 | +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |
| 6 | +import { CompareButton } from '@/components/compare/compare-button'; | |
| 7 | +import { TerminalLayout } from '@/components/layout/terminal'; | |
| 8 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 9 | +import { keyAttributes } from '@/components/ui/entity'; | |
| 10 | +import { Hint } from '@/components/ui/hint'; | |
| 11 | +import { Sheet } from '@/components/ui/sheet'; | |
| 12 | +import { WatchButton } from '@/components/watchlist/watch-button'; | |
| 13 | +import { cn } from '@/lib/cn'; | |
| 14 | +import { clientGraphExplore } from '@/lib/client-api'; | |
| 15 | +import { fmtInt } from '@/lib/format'; | |
| 16 | +import { predicateLabel, routes, typeLabel } from '@/lib/site'; | |
| 17 | +import type { EntitySummary, ExploreEdge, ExploreNode, GraphExploreMode, GraphExplorePayload, Suggestion } from '@/lib/types'; | |
| 18 | +import { colorOf, GraphCanvas } from './graph-canvas'; | |
| 19 | +import { GRAPH_MODES, graphHref, graphSlugHref } from './modes'; | |
| 20 | +import { NodeSearch } from './node-search'; | |
| 21 | + | |
| 22 | +/* | |
| 23 | + Graph workbench (client): TerminalLayout with the mode/depth/type filters in the rail, the canvas in the middle, the | |
| 24 | + selected node in the inspector. Mode and depth changes re-fetch `/graph/explore` client-side and mirror the URL with | |
| 25 | + `history.replaceState`; a new root navigates (server render, shareable URL). Expansion merges `/graph/explore?depth=1` | |
| 26 | + for the clicked node into the current graph. On small screens the inspector opens as a bottom sheet on selection. | |
| 27 | +*/ | |
| 28 | + | |
| 29 | +type Graph = { nodes: ExploreNode[]; edges: ExploreEdge[]; truncated: boolean; counts: GraphExplorePayload['counts']; predicates: string[] }; | |
| 30 | + | |
| 31 | +function mergeGraph(base: Graph, add: GraphExplorePayload, hasLevel: (id: string) => number | undefined, viaLevel: number): Graph { | |
| 32 | + const nodes = [...base.nodes]; | |
| 33 | + const seen = new Set(nodes.map((n) => n.id)); | |
| 34 | + for (const n of add.nodes) { | |
| 35 | + if (seen.has(n.id)) continue; | |
| 36 | + seen.add(n.id); | |
| 37 | + nodes.push({ ...n, level: viaLevel + n.level }); | |
| 38 | + } | |
| 39 | + const ek = new Set(base.edges.map((e) => `${e.source}>${e.target}:${e.predicate}`)); | |
| 40 | + const edges = [...base.edges]; | |
| 41 | + for (const e of add.edges) { | |
| 42 | + const k = `${e.source}>${e.target}:${e.predicate}`; | |
| 43 | + if (ek.has(k)) continue; | |
| 44 | + ek.add(k); | |
| 45 | + edges.push(e); | |
| 46 | + } | |
| 47 | + void hasLevel; | |
| 48 | + return { nodes, edges, truncated: base.truncated || add.truncated, counts: { nodes: nodes.length, edges: edges.length, by_type: countTypes(nodes) }, predicates: [...new Set([...base.predicates, ...add.predicates])] }; | |
| 49 | +} | |
| 50 | +function countTypes(nodes: ExploreNode[]): Record<string, number> { | |
| 51 | + const out: Record<string, number> = {}; | |
| 52 | + for (const n of nodes) out[n.entity_type] = (out[n.entity_type] ?? 0) + 1; | |
| 53 | + return out; | |
| 54 | +} | |
| 55 | +function asSummary(n: ExploreNode): EntitySummary { | |
| 56 | + return { id: n.id, entity_type: n.entity_type, slug: n.slug, name: n.name, description: null, status: 'active', organization: n.org && n.org_slug ? { id: '', slug: n.org_slug, name: n.org } : null, attributes: n.attributes ?? {}, quality: {}, counts: {}, first_seen_at: '', last_seen_at: '', updated_at: '' }; | |
| 57 | +} | |
| 58 | + | |
| 59 | +export function GraphWorkbench({ initial, root, mode: initialMode, depth: initialDepth, urlStyle = 'query', embedded = false }: { initial: GraphExplorePayload; root: { slug: string; name: string; entity_type: string }; mode: GraphExploreMode; depth: 1 | 2; /** `query` → /graph?node=…, `path` → /graph/<slug>?… */ urlStyle?: 'query' | 'path'; /** `/graph/[slug]`: shorter chrome, the page renders its own header. */ embedded?: boolean }) { | |
| 60 | + const router = useRouter(); | |
| 61 | + const hrefFor = urlStyle === 'path' ? graphSlugHref : graphHref; | |
| 62 | + const [mode, setMode] = useState<GraphExploreMode>(initialMode); | |
| 63 | + const [depth, setDepth] = useState<1 | 2>(initialDepth); | |
| 64 | + const [graph, setGraph] = useState<Graph>({ nodes: initial.nodes, edges: initial.edges, truncated: initial.truncated, counts: initial.counts, predicates: initial.predicates }); | |
| 65 | + const [expanded, setExpanded] = useState<Set<string>>(() => new Set([initial.root])); | |
| 66 | + const [selected, setSelected] = useState<string | null>(null); | |
| 67 | + const [hidden, setHidden] = useState<Set<string>>(new Set()); | |
| 68 | + const [pending, setPending] = useState<string | null>(null); // node id being expanded, or '*' for a full reload | |
| 69 | + const [error, setError] = useState<string | null>(null); | |
| 70 | + const [sheet, setSheet] = useState(false); | |
| 71 | + const rootId = initial.root; | |
| 72 | + const abort = useRef<AbortController | null>(null); | |
| 73 | + | |
| 74 | + // reset when the server gives a new initial payload (new root) | |
| 75 | + useEffect(() => { | |
| 76 | + setGraph({ nodes: initial.nodes, edges: initial.edges, truncated: initial.truncated, counts: initial.counts, predicates: initial.predicates }); | |
| 77 | + setExpanded(new Set([initial.root])); | |
| 78 | + setSelected(null); | |
| 79 | + setMode(initialMode); | |
| 80 | + setDepth(initialDepth); | |
| 81 | + }, [initial, initialMode, initialDepth]); | |
| 82 | + | |
| 83 | + const reload = useCallback( | |
| 84 | + async (m: GraphExploreMode, d: 1 | 2) => { | |
| 85 | + abort.current?.abort(); | |
| 86 | + const ctl = new AbortController(); | |
| 87 | + abort.current = ctl; | |
| 88 | + setPending('*'); | |
| 89 | + setError(null); | |
| 90 | + try { | |
| 91 | + const g = await clientGraphExplore(root.slug, m, d, 150, ctl.signal); | |
| 92 | + setGraph({ nodes: g.nodes, edges: g.edges, truncated: g.truncated, counts: g.counts, predicates: g.predicates }); | |
| 93 | + setExpanded(new Set([g.root])); | |
| 94 | + setSelected(null); | |
| 95 | + window.history.replaceState(null, '', hrefFor(root.slug, m, d)); | |
| 96 | + } catch (e) { | |
| 97 | + if ((e as Error).name !== 'AbortError') setError('The graph service did not answer for this mode.'); | |
| 98 | + } finally { | |
| 99 | + setPending(null); | |
| 100 | + } | |
| 101 | + }, | |
| 102 | + [root.slug, hrefFor], | |
| 103 | + ); | |
| 104 | + const changeMode = (m: GraphExploreMode) => { | |
| 105 | + setMode(m); | |
| 106 | + void reload(m, depth); | |
| 107 | + }; | |
| 108 | + const changeDepth = (d: 1 | 2) => { | |
| 109 | + setDepth(d); | |
| 110 | + void reload(mode, d); | |
| 111 | + }; | |
| 112 | + const byId = useMemo(() => new Map(graph.nodes.map((n) => [n.id, n])), [graph.nodes]); | |
| 113 | + const expand = useCallback( | |
| 114 | + async (id: string) => { | |
| 115 | + const n = byId.get(id); | |
| 116 | + if (!n || expanded.has(id) || pending) return; | |
| 117 | + setPending(id); | |
| 118 | + setError(null); | |
| 119 | + try { | |
| 120 | + const g = await clientGraphExplore(n.slug, mode, 1, 80); | |
| 121 | + setGraph((cur) => mergeGraph(cur, g, (x) => byId.get(x)?.level, n.level)); | |
| 122 | + setExpanded((cur) => new Set([...cur, id])); | |
| 123 | + } catch { | |
| 124 | + setError(`Could not expand ${n.name}.`); | |
| 125 | + } finally { | |
| 126 | + setPending(null); | |
| 127 | + } | |
| 128 | + }, | |
| 129 | + [byId, expanded, mode, pending], | |
| 130 | + ); | |
| 131 | + const pick = (s: Suggestion) => router.push(hrefFor(s.slug, GRAPH_MODES.find((m) => m.rootTypes.includes(s.entity_type))?.mode ?? mode, depth)); | |
| 132 | + const select = (id: string | null) => { | |
| 133 | + setSelected(id); | |
| 134 | + if (id && typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) setSheet(true); | |
| 135 | + }; | |
| 136 | + | |
| 137 | + const types = useMemo(() => Object.entries(countTypes(graph.nodes)).sort((a, b) => b[1] - a[1]), [graph.nodes]); | |
| 138 | + const predicateCounts = useMemo(() => [...graph.edges.reduce((m, e) => m.set(e.predicate, (m.get(e.predicate) ?? 0) + 1), new Map<string, number>()).entries()].sort((a, b) => b[1] - a[1]), [graph.edges]); | |
| 139 | + const shown = graph.nodes.filter((n) => !hidden.has(n.entity_type) || n.id === rootId).length; | |
| 140 | + const apiNodes = Number(graph.counts?.nodes ?? graph.nodes.length); | |
| 141 | + const current = (selected ? byId.get(selected) : null) ?? byId.get(rootId) ?? null; | |
| 142 | + | |
| 143 | + const filters = ( | |
| 144 | + <div className="space-y-5 text-sm"> | |
| 145 | + <div> | |
| 146 | + <p className="eyebrow mb-1.5">Root</p> | |
| 147 | + <NodeSearch onPick={pick} /> | |
| 148 | + <p className="mt-1.5 flex items-center gap-1.5 text-xs text-ink-3"> | |
| 149 | + <EntityBadge type={root.entity_type} small /> <span className="truncate text-ink-2">{root.name}</span> | |
| 150 | + </p> | |
| 151 | + </div> | |
| 152 | + <div> | |
| 153 | + <p className="eyebrow mb-1.5">Mode</p> | |
| 154 | + <ul className="space-y-px" role="radiogroup" aria-label="Graph mode"> | |
| 155 | + {GRAPH_MODES.map((m) => { | |
| 156 | + const on = m.mode === mode; | |
| 157 | + return ( | |
| 158 | + <li key={m.mode}> | |
| 159 | + <button type="button" role="radio" aria-checked={on} onClick={() => changeMode(m.mode)} className={cn('flex min-h-9 w-full items-center justify-between gap-2 px-1.5 text-left text-[13px] uppercase tracking-wide', on ? 'bg-ink text-canvas' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')} title={m.hint} data-graph-mode={m.mode}> | |
| 160 | + <span>{m.label}</span> | |
| 161 | + </button> | |
| 162 | + </li> | |
| 163 | + ); | |
| 164 | + })} | |
| 165 | + </ul> | |
| 166 | + </div> | |
| 167 | + <div> | |
| 168 | + <p className="eyebrow mb-1.5">Depth</p> | |
| 169 | + <div className="inline-flex border border-rule" role="group" aria-label="Depth"> | |
| 170 | + {([1, 2] as const).map((d) => ( | |
| 171 | + <button key={d} type="button" onClick={() => changeDepth(d)} aria-pressed={depth === d} className={cn('inline-flex h-10 items-center px-3 text-sm', d === 2 && 'border-l border-rule', depth === d ? 'bg-ink text-canvas' : 'text-ink-2 hover:text-ink')}> | |
| 172 | + {d} hop{d === 2 ? 's' : ''} | |
| 173 | + </button> | |
| 174 | + ))} | |
| 175 | + </div> | |
| 176 | + </div> | |
| 177 | + <div> | |
| 178 | + <p className="eyebrow mb-1.5">Node types</p> | |
| 179 | + <ul className="space-y-px"> | |
| 180 | + {types.map(([t, n]) => { | |
| 181 | + const off = hidden.has(t); | |
| 182 | + return ( | |
| 183 | + <li key={t}> | |
| 184 | + <button | |
| 185 | + type="button" | |
| 186 | + aria-pressed={!off} | |
| 187 | + onClick={() => setHidden((cur) => { | |
| 188 | + const next = new Set(cur); | |
| 189 | + if (next.has(t)) next.delete(t); | |
| 190 | + else next.add(t); | |
| 191 | + return next; | |
| 192 | + })} | |
| 193 | + className={cn('flex min-h-9 w-full items-center justify-between gap-2 px-1.5 text-sm hover:bg-surface-2', off && 'opacity-50')} | |
| 194 | + > | |
| 195 | + <span className="flex items-center gap-2"> | |
| 196 | + <span className="inline-block size-2.5 rounded-full" style={{ background: colorOf(t) }} aria-hidden /> | |
| 197 | + {typeLabel(t, n !== 1)} | |
| 198 | + </span> | |
| 199 | + <span className="tnum text-xs text-ink-3">{fmtInt(n)}</span> | |
| 200 | + </button> | |
| 201 | + </li> | |
| 202 | + ); | |
| 203 | + })} | |
| 204 | + </ul> | |
| 205 | + </div> | |
| 206 | + <div> | |
| 207 | + <p className="eyebrow mb-1.5">Edge predicates</p> | |
| 208 | + <ul className="space-y-0.5 text-xs"> | |
| 209 | + {predicateCounts.map(([p, n]) => ( | |
| 210 | + <li key={p} className="flex items-center justify-between gap-2"> | |
| 211 | + <span className="text-ink-2">{predicateLabel(p, 'out')}</span> | |
| 212 | + <span className="tnum text-ink-3">{fmtInt(n)}</span> | |
| 213 | + </li> | |
| 214 | + ))} | |
| 215 | + {predicateCounts.length === 0 && <li className="text-ink-3">No edges in this view.</li>} | |
| 216 | + </ul> | |
| 217 | + <p className="mt-1.5 text-[11px] text-ink-3">Dashed edges come from tier 3–4 sources.</p> | |
| 218 | + </div> | |
| 219 | + </div> | |
| 220 | + ); | |
| 221 | + | |
| 222 | + const inspector = current ? ( | |
| 223 | + <Inspector n={current} rootId={rootId} graph={graph} byId={byId} expanded={expanded} pending={pending} onExpand={expand} onSelect={select} onFocus={(n) => router.push(hrefFor(n.slug, mode, depth))} /> | |
| 224 | + ) : ( | |
| 225 | + <p className="text-sm text-ink-3">Select a node to see its facts and links.</p> | |
| 226 | + ); | |
| 227 | + | |
| 228 | + return ( | |
| 229 | + <TerminalLayout filters={filters} inspector={inspector} filtersTitle="Graph" inspectorTitle="Node" storageKey="aia-graph-inspector" filterCount={hidden.size}> | |
| 230 | + <div className="space-y-2" data-graph-workbench> | |
| 231 | + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-3"> | |
| 232 | + <span className="tnum" data-graph-count> | |
| 233 | + {fmtInt(shown)}{shown !== graph.nodes.length ? ` of ${fmtInt(graph.nodes.length)}` : ''} nodes · {fmtInt(graph.edges.length)} edges | |
| 234 | + </span> | |
| 235 | + {graph.truncated && ( | |
| 236 | + <span className="text-warning"> | |
| 237 | + truncated — {fmtInt(apiNodes)} drawn, the neighbourhood is larger <Hint align="right" text="The API stops at the node limit (150) and reports truncated: true. Expand a node or narrow the mode to see the rest." /> | |
| 238 | + </span> | |
| 239 | + )} | |
| 240 | + {pending === '*' && <span className="text-accent">Loading…</span>} | |
| 241 | + {error && <span className="text-danger">{error}</span>} | |
| 242 | + {!embedded && ( | |
| 243 | + <span className="ml-auto hidden md:inline"> | |
| 244 | + <Link href={routes.entity(root)} className="link"> | |
| 245 | + Open {root.name} → | |
| 246 | + </Link> | |
| 247 | + </span> | |
| 248 | + )} | |
| 249 | + </div> | |
| 250 | + <GraphCanvas nodes={graph.nodes} edges={graph.edges} rootId={rootId} selectedId={selected} onSelect={select} onExpand={expand} expanded={expanded} loadingId={pending && pending !== '*' ? pending : null} hiddenTypes={hidden} tall /> | |
| 251 | + {/* accessible text fallback */} | |
| 252 | + <details className="text-sm"> | |
| 253 | + <summary className="cursor-pointer py-2 text-xs text-ink-3 hover:text-ink">Every node, as a list ({fmtInt(graph.nodes.length)})</summary> | |
| 254 | + <ul className="grid gap-x-6 sm:grid-cols-2 lg:grid-cols-3" data-graph-list> | |
| 255 | + {graph.nodes.map((n) => ( | |
| 256 | + <li key={n.id} className="flex items-center gap-2 border-b border-rule py-1.5"> | |
| 257 | + <EntityBadge type={n.entity_type} small /> | |
| 258 | + <Link href={routes.entity(n)} className="truncate text-ink hover:text-accent"> | |
| 259 | + {n.name} | |
| 260 | + </Link> | |
| 261 | + {n.org && <span className="ml-auto shrink-0 text-xs text-ink-3">{n.org}</span>} | |
| 262 | + </li> | |
| 263 | + ))} | |
| 264 | + </ul> | |
| 265 | + </details> | |
| 266 | + </div> | |
| 267 | + <Sheet open={sheet} onClose={() => setSheet(false)} side="bottom" eyebrow="Node" title={current?.name ?? 'Node'}> | |
| 268 | + {inspector} | |
| 269 | + </Sheet> | |
| 270 | + </TerminalLayout> | |
| 271 | + ); | |
| 272 | +} | |
| 273 | + | |
| 274 | +/* -------------------------------------------------------------------------------------------------------- inspector */ | |
| 275 | +function Inspector({ n, rootId, graph, byId, expanded, pending, onExpand, onSelect, onFocus }: { n: ExploreNode; rootId: string; graph: Graph; byId: Map<string, ExploreNode>; expanded: Set<string>; pending: string | null; onExpand: (id: string) => void; onSelect: (id: string) => void; onFocus: (n: ExploreNode) => void }) { | |
| 276 | + const summary = asSummary(n); | |
| 277 | + const attrs = keyAttributes(summary); | |
| 278 | + const groups = new Map<string, { node: ExploreNode; dir: 'out' | 'in' }[]>(); | |
| 279 | + for (const e of graph.edges) { | |
| 280 | + if (e.source !== n.id && e.target !== n.id) continue; | |
| 281 | + const out = e.source === n.id; | |
| 282 | + const other = byId.get(out ? e.target : e.source); | |
| 283 | + if (!other) continue; | |
| 284 | + const key = `${e.predicate}|${out ? 'out' : 'in'}`; | |
| 285 | + (groups.get(key) ?? groups.set(key, []).get(key)!).push({ node: other, dir: out ? 'out' : 'in' }); | |
| 286 | + } | |
| 287 | + const degree = [...groups.values()].reduce((s, g) => s + g.length, 0); | |
| 288 | + const isRoot = n.id === rootId; | |
| 289 | + const canExpand = !isRoot && !expanded.has(n.id); | |
| 290 | + return ( | |
| 291 | + <div className="space-y-4 text-sm" data-graph-inspector> | |
| 292 | + <div> | |
| 293 | + <div className="flex flex-wrap items-center gap-1.5"> | |
| 294 | + <EntityBadge type={n.entity_type} small /> | |
| 295 | + {n.artifact_kind && <span className="text-[11px] uppercase tracking-wide text-ink-3">{n.artifact_kind}</span>} | |
| 296 | + {isRoot ? <span className="text-[11px] uppercase tracking-wide text-ink-3">root</span> : <span className="tnum text-[11px] text-ink-3">{n.level} hop{n.level === 1 ? '' : 's'} from root</span>} | |
| 297 | + </div> | |
| 298 | + <p className="mt-1 text-base font-semibold leading-tight text-ink">{n.name}</p> | |
| 299 | + {n.org && n.org_slug && ( | |
| 300 | + <Link href={routes.entity({ entity_type: 'company', slug: n.org_slug })} className="text-xs text-ink-2 hover:text-accent"> | |
| 301 | + {n.org} | |
| 302 | + </Link> | |
| 303 | + )} | |
| 304 | + </div> | |
| 305 | + {attrs.length > 0 && ( | |
| 306 | + <dl className="kv [&>div]:py-1"> | |
| 307 | + {attrs.map((a) => ( | |
| 308 | + <div key={a.label}> | |
| 309 | + <dt className="capitalize">{a.label}</dt> | |
| 310 | + <dd className="tnum text-ink">{a.value}</dd> | |
| 311 | + </div> | |
| 312 | + ))} | |
| 313 | + </dl> | |
| 314 | + )} | |
| 315 | + <div className="flex flex-wrap gap-1.5"> | |
| 316 | + <Link href={routes.entity(n)} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 317 | + <ExternalLink className="size-3.5" aria-hidden /> Open page | |
| 318 | + </Link> | |
| 319 | + <button type="button" onClick={() => onExpand(n.id)} disabled={!canExpand || !!pending} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink disabled:opacity-40" data-graph-expand> | |
| 320 | + <Maximize2 className="size-3.5" aria-hidden /> {expanded.has(n.id) ? 'Expanded' : pending === n.id ? 'Expanding…' : 'Expand'} | |
| 321 | + </button> | |
| 322 | + {!isRoot && ( | |
| 323 | + <button type="button" onClick={() => onFocus(n)} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 324 | + <Focus className="size-3.5" aria-hidden /> Make root | |
| 325 | + </button> | |
| 326 | + )} | |
| 327 | + <CompareButton e={summary} size="sm" /> | |
| 328 | + <WatchButton e={summary} size="sm" /> | |
| 329 | + </div> | |
| 330 | + <div> | |
| 331 | + <p className="eyebrow mb-1.5"> | |
| 332 | + Links in view <span className="tnum normal-case tracking-normal text-ink-3">{fmtInt(degree)}</span> | |
| 333 | + </p> | |
| 334 | + {groups.size === 0 ? ( | |
| 335 | + <p className="text-xs text-ink-3">No edges to this node in the current view{canExpand ? ' — expand it to load its neighbourhood' : ''}.</p> | |
| 336 | + ) : ( | |
| 337 | + <dl className="kv [&>div]:py-1"> | |
| 338 | + {[...groups.entries()].map(([key, items]) => { | |
| 339 | + const [pred, dir] = key.split('|') as [string, 'out' | 'in']; | |
| 340 | + return ( | |
| 341 | + <div key={key}> | |
| 342 | + <dt> | |
| 343 | + {predicateLabel(pred, dir)} <span className="tnum text-ink-3">{items.length}</span> | |
| 344 | + </dt> | |
| 345 | + <dd className="flex flex-wrap gap-x-2 gap-y-0.5"> | |
| 346 | + {items.slice(0, 12).map(({ node }) => ( | |
| 347 | + <button key={node.id} type="button" onClick={() => onSelect(node.id)} className="inline-flex items-center gap-1 text-left text-ink hover:text-accent"> | |
| 348 | + <span className="inline-block size-2 rounded-full" style={{ background: colorOf(node.entity_type) }} aria-hidden /> | |
| 349 | + <span className="truncate">{node.name}</span> | |
| 350 | + </button> | |
| 351 | + ))} | |
| 352 | + {items.length > 12 && <span className="text-xs text-ink-3">+{items.length - 12}</span>} | |
| 353 | + </dd> | |
| 354 | + </div> | |
| 355 | + ); | |
| 356 | + })} | |
| 357 | + </dl> | |
| 358 | + )} | |
| 359 | + </div> | |
| 360 | + <p className="flex items-center gap-1 text-[11px] text-ink-3"> | |
| 361 | + <GitFork className="size-3" aria-hidden /> Edges are stated relations only (developer, provider, benchmark, paper…) — nothing is inferred. | |
| 362 | + </p> | |
| 363 | + </div> | |
| 364 | + ); | |
| 365 | +} | |
added
apps/web/src/components/graph/modes.ts
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +import type { GraphExploreMode } from '@/lib/types'; | |
| 2 | + | |
| 3 | +/** The seven `/graph/explore` modes, as preset chips. Server-safe. */ | |
| 4 | +export const GRAPH_MODES: { mode: GraphExploreMode; label: string; hint: string; rootTypes: string[] }[] = [ | |
| 5 | + { mode: 'lineage', label: 'Model lineage', hint: 'base → fine-tune → quantization, families, supersessions', rootTypes: ['model', 'artifact', 'model_family', 'quantization'] }, | |
| 6 | + { mode: 'research', label: 'Research network', hint: 'papers, authors, the models and datasets they describe', rootTypes: ['paper', 'researcher'] }, | |
| 7 | + { mode: 'company', label: 'Company ecosystem', hint: 'what an organization develops, owns, operates', rootTypes: ['company', 'organization', 'lab', 'university'] }, | |
| 8 | + { mode: 'benchmark', label: 'Benchmark graph', hint: 'benchmarks and the models evaluated on them', rootTypes: ['benchmark'] }, | |
| 9 | + { mode: 'dataset', label: 'Dataset graph', hint: 'datasets and the models trained or evaluated on them', rootTypes: ['dataset'] }, | |
| 10 | + { mode: 'provider', label: 'Provider graph', hint: 'providers and the models they serve', rootTypes: ['provider'] }, | |
| 11 | + { mode: 'hardware', label: 'Hardware ecosystem', hint: 'hardware, manufacturers, runtimes', rootTypes: ['hardware'] }, | |
| 12 | +]; | |
| 13 | +export const GRAPH_MODE_SET = new Set<string>(GRAPH_MODES.map((m) => m.mode)); | |
| 14 | + | |
| 15 | +export function isGraphMode(v: string | undefined | null): v is GraphExploreMode { | |
| 16 | + return !!v && GRAPH_MODE_SET.has(v); | |
| 17 | +} | |
| 18 | +/** Best default mode for a root entity type. */ | |
| 19 | +export function defaultModeFor(entityType: string | undefined | null): GraphExploreMode { | |
| 20 | + const m = GRAPH_MODES.find((x) => entityType && x.rootTypes.includes(entityType)); | |
| 21 | + return m?.mode ?? 'lineage'; | |
| 22 | +} | |
| 23 | +export function graphHref(node: string | null, mode: GraphExploreMode, depth: 1 | 2): string { | |
| 24 | + const p = new URLSearchParams(); | |
| 25 | + if (node) p.set('node', node); | |
| 26 | + if (mode !== 'lineage') p.set('mode', mode); | |
| 27 | + if (depth === 2) p.set('depth', '2'); | |
| 28 | + const s = p.toString(); | |
| 29 | + return s ? `/graph?${s}` : '/graph'; | |
| 30 | +} | |
| 31 | +/** `/graph/<slug>?mode=&depth=` URLs for the per-entity page. */ | |
| 32 | +export function graphSlugHref(node: string, mode: GraphExploreMode, depth: 1 | 2): string { | |
| 33 | + const p = new URLSearchParams(); | |
| 34 | + if (mode !== 'lineage') p.set('mode', mode); | |
| 35 | + if (depth === 2) p.set('depth', '2'); | |
| 36 | + const s = p.toString(); | |
| 37 | + return `/graph/${encodeURIComponent(node)}${s ? `?${s}` : ''}`; | |
| 38 | +} | |
added
apps/web/src/components/graph/node-search-link.tsx
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { useRouter } from 'next/navigation'; | |
| 3 | +import type { GraphExploreMode } from '@/lib/types'; | |
| 4 | +import { defaultModeFor, graphHref } from './modes'; | |
| 5 | +import { NodeSearch as Search } from './node-search'; | |
| 6 | + | |
| 7 | +/** Standalone root picker that navigates to `/graph?node=` (empty / not-found states of the graph pages). */ | |
| 8 | +export function NodeSearch({ mode, depth }: { mode?: GraphExploreMode; depth?: 1 | 2 }) { | |
| 9 | + const router = useRouter(); | |
| 10 | + return <Search autoFocus onPick={(s) => router.push(graphHref(s.slug, mode ?? defaultModeFor(s.entity_type), depth ?? 1))} />; | |
| 11 | +} | |
added
apps/web/src/components/graph/node-search.tsx
+94 −0
@@ -0,0 +1,94 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { Search } from 'lucide-react'; | |
| 3 | +import { useEffect, useId, useRef, useState } from 'react'; | |
| 4 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 5 | +import { cn } from '@/lib/cn'; | |
| 6 | +import { clientApi } from '@/lib/client-api'; | |
| 7 | +import type { Suggestion } from '@/lib/types'; | |
| 8 | + | |
| 9 | +/** Root picker: `/search/suggest` autocomplete (≤ 8 rows), keyboard navigable, calls `onPick(suggestion)`. */ | |
| 10 | +export function NodeSearch({ onPick, placeholder = 'Pick a root: model, organization, benchmark…', className, autoFocus = false }: { onPick: (s: Suggestion) => void; placeholder?: string; className?: string; autoFocus?: boolean }) { | |
| 11 | + const [q, setQ] = useState(''); | |
| 12 | + const [items, setItems] = useState<Suggestion[]>([]); | |
| 13 | + const [open, setOpen] = useState(false); | |
| 14 | + const [active, setActive] = useState(0); | |
| 15 | + const abort = useRef<AbortController | null>(null); | |
| 16 | + const listId = useId(); | |
| 17 | + useEffect(() => { | |
| 18 | + const term = q.trim(); | |
| 19 | + if (term.length < 2) { | |
| 20 | + setItems([]); | |
| 21 | + return; | |
| 22 | + } | |
| 23 | + abort.current?.abort(); | |
| 24 | + const ctl = new AbortController(); | |
| 25 | + abort.current = ctl; | |
| 26 | + const t = setTimeout(() => { | |
| 27 | + clientApi | |
| 28 | + .suggest(term, ctl.signal) | |
| 29 | + .then((r) => { | |
| 30 | + setItems(r.items); | |
| 31 | + setActive(0); | |
| 32 | + setOpen(true); | |
| 33 | + }) | |
| 34 | + .catch(() => undefined); | |
| 35 | + }, 120); | |
| 36 | + return () => clearTimeout(t); | |
| 37 | + }, [q]); | |
| 38 | + const pick = (s: Suggestion) => { | |
| 39 | + onPick(s); | |
| 40 | + setQ(''); | |
| 41 | + setItems([]); | |
| 42 | + setOpen(false); | |
| 43 | + }; | |
| 44 | + return ( | |
| 45 | + <div className={cn('relative', className)}> | |
| 46 | + <div className="flex items-stretch border border-rule bg-surface focus-within:border-accent"> | |
| 47 | + <span className="flex items-center pl-2.5 text-ink-3"> | |
| 48 | + <Search className="size-4" aria-hidden /> | |
| 49 | + </span> | |
| 50 | + <input | |
| 51 | + type="search" | |
| 52 | + value={q} | |
| 53 | + onChange={(e) => setQ(e.target.value)} | |
| 54 | + onFocus={() => items.length && setOpen(true)} | |
| 55 | + onBlur={() => setTimeout(() => setOpen(false), 120)} | |
| 56 | + onKeyDown={(e) => { | |
| 57 | + if (e.key === 'ArrowDown') { | |
| 58 | + e.preventDefault(); | |
| 59 | + setActive((a) => Math.min(items.length - 1, a + 1)); | |
| 60 | + } else if (e.key === 'ArrowUp') { | |
| 61 | + e.preventDefault(); | |
| 62 | + setActive((a) => Math.max(0, a - 1)); | |
| 63 | + } else if (e.key === 'Enter' && items[active]) { | |
| 64 | + e.preventDefault(); | |
| 65 | + pick(items[active]); | |
| 66 | + } else if (e.key === 'Escape') setOpen(false); | |
| 67 | + }} | |
| 68 | + placeholder={placeholder} | |
| 69 | + aria-label="Search a root entity" | |
| 70 | + aria-autocomplete="list" | |
| 71 | + aria-controls={listId} | |
| 72 | + aria-expanded={open} | |
| 73 | + autoComplete="off" | |
| 74 | + autoFocus={autoFocus} | |
| 75 | + className="h-11 min-w-0 flex-1 bg-transparent px-2 text-[15px] text-ink placeholder:text-ink-3 focus:outline-none" | |
| 76 | + data-graph-search | |
| 77 | + /> | |
| 78 | + </div> | |
| 79 | + {open && items.length > 0 && ( | |
| 80 | + <ul id={listId} role="listbox" className="panel absolute left-0 right-0 top-full z-20 mt-1 max-h-72 overflow-auto py-1 text-sm shadow-lg"> | |
| 81 | + {items.map((s, i) => ( | |
| 82 | + <li key={s.id} role="option" aria-selected={i === active}> | |
| 83 | + <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => pick(s)} onMouseEnter={() => setActive(i)} className={cn('flex w-full items-center gap-2 px-2.5 py-2 text-left', i === active ? 'bg-surface-2 text-ink' : 'text-ink-2 hover:bg-surface-2')}> | |
| 84 | + <EntityBadge type={s.entity_type} small /> | |
| 85 | + <span className="truncate">{s.name}</span> | |
| 86 | + {s.organization_name && <span className="ml-auto shrink-0 text-xs text-ink-3">{s.organization_name}</span>} | |
| 87 | + </button> | |
| 88 | + </li> | |
| 89 | + ))} | |
| 90 | + </ul> | |
| 91 | + )} | |
| 92 | + </div> | |
| 93 | + ); | |
| 94 | +} | |
added
apps/web/src/components/meta/breadcrumb-ld.tsx
+35 −0
@@ -0,0 +1,35 @@ | ||
| 1 | +import { SITE_URL } from '@/lib/site'; | |
| 2 | + | |
| 3 | +/** schema.org BreadcrumbList JSON-LD for the pages D3 owns (server component; absolute URLs). */ | |
| 4 | +export function BreadcrumbLd({ items }: { items: { name: string; href: string }[] }) { | |
| 5 | + const ld = { | |
| 6 | + '@context': 'https://schema.org', | |
| 7 | + '@type': 'BreadcrumbList', | |
| 8 | + itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: `${SITE_URL}${it.href}` })), | |
| 9 | + }; | |
| 10 | + return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />; | |
| 11 | +} | |
| 12 | + | |
| 13 | +/** Visible breadcrumb trail matching the JSON-LD (last item is the current page). */ | |
| 14 | +export function Breadcrumbs({ items }: { items: { name: string; href: string }[] }) { | |
| 15 | + return ( | |
| 16 | + <nav aria-label="Breadcrumb" className="pt-5 text-xs text-ink-3"> | |
| 17 | + <ol className="flex flex-wrap items-center gap-1.5"> | |
| 18 | + {items.map((it, i) => ( | |
| 19 | + <li key={it.href} className="flex items-center gap-1.5"> | |
| 20 | + {i > 0 && <span aria-hidden>/</span>} | |
| 21 | + {i === items.length - 1 ? ( | |
| 22 | + <span className="text-ink-2" aria-current="page"> | |
| 23 | + {it.name} | |
| 24 | + </span> | |
| 25 | + ) : ( | |
| 26 | + <a href={it.href} className="hover:text-ink"> | |
| 27 | + {it.name} | |
| 28 | + </a> | |
| 29 | + )} | |
| 30 | + </li> | |
| 31 | + ))} | |
| 32 | + </ol> | |
| 33 | + </nav> | |
| 34 | + ); | |
| 35 | +} | |
modified
apps/web/src/components/meta/sitemap-data.ts
+56 −4
@@ -4,9 +4,45 @@ import { routes, SITE_URL, TYPE_PATH } from '@/lib/site'; | ||
| 4 | 4 | |
| 5 | 5 | export const SITEMAP_HEADERS = { 'content-type': 'application/xml; charset=utf-8', 'cache-control': 'public, s-maxage=3600, stale-while-revalidate=86400' }; |
| 6 | 6 | export const SHARD = 5000; |
| 7 | −const STATIC = ['/', '/models', '/companies', '/papers', '/providers', '/benchmarks', '/hardware', '/frameworks', '/datasets', '/tools', '/changes', '/timeline', '/compare', '/explore', '/methodology', '/sources', '/about', '/developers', '/bot']; | |
| 7 | +/** Static public routes (1.1 surface). Query-driven pages are listed once at their canonical default URL. */ | |
| 8 | +const STATIC = [ | |
| 9 | + '/', | |
| 10 | + '/models', | |
| 11 | + '/companies', | |
| 12 | + '/papers', | |
| 13 | + '/providers', | |
| 14 | + '/benchmarks', | |
| 15 | + '/benchmarks?view=matrix', | |
| 16 | + '/hardware', | |
| 17 | + '/hardware/fit', | |
| 18 | + '/frameworks', | |
| 19 | + '/datasets', | |
| 20 | + '/tools', | |
| 21 | + '/agents', | |
| 22 | + '/families', | |
| 23 | + '/licenses', | |
| 24 | + '/open', | |
| 25 | + '/frontier', | |
| 26 | + '/prices', | |
| 27 | + '/pulse', | |
| 28 | + '/calculator', | |
| 29 | + '/run-locally', | |
| 30 | + '/find-a-model', | |
| 31 | + '/compare', | |
| 32 | + '/graph', | |
| 33 | + '/changes', | |
| 34 | + '/timeline', | |
| 35 | + '/time-machine', | |
| 36 | + '/diff', | |
| 37 | + '/explore', | |
| 38 | + '/methodology', | |
| 39 | + '/sources', | |
| 40 | + '/about', | |
| 41 | + '/developers', | |
| 42 | + '/bot', | |
| 43 | +]; | |
| 8 | 44 | /** Types with their own sitemap series (in this order); everything else goes into the "other" series. */ |
| 9 | −const SERIES = ['model', 'company', 'paper', 'provider', 'benchmark', 'hardware', 'framework', 'dataset', 'tool', 'repository'] as const; | |
| 45 | +const SERIES = ['model', 'company', 'paper', 'provider', 'benchmark', 'hardware', 'framework', 'dataset', 'tool', 'repository', 'model_family', 'artifact', 'license'] as const; | |
| 10 | 46 | |
| 11 | 47 | function esc(s: string): string { |
| 12 | 48 | return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); |
@@ -19,9 +55,9 @@ export function toUrlset(entries: { loc: string; lastmod?: string }[]): string { | ||
| 19 | 55 | return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.map((e) => ` <url><loc>${esc(SITE_URL + e.loc)}</loc>${e.lastmod ? `<lastmod>${e.lastmod.slice(0, 10)}</lastmod>` : ''}</url>`).join('\n')}\n</urlset>\n`; |
| 20 | 56 | } |
| 21 | 57 | |
| 22 | −/** Shard ids: `static`, then `<type>-<n>` per type with entities. */ | |
| 58 | +/** Shard ids: `static`, `changes` (daily digests of the observation history), then `<type>-<n>` per type with entities. */ | |
| 23 | 59 | export async function shardIds(): Promise<string[]> { |
| 24 | − const ids = ['static']; | |
| 60 | + const ids = ['static', 'changes']; | |
| 25 | 61 | const stats = await safe(api.stats()); |
| 26 | 62 | for (const t of SERIES) { |
| 27 | 63 | const n = Number(stats?.entities?.[t] ?? 0); |
@@ -31,8 +67,24 @@ export async function shardIds(): Promise<string[]> { | ||
| 31 | 67 | return ids; |
| 32 | 68 | } |
| 33 | 69 | |
| 70 | +/** One /changes/<date> URL per UTC day from the first entity to today (bounded to 3 years). */ | |
| 71 | +async function changesEntries(): Promise<{ loc: string; lastmod?: string }[]> { | |
| 72 | + const stats = await safe(api.stats()); | |
| 73 | + const first = stats?.first_entity_at ? new Date(stats.first_entity_at) : null; | |
| 74 | + if (!first || Number.isNaN(first.getTime())) return []; | |
| 75 | + const today = new Date(); | |
| 76 | + const start = Math.max(first.getTime(), today.getTime() - 3 * 365 * 86400000); | |
| 77 | + const out: { loc: string; lastmod?: string }[] = []; | |
| 78 | + for (let t = start; t <= today.getTime(); t += 86400000) { | |
| 79 | + const day = new Date(t).toISOString().slice(0, 10); | |
| 80 | + out.push({ loc: routes.changesDay(day) }); | |
| 81 | + } | |
| 82 | + return out; | |
| 83 | +} | |
| 84 | + | |
| 34 | 85 | export async function shardEntries(id: string): Promise<{ loc: string; lastmod?: string }[] | null> { |
| 35 | 86 | if (id === 'static') return STATIC.map((loc) => ({ loc })); |
| 87 | + if (id === 'changes') return changesEntries(); | |
| 36 | 88 | const m = /^([a-z_]+)-(\d+)$/.exec(id); |
| 37 | 89 | if (!m) return null; |
| 38 | 90 | const type = m[1] as string; |
added
apps/web/src/components/research/organization-page.tsx
+355 −0
@@ -0,0 +1,355 @@ | ||
| 1 | +import { ExternalLink, GitFork } from 'lucide-react'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { ChangeRow } from '@/components/changes/change-row'; | |
| 4 | +import { CompareButton } from '@/components/compare/compare-button'; | |
| 5 | +import { Identity, SourcesTable } from '@/components/entity/blocks'; | |
| 6 | +import { DataStrip, SectionNav } from '@/components/layout/terminal'; | |
| 7 | +import { ViewBeacon } from '@/components/layout/view-beacon'; | |
| 8 | +import { BreadcrumbLd, Breadcrumbs } from '@/components/meta/breadcrumb-ld'; | |
| 9 | +import { WatchButton } from '@/components/watchlist/watch-button'; | |
| 10 | +import { Chip, EntityBadge, OpennessBadge, StatusBadge } from '@/components/ui/badges'; | |
| 11 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 12 | +import { EntityLink, QualityMark } from '@/components/ui/entity'; | |
| 13 | +import { Hint } from '@/components/ui/hint'; | |
| 14 | +import { KeyValue } from '@/components/ui/key-value'; | |
| 15 | +import { Container, Note, Section } from '@/components/ui/section'; | |
| 16 | +import { EmptyState } from '@/components/ui/unavailable'; | |
| 17 | +import { api, apiD1, apiD3, safe } from '@/lib/api'; | |
| 18 | +import { fmtAgo, fmtDate, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format'; | |
| 19 | +import { PROSE_KEYS, routes, SITE_NAME, SITE_URL, typeLabel } from '@/lib/site'; | |
| 20 | +import type { ChangeEvent, EntityDetail, EntitySummary, FamilyRow } from '@/lib/types'; | |
| 21 | + | |
| 22 | +/* | |
| 23 | + Organization page 3.0 (server, async): footprint strip → models grouped by family → families → papers → researchers → | |
| 24 | + providers operated → repositories/frameworks → timeline split CORPORATE NEWS vs MODEL EVENTS → sources. | |
| 25 | + Counts come from the listing APIs (`/models?org=`, `/families?org=`, `/papers?org=`, `/explore/researcher?org=`) and the | |
| 26 | + detail relations; what the API does not expose per organization (datasets, benchmarks) is shown as "—" with a definition. | |
| 27 | +*/ | |
| 28 | + | |
| 29 | +const ORG_TYPES = ['company', 'organization', 'lab', 'university']; | |
| 30 | +const CORPORATE = new Set(['ANNOUNCEMENT', 'NEW_COMPANY', 'NEW_ORGANIZATION', 'NEW_LAB', 'NEW_UNIVERSITY', 'PROPERTY_CHANGED']); | |
| 31 | + | |
| 32 | +function relItems(d: EntityDetail, types: string[], predicates?: string[]): EntitySummary[] { | |
| 33 | + const out: EntitySummary[] = []; | |
| 34 | + const seen = new Set<string>(); | |
| 35 | + for (const g of d.relations ?? []) { | |
| 36 | + if (predicates && !predicates.includes(g.predicate)) continue; | |
| 37 | + for (const it of g.items) { | |
| 38 | + if (!types.includes(it.entity_type) || seen.has(it.id)) continue; | |
| 39 | + seen.add(it.id); | |
| 40 | + out.push(it); | |
| 41 | + } | |
| 42 | + } | |
| 43 | + return out; | |
| 44 | +} | |
| 45 | + | |
| 46 | +type ModelRow = EntitySummary & { family?: { id: string; slug: string; name: string } | null }; | |
| 47 | + | |
| 48 | +function ModelsByFamily({ items, total, org }: { items: ModelRow[]; total: number; org: string }) { | |
| 49 | + if (!items.length) return <EmptyState title="No canonical model attributed to this organization">Models are attributed through a stated developer relation or an official model id.</EmptyState>; | |
| 50 | + const groups = new Map<string, { label: string; href: string | null; items: ModelRow[] }>(); | |
| 51 | + for (const m of items) { | |
| 52 | + const fam = m.family ?? null; | |
| 53 | + const label = fam?.name ?? (typeof m.attributes?.family === 'string' ? (m.attributes.family as string) : 'No family'); | |
| 54 | + const key = fam?.slug ?? `label:${label}`; | |
| 55 | + (groups.get(key) ?? groups.set(key, { label, href: fam ? routes.family(fam.slug) : null, items: [] }).get(key)!).items.push(m); | |
| 56 | + } | |
| 57 | + const ordered = [...groups.values()].sort((a, b) => b.items.length - a.items.length || a.label.localeCompare(b.label)); | |
| 58 | + return ( | |
| 59 | + <> | |
| 60 | + <DataTable caption="Models by family" compact> | |
| 61 | + <thead> | |
| 62 | + <tr> | |
| 63 | + <Th>Model</Th> | |
| 64 | + <Th num>Params</Th> | |
| 65 | + <Th num>Context</Th> | |
| 66 | + <Th>Openness</Th> | |
| 67 | + <Th>Status</Th> | |
| 68 | + <Th>Released</Th> | |
| 69 | + <Th num>Quality</Th> | |
| 70 | + </tr> | |
| 71 | + </thead> | |
| 72 | + <tbody> | |
| 73 | + {ordered.map((g) => ( | |
| 74 | + <FamilyRows key={g.label} g={g} /> | |
| 75 | + ))} | |
| 76 | + </tbody> | |
| 77 | + </DataTable> | |
| 78 | + {total > items.length && ( | |
| 79 | + <Note className="mt-2"> | |
| 80 | + Showing {fmtInt(items.length)} of {fmtInt(total)} — <Link href={`/models?org=${encodeURIComponent(org)}`} className="link">all models by this organization →</Link> | |
| 81 | + </Note> | |
| 82 | + )} | |
| 83 | + </> | |
| 84 | + ); | |
| 85 | +} | |
| 86 | +function FamilyRows({ g }: { g: { label: string; href: string | null; items: ModelRow[] } }) { | |
| 87 | + return ( | |
| 88 | + <> | |
| 89 | + <tr className="bg-surface-2/60"> | |
| 90 | + <td colSpan={7} className="!py-1.5"> | |
| 91 | + <span className="eyebrow inline-flex items-center gap-2"> | |
| 92 | + {g.href ? ( | |
| 93 | + <Link href={g.href} className="hover:text-ink"> | |
| 94 | + {g.label} | |
| 95 | + </Link> | |
| 96 | + ) : ( | |
| 97 | + g.label | |
| 98 | + )} | |
| 99 | + <span className="tnum normal-case tracking-normal text-ink-3">{fmtInt(g.items.length)}</span> | |
| 100 | + </span> | |
| 101 | + </td> | |
| 102 | + </tr> | |
| 103 | + {g.items.map((m) => { | |
| 104 | + const a = m.attributes ?? {}; | |
| 105 | + return ( | |
| 106 | + <tr key={m.id}> | |
| 107 | + <Td primary> | |
| 108 | + <EntityLink e={m} /> | |
| 109 | + </Td> | |
| 110 | + <Td num label="Params" className="tnum">{fmtParams(a.parameter_count)}</Td> | |
| 111 | + <Td num label="Context" className="tnum">{num(a.context_length) === null ? '—' : fmtTokens(a.context_length)}</Td> | |
| 112 | + <Td label="Openness">{typeof a.openness === 'string' ? <OpennessBadge openness={a.openness} /> : <span className="text-ink-3">—</span>}</Td> | |
| 113 | + <Td label="Status"> | |
| 114 | + <StatusBadge status={typeof a.status === 'string' ? a.status : m.status} /> | |
| 115 | + </Td> | |
| 116 | + <Td label="Released" className="tnum text-ink-2">{typeof a.release_date === 'string' ? fmtDate(a.release_date) : '—'}</Td> | |
| 117 | + <Td num label="Quality"> | |
| 118 | + <QualityMark q={m.quality?.score} /> | |
| 119 | + </Td> | |
| 120 | + </tr> | |
| 121 | + ); | |
| 122 | + })} | |
| 123 | + </> | |
| 124 | + ); | |
| 125 | +} | |
| 126 | + | |
| 127 | +function FamiliesTable({ items }: { items: FamilyRow[] }) { | |
| 128 | + if (!items.length) return <p className="text-sm text-ink-3">No family recorded for this organization.</p>; | |
| 129 | + return ( | |
| 130 | + <DataTable caption="Families" compact> | |
| 131 | + <thead> | |
| 132 | + <tr> | |
| 133 | + <Th>Family</Th> | |
| 134 | + <Th num>Models</Th> | |
| 135 | + <Th>First release</Th> | |
| 136 | + <Th>Last release</Th> | |
| 137 | + <Th>Modalities</Th> | |
| 138 | + <Th>Canonical</Th> | |
| 139 | + </tr> | |
| 140 | + </thead> | |
| 141 | + <tbody> | |
| 142 | + {items.map((f) => ( | |
| 143 | + <tr key={f.slug}> | |
| 144 | + <Td primary> | |
| 145 | + {f.canonical ? ( | |
| 146 | + <Link href={routes.family(f.slug)} className="text-ink hover:text-accent hover:underline"> | |
| 147 | + {f.name} | |
| 148 | + </Link> | |
| 149 | + ) : ( | |
| 150 | + <span>{f.name}</span> | |
| 151 | + )} | |
| 152 | + </Td> | |
| 153 | + <Td num label="Models" className="tnum">{fmtInt(f.model_count)}</Td> | |
| 154 | + <Td label="First release" className="tnum text-ink-2">{fmtDate(f.first_release)}</Td> | |
| 155 | + <Td label="Last release" className="tnum text-ink-2">{fmtDate(f.last_release)}</Td> | |
| 156 | + <Td label="Modalities" className="text-xs text-ink-2">{f.modalities?.length ? f.modalities.join(', ') : '—'}</Td> | |
| 157 | + <Td label="Canonical">{f.canonical ? <Chip tone="accent">entity</Chip> : <Chip>legacy label</Chip>}</Td> | |
| 158 | + </tr> | |
| 159 | + ))} | |
| 160 | + </tbody> | |
| 161 | + </DataTable> | |
| 162 | + ); | |
| 163 | +} | |
| 164 | + | |
| 165 | +function List({ items, empty }: { items: EntitySummary[]; empty: React.ReactNode }) { | |
| 166 | + if (!items.length) return <p className="text-sm text-ink-3">{empty}</p>; | |
| 167 | + return ( | |
| 168 | + <ul className="divide-y divide-rule border-y border-rule"> | |
| 169 | + {items.map((e) => ( | |
| 170 | + <li key={e.id} className="flex items-center gap-2 py-2 text-sm"> | |
| 171 | + <EntityBadge type={e.entity_type} small /> | |
| 172 | + <EntityLink e={e} className="truncate" /> | |
| 173 | + {typeof e.attributes?.published_at === 'string' && <span className="ml-auto shrink-0 text-xs text-ink-3">{fmtDate(e.attributes.published_at as string)}</span>} | |
| 174 | + </li> | |
| 175 | + ))} | |
| 176 | + </ul> | |
| 177 | + ); | |
| 178 | +} | |
| 179 | + | |
| 180 | +export async function OrganizationPage({ d, canonical }: { d: EntityDetail; canonical: string }) { | |
| 181 | + const a = d.attributes ?? {}; | |
| 182 | + const [models, families, papers, researchers, news, tl] = await Promise.all([ | |
| 183 | + safe(api.models({ org: d.slug, limit: 200, sort: 'release' })), | |
| 184 | + safe(apiD1.families({ org: d.slug, limit: 50, sort: 'models' })), | |
| 185 | + safe(api.papers({ org: d.slug, limit: 30, sort: 'published' })), | |
| 186 | + safe(api.explore('researcher', { org: d.slug, limit: 30 })), | |
| 187 | + safe(apiD3.changes({ entity: d.slug, type: 'ANNOUNCEMENT', include_backfill: 1, limit: 30 })), | |
| 188 | + safe(apiD3.entityTimeline(d.slug, { include_backfill: 1, limit: 100 })), | |
| 189 | + ]); | |
| 190 | + const modelItems = (models?.items ?? []) as ModelRow[]; | |
| 191 | + const modelTotal = num(models?.total) ?? num(d.models?.total) ?? modelItems.length; | |
| 192 | + const famItems = families?.items ?? []; | |
| 193 | + const paperItems = papers?.items ?? d.papers ?? []; | |
| 194 | + const researcherItems = (researchers?.items ?? []).filter((r) => r.entity_type === 'researcher'); | |
| 195 | + const withIds = researcherItems.filter((r) => (r.counts?.claims ?? 0) > 0 || (r.organization && r.organization.slug === d.slug)); | |
| 196 | + const providers = relItems(d, ['provider'], ['operates', 'owns']); | |
| 197 | + const repos = [...(d.repositories ?? []), ...relItems(d, ['repository', 'framework', 'library'])].filter((x, i, arr) => arr.findIndex((y) => y.id === x.id) === i); | |
| 198 | + const datasets = relItems(d, ['dataset']); | |
| 199 | + const benchmarks = relItems(d, ['benchmark']); | |
| 200 | + const newsItems: ChangeEvent[] = news?.items ?? []; | |
| 201 | + const modelEvents: ChangeEvent[] = (tl?.items ?? []).filter((e) => !CORPORATE.has(e.event_type) && e.event_type !== 'ENTITY_MERGED'); | |
| 202 | + const corporateFromTimeline: ChangeEvent[] = (tl?.items ?? []).filter((e) => CORPORATE.has(e.event_type) && !newsItems.some((n) => n.id === e.id)); | |
| 203 | + const corporate = [...newsItems, ...corporateFromTimeline].sort((x, y) => ((y.occurred_at ?? y.observed_at) < (x.occurred_at ?? x.observed_at) ? -1 : 1)); | |
| 204 | + const link = ['website', 'official_url'].map((k) => a[k]).find((v): v is string => typeof v === 'string' && /^https?:\/\//.test(v)) ?? null; | |
| 205 | + const crumbs = [{ name: SITE_NAME, href: '/' }, { name: 'Organizations', href: '/companies' }, { name: d.name, href: canonical }]; | |
| 206 | + const ld = { '@context': 'https://schema.org', '@type': 'Organization', name: d.name, url: `${SITE_URL}${canonical}`, description: d.description ?? undefined, alternateName: d.aliases?.length ? d.aliases : undefined, foundingDate: a.founded ? String(a.founded) : undefined, sameAs: link ? [link] : undefined, location: typeof a.headquarters === 'string' ? a.headquarters : undefined }; | |
| 207 | + const specRows = Object.keys(a) | |
| 208 | + .filter((k) => !PROSE_KEYS.has(k) && !['founders', 'leadership'].includes(k)) | |
| 209 | + .map((k) => ({ key: k, raw: a[k] })); | |
| 210 | + const sections = [ | |
| 211 | + { id: 'models', label: 'Models' }, | |
| 212 | + { id: 'families', label: 'Families' }, | |
| 213 | + { id: 'papers', label: 'Papers' }, | |
| 214 | + { id: 'researchers', label: 'Researchers' }, | |
| 215 | + { id: 'providers', label: 'Providers' }, | |
| 216 | + { id: 'repositories', label: 'Code' }, | |
| 217 | + { id: 'news', label: 'Corporate news' }, | |
| 218 | + { id: 'model-events', label: 'Model events' }, | |
| 219 | + { id: 'sources', label: 'Sources' }, | |
| 220 | + ]; | |
| 221 | + | |
| 222 | + return ( | |
| 223 | + <Container wide> | |
| 224 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} /> | |
| 225 | + <BreadcrumbLd items={crumbs} /> | |
| 226 | + <ViewBeacon path={canonical} /> | |
| 227 | + <Breadcrumbs items={crumbs} /> | |
| 228 | + <header className="pb-5 pt-4 md:pb-6 md:pt-5"> | |
| 229 | + <div className="flex flex-wrap items-center gap-2"> | |
| 230 | + <EntityBadge type={d.entity_type} /> | |
| 231 | + {typeof a.org_kind === 'string' && a.org_kind.toLowerCase() !== typeLabel(d.entity_type).toLowerCase() && <Chip>{a.org_kind}</Chip>} | |
| 232 | + {typeof a.country === 'string' && <Chip className="mono">{a.country}</Chip>} | |
| 233 | + </div> | |
| 234 | + <div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> | |
| 235 | + <div className="min-w-0"> | |
| 236 | + <h1 className="display text-[30px] md:text-[44px]">{d.name}</h1> | |
| 237 | + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[15px] text-ink-2"> | |
| 238 | + {typeof a.headquarters === 'string' && <span>{a.headquarters}</span>} | |
| 239 | + {a.founded ? <span>· founded {String(a.founded).slice(0, 4)}</span> : null} | |
| 240 | + {link && ( | |
| 241 | + <a href={link} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-ink-3 hover:text-accent"> | |
| 242 | + {link.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '').slice(0, 48)} <ExternalLink className="size-3.5" aria-hidden /> | |
| 243 | + </a> | |
| 244 | + )} | |
| 245 | + </p> | |
| 246 | + {d.description && <p className="mt-3 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>} | |
| 247 | + <div className="mt-4 flex flex-wrap items-center gap-2" aria-label="Actions"> | |
| 248 | + <CompareButton e={d} /> | |
| 249 | + <WatchButton e={d} /> | |
| 250 | + <Link href={`/graph/${encodeURIComponent(d.slug)}?mode=company`} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 251 | + <GitFork className="size-3.5" aria-hidden /> Company ecosystem graph | |
| 252 | + </Link> | |
| 253 | + <Link href={routes.timeline({ entity: d.slug })} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 254 | + Timeline | |
| 255 | + </Link> | |
| 256 | + <Link href={routes.diff({ scope: `org:${d.slug}` })} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 257 | + Diff (last 7 days) | |
| 258 | + </Link> | |
| 259 | + </div> | |
| 260 | + </div> | |
| 261 | + <div className="shrink-0 text-xs text-ink-3 lg:text-right"> | |
| 262 | + <QualityMark q={d.quality?.score} label /> | |
| 263 | + <p className="mt-1" title={d.updated_at}> | |
| 264 | + Updated {fmtAgo(d.updated_at)} · first seen {fmtDate(d.first_seen_at)} | |
| 265 | + </p> | |
| 266 | + <p className="mono mt-0.5 text-[11px]">{d.id}</p> | |
| 267 | + </div> | |
| 268 | + </div> | |
| 269 | + </header> | |
| 270 | + | |
| 271 | + <DataStrip | |
| 272 | + dense | |
| 273 | + items={[ | |
| 274 | + { label: 'Models', value: fmtInt(modelTotal), definition: 'Canonical model releases attributed to this organization (artifacts and folded variants excluded).', href: '#models' }, | |
| 275 | + { label: 'Families', value: fmtInt(families?.total ?? famItems.length), definition: 'Model families whose official organization is this one.', href: '#families' }, | |
| 276 | + { label: 'Papers', value: fmtInt(papers?.total ?? paperItems.length), definition: 'Papers with this organization stated as publisher (arXiv metadata carries no affiliation, so this is often zero).', href: '#papers' }, | |
| 277 | + { label: 'Researchers', value: fmtInt(researchers?.total ?? researcherItems.length), definition: 'Researcher records affiliated with this organization by a stated relation.', href: '#researchers' }, | |
| 278 | + { label: 'Datasets', value: datasets.length ? fmtInt(datasets.length) : '—', definition: 'Datasets linked to this organization by a stated relation; the API has no per-organization dataset count.' }, | |
| 279 | + { label: 'Benchmarks', value: benchmarks.length ? fmtInt(benchmarks.length) : '—', definition: 'Benchmarks created by this organization (stated relation). Results by this organization’s models live on each leaderboard (?org=).' }, | |
| 280 | + { label: 'Providers', value: fmtInt(providers.length), definition: 'Inference providers operated or owned by this organization.', href: '#providers' }, | |
| 281 | + { label: 'Repositories', value: fmtInt(repos.length), definition: 'Repositories, frameworks and libraries linked by a stated relation.', href: '#repositories' }, | |
| 282 | + { label: 'Announcements', value: fmtInt(news?.total ?? corporate.length), definition: 'ANNOUNCEMENT events from this organization’s own news pages (including historical backfill).', href: '#news' }, | |
| 283 | + ]} | |
| 284 | + /> | |
| 285 | + <SectionNav items={sections} className="mt-3" /> | |
| 286 | + | |
| 287 | + <div className="grid gap-10 pb-16 lg:grid-cols-[minmax(0,1fr)_22rem]"> | |
| 288 | + <div className="min-w-0"> | |
| 289 | + <Section id="models" eyebrow="Models" title={<>Models <span className="tnum text-base font-normal text-ink-3">{fmtInt(modelTotal)}</span></>} lede="Grouped by family (canonical family entity when known, legacy label otherwise)." action={{ href: `/models?org=${encodeURIComponent(d.slug)}`, label: 'Filter in Models' }}> | |
| 290 | + <ModelsByFamily items={modelItems} total={modelTotal} org={d.slug} /> | |
| 291 | + </Section> | |
| 292 | + <Section id="families" eyebrow="Families" title={<>Families <span className="tnum text-base font-normal text-ink-3">{fmtInt(families?.total ?? famItems.length)}</span></>}> | |
| 293 | + <FamiliesTable items={famItems} /> | |
| 294 | + </Section> | |
| 295 | + <Section id="papers" eyebrow="Research" title={<>Papers <span className="tnum text-base font-normal text-ink-3">{fmtInt(papers?.total ?? paperItems.length)}</span></>} action={{ href: `/papers?org=${encodeURIComponent(d.slug)}`, label: 'All' }}> | |
| 296 | + <List items={paperItems} empty={<>No paper is attributed to {d.name}: arXiv metadata carries author names but no affiliation, and a paper is linked to an organization only when an official model card or lab page cites it.</>} /> | |
| 297 | + </Section> | |
| 298 | + <Section id="researchers" eyebrow="People" title={<>Researchers <span className="tnum text-base font-normal text-ink-3">{fmtInt(withIds.length)}</span></>} lede="Only researcher records with an identifier or a stated affiliation are listed."> | |
| 299 | + <List items={withIds} empty={<>No researcher is affiliated with {d.name} by a stated relation. The atlas's researcher rows come from arXiv author lists (name only, no identifier, no affiliation) and are not attributed to organizations.</>} /> | |
| 300 | + {researcherItems.length > withIds.length && <Note className="mt-2">{fmtInt(researcherItems.length - withIds.length)} name-only legacy rows omitted.</Note>} | |
| 301 | + </Section> | |
| 302 | + <Section id="providers" eyebrow="Serving" title={<>Providers operated <span className="tnum text-base font-normal text-ink-3">{fmtInt(providers.length)}</span></>}> | |
| 303 | + <List items={providers} empty="No provider operated by this organization is recorded." /> | |
| 304 | + </Section> | |
| 305 | + <Section id="repositories" eyebrow="Code" title={<>Repositories & frameworks <span className="tnum text-base font-normal text-ink-3">{fmtInt(repos.length)}</span></>}> | |
| 306 | + <List items={repos} empty="No repository or framework linked." /> | |
| 307 | + </Section> | |
| 308 | + <Section id="news" eyebrow="Timeline" title={<>Corporate news <span className="tnum text-base font-normal text-ink-3">{fmtInt(news?.total ?? corporate.length)}</span></>} lede="ANNOUNCEMENT events from the organization's own channels, newest first (dates are publication dates when stated)."> | |
| 309 | + {corporate.length === 0 ? ( | |
| 310 | + <EmptyState title="No announcement recorded">The news connector for this organization has not produced announcements yet.</EmptyState> | |
| 311 | + ) : ( | |
| 312 | + <ul className="border-t border-rule"> | |
| 313 | + {corporate.slice(0, 30).map((e) => ( | |
| 314 | + <ChangeRow key={e.id} e={e} showDate live={false} /> | |
| 315 | + ))} | |
| 316 | + </ul> | |
| 317 | + )} | |
| 318 | + </Section> | |
| 319 | + <Section id="model-events" eyebrow="Timeline" title={<>Model events <span className="tnum text-base font-normal text-ink-3">{fmtInt(modelEvents.length)}</span></>} lede="New models, releases, deprecations, price and property changes for the models this organization develops." action={{ href: routes.timeline({ entity: d.slug }), label: 'Full timeline' }}> | |
| 320 | + {modelEvents.length === 0 ? ( | |
| 321 | + <EmptyState title="No model event recorded" /> | |
| 322 | + ) : ( | |
| 323 | + <ul className="border-t border-rule"> | |
| 324 | + {modelEvents.slice(0, 40).map((e) => ( | |
| 325 | + <ChangeRow key={e.id} e={e} showDate live={false} /> | |
| 326 | + ))} | |
| 327 | + </ul> | |
| 328 | + )} | |
| 329 | + {modelEvents.length > 40 && <Note className="mt-2">First 40 of {fmtInt(modelEvents.length)} loaded — the full timeline has cursor paging.</Note>} | |
| 330 | + </Section> | |
| 331 | + <Section id="sources" eyebrow="Sources" title={<>Sources <span className="tnum text-base font-normal text-ink-3">{fmtInt(d.sources?.length ?? 0)}</span></>}> | |
| 332 | + <SourcesTable sources={d.sources ?? []} /> | |
| 333 | + </Section> | |
| 334 | + </div> | |
| 335 | + <aside className="min-w-0 space-y-8 lg:pt-8"> | |
| 336 | + <section> | |
| 337 | + <p className="eyebrow mb-2 inline-flex items-center gap-1"> | |
| 338 | + Record <Hint align="right" text="Every value shows its source, tier and observation time; click a value for the claim behind it." /> | |
| 339 | + </p> | |
| 340 | + <KeyValue rows={specRows} provenance={d.provenance} slug={d.slug} entity={{ name: d.name, entity_type: d.entity_type }} dense /> | |
| 341 | + </section> | |
| 342 | + <section> | |
| 343 | + <p className="eyebrow mb-2">Identity</p> | |
| 344 | + <Identity d={d} /> | |
| 345 | + <p className="mono mt-2 break-all text-[11px] text-ink-3">slug {d.slug}</p> | |
| 346 | + </section> | |
| 347 | + <section> | |
| 348 | + <p className="eyebrow mb-2">Definition</p> | |
| 349 | + <p className="text-xs text-ink-3">Organizations = companies, labs and universities ({ORG_TYPES.join(', ')}). Counts on this page are live from the listing APIs and the stated relations of this record.</p> | |
| 350 | + </section> | |
| 351 | + </aside> | |
| 352 | + </div> | |
| 353 | + </Container> | |
| 354 | + ); | |
| 355 | +} | |
added
apps/web/src/components/research/paper-page.tsx
+272 −0
@@ -0,0 +1,272 @@ | ||
| 1 | +import { ExternalLink, FileText } from 'lucide-react'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { SourcesTable, TimelineList } from '@/components/entity/blocks'; | |
| 4 | +import { ViewBeacon } from '@/components/layout/view-beacon'; | |
| 5 | +import { BreadcrumbLd, Breadcrumbs } from '@/components/meta/breadcrumb-ld'; | |
| 6 | +import { WatchButton } from '@/components/watchlist/watch-button'; | |
| 7 | +import { Chip, EntityBadge } from '@/components/ui/badges'; | |
| 8 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 9 | +import { EntityLink, QualityMark } from '@/components/ui/entity'; | |
| 10 | +import { KeyValue } from '@/components/ui/key-value'; | |
| 11 | +import { Container, Note, Section } from '@/components/ui/section'; | |
| 12 | +import { EmptyState } from '@/components/ui/unavailable'; | |
| 13 | +import { api, safe } from '@/lib/api'; | |
| 14 | +import { fmtAgo, fmtDate, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format'; | |
| 15 | +import { routes, SITE_NAME, SITE_URL, typeLabel } from '@/lib/site'; | |
| 16 | +import type { EntityDetail, EntitySummary } from '@/lib/types'; | |
| 17 | + | |
| 18 | +/* | |
| 19 | + Paper page (server, async). Sections: header (title, published, venue, arXiv, PDF/code) → abstract → authors (researcher | |
| 20 | + pages when the `authored` relation names them) → organizations → models introduced (`described_by` inbound) → datasets → | |
| 21 | + benchmarks → repositories → related papers (shared entities) → sources → timeline. Everything comes from the detail | |
| 22 | + payload's relations; nothing is inferred beyond grouping by entity type. | |
| 23 | +*/ | |
| 24 | + | |
| 25 | +function relItems(d: EntityDetail, types: string[], predicate?: string): EntitySummary[] { | |
| 26 | + const out: EntitySummary[] = []; | |
| 27 | + const seen = new Set<string>(); | |
| 28 | + for (const g of d.relations ?? []) { | |
| 29 | + if (predicate && g.predicate !== predicate) continue; | |
| 30 | + for (const it of g.items) { | |
| 31 | + if (!types.includes(it.entity_type) || seen.has(it.id)) continue; | |
| 32 | + seen.add(it.id); | |
| 33 | + out.push(it); | |
| 34 | + } | |
| 35 | + } | |
| 36 | + return out; | |
| 37 | +} | |
| 38 | + | |
| 39 | +function ModelsIntroduced({ items }: { items: EntitySummary[] }) { | |
| 40 | + if (!items.length) return <EmptyState title="No model links this paper yet">Model pages link papers through their model cards and documentation; the relation is written only when a source states it.</EmptyState>; | |
| 41 | + return ( | |
| 42 | + <DataTable caption="Models introduced or described" compact> | |
| 43 | + <thead> | |
| 44 | + <tr> | |
| 45 | + <Th>Model</Th> | |
| 46 | + <Th>Type</Th> | |
| 47 | + <Th>Organization</Th> | |
| 48 | + <Th num>Params</Th> | |
| 49 | + <Th num>Context</Th> | |
| 50 | + <Th>Released</Th> | |
| 51 | + </tr> | |
| 52 | + </thead> | |
| 53 | + <tbody> | |
| 54 | + {items.map((m) => { | |
| 55 | + const a = m.attributes ?? {}; | |
| 56 | + return ( | |
| 57 | + <tr key={m.id}> | |
| 58 | + <Td primary> | |
| 59 | + <EntityLink e={m} /> | |
| 60 | + </Td> | |
| 61 | + <Td label="Type"> | |
| 62 | + <EntityBadge type={m.entity_type} small /> | |
| 63 | + </Td> | |
| 64 | + <Td label="Organization" className="text-ink-2">{m.organization?.name ?? '—'}</Td> | |
| 65 | + <Td num label="Params" className="tnum">{fmtParams(a.parameter_count)}</Td> | |
| 66 | + <Td num label="Context" className="tnum">{num(a.context_length) === null ? '—' : fmtTokens(a.context_length)}</Td> | |
| 67 | + <Td label="Released" className="tnum text-ink-2">{typeof a.release_date === 'string' ? fmtDate(a.release_date) : '—'}</Td> | |
| 68 | + </tr> | |
| 69 | + ); | |
| 70 | + })} | |
| 71 | + </tbody> | |
| 72 | + </DataTable> | |
| 73 | + ); | |
| 74 | +} | |
| 75 | + | |
| 76 | +function List({ items, empty }: { items: EntitySummary[]; empty: string }) { | |
| 77 | + if (!items.length) return <p className="text-sm text-ink-3">{empty}</p>; | |
| 78 | + return ( | |
| 79 | + <ul className="divide-y divide-rule border-y border-rule"> | |
| 80 | + {items.map((e) => ( | |
| 81 | + <li key={e.id} className="flex items-center gap-2 py-2 text-sm"> | |
| 82 | + <EntityBadge type={e.entity_type} small /> | |
| 83 | + <EntityLink e={e} className="truncate" /> | |
| 84 | + {e.organization && <span className="ml-auto shrink-0 text-xs text-ink-3">{e.organization.name}</span>} | |
| 85 | + </li> | |
| 86 | + ))} | |
| 87 | + </ul> | |
| 88 | + ); | |
| 89 | +} | |
| 90 | + | |
| 91 | +export async function PaperPage({ d, canonical }: { d: EntityDetail; canonical: string }) { | |
| 92 | + const a = d.attributes ?? {}; | |
| 93 | + const authors: string[] = Array.isArray(a.authors) ? (a.authors as unknown[]).map(String) : []; | |
| 94 | + const researchers = relItems(d, ['researcher'], 'authored'); | |
| 95 | + const byName = new Map(researchers.map((r) => [r.name.toLowerCase(), r])); | |
| 96 | + const models = relItems(d, ['model', 'artifact', 'quantization']); | |
| 97 | + const datasets = relItems(d, ['dataset']); | |
| 98 | + const benchmarks = relItems(d, ['benchmark']); | |
| 99 | + const repos = [...relItems(d, ['repository', 'framework', 'library']), ...(d.repositories ?? [])].filter((x, i, arr) => arr.findIndex((y) => y.id === x.id) === i); | |
| 100 | + const orgs = relItems(d, ['company', 'organization', 'lab', 'university']); | |
| 101 | + const related = await safe(api.entityRelated(d.slug, 12)); | |
| 102 | + const relatedPapers = (related?.items ?? []).filter((r) => r.entity_type === 'paper' && r.id !== d.id); | |
| 103 | + const relatedOther = (related?.items ?? []).filter((r) => r.entity_type !== 'paper' && r.id !== d.id); | |
| 104 | + const arxiv = typeof a.arxiv_id === 'string' ? a.arxiv_id : d.identifiers?.find((i) => i.scheme === 'arxiv')?.value; | |
| 105 | + const pdf = typeof a.pdf_url === 'string' ? a.pdf_url : arxiv ? `https://arxiv.org/pdf/${arxiv}` : null; | |
| 106 | + const code = typeof a.code_url === 'string' ? a.code_url : null; | |
| 107 | + const cats = [...new Set([...(typeof a.primary_category === 'string' ? [a.primary_category] : []), ...(Array.isArray(a.categories) ? (a.categories as unknown[]).map(String) : [])])]; | |
| 108 | + const crumbs = [{ name: SITE_NAME, href: '/' }, { name: 'Research', href: '/papers' }, { name: d.name, href: canonical }]; | |
| 109 | + const ld = { | |
| 110 | + '@context': 'https://schema.org', | |
| 111 | + '@type': 'ScholarlyArticle', | |
| 112 | + headline: d.name, | |
| 113 | + name: d.name, | |
| 114 | + url: `${SITE_URL}${canonical}`, | |
| 115 | + datePublished: typeof a.published_at === 'string' ? a.published_at : undefined, | |
| 116 | + author: authors.slice(0, 30).map((n) => ({ '@type': 'Person', name: n })), | |
| 117 | + abstract: typeof a.abstract === 'string' ? a.abstract : undefined, | |
| 118 | + sameAs: [pdf, arxiv ? `https://arxiv.org/abs/${arxiv}` : null].filter(Boolean), | |
| 119 | + identifier: arxiv ? { '@type': 'PropertyValue', propertyID: 'arxiv', value: arxiv } : undefined, | |
| 120 | + publisher: d.organization ? { '@type': 'Organization', name: d.organization.name } : undefined, | |
| 121 | + }; | |
| 122 | + const specRows = ['published_at', 'venue', 'doi', 'arxiv_id', 'primary_category', 'pdf_url', 'code_url', 'updated_at'].filter((k) => a[k] !== undefined && a[k] !== null && a[k] !== '').map((k) => ({ key: k, raw: a[k] })); | |
| 123 | + | |
| 124 | + return ( | |
| 125 | + <Container wide> | |
| 126 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} /> | |
| 127 | + <BreadcrumbLd items={crumbs} /> | |
| 128 | + <ViewBeacon path={canonical} /> | |
| 129 | + <Breadcrumbs items={crumbs} /> | |
| 130 | + <header className="pb-6 pt-4 md:pb-8 md:pt-5"> | |
| 131 | + <div className="flex flex-wrap items-center gap-2"> | |
| 132 | + <EntityBadge type="paper" /> | |
| 133 | + {cats.map((c) => ( | |
| 134 | + <Link key={c} href={`/papers?category=${encodeURIComponent(c)}`}> | |
| 135 | + <Chip tone={c === a.primary_category ? 'accent' : 'neutral'} className="mono">{c}</Chip> | |
| 136 | + </Link> | |
| 137 | + ))} | |
| 138 | + </div> | |
| 139 | + <div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> | |
| 140 | + <div className="min-w-0"> | |
| 141 | + <h1 className="display text-[26px] leading-tight md:text-[36px]">{d.name}</h1> | |
| 142 | + <p className="tnum mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[15px] text-ink-2"> | |
| 143 | + {typeof a.published_at === 'string' && <span>Published {fmtDate(a.published_at)}</span>} | |
| 144 | + {typeof a.venue === 'string' && <span>· {a.venue}</span>} | |
| 145 | + {arxiv && ( | |
| 146 | + <a href={`https://arxiv.org/abs/${arxiv}`} target="_blank" rel="noopener noreferrer" className="mono inline-flex items-center gap-1 text-ink-3 hover:text-accent"> | |
| 147 | + arXiv:{arxiv} <ExternalLink className="size-3.5" aria-hidden /> | |
| 148 | + </a> | |
| 149 | + )} | |
| 150 | + </p> | |
| 151 | + <div className="mt-4 flex flex-wrap items-center gap-2" aria-label="Actions"> | |
| 152 | + {pdf && ( | |
| 153 | + <a href={pdf} target="_blank" rel="noopener noreferrer" className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 154 | + <FileText className="size-3.5" aria-hidden /> PDF | |
| 155 | + </a> | |
| 156 | + )} | |
| 157 | + {code && ( | |
| 158 | + <a href={code} target="_blank" rel="noopener noreferrer" className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 159 | + Code <ExternalLink className="size-3.5" aria-hidden /> | |
| 160 | + </a> | |
| 161 | + )} | |
| 162 | + <WatchButton e={d} /> | |
| 163 | + <Link href={`/graph/${encodeURIComponent(d.slug)}?mode=research`} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 164 | + Research graph | |
| 165 | + </Link> | |
| 166 | + </div> | |
| 167 | + </div> | |
| 168 | + <div className="shrink-0 text-xs text-ink-3 lg:text-right"> | |
| 169 | + <QualityMark q={d.quality?.score} label /> | |
| 170 | + <p className="mt-1" title={d.updated_at}> | |
| 171 | + Updated {fmtAgo(d.updated_at)} · first seen {fmtDate(d.first_seen_at)} | |
| 172 | + </p> | |
| 173 | + <p className="mono mt-0.5 text-[11px]">{d.id}</p> | |
| 174 | + </div> | |
| 175 | + </div> | |
| 176 | + </header> | |
| 177 | + | |
| 178 | + <div className="grid gap-10 pb-16 lg:grid-cols-[minmax(0,1fr)_22rem]"> | |
| 179 | + <div className="min-w-0"> | |
| 180 | + {typeof a.abstract === 'string' && ( | |
| 181 | + <Section id="abstract" eyebrow="Abstract" hairline={false} className="pt-0"> | |
| 182 | + <p className="prose-atlas max-w-3xl text-[15px] leading-relaxed text-ink-2">{a.abstract}</p> | |
| 183 | + </Section> | |
| 184 | + )} | |
| 185 | + <Section id="authors" eyebrow="Authors" title={<>Authors <span className="tnum text-base font-normal text-ink-3">{fmtInt(authors.length || researchers.length)}</span></>}> | |
| 186 | + {authors.length === 0 && researchers.length === 0 ? ( | |
| 187 | + <p className="text-sm text-ink-3">Author list not stated by the source.</p> | |
| 188 | + ) : ( | |
| 189 | + <p className="flex flex-wrap gap-x-3 gap-y-1 text-sm"> | |
| 190 | + {(authors.length ? authors : researchers.map((r) => r.name)).map((n, i) => { | |
| 191 | + const r = byName.get(n.toLowerCase()); | |
| 192 | + return r ? ( | |
| 193 | + <Link key={`${n}-${i}`} href={routes.entity(r)} className="text-ink hover:text-accent hover:underline"> | |
| 194 | + {n} | |
| 195 | + </Link> | |
| 196 | + ) : ( | |
| 197 | + <span key={`${n}-${i}`} className="text-ink-2"> | |
| 198 | + {n} | |
| 199 | + </span> | |
| 200 | + ); | |
| 201 | + })} | |
| 202 | + </p> | |
| 203 | + )} | |
| 204 | + <Note className="mt-2">Linked names open researcher pages (created from the paper's author list; name-only, no affiliation unless a source states it). Unlinked names have no researcher record yet.</Note> | |
| 205 | + </Section> | |
| 206 | + <Section id="organizations" eyebrow="Organizations" title={<>Organizations <span className="tnum text-base font-normal text-ink-3">{fmtInt(orgs.length + (d.organization ? 1 : 0))}</span></>}> | |
| 207 | + {d.organization || orgs.length ? ( | |
| 208 | + <List items={[...(d.organization ? [{ id: d.organization.id, entity_type: 'company', slug: d.organization.slug, name: d.organization.name, description: null, status: 'active', organization: null, attributes: {}, quality: {}, counts: {}, first_seen_at: '', last_seen_at: '', updated_at: '' } as EntitySummary] : []), ...orgs]} empty="" /> | |
| 209 | + ) : ( | |
| 210 | + <p className="text-sm text-ink-3">No organization stated. arXiv metadata does not carry affiliations; an organization is linked only when a model card or lab page cites the paper.</p> | |
| 211 | + )} | |
| 212 | + </Section> | |
| 213 | + <Section id="models" eyebrow="Models" title={<>Models introduced or described <span className="tnum text-base font-normal text-ink-3">{fmtInt(models.length)}</span></>} lede="Inbound described_by relations from model cards and documentation."> | |
| 214 | + <ModelsIntroduced items={models} /> | |
| 215 | + </Section> | |
| 216 | + <div className="grid gap-8 md:grid-cols-2"> | |
| 217 | + <Section id="datasets" eyebrow="Datasets" title={<>Datasets used <span className="tnum text-base font-normal text-ink-3">{fmtInt(datasets.length)}</span></>}> | |
| 218 | + <List items={datasets} empty="No dataset relation recorded." /> | |
| 219 | + </Section> | |
| 220 | + <Section id="benchmarks" eyebrow="Benchmarks" title={<>Benchmarks used <span className="tnum text-base font-normal text-ink-3">{fmtInt(benchmarks.length)}</span></>}> | |
| 221 | + <List items={benchmarks} empty="No benchmark relation recorded." /> | |
| 222 | + </Section> | |
| 223 | + </div> | |
| 224 | + <Section id="repositories" eyebrow="Code" title={<>Repositories & frameworks <span className="tnum text-base font-normal text-ink-3">{fmtInt(repos.length)}</span></>}> | |
| 225 | + <List items={repos} empty="No repository linked." /> | |
| 226 | + </Section> | |
| 227 | + <Section id="related" eyebrow="Related" title={<>Related papers <span className="tnum text-base font-normal text-ink-3">{fmtInt(relatedPapers.length)}</span></>} lede="Papers sharing an organization, a family or a relation with this one."> | |
| 228 | + <List items={relatedPapers} empty="No related paper found through shared entities." /> | |
| 229 | + {relatedOther.length > 0 && ( | |
| 230 | + <> | |
| 231 | + <p className="eyebrow mb-1.5 mt-5">Other related entities</p> | |
| 232 | + <List items={relatedOther} empty="" /> | |
| 233 | + </> | |
| 234 | + )} | |
| 235 | + </Section> | |
| 236 | + <Section id="timeline" eyebrow="Timeline" title={<>Timeline <span className="tnum text-base font-normal text-ink-3">{fmtInt(d.timeline?.length ?? 0)}</span></>}> | |
| 237 | + <TimelineList events={d.timeline ?? []} slug={d.slug} /> | |
| 238 | + </Section> | |
| 239 | + <Section id="sources" eyebrow="Sources" title={<>Sources <span className="tnum text-base font-normal text-ink-3">{fmtInt(d.sources?.length ?? 0)}</span></>}> | |
| 240 | + <SourcesTable sources={d.sources ?? []} /> | |
| 241 | + </Section> | |
| 242 | + </div> | |
| 243 | + <aside className="min-w-0 space-y-8"> | |
| 244 | + <section> | |
| 245 | + <p className="eyebrow mb-2">Record</p> | |
| 246 | + <KeyValue rows={specRows} provenance={d.provenance} slug={d.slug} entity={{ name: d.name, entity_type: d.entity_type }} dense /> | |
| 247 | + <p className="mono mt-2 break-all text-[11px] text-ink-3">slug {d.slug}</p> | |
| 248 | + </section> | |
| 249 | + {d.identifiers?.length > 0 && ( | |
| 250 | + <section> | |
| 251 | + <p className="eyebrow mb-2">Identifiers</p> | |
| 252 | + <ul className="space-y-0.5 text-xs"> | |
| 253 | + {d.identifiers.map((i) => ( | |
| 254 | + <li key={`${i.scheme}:${i.value}`} className="flex gap-2"> | |
| 255 | + <span className="mono text-ink-3">{i.scheme}</span> | |
| 256 | + <span className="mono truncate text-ink-2">{i.value}</span> | |
| 257 | + </li> | |
| 258 | + ))} | |
| 259 | + </ul> | |
| 260 | + </section> | |
| 261 | + )} | |
| 262 | + <section> | |
| 263 | + <p className="eyebrow mb-2">Type</p> | |
| 264 | + <p className="text-sm text-ink-2"> | |
| 265 | + {typeLabel(d.entity_type)} · <Link href={routes.methodology()} className="link">how papers are recorded</Link> | |
| 266 | + </p> | |
| 267 | + </section> | |
| 268 | + </aside> | |
| 269 | + </div> | |
| 270 | + </Container> | |
| 271 | + ); | |
| 272 | +} | |
added
apps/web/src/components/research/researcher-page.tsx
+177 −0
@@ -0,0 +1,177 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { SourcesTable } from '@/components/entity/blocks'; | |
| 3 | +import { ViewBeacon } from '@/components/layout/view-beacon'; | |
| 4 | +import { BreadcrumbLd, Breadcrumbs } from '@/components/meta/breadcrumb-ld'; | |
| 5 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 6 | +import { EntityLink, QualityMark } from '@/components/ui/entity'; | |
| 7 | +import { Container, Note, Section } from '@/components/ui/section'; | |
| 8 | +import { api, safe } from '@/lib/api'; | |
| 9 | +import { fmtAgo, fmtDate, fmtInt } from '@/lib/format'; | |
| 10 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 11 | +import type { EntityDetail, EntitySummary } from '@/lib/types'; | |
| 12 | + | |
| 13 | +/* | |
| 14 | + Researcher page (server, async): affiliations (stated relations / organization), papers (`authored` out), coauthors | |
| 15 | + (researchers named on the same papers — from the papers' `authored` relations). Public professional data only; the | |
| 16 | + page says plainly when the record is a name-only row created from an author list. | |
| 17 | +*/ | |
| 18 | + | |
| 19 | +function relItems(d: EntityDetail, types: string[], predicates?: string[]): EntitySummary[] { | |
| 20 | + const out: EntitySummary[] = []; | |
| 21 | + const seen = new Set<string>(); | |
| 22 | + for (const g of d.relations ?? []) { | |
| 23 | + if (predicates && !predicates.includes(g.predicate)) continue; | |
| 24 | + for (const it of g.items) if (types.includes(it.entity_type) && !seen.has(it.id)) { | |
| 25 | + seen.add(it.id); | |
| 26 | + out.push(it); | |
| 27 | + } | |
| 28 | + } | |
| 29 | + return out; | |
| 30 | +} | |
| 31 | + | |
| 32 | +export async function ResearcherPage({ d, canonical }: { d: EntityDetail; canonical: string }) { | |
| 33 | + const papers = relItems(d, ['paper'], ['authored']); | |
| 34 | + const affiliations = relItems(d, ['company', 'organization', 'lab', 'university']); | |
| 35 | + const details = await Promise.all(papers.slice(0, 8).map((p) => safe(api.entity(p.slug)))); | |
| 36 | + const co = new Map<string, EntitySummary & { shared: number }>(); | |
| 37 | + for (const pd of details) { | |
| 38 | + if (!pd) continue; | |
| 39 | + for (const r of relItems(pd, ['researcher'], ['authored'])) { | |
| 40 | + if (r.id === d.id) continue; | |
| 41 | + const cur = co.get(r.id); | |
| 42 | + if (cur) cur.shared += 1; | |
| 43 | + else co.set(r.id, { ...r, shared: 1 }); | |
| 44 | + } | |
| 45 | + } | |
| 46 | + const coauthors = [...co.values()].sort((a, b) => b.shared - a.shared || a.name.localeCompare(b.name)); | |
| 47 | + const nameOnly = !d.identifiers?.length && !d.organization && affiliations.length === 0; | |
| 48 | + const crumbs = [{ name: SITE_NAME, href: '/' }, { name: 'Researchers', href: '/explore/researcher' }, { name: d.name, href: canonical }]; | |
| 49 | + const ld = { '@context': 'https://schema.org', '@type': 'Person', name: d.name, url: `${SITE_URL}${canonical}`, affiliation: (d.organization ? [d.organization] : affiliations).map((o) => ({ '@type': 'Organization', name: o.name })), identifier: d.identifiers?.map((i) => ({ '@type': 'PropertyValue', propertyID: i.scheme, value: i.value })) }; | |
| 50 | + | |
| 51 | + return ( | |
| 52 | + <Container wide> | |
| 53 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} /> | |
| 54 | + <BreadcrumbLd items={crumbs} /> | |
| 55 | + <ViewBeacon path={canonical} /> | |
| 56 | + <Breadcrumbs items={crumbs} /> | |
| 57 | + <header className="pb-6 pt-4 md:pb-8 md:pt-5"> | |
| 58 | + <div className="flex flex-wrap items-center gap-2"> | |
| 59 | + <EntityBadge type="researcher" /> | |
| 60 | + {nameOnly && <span className="rounded-[3px] bg-surface-2 px-1.5 text-[10px] uppercase tracking-wide text-ink-3">name-only record</span>} | |
| 61 | + </div> | |
| 62 | + <div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> | |
| 63 | + <div className="min-w-0"> | |
| 64 | + <h1 className="display text-[30px] md:text-[44px]">{d.name}</h1> | |
| 65 | + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[15px] text-ink-2"> | |
| 66 | + {d.organization && ( | |
| 67 | + <Link href={routes.entity({ entity_type: 'company', slug: d.organization.slug })} className="font-medium text-ink hover:text-accent"> | |
| 68 | + {d.organization.name} | |
| 69 | + </Link> | |
| 70 | + )} | |
| 71 | + <span className="tnum text-ink-3"> | |
| 72 | + {fmtInt(papers.length)} paper{papers.length === 1 ? '' : 's'} in the atlas | |
| 73 | + </span> | |
| 74 | + </p> | |
| 75 | + {nameOnly && <Note className="mt-3 max-w-2xl">This record was created from a paper's author list. It carries no identifier (ORCID, OpenAlex…) and no affiliation, so homonyms may be merged and spelling variants split. Only public professional data — papers and stated affiliations — is shown.</Note>} | |
| 76 | + </div> | |
| 77 | + <div className="shrink-0 text-xs text-ink-3 lg:text-right"> | |
| 78 | + <QualityMark q={d.quality?.score} label /> | |
| 79 | + <p className="mt-1" title={d.updated_at}> | |
| 80 | + Updated {fmtAgo(d.updated_at)} · first seen {fmtDate(d.first_seen_at)} | |
| 81 | + </p> | |
| 82 | + <p className="mono mt-0.5 text-[11px]">{d.id}</p> | |
| 83 | + </div> | |
| 84 | + </div> | |
| 85 | + </header> | |
| 86 | + | |
| 87 | + <div className="grid gap-10 pb-16 lg:grid-cols-[minmax(0,1fr)_22rem]"> | |
| 88 | + <div className="min-w-0"> | |
| 89 | + <Section id="affiliations" eyebrow="Affiliations" title={<>Affiliations <span className="tnum text-base font-normal text-ink-3">{fmtInt(affiliations.length + (d.organization ? 1 : 0))}</span></>} hairline={false} className="pt-0"> | |
| 90 | + {d.organization || affiliations.length ? ( | |
| 91 | + <ul className="divide-y divide-rule border-y border-rule text-sm"> | |
| 92 | + {d.organization && ( | |
| 93 | + <li className="flex items-center gap-2 py-2"> | |
| 94 | + <EntityBadge type="company" small /> | |
| 95 | + <Link href={routes.entity({ entity_type: 'company', slug: d.organization.slug })} className="text-ink hover:text-accent hover:underline"> | |
| 96 | + {d.organization.name} | |
| 97 | + </Link> | |
| 98 | + </li> | |
| 99 | + )} | |
| 100 | + {affiliations.map((o) => ( | |
| 101 | + <li key={o.id} className="flex items-center gap-2 py-2"> | |
| 102 | + <EntityBadge type={o.entity_type} small /> | |
| 103 | + <EntityLink e={o} /> | |
| 104 | + </li> | |
| 105 | + ))} | |
| 106 | + </ul> | |
| 107 | + ) : ( | |
| 108 | + <p className="text-sm text-ink-3">No affiliation stated by a source.</p> | |
| 109 | + )} | |
| 110 | + </Section> | |
| 111 | + <Section id="papers" eyebrow="Research" title={<>Papers <span className="tnum text-base font-normal text-ink-3">{fmtInt(papers.length)}</span></>}> | |
| 112 | + {papers.length === 0 ? ( | |
| 113 | + <p className="text-sm text-ink-3">No paper linked.</p> | |
| 114 | + ) : ( | |
| 115 | + <ul className="divide-y divide-rule border-y border-rule"> | |
| 116 | + {papers.map((p) => ( | |
| 117 | + <li key={p.id} className="py-2 text-sm"> | |
| 118 | + <EntityLink e={p} /> | |
| 119 | + <span className="tnum ml-2 text-xs text-ink-3">{typeof p.attributes?.published_at === 'string' ? fmtDate(p.attributes.published_at as string) : ''}</span> | |
| 120 | + </li> | |
| 121 | + ))} | |
| 122 | + </ul> | |
| 123 | + )} | |
| 124 | + </Section> | |
| 125 | + <Section id="coauthors" eyebrow="Network" title={<>Co-authors <span className="tnum text-base font-normal text-ink-3">{fmtInt(coauthors.length)}</span></>} lede={papers.length > 8 ? 'From the first 8 papers.' : 'Researchers named on the same papers.'}> | |
| 126 | + {coauthors.length === 0 ? ( | |
| 127 | + <p className="text-sm text-ink-3">No co-author record found on the linked papers.</p> | |
| 128 | + ) : ( | |
| 129 | + <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3"> | |
| 130 | + {coauthors.map((c) => ( | |
| 131 | + <li key={c.id} className="flex items-center justify-between gap-2 border-b border-rule py-1.5 text-sm"> | |
| 132 | + <EntityLink e={c} className="truncate" /> | |
| 133 | + <span className="tnum shrink-0 text-xs text-ink-3"> | |
| 134 | + {c.shared} shared | |
| 135 | + </span> | |
| 136 | + </li> | |
| 137 | + ))} | |
| 138 | + </ul> | |
| 139 | + )} | |
| 140 | + </Section> | |
| 141 | + <Section id="sources" eyebrow="Sources" title={<>Sources <span className="tnum text-base font-normal text-ink-3">{fmtInt(d.sources?.length ?? 0)}</span></>}> | |
| 142 | + <SourcesTable sources={d.sources ?? []} /> | |
| 143 | + </Section> | |
| 144 | + </div> | |
| 145 | + <aside className="min-w-0 space-y-8"> | |
| 146 | + <section> | |
| 147 | + <p className="eyebrow mb-2">Identifiers</p> | |
| 148 | + {d.identifiers?.length ? ( | |
| 149 | + <ul className="space-y-0.5 text-xs"> | |
| 150 | + {d.identifiers.map((i) => ( | |
| 151 | + <li key={`${i.scheme}:${i.value}`} className="flex gap-2"> | |
| 152 | + <span className="mono text-ink-3">{i.scheme}</span> | |
| 153 | + <span className="mono truncate text-ink-2">{i.value}</span> | |
| 154 | + </li> | |
| 155 | + ))} | |
| 156 | + </ul> | |
| 157 | + ) : ( | |
| 158 | + <p className="text-xs text-ink-3">None recorded.</p> | |
| 159 | + )} | |
| 160 | + </section> | |
| 161 | + {d.aliases?.length > 1 && ( | |
| 162 | + <section> | |
| 163 | + <p className="eyebrow mb-2">Also written as</p> | |
| 164 | + <p className="text-sm text-ink-2">{d.aliases.filter((x) => x !== d.name).join(' · ')}</p> | |
| 165 | + </section> | |
| 166 | + )} | |
| 167 | + <section> | |
| 168 | + <p className="eyebrow mb-2">Graph</p> | |
| 169 | + <Link href={`/graph/${encodeURIComponent(d.slug)}?mode=research`} className="link text-sm"> | |
| 170 | + Research network → | |
| 171 | + </Link> | |
| 172 | + </section> | |
| 173 | + </aside> | |
| 174 | + </div> | |
| 175 | + </Container> | |
| 176 | + ); | |
| 177 | +} | |
added
apps/web/src/components/temporal/dates.ts
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +/** Date helpers for the temporal pages (UTC, YYYY-MM-DD). Server-safe, no data. */ | |
| 2 | +export const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/; | |
| 3 | + | |
| 4 | +export function todayUtc(): string { | |
| 5 | + return new Date().toISOString().slice(0, 10); | |
| 6 | +} | |
| 7 | +export function daysBefore(iso: string, days: number): string { | |
| 8 | + const d = new Date(`${iso}T00:00:00Z`); | |
| 9 | + d.setUTCDate(d.getUTCDate() - days); | |
| 10 | + return d.toISOString().slice(0, 10); | |
| 11 | +} | |
| 12 | +export function monthsBefore(iso: string, months: number): string { | |
| 13 | + const d = new Date(`${iso}T00:00:00Z`); | |
| 14 | + d.setUTCMonth(d.getUTCMonth() - months); | |
| 15 | + return d.toISOString().slice(0, 10); | |
| 16 | +} | |
| 17 | +export function firstOfMonth(iso: string): string { | |
| 18 | + return `${iso.slice(0, 7)}-01`; | |
| 19 | +} | |
| 20 | +export function firstOfQuarter(iso: string): string { | |
| 21 | + const m = Number(iso.slice(5, 7)); | |
| 22 | + const q = Math.floor((m - 1) / 3) * 3 + 1; | |
| 23 | + return `${iso.slice(0, 4)}-${String(q).padStart(2, '0')}-01`; | |
| 24 | +} | |
| 25 | +export function validDay(v: string | undefined | null): string | null { | |
| 26 | + if (!v || !ISO_DAY.test(v)) return null; | |
| 27 | + const d = new Date(`${v}T00:00:00Z`); | |
| 28 | + return Number.isNaN(d.getTime()) ? null : v; | |
| 29 | +} | |
added
apps/web/src/components/temporal/diff-sections.tsx
+306 −0
@@ -0,0 +1,306 @@ | ||
| 1 | +import { ExternalLink } from 'lucide-react'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import type { ReactNode } from 'react'; | |
| 4 | +import { ChangeRow } from '@/components/changes/change-row'; | |
| 5 | +import { EntityBadge, ImportanceMark, OpennessBadge } from '@/components/ui/badges'; | |
| 6 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 7 | +import { EntityLink } from '@/components/ui/entity'; | |
| 8 | +import { Note, Section } from '@/components/ui/section'; | |
| 9 | +import { EmptyState } from '@/components/ui/unavailable'; | |
| 10 | +import { fmtDate, fmtDeltaPct, fmtInt, fmtParams, fmtScore, fmtTokens, fmtUsdPerM, hostOf, num } from '@/lib/format'; | |
| 11 | +import { routes } from '@/lib/site'; | |
| 12 | +import type { ChangeEvent, DiffPayload11, EntitySummary } from '@/lib/types'; | |
| 13 | + | |
| 14 | +/* Diff sections (server). Dense rows with typed deltas; every row keeps its source link and a History link. */ | |
| 15 | + | |
| 16 | +export function Capped({ shown, total, what }: { shown: number; total: number | null; what: string }) { | |
| 17 | + if (total === null || total <= shown) return null; | |
| 18 | + return <Note className="mt-2">Showing the first {fmtInt(shown)} of {fmtInt(total)} {what}. Narrow the scope or the dates to see everything.</Note>; | |
| 19 | +} | |
| 20 | + | |
| 21 | +function SectionTitle({ title, n }: { title: string; n: number | null }) { | |
| 22 | + return ( | |
| 23 | + <> | |
| 24 | + {title} <span className="tnum text-base font-normal text-ink-3">{fmtInt(n)}</span> | |
| 25 | + </> | |
| 26 | + ); | |
| 27 | +} | |
| 28 | + | |
| 29 | +function Src({ url }: { url: string | null }) { | |
| 30 | + if (!url) return <span className="text-ink-3">—</span>; | |
| 31 | + return ( | |
| 32 | + <a href={url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-xs text-ink-3 hover:text-accent"> | |
| 33 | + {hostOf(url) ?? 'source'} <ExternalLink className="size-3" aria-hidden /> | |
| 34 | + </a> | |
| 35 | + ); | |
| 36 | +} | |
| 37 | +function When({ e }: { e: ChangeEvent }) { | |
| 38 | + const at = e.occurred_at ?? e.effective_at ?? e.observed_at; | |
| 39 | + return ( | |
| 40 | + <time dateTime={at} title={`observed ${e.observed_at}`} className="tnum text-xs text-ink-2"> | |
| 41 | + {fmtDate(at)} | |
| 42 | + </time> | |
| 43 | + ); | |
| 44 | +} | |
| 45 | + | |
| 46 | +/** New entities of one type as a dense table. */ | |
| 47 | +export function NewEntitiesSection({ id, title, items, total, empty, type }: { id: string; title: string; items: EntitySummary[]; total: number | null; empty: string; type: 'model' | 'paper' | 'other' }) { | |
| 48 | + return ( | |
| 49 | + <Section id={id} eyebrow={title} title={<SectionTitle title={title} n={total ?? items.length} />}> | |
| 50 | + {items.length === 0 ? ( | |
| 51 | + <EmptyState title={empty} /> | |
| 52 | + ) : ( | |
| 53 | + <> | |
| 54 | + <DataTable caption={title} compact scroll> | |
| 55 | + <thead> | |
| 56 | + <tr> | |
| 57 | + <Th>{type === 'paper' ? 'Paper' : type === 'model' ? 'Model' : 'Entity'}</Th> | |
| 58 | + {type === 'other' && <Th>Type</Th>} | |
| 59 | + <Th>Organization</Th> | |
| 60 | + {type === 'model' && ( | |
| 61 | + <> | |
| 62 | + <Th num>Params</Th> | |
| 63 | + <Th num>Context</Th> | |
| 64 | + <Th>Openness</Th> | |
| 65 | + <Th>Released</Th> | |
| 66 | + </> | |
| 67 | + )} | |
| 68 | + {type === 'paper' && <Th>Published</Th>} | |
| 69 | + <Th>First seen</Th> | |
| 70 | + </tr> | |
| 71 | + </thead> | |
| 72 | + <tbody> | |
| 73 | + {items.map((e) => { | |
| 74 | + const a = e.attributes ?? {}; | |
| 75 | + return ( | |
| 76 | + <tr key={e.id}> | |
| 77 | + <Td primary> | |
| 78 | + <EntityLink e={e} /> | |
| 79 | + </Td> | |
| 80 | + {type === 'other' && ( | |
| 81 | + <Td label="Type"> | |
| 82 | + <EntityBadge type={e.entity_type} small /> | |
| 83 | + </Td> | |
| 84 | + )} | |
| 85 | + <Td label="Organization" className="text-ink-2"> | |
| 86 | + {e.organization ? <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent">{e.organization.name}</Link> : <span className="text-ink-3">—</span>} | |
| 87 | + </Td> | |
| 88 | + {type === 'model' && ( | |
| 89 | + <> | |
| 90 | + <Td num label="Params" className="tnum">{fmtParams(a.parameter_count)}</Td> | |
| 91 | + <Td num label="Context" className="tnum">{num(a.context_length) === null ? '—' : fmtTokens(a.context_length)}</Td> | |
| 92 | + <Td label="Openness">{typeof a.openness === 'string' ? <OpennessBadge openness={a.openness} /> : <span className="text-ink-3">—</span>}</Td> | |
| 93 | + <Td label="Released" className="tnum text-ink-2">{typeof a.release_date === 'string' ? fmtDate(a.release_date) : '—'}</Td> | |
| 94 | + </> | |
| 95 | + )} | |
| 96 | + {type === 'paper' && <Td label="Published" className="tnum text-ink-2">{typeof a.published_at === 'string' ? fmtDate(a.published_at) : '—'}</Td>} | |
| 97 | + <Td label="First seen" className="tnum text-xs text-ink-3">{fmtDate(e.first_seen_at)}</Td> | |
| 98 | + </tr> | |
| 99 | + ); | |
| 100 | + })} | |
| 101 | + </tbody> | |
| 102 | + </DataTable> | |
| 103 | + <Capped shown={items.length} total={total} what="entities" /> | |
| 104 | + </> | |
| 105 | + )} | |
| 106 | + </Section> | |
| 107 | + ); | |
| 108 | +} | |
| 109 | + | |
| 110 | +type PriceObj = { input_per_mtok?: unknown; output_per_mtok?: unknown }; | |
| 111 | +function priceOf(v: unknown): PriceObj | null { | |
| 112 | + return v && typeof v === 'object' ? (v as PriceObj) : null; | |
| 113 | +} | |
| 114 | +function DeltaCell({ from, to, fmt }: { from: unknown; to: unknown; fmt: (v: unknown) => string }) { | |
| 115 | + const a = num(from); | |
| 116 | + const b = num(to); | |
| 117 | + const pct = fmtDeltaPct(a, b); | |
| 118 | + return ( | |
| 119 | + <span className="tnum inline-flex flex-wrap items-baseline gap-1"> | |
| 120 | + <span className="text-ink-3 line-through decoration-ink-3/60">{a === null ? '—' : fmt(a)}</span> | |
| 121 | + <span className="text-ink-3">→</span> | |
| 122 | + <span className="font-medium text-ink">{b === null ? '—' : fmt(b)}</span> | |
| 123 | + {pct && <span className={`text-[11px] ${b !== null && a !== null && b < a ? 'text-positive' : 'text-accent-2'}`}>{pct}</span>} | |
| 124 | + </span> | |
| 125 | + ); | |
| 126 | +} | |
| 127 | + | |
| 128 | +/** PRICE_CHANGED / PROVIDER_LISTED rows: old/new are `{ input_per_mtok, output_per_mtok }` objects. */ | |
| 129 | +export function PriceChangesSection({ id, title, items, total, empty }: { id: string; title: string; items: ChangeEvent[]; total: number | null; empty: string }) { | |
| 130 | + return ( | |
| 131 | + <Section id={id} eyebrow={title} title={<SectionTitle title={title} n={total ?? items.length} />}> | |
| 132 | + {items.length === 0 ? ( | |
| 133 | + <EmptyState title={empty} /> | |
| 134 | + ) : ( | |
| 135 | + <> | |
| 136 | + <DataTable caption={title} compact scroll> | |
| 137 | + <thead> | |
| 138 | + <tr> | |
| 139 | + <Th>Date</Th> | |
| 140 | + <Th>Model</Th> | |
| 141 | + <Th>Provider</Th> | |
| 142 | + <Th num>Input / 1M</Th> | |
| 143 | + <Th num>Output / 1M</Th> | |
| 144 | + <Th>Source</Th> | |
| 145 | + </tr> | |
| 146 | + </thead> | |
| 147 | + <tbody> | |
| 148 | + {items.map((e) => { | |
| 149 | + const o = priceOf(e.old_value); | |
| 150 | + const n = priceOf(e.new_value); | |
| 151 | + const provider = typeof e.meta?.provider === 'string' ? e.meta.provider : e.entity?.entity_type === 'provider' ? e.entity.name : null; | |
| 152 | + return ( | |
| 153 | + <tr key={e.id}> | |
| 154 | + <Td label="Date"> | |
| 155 | + <span className="inline-flex items-center gap-2"> | |
| 156 | + <ImportanceMark importance={e.importance} /> <When e={e} /> | |
| 157 | + </span> | |
| 158 | + </Td> | |
| 159 | + <Td primary>{e.entity ? <EntityLink e={e.entity} /> : <span className="text-ink-2">{e.summary}</span>}</Td> | |
| 160 | + <Td label="Provider" className="text-ink-2">{provider ?? '—'}</Td> | |
| 161 | + <Td num label="Input">{o || n ? <DeltaCell from={o?.input_per_mtok} to={n?.input_per_mtok} fmt={(v) => fmtUsdPerM(v)} /> : '—'}</Td> | |
| 162 | + <Td num label="Output">{o || n ? <DeltaCell from={o?.output_per_mtok} to={n?.output_per_mtok} fmt={(v) => fmtUsdPerM(v)} /> : '—'}</Td> | |
| 163 | + <Td label="Source"> | |
| 164 | + <Src url={e.source_url} /> | |
| 165 | + </Td> | |
| 166 | + </tr> | |
| 167 | + ); | |
| 168 | + })} | |
| 169 | + </tbody> | |
| 170 | + </DataTable> | |
| 171 | + <Capped shown={items.length} total={total} what="price events" /> | |
| 172 | + </> | |
| 173 | + )} | |
| 174 | + </Section> | |
| 175 | + ); | |
| 176 | +} | |
| 177 | + | |
| 178 | +/** CONTEXT_CHANGED (and other numeric property events): old → new with %. */ | |
| 179 | +export function ContextChangesSection({ id, title, items, total, empty }: { id: string; title: string; items: ChangeEvent[]; total: number | null; empty: string }) { | |
| 180 | + return ( | |
| 181 | + <Section id={id} eyebrow={title} title={<SectionTitle title={title} n={total ?? items.length} />}> | |
| 182 | + {items.length === 0 ? ( | |
| 183 | + <EmptyState title={empty} /> | |
| 184 | + ) : ( | |
| 185 | + <> | |
| 186 | + <DataTable caption={title} compact scroll> | |
| 187 | + <thead> | |
| 188 | + <tr> | |
| 189 | + <Th>Date</Th> | |
| 190 | + <Th>Model</Th> | |
| 191 | + <Th>Organization</Th> | |
| 192 | + <Th num>Context window</Th> | |
| 193 | + <Th>Source</Th> | |
| 194 | + <Th>History</Th> | |
| 195 | + </tr> | |
| 196 | + </thead> | |
| 197 | + <tbody> | |
| 198 | + {items.map((e) => ( | |
| 199 | + <tr key={e.id}> | |
| 200 | + <Td label="Date"> | |
| 201 | + <span className="inline-flex items-center gap-2"> | |
| 202 | + <ImportanceMark importance={e.importance} /> <When e={e} /> | |
| 203 | + </span> | |
| 204 | + </Td> | |
| 205 | + <Td primary>{e.entity ? <EntityLink e={e.entity} /> : <span className="text-ink-2">{e.summary}</span>}</Td> | |
| 206 | + <Td label="Organization" className="text-ink-2">{e.entity?.organization?.name ?? '—'}</Td> | |
| 207 | + <Td num label="Context"> | |
| 208 | + <DeltaCell from={e.old_value} to={e.new_value} fmt={(v) => `${fmtTokens(v)} tokens`} /> | |
| 209 | + </Td> | |
| 210 | + <Td label="Source"> | |
| 211 | + <Src url={e.source_url} /> | |
| 212 | + </Td> | |
| 213 | + <Td label="History">{e.entity ? <Link href={routes.entityHistory(e.entity, e.property ?? undefined)} className="link text-xs">claims →</Link> : '—'}</Td> | |
| 214 | + </tr> | |
| 215 | + ))} | |
| 216 | + </tbody> | |
| 217 | + </DataTable> | |
| 218 | + <Capped shown={items.length} total={total} what="context changes" /> | |
| 219 | + </> | |
| 220 | + )} | |
| 221 | + </Section> | |
| 222 | + ); | |
| 223 | +} | |
| 224 | + | |
| 225 | +export function LeadersSection({ id, items, a, b }: { id: string; items: NonNullable<DiffPayload11['new_benchmark_leaders']>; a: string; b: string }) { | |
| 226 | + return ( | |
| 227 | + <Section id={id} eyebrow="New benchmark leaders" title={<SectionTitle title="New benchmark leaders" n={items.length} />} lede={`Benchmarks whose primary-group leader (computed from results observed by each date) differs between ${fmtDate(a)} and ${fmtDate(b)}.`}> | |
| 228 | + {items.length === 0 ? ( | |
| 229 | + <EmptyState title="No benchmark changed leader between these dates" /> | |
| 230 | + ) : ( | |
| 231 | + <DataTable caption="New benchmark leaders" compact scroll> | |
| 232 | + <thead> | |
| 233 | + <tr> | |
| 234 | + <Th>Benchmark</Th> | |
| 235 | + <Th>Leader at {fmtDate(a)}</Th> | |
| 236 | + <Th num>Score</Th> | |
| 237 | + <Th>Leader at {fmtDate(b)}</Th> | |
| 238 | + <Th num>Score</Th> | |
| 239 | + <Th>Metric</Th> | |
| 240 | + </tr> | |
| 241 | + </thead> | |
| 242 | + <tbody> | |
| 243 | + {items.map((l) => ( | |
| 244 | + <tr key={l.benchmark.id}> | |
| 245 | + <Td primary> | |
| 246 | + <Link href={routes.benchmark(l.benchmark.slug)} className="text-ink hover:text-accent hover:underline"> | |
| 247 | + {l.benchmark.name} | |
| 248 | + </Link> | |
| 249 | + {l.benchmark.category && <span className="block text-[11px] text-ink-3">{l.benchmark.category}</span>} | |
| 250 | + </Td> | |
| 251 | + <Td label={`At ${fmtDate(a)}`}>{l.at_a ? <EntityLink e={l.at_a.model} className="text-ink-2" /> : <span className="text-ink-3">no result observed yet</span>}</Td> | |
| 252 | + <Td num label="Score" className="tnum text-ink-2">{l.at_a ? fmtScore(l.at_a.score) : '—'}</Td> | |
| 253 | + <Td label={`At ${fmtDate(b)}`}>{l.at_b ? <EntityLink e={l.at_b.model} className="font-medium" /> : <span className="text-ink-3">—</span>}</Td> | |
| 254 | + <Td num label="Score" className="tnum font-medium">{l.at_b ? fmtScore(l.at_b.score) : '—'}</Td> | |
| 255 | + <Td label="Metric" className="text-xs text-ink-2">{l.at_b?.metric ?? l.at_a?.metric ?? '—'}{l.at_b?.trust_level ? <span className="block text-ink-3">{l.at_b.trust_level}</span> : null}</Td> | |
| 256 | + </tr> | |
| 257 | + ))} | |
| 258 | + </tbody> | |
| 259 | + </DataTable> | |
| 260 | + )} | |
| 261 | + </Section> | |
| 262 | + ); | |
| 263 | +} | |
| 264 | + | |
| 265 | +/** Generic event list section (property / provider / hardware changes). */ | |
| 266 | +export function EventsSection({ id, title, items, total, empty, lede }: { id: string; title: string; items: ChangeEvent[]; total: number | null; empty: string; lede?: ReactNode }) { | |
| 267 | + return ( | |
| 268 | + <Section id={id} eyebrow={title} title={<SectionTitle title={title} n={total ?? items.length} />} lede={lede}> | |
| 269 | + {items.length === 0 ? ( | |
| 270 | + <EmptyState title={empty} /> | |
| 271 | + ) : ( | |
| 272 | + <> | |
| 273 | + <ul className="border-t border-rule"> | |
| 274 | + {items.map((e) => ( | |
| 275 | + <ChangeRow key={e.id} e={e} showDate live={false} /> | |
| 276 | + ))} | |
| 277 | + </ul> | |
| 278 | + <Capped shown={items.length} total={total} what="events" /> | |
| 279 | + </> | |
| 280 | + )} | |
| 281 | + </Section> | |
| 282 | + ); | |
| 283 | +} | |
| 284 | + | |
| 285 | +export function EntitiesSection({ id, title, items, total, empty }: { id: string; title: string; items: EntitySummary[]; total: number | null; empty: string }) { | |
| 286 | + return ( | |
| 287 | + <Section id={id} eyebrow={title} title={<SectionTitle title={title} n={total ?? items.length} />}> | |
| 288 | + {items.length === 0 ? ( | |
| 289 | + <EmptyState title={empty} /> | |
| 290 | + ) : ( | |
| 291 | + <> | |
| 292 | + <ul className="grid gap-x-8 border-t border-rule sm:grid-cols-2"> | |
| 293 | + {items.map((e) => ( | |
| 294 | + <li key={e.id} className="flex items-center gap-2 border-b border-rule py-2 text-sm"> | |
| 295 | + <EntityBadge type={e.entity_type} small /> | |
| 296 | + <EntityLink e={e} className="truncate" /> | |
| 297 | + {e.organization && <span className="ml-auto shrink-0 text-xs text-ink-3">{e.organization.name}</span>} | |
| 298 | + </li> | |
| 299 | + ))} | |
| 300 | + </ul> | |
| 301 | + <Capped shown={items.length} total={total} what="entities" /> | |
| 302 | + </> | |
| 303 | + )} | |
| 304 | + </Section> | |
| 305 | + ); | |
| 306 | +} | |
added
apps/web/src/components/temporal/time-machine-tables.tsx
+221 −0
@@ -0,0 +1,221 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Evidence } from '@/components/evidence'; | |
| 3 | +import { Chip, OpennessBadge, StatusBadge, TierBadge } from '@/components/ui/badges'; | |
| 4 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 5 | +import { EntityLink } from '@/components/ui/entity'; | |
| 6 | +import { Note } from '@/components/ui/section'; | |
| 7 | +import { fmtDate, fmtInt, fmtParams, fmtScore, fmtTokens, fmtUsdPerM, num } from '@/lib/format'; | |
| 8 | +import { routes } from '@/lib/site'; | |
| 9 | +import type { EntitySummary, Price, TimeMachineLeader, TimeMachineModelRow } from '@/lib/types'; | |
| 10 | + | |
| 11 | +/* Time-machine tables (server). Every as-of value is an evidence trigger (claim validity in the drawer); the "today" | |
| 12 | + column comes from the same payload (`model.attributes` is the current record), so nothing extra is fetched. */ | |
| 13 | + | |
| 14 | +function AsOf({ e, property, value, display }: { e: EntitySummary; property: string; value: unknown; display: string }) { | |
| 15 | + if (value === null || value === undefined || value === '') return <span className="text-ink-3">—</span>; | |
| 16 | + return ( | |
| 17 | + <Evidence slug={e.slug} property={property} value={value} display={display} entity={{ name: e.name, entity_type: e.entity_type }}> | |
| 18 | + <span className="tnum">{display}</span> | |
| 19 | + </Evidence> | |
| 20 | + ); | |
| 21 | +} | |
| 22 | +function Today({ asof, now, format }: { asof: unknown; now: unknown; format: (v: unknown) => string }) { | |
| 23 | + const a = asof === null || asof === undefined || asof === '' ? null : format(asof); | |
| 24 | + const b = now === null || now === undefined || now === '' ? null : format(now); | |
| 25 | + if (b === null) return <span className="text-ink-3">—</span>; | |
| 26 | + if (a === b) return <span className="tnum text-ink-3">same</span>; | |
| 27 | + return <span className="tnum text-accent-2">{b}</span>; | |
| 28 | +} | |
| 29 | + | |
| 30 | +export function ModelsAsOf({ rows, total, date, limit }: { rows: TimeMachineModelRow[]; total: number | null; date: string; limit: number }) { | |
| 31 | + return ( | |
| 32 | + <> | |
| 33 | + <DataTable caption={`Models as of ${date}`} scroll compact> | |
| 34 | + <thead> | |
| 35 | + <tr> | |
| 36 | + <Th>Model</Th> | |
| 37 | + <Th>Organization</Th> | |
| 38 | + <Th num>Params</Th> | |
| 39 | + <Th num>Context (as of)</Th> | |
| 40 | + <Th num>Context (today)</Th> | |
| 41 | + <Th>Openness</Th> | |
| 42 | + <Th>Status (as of)</Th> | |
| 43 | + <Th>Status (today)</Th> | |
| 44 | + <Th>Released</Th> | |
| 45 | + <Th>Basis</Th> | |
| 46 | + <Th>History</Th> | |
| 47 | + </tr> | |
| 48 | + </thead> | |
| 49 | + <tbody> | |
| 50 | + {rows.length === 0 && <EmptyRow cols={11}>No canonical model existed at this date according to release dates and dated claims.</EmptyRow>} | |
| 51 | + {rows.map(({ model: m, attributes_as_of: a, observed_then, reconstructed }) => ( | |
| 52 | + <tr key={m.id}> | |
| 53 | + <Td primary> | |
| 54 | + <EntityLink e={m} /> | |
| 55 | + </Td> | |
| 56 | + <Td label="Organization" className="text-ink-2"> | |
| 57 | + {m.organization ? <Link href={routes.entity({ entity_type: 'company', slug: m.organization.slug })} className="hover:text-accent">{m.organization.name}</Link> : <span className="text-ink-3">—</span>} | |
| 58 | + </Td> | |
| 59 | + <Td num label="Params"> | |
| 60 | + <AsOf e={m} property="parameter_count" value={a.parameter_count ?? null} display={fmtParams(a.parameter_count)} /> | |
| 61 | + </Td> | |
| 62 | + <Td num label="Context (as of)"> | |
| 63 | + <AsOf e={m} property="context_length" value={a.context_length ?? null} display={fmtTokens(a.context_length)} /> | |
| 64 | + </Td> | |
| 65 | + <Td num label="Context (today)"> | |
| 66 | + <Today asof={a.context_length} now={m.attributes?.context_length} format={(v) => fmtTokens(v)} /> | |
| 67 | + </Td> | |
| 68 | + <Td label="Openness">{typeof a.openness === 'string' ? <OpennessBadge openness={a.openness} /> : <span className="text-ink-3">—</span>}</Td> | |
| 69 | + <Td label="Status (as of)">{typeof a.status === 'string' ? <StatusBadge status={a.status} /> : <span className="text-ink-3">—</span>}</Td> | |
| 70 | + <Td label="Status (today)"> | |
| 71 | + <Today asof={a.status} now={m.attributes?.status ?? m.status} format={(v) => String(v)} /> | |
| 72 | + </Td> | |
| 73 | + <Td label="Released" className="tnum text-ink-2"> | |
| 74 | + {typeof a.release_date === 'string' ? fmtDate(a.release_date) : typeof m.attributes?.release_date === 'string' ? fmtDate(m.attributes.release_date as string) : '—'} | |
| 75 | + </Td> | |
| 76 | + <Td label="Basis">{observed_then ? <Chip tone="accent">observed</Chip> : reconstructed ? <Chip tone="estimated">reconstructed</Chip> : <Chip>—</Chip>}</Td> | |
| 77 | + <Td label="History"> | |
| 78 | + <Link href={routes.entityAsOf(m, date)} className="link text-xs whitespace-nowrap"> | |
| 79 | + as of {date} → | |
| 80 | + </Link> | |
| 81 | + </Td> | |
| 82 | + </tr> | |
| 83 | + ))} | |
| 84 | + </tbody> | |
| 85 | + </DataTable> | |
| 86 | + {total !== null && total > rows.length && <Note className="mt-2">Showing {fmtInt(rows.length)} of {fmtInt(total)} models that existed on this date (API cap {fmtInt(limit)}). Open a model's History tab for its full as-of record.</Note>} | |
| 87 | + <Note className="mt-1">“Today” columns compare the as-of value with the current record; “same” means no recorded change. Click any as-of value for the claim behind it (source, tier, valid from → to).</Note> | |
| 88 | + </> | |
| 89 | + ); | |
| 90 | +} | |
| 91 | + | |
| 92 | +export function PricesAsOf({ rows, total, date }: { rows: Price[]; total: number | null; date: string }) { | |
| 93 | + return ( | |
| 94 | + <> | |
| 95 | + <DataTable caption={`Provider prices valid on ${date}`} scroll compact> | |
| 96 | + <thead> | |
| 97 | + <tr> | |
| 98 | + <Th>Model</Th> | |
| 99 | + <Th>Provider</Th> | |
| 100 | + <Th num>Input / 1M</Th> | |
| 101 | + <Th num>Output / 1M</Th> | |
| 102 | + <Th num>Context</Th> | |
| 103 | + <Th>Valid from</Th> | |
| 104 | + <Th>Valid to</Th> | |
| 105 | + <Th>Source</Th> | |
| 106 | + </tr> | |
| 107 | + </thead> | |
| 108 | + <tbody> | |
| 109 | + {rows.length === 0 && <EmptyRow cols={8}>No price row's validity interval covers this date (price history starts when AI Atlas first observed the provider).</EmptyRow>} | |
| 110 | + {rows.map((p) => ( | |
| 111 | + <tr key={p.id}> | |
| 112 | + <Td primary> | |
| 113 | + <EntityLink e={p.model} /> | |
| 114 | + </Td> | |
| 115 | + <Td label="Provider"> | |
| 116 | + <EntityLink e={p.provider} className="text-ink-2" /> | |
| 117 | + </Td> | |
| 118 | + <Td num label="Input" className="tnum text-accent-2">{fmtUsdPerM(p.input_per_mtok)}</Td> | |
| 119 | + <Td num label="Output" className="tnum text-accent-2">{fmtUsdPerM(p.output_per_mtok)}</Td> | |
| 120 | + <Td num label="Context" className="tnum">{num(p.context_length) === null ? '—' : fmtTokens(p.context_length)}</Td> | |
| 121 | + <Td label="Valid from" className="tnum text-ink-2">{fmtDate(p.valid_from)}</Td> | |
| 122 | + <Td label="Valid to" className="tnum text-ink-2">{p.valid_to ? fmtDate(p.valid_to) : <span className="text-positive">current</span>}</Td> | |
| 123 | + <Td label="Source"> | |
| 124 | + <span className="inline-flex items-center gap-1.5"> | |
| 125 | + <TierBadge tier={p.tier} /> | |
| 126 | + {p.source_url && ( | |
| 127 | + <a href={p.source_url} target="_blank" rel="noopener noreferrer" className="link text-xs"> | |
| 128 | + source | |
| 129 | + </a> | |
| 130 | + )} | |
| 131 | + </span> | |
| 132 | + </Td> | |
| 133 | + </tr> | |
| 134 | + ))} | |
| 135 | + </tbody> | |
| 136 | + </DataTable> | |
| 137 | + {total !== null && total > rows.length && <Note className="mt-2">Showing {fmtInt(rows.length)} of {fmtInt(total)} offers valid on this date.</Note>} | |
| 138 | + </> | |
| 139 | + ); | |
| 140 | +} | |
| 141 | + | |
| 142 | +export function LeadersAsOf({ rows, date }: { rows: TimeMachineLeader[]; date: string }) { | |
| 143 | + return ( | |
| 144 | + <> | |
| 145 | + <DataTable caption={`Benchmark leaders as of ${date}`} scroll compact> | |
| 146 | + <thead> | |
| 147 | + <tr> | |
| 148 | + <Th>Benchmark</Th> | |
| 149 | + <Th>Leader</Th> | |
| 150 | + <Th>Organization</Th> | |
| 151 | + <Th num>Score</Th> | |
| 152 | + <Th>Metric · group</Th> | |
| 153 | + <Th>Trust</Th> | |
| 154 | + <Th num>Models ranked</Th> | |
| 155 | + </tr> | |
| 156 | + </thead> | |
| 157 | + <tbody> | |
| 158 | + {rows.length === 0 && <EmptyRow cols={7}>No benchmark result had been observed by this date — leaders are known only once a result is observed (evaluation dates are not used).</EmptyRow>} | |
| 159 | + {rows.map((r) => ( | |
| 160 | + <tr key={r.benchmark.id}> | |
| 161 | + <Td primary> | |
| 162 | + <Link href={routes.benchmark(r.benchmark.slug)} className="text-ink hover:text-accent hover:underline"> | |
| 163 | + {r.benchmark.name} | |
| 164 | + </Link> | |
| 165 | + {r.benchmark.category && <span className="block text-[11px] text-ink-3">{r.benchmark.category}</span>} | |
| 166 | + </Td> | |
| 167 | + <Td label="Leader">{r.leader ? <EntityLink e={r.leader.model} /> : <span className="text-ink-3">—</span>}</Td> | |
| 168 | + <Td label="Organization" className="text-ink-2">{r.leader?.model.organization?.name ?? '—'}</Td> | |
| 169 | + <Td num label="Score" className="tnum font-medium">{r.leader ? fmtScore(r.leader.score) : '—'}</Td> | |
| 170 | + <Td label="Metric" className="text-xs text-ink-2"> | |
| 171 | + {r.leader?.metric ?? '—'} | |
| 172 | + {r.leader?.group_label && r.leader.group_label !== r.leader.metric && <span className="block text-ink-3">{r.leader.group_label}</span>} | |
| 173 | + </Td> | |
| 174 | + <Td label="Trust" className="text-xs text-ink-2">{r.leader?.trust_level ?? '—'}</Td> | |
| 175 | + <Td num label="Models ranked" className="tnum">{r.leader?.n_models === undefined ? '—' : fmtInt(r.leader.n_models)}</Td> | |
| 176 | + </tr> | |
| 177 | + ))} | |
| 178 | + </tbody> | |
| 179 | + </DataTable> | |
| 180 | + <Note className="mt-2">One row per benchmark: the best current row of the primary comparability group among results observed by the date. See the benchmark's Frontier tab for the full leader history.</Note> | |
| 181 | + </> | |
| 182 | + ); | |
| 183 | +} | |
| 184 | + | |
| 185 | +export function HardwareAsOf({ rows, date }: { rows: { hardware: EntitySummary; reconstructed?: boolean }[]; date: string }) { | |
| 186 | + return ( | |
| 187 | + <DataTable caption={`Hardware as of ${date}`} scroll compact> | |
| 188 | + <thead> | |
| 189 | + <tr> | |
| 190 | + <Th>Hardware</Th> | |
| 191 | + <Th>Manufacturer</Th> | |
| 192 | + <Th>Kind</Th> | |
| 193 | + <Th num>Memory</Th> | |
| 194 | + <Th num>Bandwidth</Th> | |
| 195 | + <Th>Released</Th> | |
| 196 | + <Th>Basis</Th> | |
| 197 | + </tr> | |
| 198 | + </thead> | |
| 199 | + <tbody> | |
| 200 | + {rows.length === 0 && <EmptyRow cols={7}>No hardware with a release date on or before this date.</EmptyRow>} | |
| 201 | + {rows.map(({ hardware: h, reconstructed }) => { | |
| 202 | + const a = h.attributes ?? {}; | |
| 203 | + const mem = Array.isArray(a.memory_gb) ? (a.memory_gb as unknown[]).map((x) => fmtInt(x)).join(' / ') : num(a.memory_gb) === null ? '—' : fmtInt(a.memory_gb); | |
| 204 | + return ( | |
| 205 | + <tr key={h.id}> | |
| 206 | + <Td primary> | |
| 207 | + <EntityLink e={h} /> | |
| 208 | + </Td> | |
| 209 | + <Td label="Manufacturer" className="text-ink-2">{typeof a.manufacturer === 'string' ? a.manufacturer : h.organization?.name ?? '—'}</Td> | |
| 210 | + <Td label="Kind" className="text-ink-2">{typeof a.kind === 'string' ? a.kind : '—'}</Td> | |
| 211 | + <Td num label="Memory" className="tnum">{mem === '—' ? mem : `${mem} GB`}</Td> | |
| 212 | + <Td num label="Bandwidth" className="tnum">{num(a.memory_bandwidth_gbs) === null ? '—' : `${fmtInt(a.memory_bandwidth_gbs)} GB/s`}</Td> | |
| 213 | + <Td label="Released" className="tnum text-ink-2">{typeof a.release_date === 'string' ? fmtDate(a.release_date) : '—'}</Td> | |
| 214 | + <Td label="Basis">{reconstructed ? <Chip tone="estimated">reconstructed</Chip> : <Chip tone="accent">observed</Chip>}</Td> | |
| 215 | + </tr> | |
| 216 | + ); | |
| 217 | + })} | |
| 218 | + </tbody> | |
| 219 | + </DataTable> | |
| 220 | + ); | |
| 221 | +} | |
added
apps/web/src/components/timeline/lanes.ts
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +import type { Lane } from '@/components/charts'; | |
| 2 | +import type { ChangeEvent } from '@/lib/types'; | |
| 3 | + | |
| 4 | +/** Timeline 2.0 lanes: event `category` → lane. Colours are entity-type tokens (prices use the money accent). Server-safe. */ | |
| 5 | +export const TIMELINE_LANES: (Lane & { categories: string[] })[] = [ | |
| 6 | + { key: 'models', label: 'Models', color: 'var(--type-model)', categories: ['model', 'update'] }, | |
| 7 | + { key: 'research', label: 'Research', color: 'var(--type-paper)', categories: ['paper'] }, | |
| 8 | + { key: 'prices', label: 'Prices', color: 'var(--accent-2)', categories: ['price'] }, | |
| 9 | + { key: 'benchmarks', label: 'Benchmarks', color: 'var(--type-benchmark)', categories: ['benchmark'] }, | |
| 10 | + { key: 'providers', label: 'Providers', color: 'var(--type-provider)', categories: ['provider'] }, | |
| 11 | + { key: 'hardware', label: 'Hardware', color: 'var(--type-hardware)', categories: ['hardware'] }, | |
| 12 | + { key: 'frameworks', label: 'Frameworks', color: 'var(--type-framework)', categories: ['framework', 'repository', 'tool'] }, | |
| 13 | + { key: 'companies', label: 'Companies', color: 'var(--type-company)', categories: ['company', 'release', 'announcement', 'regulation', 'incident'] }, | |
| 14 | + { key: 'other', label: 'Other', color: 'var(--type-tool)', categories: [] }, | |
| 15 | +]; | |
| 16 | +const BY_CATEGORY = new Map<string, string>(); | |
| 17 | +for (const l of TIMELINE_LANES) for (const c of l.categories) BY_CATEGORY.set(c, l.key); | |
| 18 | + | |
| 19 | +export function laneOf(e: Pick<ChangeEvent, 'category'>): string { | |
| 20 | + return BY_CATEGORY.get(e.category) ?? 'other'; | |
| 21 | +} | |
| 22 | +export function laneColor(key: string): string { | |
| 23 | + return TIMELINE_LANES.find((l) => l.key === key)?.color ?? 'var(--type-tool)'; | |
| 24 | +} | |
| 25 | +/** When the event happened (1.1 `occurred_at`, else effective, else observed). */ | |
| 26 | +export function occurredAt(e: ChangeEvent): string { | |
| 27 | + return e.occurred_at ?? e.effective_at ?? e.observed_at; | |
| 28 | +} | |
added
apps/web/src/components/timeline/mobile-lane-strip.tsx
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +import { scaleTime } from 'd3-scale'; | |
| 2 | +import { fmtDate } from '@/lib/format'; | |
| 3 | +import type { ChangeEvent } from '@/lib/types'; | |
| 4 | +import { laneColor, laneOf, occurredAt, TIMELINE_LANES } from './lanes'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Mobile counterpart of `TimelineLanes`: one lane, dots coloured by category and sized by importance (server-safe SVG, | |
| 8 | + * no interaction — the list below is the interactive surface on small screens). Legend = the lanes present. | |
| 9 | + */ | |
| 10 | +export function MobileLaneStrip({ events, className }: { events: ChangeEvent[]; className?: string }) { | |
| 11 | + const w = 360; | |
| 12 | + const h = 58; | |
| 13 | + const pad = { l: 8, r: 8, t: 8, b: 18 }; | |
| 14 | + const ts = events.map((e) => new Date(occurredAt(e)).getTime()).filter((t) => Number.isFinite(t)); | |
| 15 | + if (ts.length < 2) return null; | |
| 16 | + const lo = Math.min(...ts); | |
| 17 | + const hi = Math.max(...ts); | |
| 18 | + const x = scaleTime() | |
| 19 | + .domain([new Date(lo), new Date(hi === lo ? lo + 86400000 : hi)]) | |
| 20 | + .range([pad.l, w - pad.r]); | |
| 21 | + const ticks = x.ticks(4); | |
| 22 | + const present = new Set(events.map(laneOf)); | |
| 23 | + return ( | |
| 24 | + <figure className={className} data-mobile-lane-strip> | |
| 25 | + <svg viewBox={`0 0 ${w} ${h}`} className="block w-full" role="img" aria-label="Events over time, coloured by category"> | |
| 26 | + <title>Events over time</title> | |
| 27 | + <line x1={pad.l} x2={w - pad.r} y1={h / 2 - 4} y2={h / 2 - 4} stroke="var(--rule-strong)" /> | |
| 28 | + {ticks.map((t, i) => ( | |
| 29 | + <g key={i}> | |
| 30 | + <line x1={x(t)} x2={x(t)} y1={pad.t} y2={h - pad.b} stroke="var(--rule)" strokeDasharray="2 3" /> | |
| 31 | + <text x={x(t)} y={h - 5} textAnchor="middle" fontSize={9} fill="var(--ink-3)"> | |
| 32 | + {t.toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' })} | |
| 33 | + </text> | |
| 34 | + </g> | |
| 35 | + ))} | |
| 36 | + {events.map((e) => { | |
| 37 | + const t = new Date(occurredAt(e)).getTime(); | |
| 38 | + if (!Number.isFinite(t)) return null; | |
| 39 | + const jitter = ((e.id.charCodeAt(e.id.length - 1) + e.id.charCodeAt(e.id.length - 2)) % 7) - 3; | |
| 40 | + return <circle key={e.id} cx={x(new Date(t))} cy={h / 2 - 4 + jitter} r={1.6 + Math.min(3, e.importance) * 0.9} fill={laneColor(laneOf(e))} fillOpacity={e.importance >= 3 ? 0.95 : 0.6} />; | |
| 41 | + })} | |
| 42 | + </svg> | |
| 43 | + <figcaption className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-ink-3"> | |
| 44 | + {TIMELINE_LANES.filter((l) => present.has(l.key)).map((l) => ( | |
| 45 | + <span key={l.key} className="inline-flex items-center gap-1"> | |
| 46 | + <span className="inline-block size-2 rounded-full" style={{ background: l.color }} aria-hidden /> | |
| 47 | + {l.label} | |
| 48 | + </span> | |
| 49 | + ))} | |
| 50 | + <span className="ml-auto tnum"> | |
| 51 | + {fmtDate(new Date(lo).toISOString())} → {fmtDate(new Date(hi).toISOString())} | |
| 52 | + </span> | |
| 53 | + </figcaption> | |
| 54 | + </figure> | |
| 55 | + ); | |
| 56 | +} | |
added
apps/web/src/components/timeline/timeline-workbench.tsx
+145 −0
@@ -0,0 +1,145 @@ | ||
| 1 | +'use client'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { useMemo, useState } from 'react'; | |
| 4 | +import { ChangeRow } from '@/components/changes/change-row'; | |
| 5 | +import { TimelineLanes, type LaneEvent } from '@/components/charts'; | |
| 6 | +import { Hint } from '@/components/ui/hint'; | |
| 7 | +import { fmtDate, fmtInt, fmtMonth } from '@/lib/format'; | |
| 8 | +import { eventLabel, routes } from '@/lib/site'; | |
| 9 | +import type { ChangeEvent } from '@/lib/types'; | |
| 10 | +import { laneOf, occurredAt, TIMELINE_LANES } from './lanes'; | |
| 11 | +import { MobileLaneStrip } from './mobile-lane-strip'; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Timeline 2.0 body (client): lanes chart (brushable) → the list below is filtered to the brushed range; month headings | |
| 15 | + * stick under the header; per-month anchor nav. Everything rendered comes from the events the page fetched. | |
| 16 | + */ | |
| 17 | +export function TimelineWorkbench({ events, semantics, backfill }: { events: ChangeEvent[]; semantics?: Record<string, string> | null; backfill: boolean }) { | |
| 18 | + const [range, setRange] = useState<{ from: number; to: number } | null>(null); | |
| 19 | + const lanes = useMemo(() => { | |
| 20 | + const present = new Set(events.map(laneOf)); | |
| 21 | + return TIMELINE_LANES.filter((l) => present.has(l.key)).map(({ key, label, color }) => ({ key, label, color })); | |
| 22 | + }, [events]); | |
| 23 | + const laneEvents: LaneEvent[] = useMemo( | |
| 24 | + () => | |
| 25 | + events.map((e) => ({ | |
| 26 | + id: e.id, | |
| 27 | + lane: laneOf(e), | |
| 28 | + at: occurredAt(e), | |
| 29 | + importance: e.importance, | |
| 30 | + label: e.summary, | |
| 31 | + sub: e.entity ? `${eventLabel(e.event_type)} · ${e.entity.name}` : eventLabel(e.event_type), | |
| 32 | + href: e.entity ? routes.entity(e.entity) : undefined, | |
| 33 | + })), | |
| 34 | + [events], | |
| 35 | + ); | |
| 36 | + const [domFrom, domTo] = useMemo(() => { | |
| 37 | + const ts = events.map((e) => new Date(occurredAt(e)).getTime()).filter((t) => Number.isFinite(t)); | |
| 38 | + if (!ts.length) return [undefined, undefined] as const; | |
| 39 | + const lo = Math.min(...ts); | |
| 40 | + const hi = Math.max(...ts); | |
| 41 | + // Deterministic domain (no Date.now()): pad a single-day span by one day on each side so SSR and hydration agree. | |
| 42 | + return hi - lo < 86400000 ? ([lo - 86400000, lo + 86400000] as const) : ([lo, hi] as const); | |
| 43 | + }, [events]); | |
| 44 | + const filtered = useMemo(() => { | |
| 45 | + if (!range) return events; | |
| 46 | + return events.filter((e) => { | |
| 47 | + const t = new Date(occurredAt(e)).getTime(); | |
| 48 | + return t >= range.from && t <= range.to; | |
| 49 | + }); | |
| 50 | + }, [events, range]); | |
| 51 | + const months = useMemo(() => { | |
| 52 | + const map = new Map<string, ChangeEvent[]>(); | |
| 53 | + for (const e of filtered) { | |
| 54 | + const m = occurredAt(e).slice(0, 7); | |
| 55 | + (map.get(m) ?? map.set(m, []).get(m)!).push(e); | |
| 56 | + } | |
| 57 | + return [...map.entries()].sort((a, b) => (a[0] < b[0] ? 1 : -1)).map(([month, items]) => ({ month, items })); | |
| 58 | + }, [filtered]); | |
| 59 | + | |
| 60 | + return ( | |
| 61 | + <div className="space-y-6" data-timeline-workbench> | |
| 62 | + <section aria-label="Lanes"> | |
| 63 | + <div className="mb-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-3"> | |
| 64 | + <span className="eyebrow"> | |
| 65 | + Lanes <span className="normal-case tracking-normal text-ink-3">· dots sized by importance · drag to select a range</span> | |
| 66 | + </span> | |
| 67 | + <span className="inline-flex items-center gap-1"> | |
| 68 | + occurred vs observed vs recorded | |
| 69 | + <Hint | |
| 70 | + align="right" | |
| 71 | + text={ | |
| 72 | + <span className="block space-y-1 text-left"> | |
| 73 | + <span className="block"> | |
| 74 | + <b>Occurred</b> — {semantics?.occurred_at ?? 'coalesce(effective_at, observed_at): when the change happened (effective date when a source states it).'} | |
| 75 | + </span> | |
| 76 | + <span className="block"> | |
| 77 | + <b>Observed</b> — {semantics?.observed_at ?? 'when AI Atlas first saw the change.'} | |
| 78 | + </span> | |
| 79 | + <span className="block"> | |
| 80 | + <b>Recorded</b> — {semantics?.recorded_at ?? 'when the row was written.'} | |
| 81 | + </span> | |
| 82 | + <span className="block"> | |
| 83 | + <b>Backfill</b> — {semantics?.is_backfill ?? 'history imported when a source is first crawled; never shown as “today”.'} | |
| 84 | + </span> | |
| 85 | + </span> | |
| 86 | + } | |
| 87 | + /> | |
| 88 | + </span> | |
| 89 | + {backfill && <span className="rounded-[3px] bg-surface-2 px-1.5 text-[10px] uppercase tracking-wide">including historical backfill</span>} | |
| 90 | + </div> | |
| 91 | + <div className="hidden md:block"> | |
| 92 | + <TimelineLanes lanes={lanes} events={laneEvents} from={domFrom} to={domTo} brushable onRange={(a, b) => setRange({ from: a.getTime(), to: b.getTime() })} title="Events by lane" /> | |
| 93 | + </div> | |
| 94 | + <div className="md:hidden"> | |
| 95 | + <MobileLaneStrip events={events} /> | |
| 96 | + </div> | |
| 97 | + <p className="tnum mt-1 flex flex-wrap items-center gap-x-3 text-xs text-ink-3"> | |
| 98 | + {range ? ( | |
| 99 | + <> | |
| 100 | + <span className="text-ink-2"> | |
| 101 | + Range {fmtDate(new Date(range.from).toISOString())} → {fmtDate(new Date(range.to).toISOString())} · {fmtInt(filtered.length)} of {fmtInt(events.length)} loaded events | |
| 102 | + </span> | |
| 103 | + <button type="button" onClick={() => setRange(null)} className="link"> | |
| 104 | + Clear range | |
| 105 | + </button> | |
| 106 | + </> | |
| 107 | + ) : ( | |
| 108 | + <span>{fmtInt(events.length)} events loaded · every dot is one event; hover for its summary</span> | |
| 109 | + )} | |
| 110 | + </p> | |
| 111 | + </section> | |
| 112 | + | |
| 113 | + <div className="grid gap-x-10 lg:grid-cols-[10rem_minmax(0,1fr)]"> | |
| 114 | + <nav aria-label="Months" className="no-scrollbar -mx-4 mb-4 flex gap-1 overflow-x-auto px-4 lg:sticky lg:top-[calc(var(--header-h)+1rem)] lg:mx-0 lg:mb-0 lg:block lg:max-h-[70vh] lg:space-y-0.5 lg:self-start lg:overflow-y-auto lg:px-0"> | |
| 115 | + {months.map((m) => ( | |
| 116 | + <a key={m.month} href={`#m-${m.month}`} className="tnum flex h-9 shrink-0 items-center justify-between gap-2 border border-rule px-2 text-xs text-ink-2 hover:text-ink lg:h-8 lg:border-0 lg:px-1"> | |
| 117 | + <span>{fmtMonth(m.month)}</span> | |
| 118 | + <span className="text-ink-3">{fmtInt(m.items.length)}</span> | |
| 119 | + </a> | |
| 120 | + ))} | |
| 121 | + </nav> | |
| 122 | + <div className="min-w-0"> | |
| 123 | + {months.length === 0 && <p className="border-y border-rule py-8 text-center text-sm text-ink-3">No events in the selected range.</p>} | |
| 124 | + {months.map((m) => ( | |
| 125 | + <section key={m.month} id={`m-${m.month}`} className="scroll-mt-24 pb-8"> | |
| 126 | + <h2 className="eyebrow sticky top-[var(--header-h)] z-10 -mx-4 flex items-baseline justify-between bg-canvas/95 px-4 py-2 backdrop-blur md:mx-0 md:px-0"> | |
| 127 | + <span> | |
| 128 | + {fmtMonth(m.month)} <span className="tnum text-ink-3">{fmtInt(m.items.length)}</span> | |
| 129 | + </span> | |
| 130 | + <Link href={routes.changesDay(`${m.month}-01`)} className="normal-case tracking-normal text-ink-3 hover:text-ink"> | |
| 131 | + digest → | |
| 132 | + </Link> | |
| 133 | + </h2> | |
| 134 | + <ul className="border-t border-rule"> | |
| 135 | + {m.items.map((e) => ( | |
| 136 | + <ChangeRow key={e.id} e={e} showDate live={false} /> | |
| 137 | + ))} | |
| 138 | + </ul> | |
| 139 | + </section> | |
| 140 | + ))} | |
| 141 | + </div> | |
| 142 | + </div> | |
| 143 | + </div> | |
| 144 | + ); | |
| 145 | +} | |
modified
apps/web/src/lib/admin/actions.ts
+55 −0
@@ -148,3 +148,58 @@ export async function recomputeStatsAction(): Promise<void> { | ||
| 148 | 148 | export async function recomputeQualityAction(): Promise<void> { |
| 149 | 149 | await run('/admin/cache', () => 'Quality recompute triggered.', () => adminApi.recomputeQuality()); |
| 150 | 150 | } |
| 151 | + | |
| 152 | +// ------------------------------------------------------------------------------------------------------------ D3 workbenches | |
| 153 | +const DECISIONS = new Set(['merge', 'alias', 'variant_of', 'family_member', 'keep_separate', 'defer']); | |
| 154 | +/** Entity resolution: persists the decision for the pair (a, b) and applies it (merge / alias / variant_of / family_member). */ | |
| 155 | +export async function resolutionAction(formData: FormData): Promise<void> { | |
| 156 | + const a = String(formData.get('a') ?? ''); | |
| 157 | + const b = String(formData.get('b') ?? ''); | |
| 158 | + const decision = String(formData.get('decision') ?? ''); | |
| 159 | + const note = String(formData.get('note') ?? '').trim(); | |
| 160 | + const ret = String(formData.get('return') ?? '/admin/entity-resolution'); | |
| 161 | + if (!DECISIONS.has(decision)) back(ret, 'Unknown decision.', false); | |
| 162 | + await run( | |
| 163 | + ret, | |
| 164 | + (r) => { | |
| 165 | + const x = r as { applied?: boolean; effect?: unknown } | null; | |
| 166 | + return `Decision “${decision.replace('_', ' ')}” recorded for ${a} / ${b}${x?.applied ? ' and applied' : ' (recorded, not applied)'}.`; | |
| 167 | + }, | |
| 168 | + () => adminApi.resolve(a, b, decision as 'merge', note), | |
| 169 | + ); | |
| 170 | +} | |
| 171 | + | |
| 172 | +export async function anomalyAction(formData: FormData): Promise<void> { | |
| 173 | + const id = String(formData.get('id') ?? ''); | |
| 174 | + const status = String(formData.get('status') ?? '') as 'resolved' | 'ignored' | 'open'; | |
| 175 | + const note = String(formData.get('note') ?? '').trim(); | |
| 176 | + const ret = String(formData.get('return') ?? '/admin/anomalies'); | |
| 177 | + if (!['resolved', 'ignored', 'open'].includes(status)) back(ret, 'Unknown status.', false); | |
| 178 | + await run(ret, () => `Anomaly ${id} marked ${status}.`, () => adminApi.anomalyAction(id, status, note)); | |
| 179 | +} | |
| 180 | + | |
| 181 | +export async function quarantineAction(formData: FormData): Promise<void> { | |
| 182 | + const id = String(formData.get('id') ?? ''); | |
| 183 | + const action = String(formData.get('action') ?? '') as 'release' | 'discard'; | |
| 184 | + const note = String(formData.get('note') ?? '').trim(); | |
| 185 | + const ret = String(formData.get('return') ?? '/admin/quarantine'); | |
| 186 | + if (action !== 'release' && action !== 'discard') back(ret, 'Unknown action.', false); | |
| 187 | + await run(ret, () => `Quarantined run ${id} ${action === 'release' ? 'released' : 'discarded'}.`, () => adminApi.quarantineAction(id, action, note)); | |
| 188 | +} | |
| 189 | + | |
| 190 | +/** Rollback of one connector run: retracts its claims, closes its rows, flags its events — deletes nothing. */ | |
| 191 | +export async function rollbackAction(formData: FormData): Promise<void> { | |
| 192 | + const runId = String(formData.get('run_id') ?? ''); | |
| 193 | + const confirm = String(formData.get('confirm') ?? ''); | |
| 194 | + const ret = String(formData.get('return') ?? '/admin/runs'); | |
| 195 | + if (confirm !== runId) back(ret, 'Rollback not confirmed (the run id must be repeated).', false); | |
| 196 | + await run( | |
| 197 | + ret, | |
| 198 | + (r) => { | |
| 199 | + const x = r as { counts?: Record<string, unknown>; connector?: string | null } | null; | |
| 200 | + const counts = x?.counts ? Object.entries(x.counts).map(([k, v]) => `${k} ${String(v)}`).join(' · ') : ''; | |
| 201 | + return `Run ${runId}${x?.connector ? ` (${x.connector})` : ''} rolled back${counts ? ` — ${counts}` : ''}.`; | |
| 202 | + }, | |
| 203 | + () => adminApi.rollback(runId), | |
| 204 | + ); | |
| 205 | +} | |
modified
apps/web/src/lib/admin/admin-api.ts
+12 −1
@@ -2,7 +2,7 @@ import 'server-only'; | ||
| 2 | 2 | import { redirect } from 'next/navigation'; |
| 3 | 3 | import { API_URL, ApiError, type Query } from '@/lib/api'; |
| 4 | 4 | import { getAdminToken } from './session'; |
| 5 | −import type { AdminConnectorsPayload, AdminDocument, AdminError, AdminJobsPayload, AdminLlmHealth, AdminLlmJobsPayload, AdminOverview, AdminPage, AdminRun, AdminSnapshot, DuplicatesPayload, Infrastructure, ReviewPayload } from './types'; | |
| 5 | +import type { AdminConnectorsPayload, AdminDocument, AdminError, AdminJobsPayload, AdminLlmHealth, AdminLlmJobsPayload, AdminOverview, AdminPage, AdminQuality, AdminRun, AdminSnapshot, AnomaliesPayload, AuditPayload, DuplicatesPayload, ExtractionPayload, Infrastructure, QuarantinePayload, ResolutionDecision, ResolutionPayload, ReviewPayload, RollbackResult } from './types'; | |
| 6 | 6 | |
| 7 | 7 | /** |
| 8 | 8 | * Server-only admin client. Every call carries `x-aia-admin-token` from the httpOnly cookie (or an explicit token |
@@ -100,6 +100,17 @@ export const adminApi = { | ||
| 100 | 100 | flushCache: () => adminRequest<{ flushed: number }>('/cache/flush', { method: 'POST', body: {} }), |
| 101 | 101 | recomputeStats: () => adminRequest<unknown>('/stats/recompute', { method: 'POST', body: {} }), |
| 102 | 102 | recomputeQuality: () => adminRequest<unknown>('/quality/recompute', { method: 'POST', body: {} }), |
| 103 | + // ---- D3 workbenches (1.1) | |
| 104 | + quality: () => adminRequest<AdminQuality>('/quality'), | |
| 105 | + entityResolution: (query: Query) => adminRequest<ResolutionPayload>('/entity-resolution', { query }), | |
| 106 | + resolve: (a: string, b: string, decision: ResolutionDecision, note?: string) => adminRequest<{ ok: boolean; a: string; b: string; decision: string; applied: boolean; effect?: unknown }>(`/entity-resolution/${encodeURIComponent(a)}/${encodeURIComponent(b)}`, { method: 'POST', body: { decision, note: note || undefined } }), | |
| 107 | + anomalies: (query: Query) => adminRequest<AnomaliesPayload>('/anomalies', { query }), | |
| 108 | + anomalyAction: (id: string, status: 'resolved' | 'ignored' | 'open', note?: string) => adminRequest<unknown>(`/anomalies/${encodeURIComponent(id)}`, { method: 'POST', body: { status, note: note || undefined } }), | |
| 109 | + extraction: (snapshotId: string, textLimit = 20000) => adminRequest<ExtractionPayload>(`/extractions/${encodeURIComponent(snapshotId)}`, { query: { text_limit: textLimit } }), | |
| 110 | + quarantine: (query: Query) => adminRequest<QuarantinePayload>('/quarantine', { query }), | |
| 111 | + quarantineAction: (id: string, action: 'release' | 'discard', note?: string) => adminRequest<unknown>(`/quarantine/${encodeURIComponent(id)}`, { method: 'POST', body: { action, note: note || undefined } }), | |
| 112 | + audit: (query: Query) => adminRequest<AuditPayload>('/audit', { query }), | |
| 113 | + rollback: (runId: string) => adminRequest<RollbackResult>(`/runs/${encodeURIComponent(runId)}/rollback`, { method: 'POST', body: {} }), | |
| 103 | 114 | }; |
| 104 | 115 | |
| 105 | 116 | /** Public (non-admin) history lookup used to map a conflict payload to concrete claim ids. */ |
modified
apps/web/src/lib/admin/types.ts
+159 −0
@@ -258,3 +258,162 @@ export interface Infrastructure { | ||
| 258 | 258 | data_dir_exists?: boolean; |
| 259 | 259 | database?: { db_bytes: Num; database?: string; pg_version?: string; connections?: Num; max_connections?: Num; tables?: { table: string; total_bytes: Num; data_bytes: Num; rows_estimate: Num }[] }; |
| 260 | 260 | } |
| 261 | + | |
| 262 | +// ---- D3 admin workbenches (docs/API.md "Admin routes", 1.1) ---- | |
| 263 | +export type CountSample<T = Record<string, unknown>> = { count: Num; sample: T[] }; | |
| 264 | +export interface AdminQuality { | |
| 265 | + duplicate_candidates: { pending_decisions: CountSample; review_merge_candidates: CountSample<{ id: string; entity_ids: string[]; reason: string | null; created_at: string }> }; | |
| 266 | + taxonomy_violations: { unmapped_taxonomy_rows: CountSample<{ domain: string; raw: string; count: Num; last_seen_at: string | null }>; openness_unknown_vocab: CountSample; status_unknown_vocab: CountSample; license_unclassified: CountSample }; | |
| 267 | + impossible_values: { count: Num; by_check: { check_name: string; severity: string; n: Num }[]; sample: Record<string, unknown>[] }; | |
| 268 | + conflicting_t1_claims: CountSample; | |
| 269 | + models_without_organization: CountSample<{ id: string; slug: string; name: string; entity_type: string }>; | |
| 270 | + models_without_release_source: CountSample<{ id: string; slug: string; name: string; entity_type: string }>; | |
| 271 | + models_without_parameters: CountSample<{ id: string; slug: string; name: string; entity_type: string }>; | |
| 272 | + orphan_benchmark_results: CountSample; | |
| 273 | + benchmarks_without_results: CountSample<{ id: string; slug: string; name: string; entity_type: string }>; | |
| 274 | + unresolved_provider_deployments: CountSample<{ price_id: string; provider_model_id: string; model: string; provider: string }>; | |
| 275 | + quantisations_typed_as_models: CountSample<{ id: string; slug: string; name: string; hf_repo?: string; quant_formats?: string[]; kind?: string }>; | |
| 276 | + stale_sources: CountSample<{ name: string; health: string; last_success_at: string | null; interval_seconds: Num; consecutive_failures: Num }>; | |
| 277 | + empty_public_categories: CountSample<string>; | |
| 278 | + quarantined_runs_pending: CountSample; | |
| 279 | + review_queue_priority: ({ kind: string; id: string; reasons: string[]; slug?: string; entity_id?: string; check?: string; severity?: string; message?: string; name?: string } & Record<string, unknown>)[]; | |
| 280 | + note?: string; | |
| 281 | + [k: string]: unknown; | |
| 282 | +} | |
| 283 | + | |
| 284 | +export interface ResolutionSide { | |
| 285 | + id: string; | |
| 286 | + slug: string; | |
| 287 | + name: string; | |
| 288 | + entity_type: string; | |
| 289 | + organization: string | null; | |
| 290 | + organization_slug?: string | null; | |
| 291 | + family: string | null; | |
| 292 | + parameter_count: Num; | |
| 293 | + active_parameter_count: Num; | |
| 294 | + release_date: string | null; | |
| 295 | + architecture: string | null; | |
| 296 | + model_type?: string | null; | |
| 297 | + hf_repo: string | null; | |
| 298 | + openness?: string | null; | |
| 299 | + context_length?: Num; | |
| 300 | + identifiers: { scheme: string; value: string }[]; | |
| 301 | + aliases: string[]; | |
| 302 | + relations: Num; | |
| 303 | + sources: Num; | |
| 304 | + claims: Num; | |
| 305 | + prices: Num; | |
| 306 | + results: Num; | |
| 307 | + first_seen_at: string | null; | |
| 308 | + merged_into?: string | null; | |
| 309 | + canonical_id?: string | null; | |
| 310 | + identity_confidence?: string | null; | |
| 311 | + variant_key?: string | null; | |
| 312 | + name_analysis?: Record<string, unknown> | null; | |
| 313 | +} | |
| 314 | +export type ResolutionDecision = 'merge' | 'alias' | 'variant_of' | 'family_member' | 'keep_separate' | 'defer'; | |
| 315 | +export interface ResolutionItem { | |
| 316 | + a: ResolutionSide; | |
| 317 | + b: ResolutionSide; | |
| 318 | + signals: ({ kind: string } & Record<string, unknown>)[]; | |
| 319 | + similarity: Num; | |
| 320 | + same_variant_key: boolean; | |
| 321 | + same_organization: boolean; | |
| 322 | + decision: ({ decision: ResolutionDecision; note?: string | null; decided_at?: string | null; applied?: boolean } & Record<string, unknown>) | string | null; | |
| 323 | + hint: string | null; | |
| 324 | +} | |
| 325 | +export interface ResolutionPayload { | |
| 326 | + items: ResolutionItem[]; | |
| 327 | + total: number; | |
| 328 | + threshold: Num; | |
| 329 | + status: string; | |
| 330 | + decisions: ResolutionDecision[]; | |
| 331 | + note?: string; | |
| 332 | +} | |
| 333 | + | |
| 334 | +export interface Anomaly { | |
| 335 | + id: string; | |
| 336 | + entity_id: string | null; | |
| 337 | + check_name: string; | |
| 338 | + severity: string; | |
| 339 | + message: string; | |
| 340 | + value: unknown; | |
| 341 | + detail: Record<string, unknown> | null; | |
| 342 | + status: string; | |
| 343 | + first_seen_at: string; | |
| 344 | + last_seen_at: string; | |
| 345 | + resolved_at: string | null; | |
| 346 | + resolution: Record<string, unknown> | null; | |
| 347 | + slug?: string | null; | |
| 348 | + entity_name?: string | null; | |
| 349 | + entity_type?: string | null; | |
| 350 | +} | |
| 351 | +export type AnomaliesPayload = AdminPage<Anomaly> & { by_check?: { check_name: string; severity: string; status: string; n: Num }[] }; | |
| 352 | + | |
| 353 | +export interface QuarantineItem { | |
| 354 | + id: string; | |
| 355 | + run_id?: string | null; | |
| 356 | + connector_name?: string | null; | |
| 357 | + reason?: string | null; | |
| 358 | + status: string; | |
| 359 | + created_at?: string; | |
| 360 | + counts?: Record<string, Num>; | |
| 361 | + [k: string]: unknown; | |
| 362 | +} | |
| 363 | +export type QuarantinePayload = AdminPage<QuarantineItem>; | |
| 364 | + | |
| 365 | +export interface AuditRow { | |
| 366 | + id: number | string; | |
| 367 | + actor: string; | |
| 368 | + action: string; | |
| 369 | + target: string | null; | |
| 370 | + payload: Record<string, unknown> | null; | |
| 371 | + ip: string | null; | |
| 372 | + created_at: string; | |
| 373 | +} | |
| 374 | +export type AuditPayload = AdminPage<AuditRow>; | |
| 375 | + | |
| 376 | +export interface ExtractionClaim { | |
| 377 | + id: string; | |
| 378 | + entity_id: string | null; | |
| 379 | + entity_slug?: string | null; | |
| 380 | + property: string; | |
| 381 | + value: unknown; | |
| 382 | + value_raw?: unknown; | |
| 383 | + unit: string | null; | |
| 384 | + status: string; | |
| 385 | + confidence: string; | |
| 386 | + extractor: string; | |
| 387 | + tier: Num; | |
| 388 | + [k: string]: unknown; | |
| 389 | +} | |
| 390 | +export interface ExtractionSpan { | |
| 391 | + claim_id: string; | |
| 392 | + property: string; | |
| 393 | + value: unknown; | |
| 394 | + found: boolean; | |
| 395 | + offset?: number | null; | |
| 396 | + match?: string | null; | |
| 397 | + context?: string | null; | |
| 398 | + tried?: string[]; | |
| 399 | +} | |
| 400 | +export interface ExtractionPayload extends AdminSnapshot { | |
| 401 | + previous_snapshot: { id: string; observed_at: string; content_hash: string | null } | null; | |
| 402 | + claims: ExtractionClaim[]; | |
| 403 | + relations: Record<string, unknown>[]; | |
| 404 | + results: Record<string, unknown>[]; | |
| 405 | + prices: Record<string, unknown>[]; | |
| 406 | + events: Record<string, unknown>[]; | |
| 407 | + llm_jobs: Record<string, unknown>[]; | |
| 408 | + entity_candidates: { id: string; slug: string; canonical_name: string; entity_type: string; merged_into: string | null; identity_confidence: string | null }[]; | |
| 409 | + spans: ExtractionSpan[]; | |
| 410 | + spans_found: Num; | |
| 411 | + note?: string; | |
| 412 | +} | |
| 413 | +export interface RollbackResult { | |
| 414 | + ok: boolean; | |
| 415 | + run_id: string; | |
| 416 | + connector: string | null; | |
| 417 | + counts: Record<string, Num>; | |
| 418 | + note?: string; | |
| 419 | +} | |
modified
apps/web/src/lib/client-api.ts
+26 −0
@@ -72,3 +72,29 @@ export async function clientTry(path: string, signal?: AbortSignal): Promise<{ s | ||
| 72 | 72 | return { status: res.status, ms: Math.round(performance.now() - t0), body, headers }; |
| 73 | 73 | } |
| 74 | 74 | // ---- /D3 ---- |
| 75 | + | |
| 76 | +// ---- D3 (temporal/graph/admin) ---- | |
| 77 | +import type { GraphExploreMode, GraphExplorePayload } from './types'; | |
| 78 | +/** Graph explorer: root change and progressive neighbourhood expansion (merged client-side). */ | |
| 79 | +export function clientGraphExplore(node: string, mode: GraphExploreMode, depth: 1 | 2 = 1, limit = 150, signal?: AbortSignal): Promise<GraphExplorePayload> { | |
| 80 | + return get<GraphExplorePayload>(`/graph/explore?node=${enc(node)}&mode=${enc(mode)}&depth=${depth}&limit=${limit}`, signal); | |
| 81 | +} | |
| 82 | +/** Same-origin GET of any public route for the /developers request builder (returns status + parsed body or text). */ | |
| 83 | +export async function clientTry(path: string, signal?: AbortSignal): Promise<{ status: number; ms: number; body: unknown; headers: Record<string, string> }> { | |
| 84 | + const t0 = performance.now(); | |
| 85 | + const res = await fetch(`/api/v1${path}`, { headers: { accept: 'application/json' }, signal }); | |
| 86 | + const text = await res.text(); | |
| 87 | + let body: unknown = text; | |
| 88 | + try { | |
| 89 | + body = JSON.parse(text); | |
| 90 | + } catch { | |
| 91 | + /* keep text */ | |
| 92 | + } | |
| 93 | + const headers: Record<string, string> = {}; | |
| 94 | + for (const k of ['x-api-version', 'etag', 'cache-control', 'content-type']) { | |
| 95 | + const v = res.headers.get(k); | |
| 96 | + if (v) headers[k] = v; | |
| 97 | + } | |
| 98 | + return { status: res.status, ms: Math.round(performance.now() - t0), body, headers }; | |
| 99 | +} | |
| 100 | +// ---- /D3 ---- | |
| 75 | 101 | |