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