SPB Git

spb/airiskindex

Public

The most methodologically rigorous, fully transparent AI job-exposure index.

feat(web): ranking home page + occupation detail pages with demo dataset

- ranking: substitution leaderboard with CI whiskers, hover tooltips,
  sr-only table fallback; sequential single-hue marks per dataviz specs
- detail: sub-score stat tiles with interval strips, highly-exposed-tasks
  meter, per-task bars with CI, visible data-table fallback,
  adaptation-not-doom framing copy
- seed: 10 real O*NET occupations x 3 tasks with deterministic synthetic
  two-model panel ratings (demo-panel-a/b); preview banner in layout
- light + dark viz roles from the validated reference palette

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed (Aug 5, 2026) parent 61f6dac Browse files

6 changed files +756 −60

modified apps/web/app/globals.css +41 −0
@@ -1,3 +1,44 @@
1 @tailwind base; 1 @tailwind base;
2 @tailwind components; 2 @tailwind components;
3 @tailwind utilities; 3 @tailwind utilities;
4+
5+/* Data-viz roles — reference palette (dataviz skill, references/palette.md).
6+ Charts reference roles, never raw hex; dark steps are selected, not flipped. */
7+:root {
8+ color-scheme: light;
9+ --page: #f9f9f7;
10+ --surface-1: #fcfcfb;
11+ --ink: #0b0b0b;
12+ --ink-2: #52514e;
13+ --muted: #898781;
14+ --grid: #e1e0d9;
15+ --baseline: #c3c2b7;
16+ --border: rgba(11, 11, 11, 0.1);
17+ --seq: #2a78d6; /* sequential blue 450 (light) */
18+ --seq-deep: #1c5cab;
19+ --seq-track: #cde2fb; /* lighter step of the same ramp (meter/bar track) */
20+ --wash: rgba(42, 120, 214, 0.1); /* series hue at ~10% — area wash */
21+}
22+
23+@media (prefers-color-scheme: dark) {
24+ :root {
25+ color-scheme: dark;
26+ --page: #0d0d0d;
27+ --surface-1: #1a1a19;
28+ --ink: #ffffff;
29+ --ink-2: #c3c2b7;
30+ --muted: #898781;
31+ --grid: #2c2c2a;
32+ --baseline: #383835;
33+ --border: rgba(255, 255, 255, 0.1);
34+ --seq: #3987e5; /* sequential blue 400 (dark) */
35+ --seq-deep: #6da7ec;
36+ --seq-track: #0d366b; /* recessive step of the same ramp on dark */
37+ --wash: rgba(57, 135, 229, 0.12);
38+ }
39+}
40+
41+body {
42+ background: var(--page);
43+ color: var(--ink);
44+}
modified apps/web/app/layout.tsx +37 −1
@@ -1,5 +1,7 @@
1 import type { Metadata } from "next"; 1 import type { Metadata } from "next";
2+import Link from "next/link";
2 import type { ReactNode } from "react"; 3 import type { ReactNode } from "react";
4+import { INDEX_VERSION } from "@airiskindex/scoring";
3 import "./globals.css"; 5 import "./globals.css";
4 6
5 export const metadata: Metadata = { 7 export const metadata: Metadata = {
@@ -11,7 +13,41 @@ export const metadata: Metadata = {
11 export default function RootLayout({ children }: { children: ReactNode }): JSX.Element { 13 export default function RootLayout({ children }: { children: ReactNode }): JSX.Element {
12 return ( 14 return (
13 <html lang="en"> 15 <html lang="en">
14− <body className="min-h-screen bg-white text-slate-900 antialiased">{children}</body> 16+ <body className="min-h-screen antialiased">
17+ <div className="border-b border-[var(--border)] bg-[var(--surface-1)] px-6 py-2 text-center text-xs text-[var(--ink-2)]">
18+ Preview — scores below are computed from a demonstration dataset while the first
19+ published index run ({INDEX_VERSION.replace("-draft.1", "")}) is in progress.
20+ </div>
21+ <header className="border-b border-[var(--border)] bg-[var(--surface-1)]">
22+ <nav className="mx-auto flex max-w-4xl items-center justify-between px-6 py-4">
23+ <Link href="/" className="font-semibold tracking-tight">
24+ AI Risk Index
25+ </Link>
26+ <div className="flex items-center gap-5 text-sm text-[var(--ink-2)]">
27+ <Link href="/#ranking" className="hover:text-[var(--ink)]">
28+ Ranking
29+ </Link>
30+ <Link href="/#concepts" className="hover:text-[var(--ink)]">
31+ Method
32+ </Link>
33+ <Link href="/api/v1/methodology" className="hover:text-[var(--ink)]">
34+ API
35+ </Link>
36+ </div>
37+ </nav>
38+ </header>
39+ {children}
40+ <footer className="mt-16 border-t border-[var(--border)] bg-[var(--surface-1)]">
41+ <div className="mx-auto max-w-4xl px-6 py-8 text-xs leading-relaxed text-[var(--muted)]">
42+ <p>
43+ Methodology {INDEX_VERSION} · every score traces to a stored, immutable computation
44+ run · weights, prompts and formulas are public. Occupation and task data from O*NET
45+ (CC BY 4.0, U.S. Department of Labor). This index measures task-level pressure, not
46+ certainty of job loss — read it as adaptation guidance.
47+ </p>
48+ </div>
49+ </footer>
50+ </body>
15 </html> 51 </html>
16 ); 52 );
17 } 53 }
added apps/web/app/occupations/[code]/page.tsx +206 −0
@@ -0,0 +1,206 @@
1+import Link from "next/link";
2+import { notFound } from "next/navigation";
3+import { prisma } from "@airiskindex/db";
4+import { HIGH_EXPOSURE_THRESHOLD } from "@airiskindex/scoring";
5+import { ScoreBar, ScoreTile, ShareMeter } from "@/components/score-marks";
6+
7+export const dynamic = "force-dynamic";
8+
9+async function loadOccupation(code: string) {
10+ const occupation = await prisma.occupation.findUnique({
11+ where: { code },
12+ include: { tasks: { orderBy: { importance: "desc" } } },
13+ });
14+ if (!occupation) return null;
15+
16+ const score = await prisma.occupationScore.findFirst({
17+ where: { occupationCode: code },
18+ orderBy: { run: { createdAt: "desc" } },
19+ include: { run: true },
20+ });
21+
22+ const taskScores = score
23+ ? await prisma.taskScore.findMany({
24+ where: { runId: score.runId, taskId: { in: occupation.tasks.map((task) => task.id) } },
25+ })
26+ : [];
27+
28+ return { occupation, score, taskScores };
29+}
30+
31+export default async function OccupationPage({
32+ params,
33+}: {
34+ params: { code: string };
35+}): Promise<JSX.Element> {
36+ const data = await loadOccupation(params.code);
37+ if (!data) notFound();
38+ const { occupation, score, taskScores } = data;
39+ const byTask = new Map(taskScores.map((entry) => [entry.taskId, entry]));
40+ const rankedTasks = [...occupation.tasks].sort(
41+ (a, b) => (byTask.get(b.id)?.substitution ?? -1) - (byTask.get(a.id)?.substitution ?? -1),
42+ );
43+
44+ return (
45+ <main className="mx-auto max-w-4xl px-6 py-12">
46+ <Link href="/#ranking" className="text-sm text-[var(--ink-2)] hover:text-[var(--ink)]">
47+ ← Ranking
48+ </Link>
49+ <div className="mt-4 flex flex-wrap items-baseline gap-x-3 gap-y-1">
50+ <h1 className="text-3xl font-bold tracking-tight">{occupation.title}</h1>
51+ <span className="rounded-full border border-[var(--border)] px-2.5 py-0.5 text-xs text-[var(--muted)]">
52+ {occupation.code}
53+ </span>
54+ </div>
55+ {occupation.description && (
56+ <p className="mt-3 max-w-2xl text-[var(--ink-2)]">{occupation.description}</p>
57+ )}
58+
59+ {!score ? (
60+ <div className="mt-10 rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-8 text-sm text-[var(--ink-2)]">
61+ This occupation has not been scored yet — its tasks are queued for the rater panel.
62+ </div>
63+ ) : (
64+ <>
65+ <section className="mt-10">
66+ <div className="grid gap-4 sm:grid-cols-3">
67+ <ScoreTile
68+ label="Substitution"
69+ band={{
70+ low: score.substitutionLow,
71+ score: score.substitution,
72+ high: score.substitutionHigh,
73+ }}
74+ hint="Composite pressure that AI replaces the human on this occupation's tasks — capability discounted by cost, barriers and adoption."
75+ />
76+ <ScoreTile
77+ label="Exposure"
78+ band={{ low: score.exposureLow, score: score.exposure, high: score.exposureHigh }}
79+ hint="Technical capability only: could AI perform these tasks, regardless of whether anyone deploys it."
80+ />
81+ <ScoreTile
82+ label="Augmentation"
83+ band={{
84+ low: score.augmentationLow,
85+ score: score.augmentation,
86+ high: score.augmentationHigh,
87+ }}
88+ hint="How much AI assists a human doing this work. High augmentation with moderate substitution reads as a changing job, not a disappearing one."
89+ />
90+ </div>
91+ <div className="mt-4">
92+ <ShareMeter
93+ share={score.highlyExposedTaskShare}
94+ label={`Tasks with substitution ≥ ${HIGH_EXPOSURE_THRESHOLD}`}
95+ />
96+ </div>
97+ <p className="mt-3 text-xs text-[var(--muted)]">
98+ Run {score.run.indexVersion} · computed {score.run.createdAt.toISOString().slice(0, 10)} ·
99+ rater panel: {score.run.raterModels.join(", ")} · intervals span rater disagreement.
100+ </p>
101+ </section>
102+
103+ <section className="mt-12">
104+ <h2 className="text-xl font-semibold tracking-tight">Task breakdown</h2>
105+ <p className="mt-1 text-sm text-[var(--ink-2)]">
106+ Substitution pressure per task, weighted by O*NET importance in the composite. Hover
107+ a bar for the full breakdown.
108+ </p>
109+ <div className="mt-5 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]">
110+ {rankedTasks.map((task) => {
111+ const ts = byTask.get(task.id);
112+ return (
113+ <div
114+ key={task.id}
115+ className="group relative border-b border-[var(--grid)] px-5 py-4 last:border-b-0 hover:bg-[var(--wash)]"
116+ >
117+ <div className="flex items-baseline gap-3">
118+ <p className="truncate text-sm">{task.statement}</p>
119+ <span className="ml-auto pl-3 text-sm font-semibold tabular-nums">
120+ {ts ? ts.substitution.toFixed(0) : "—"}
121+ </span>
122+ </div>
123+ <div className="mt-2">
124+ {ts ? (
125+ <ScoreBar
126+ band={{
127+ low: ts.substitutionLow,
128+ score: ts.substitution,
129+ high: ts.substitutionHigh,
130+ }}
131+ thick={8}
132+ />
133+ ) : (
134+ <div className="h-[8px] rounded-[4px] bg-[var(--seq-track)]" />
135+ )}
136+ </div>
137+ {ts && (
138+ <div className="pointer-events-none absolute right-5 top-1 z-10 hidden rounded-lg border border-[var(--border)] bg-[var(--surface-1)] px-3 py-2 text-xs text-[var(--ink-2)] shadow-sm group-hover:block">
139+ CI {ts.substitutionLow.toFixed(0)}–{ts.substitutionHigh.toFixed(0)} ·
140+ exposure {ts.exposure.toFixed(0)} · augmentation{" "}
141+ {ts.augmentation.toFixed(0)}
142+ {task.importance != null && <> · importance {task.importance.toFixed(1)}/5</>}
143+ </div>
144+ )}
145+ </div>
146+ );
147+ })}
148+ </div>
149+
150+ {/* Visible data table — chart fallback (CLAUDE.md §5) */}
151+ <details className="mt-4">
152+ <summary className="cursor-pointer text-sm text-[var(--ink-2)] hover:text-[var(--ink)]">
153+ View as data table
154+ </summary>
155+ <div className="mt-3 overflow-x-auto rounded-xl border border-[var(--border)] bg-[var(--surface-1)]">
156+ <table className="w-full text-left text-sm">
157+ <caption className="sr-only">
158+ Per-task scores with confidence intervals for {occupation.title}
159+ </caption>
160+ <thead className="border-b border-[var(--grid)] text-xs uppercase tracking-wide text-[var(--muted)]">
161+ <tr>
162+ <th className="px-4 py-3 font-medium">Task</th>
163+ <th className="px-4 py-3 font-medium">Importance</th>
164+ <th className="px-4 py-3 font-medium">Substitution (CI)</th>
165+ <th className="px-4 py-3 font-medium">Exposure</th>
166+ <th className="px-4 py-3 font-medium">Augmentation</th>
167+ </tr>
168+ </thead>
169+ <tbody className="tabular-nums">
170+ {rankedTasks.map((task) => {
171+ const ts = byTask.get(task.id);
172+ return (
173+ <tr key={task.id} className="border-b border-[var(--grid)] last:border-b-0">
174+ <td className="max-w-md px-4 py-3">{task.statement}</td>
175+ <td className="px-4 py-3">{task.importance?.toFixed(1) ?? "—"}</td>
176+ <td className="px-4 py-3">
177+ {ts
178+ ? `${ts.substitution.toFixed(1)} (${ts.substitutionLow.toFixed(1)}–${ts.substitutionHigh.toFixed(1)})`
179+ : "—"}
180+ </td>
181+ <td className="px-4 py-3">{ts ? ts.exposure.toFixed(1) : "—"}</td>
182+ <td className="px-4 py-3">{ts ? ts.augmentation.toFixed(1) : "—"}</td>
183+ </tr>
184+ );
185+ })}
186+ </tbody>
187+ </table>
188+ </div>
189+ </details>
190+ </section>
191+
192+ <section className="mt-12 rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-6">
193+ <h2 className="font-semibold">How to read this</h2>
194+ <p className="mt-2 text-sm leading-relaxed text-[var(--ink-2)]">
195+ A high substitution score does not mean this job disappears — it means a large share
196+ of its current tasks face replacement pressure, so the mix of tasks is likely to
197+ change. Occupations with high augmentation alongside substitution typically
198+ reorganize around the protected tasks. Wide confidence intervals mean the rater
199+ panel disagreed: treat those scores as open questions, not verdicts.
200+ </p>
201+ </section>
202+ </>
203+ )}
204+ </main>
205+ );
206+}
modified apps/web/app/page.tsx +138 −38
@@ -1,5 +1,8 @@
1−import { ScoreBandPill } from "@airiskindex/ui"; 1+import Link from "next/link";
2−import { INDEX_VERSION } from "@airiskindex/scoring"; 2+import { prisma } from "@airiskindex/db";
3+import { ScoreBar } from "@/components/score-marks";
4+
5+export const dynamic = "force-dynamic";
3 6
4 const CONCEPTS = [ 7 const CONCEPTS = [
5 { 8 {
@@ -10,7 +13,7 @@ const CONCEPTS = [
10 { 13 {
11 name: "Substitution", 14 name: "Substitution",
12 description: 15 description:
13− "AI actually replaces the human performing the task, once cost, adoption and real-world barriers are accounted for.", 16+ "AI actually replaces the human performing the task, once cost, adoption velocity and real-world barriers are accounted for. This is the headline score.",
14 }, 17 },
15 { 18 {
16 name: "Augmentation", 19 name: "Augmentation",
@@ -19,51 +22,148 @@ const CONCEPTS = [
19 }, 22 },
20 ] as const; 23 ] as const;
21 24
22−export default function HomePage(): JSX.Element { 25+async function loadRanking() {
26+ try {
27+ const run = await prisma.scoreRun.findFirst({ orderBy: { createdAt: "desc" } });
28+ if (!run) return { run: null, rows: [] as never[] };
29+ const rows = await prisma.occupationScore.findMany({
30+ where: { runId: run.id },
31+ orderBy: { substitution: "desc" },
32+ include: { occupation: { select: { code: true, title: true } } },
33+ });
34+ return { run, rows };
35+ } catch {
36+ return { run: null, rows: [] as never[] };
37+ }
38+}
39+
40+export default async function HomePage(): Promise<JSX.Element> {
41+ const { run, rows } = await loadRanking();
42+
23 return ( 43 return (
24− <main className="mx-auto max-w-3xl px-6 py-16"> 44+ <main className="mx-auto max-w-4xl px-6 py-14">
25− <p className="text-sm font-medium uppercase tracking-wide text-slate-500"> 45+ <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--muted)]">
26− AI Risk Index · methodology {INDEX_VERSION} 46+ The transparent, task-based AI job-exposure index
27 </p> 47 </p>
28− <h1 className="mt-2 text-4xl font-bold tracking-tight"> 48+ <h1 className="mt-3 max-w-2xl text-4xl font-bold tracking-tight sm:text-5xl">
29 How is your occupation exposed to AI — task by task? 49 How is your occupation exposed to AI — task by task?
30 </h1> 50 </h1>
31− <p className="mt-4 text-lg text-slate-600"> 51+ <p className="mt-4 max-w-2xl text-lg leading-relaxed text-[var(--ink-2)]">
32− We score occupations from their individual tasks, report three separate sub-scores with 52+ Occupations are scored from their individual tasks by a multi-model rater panel. Three
33− confidence intervals, and publish every weight, prompt and formula. Built for adaptation 53+ sub-scores, confidence intervals from rater disagreement, versioned methodology, public
34− planning — not headlines. 54+ API. Built for adaptation planning — not headlines.
35 </p> 55 </p>
36 56
37− <div className="mt-8 flex flex-wrap gap-3"> 57+ <section id="ranking" className="mt-14">
38− {/* Example occupation from the published methodology example */} 58+ <div className="flex items-baseline justify-between">
39− <ScoreBandPill label="Substitution" low={40.9} score={56.7} high={68.4} /> 59+ <h2 className="text-xl font-semibold tracking-tight">Substitution ranking</h2>
40− <ScoreBandPill label="Exposure" low={42.0} score={57.1} high={74.1} /> 60+ {run && (
41− <ScoreBandPill label="Augmentation" low={46.9} score={71.9} high={84.4} /> 61+ <p className="text-xs text-[var(--muted)]">
42− </div> 62+ run {run.indexVersion} · {run.createdAt.toISOString().slice(0, 10)}
63+ </p>
64+ )}
65+ </div>
66+ <p className="mt-1 text-sm text-[var(--ink-2)]">
67+ Composite substitution pressure, 0–100. The whisker marks the confidence interval from
68+ rater disagreement — a wide band is a claim we hold loosely.
69+ </p>
43 70
44− <section className="mt-12 grid gap-6 sm:grid-cols-3"> 71+ {rows.length === 0 ? (
45− {CONCEPTS.map((concept) => ( 72+ <div className="mt-8 rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-8 text-sm text-[var(--ink-2)]">
46− <div key={concept.name} className="rounded-lg border border-slate-200 p-5"> 73+ No score run published yet. Scores appear here after the first rating batch and{" "}
47− <h2 className="font-semibold">{concept.name}</h2> 74+ <code>score:recompute</code>.
48− <p className="mt-2 text-sm text-slate-600">{concept.description}</p>
49 </div> 75 </div>
50− ))} 76+ ) : (
77+ <>
78+ <ol className="mt-6 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]">
79+ {rows.map((row, index) => (
80+ <li key={row.occupationCode} className="border-b border-[var(--grid)] last:border-b-0">
81+ <Link
82+ href={`/occupations/${row.occupationCode}`}
83+ className="group relative block px-5 py-4 transition-colors hover:bg-[var(--wash)]"
84+ >
85+ <div className="flex items-baseline gap-3">
86+ <span className="w-6 shrink-0 text-right text-sm tabular-nums text-[var(--muted)]">
87+ {index + 1}
88+ </span>
89+ <span className="truncate font-medium">{row.occupation.title}</span>
90+ <span className="hidden text-xs text-[var(--muted)] sm:inline">
91+ {row.occupation.code}
92+ </span>
93+ <span className="ml-auto pl-3 text-sm font-semibold tabular-nums">
94+ {row.substitution.toFixed(0)}
95+ </span>
96+ </div>
97+ <div className="mt-2 pl-9">
98+ <ScoreBar
99+ band={{
100+ low: row.substitutionLow,
101+ score: row.substitution,
102+ high: row.substitutionHigh,
103+ }}
104+ />
105+ </div>
106+ {/* hover tooltip */}
107+ <div className="pointer-events-none absolute right-5 top-1 z-10 hidden rounded-lg border border-[var(--border)] bg-[var(--surface-1)] px-3 py-2 text-xs text-[var(--ink-2)] shadow-sm group-hover:block">
108+ CI {row.substitutionLow.toFixed(0)}–{row.substitutionHigh.toFixed(0)} ·
109+ exposure {row.exposure.toFixed(0)} · augmentation{" "}
110+ {row.augmentation.toFixed(0)}
111+ </div>
112+ </Link>
113+ </li>
114+ ))}
115+ </ol>
116+
117+ {/* Accessible table fallback (CLAUDE.md §5) */}
118+ <table className="sr-only">
119+ <caption>Occupations ranked by substitution score, with sub-scores</caption>
120+ <thead>
121+ <tr>
122+ <th>Rank</th>
123+ <th>Occupation</th>
124+ <th>Substitution (CI)</th>
125+ <th>Exposure</th>
126+ <th>Augmentation</th>
127+ </tr>
128+ </thead>
129+ <tbody>
130+ {rows.map((row, index) => (
131+ <tr key={row.occupationCode}>
132+ <td>{index + 1}</td>
133+ <td>
134+ {row.occupation.title} ({row.occupation.code})
135+ </td>
136+ <td>
137+ {row.substitution.toFixed(1)} ({row.substitutionLow.toFixed(1)}–
138+ {row.substitutionHigh.toFixed(1)})
139+ </td>
140+ <td>{row.exposure.toFixed(1)}</td>
141+ <td>{row.augmentation.toFixed(1)}</td>
142+ </tr>
143+ ))}
144+ </tbody>
145+ </table>
146+ </>
147+ )}
51 </section> 148 </section>
52 149
53− <section className="mt-12 text-sm text-slate-600"> 150+ <section id="concepts" className="mt-16">
54− <h2 className="text-base font-semibold text-slate-900">Public API</h2> 151+ <h2 className="text-xl font-semibold tracking-tight">
55− <ul className="mt-2 list-inside list-disc space-y-1"> 152+ Three scores, never collapsed into one
56− <li> 153+ </h2>
57− <code>/api/v1/occupations</code> — search occupations 154+ <div className="mt-5 grid gap-4 sm:grid-cols-3">
58− </li> 155+ {CONCEPTS.map((concept) => (
59− <li> 156+ <div
60− <code>/api/v1/occupations/:code</code> — full score breakdown with sub-scores and CI 157+ key={concept.name}
61− bounds 158+ className="rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-5"
62− </li> 159+ >
63− <li> 160+ <h3 className="font-semibold">{concept.name}</h3>
64− <code>/api/v1/methodology</code> — machine-readable weights and version metadata 161+ <p className="mt-2 text-sm leading-relaxed text-[var(--ink-2)]">
65− </li> 162+ {concept.description}
66− </ul> 163+ </p>
164+ </div>
165+ ))}
166+ </div>
67 </section> 167 </section>
68 </main> 168 </main>
69 ); 169 );
added apps/web/components/score-marks.tsx +107 −0
@@ -0,0 +1,107 @@
1+/**
2+ * Chart marks for 0–100 scores with confidence bounds.
3+ * Specs per the dataviz skill: thin bars (≤24px) with a 4px rounded data-end
4+ * and a square baseline, track = lighter step of the same ramp, CI whisker in
5+ * muted ink, values in text tokens (never the series color).
6+ */
7+
8+export interface Band {
9+ low: number;
10+ score: number;
11+ high: number;
12+}
13+
14+const pct = (value: number): string => `${Math.max(0, Math.min(100, value))}%`;
15+
16+/** Horizontal score bar with CI whisker on a 0–100 track. */
17+export function ScoreBar({ band, thick = 10 }: { band: Band; thick?: number }): JSX.Element {
18+ return (
19+ <div
20+ aria-hidden="true"
21+ className="relative w-full rounded-r-[4px] bg-[var(--seq-track)]"
22+ style={{ height: thick }}
23+ >
24+ <div
25+ className="absolute inset-y-0 left-0 rounded-r-[4px] bg-[var(--seq)]"
26+ style={{ width: pct(band.score) }}
27+ />
28+ {/* CI whisker: hairline + end ticks, muted ink over the track */}
29+ <div
30+ className="absolute top-1/2 h-px -translate-y-1/2 bg-[var(--muted)]"
31+ style={{ left: pct(band.low), width: pct(band.high - band.low) }}
32+ />
33+ <div
34+ className="absolute top-1/2 h-[7px] w-px -translate-y-1/2 bg-[var(--muted)]"
35+ style={{ left: pct(band.low) }}
36+ />
37+ <div
38+ className="absolute top-1/2 h-[7px] w-px -translate-y-1/2 bg-[var(--muted)]"
39+ style={{ left: pct(band.high) }}
40+ />
41+ </div>
42+ );
43+}
44+
45+/** Compact interval strip for stat tiles: band wash + point marker. */
46+export function IntervalStrip({ band }: { band: Band }): JSX.Element {
47+ return (
48+ <div aria-hidden="true" className="relative h-[6px] w-full rounded-[3px] bg-[var(--seq-track)]">
49+ <div
50+ className="absolute inset-y-0 rounded-[3px] bg-[var(--seq)] opacity-40"
51+ style={{ left: pct(band.low), width: pct(band.high - band.low) }}
52+ />
53+ {/* point marker ≥8px with a 2px surface ring */}
54+ <div
55+ className="absolute top-1/2 h-[10px] w-[10px] -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[var(--surface-1)] bg-[var(--seq)]"
56+ style={{ left: pct(band.score) }}
57+ />
58+ </div>
59+ );
60+}
61+
62+/** Stat tile: label · value · CI, with the interval strip underneath. */
63+export function ScoreTile({
64+ label,
65+ band,
66+ hint,
67+}: {
68+ label: string;
69+ band: Band;
70+ hint: string;
71+}): JSX.Element {
72+ return (
73+ <div className="rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-5">
74+ <p className="text-sm font-medium text-[var(--ink-2)]">{label}</p>
75+ <p className="mt-1 text-4xl font-semibold">
76+ {band.score.toFixed(0)}
77+ <span className="ml-2 align-middle text-sm font-normal text-[var(--muted)]">
78+ CI {band.low.toFixed(0)}–{band.high.toFixed(0)}
79+ </span>
80+ </p>
81+ <div className="mt-3">
82+ <IntervalStrip band={band} />
83+ </div>
84+ <p className="mt-3 text-xs leading-relaxed text-[var(--muted)]">{hint}</p>
85+ </div>
86+ );
87+}
88+
89+/** Meter: fill + same-ramp track (marks-and-anatomy §Figures). */
90+export function ShareMeter({ share, label }: { share: number; label: string }): JSX.Element {
91+ const value = Math.round(share * 100);
92+ return (
93+ <div className="rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-5">
94+ <div className="flex items-baseline justify-between">
95+ <p className="text-sm font-medium text-[var(--ink-2)]">{label}</p>
96+ <p className="text-2xl font-semibold">{value}%</p>
97+ </div>
98+ <div className="mt-3 h-[10px] w-full rounded-[4px] bg-[var(--seq-track)]">
99+ <div
100+ className="h-full rounded-[4px] bg-[var(--seq)]"
101+ style={{ width: `${value}%` }}
102+ aria-hidden="true"
103+ />
104+ </div>
105+ </div>
106+ );
107+}
modified packages/db/prisma/seed.ts +227 −21
@@ -1,41 +1,247 @@
1−import { readFileSync } from "node:fs";
2 import { PrismaClient } from "@prisma/client"; 1 import { PrismaClient } from "@prisma/client";
3 2
4−// Seeds the synthetic methodology example occupation so the app and API have 3+// Demo seed: 10 real O*NET-SOC occupations with plausible hand-authored task
5−// demo data before the real O*NET ETL runs. Real data: `cd apps/etl && make pipeline`. 4+// ratings from a synthetic two-model panel ("demo-panel-a/b"). This exists so
5+// the site has data before the real O*NET ETL + LLM rating batch runs; the web
6+// layout shows a "Preview — demonstration dataset" banner, and every score
7+// still traces to a ScoreRun whose raterModels make the provenance explicit.
8+// Deterministic on purpose — reseeding yields identical ratings.
9+
6 const prisma = new PrismaClient(); 10 const prisma = new PrismaClient();
7 11
8−const example = JSON.parse( 12+const DIMS = [
9− readFileSync(new URL("../../../docs/methodology/examples/example-analyst.json", import.meta.url), "utf8"), 13+ "automatability",
10−); 14+ "feasibility",
15+ "cost_ratio",
16+ "barriers",
17+ "adoption_velocity",
18+ "augmentation",
19+] as const;
11 20
12−async function main(): Promise<void> { 21+type Dim = (typeof DIMS)[number];
13− const { code, title } = example.occupation; 22+type Profile = Record<Dim, number>;
23+
24+interface DemoTask {
25+ id: string;
26+ statement: string;
27+ importance: number;
28+ adjust?: Partial<Profile>;
29+}
30+
31+interface DemoOccupation {
32+ code: string;
33+ title: string;
34+ description: string;
35+ wageCents: number; // median annual, USD cents
36+ profile: Profile;
37+ tasks: DemoTask[];
38+}
39+
40+const p = (
41+ automatability: number,
42+ feasibility: number,
43+ cost_ratio: number,
44+ barriers: number,
45+ adoption_velocity: number,
46+ augmentation: number,
47+): Profile => ({ automatability, feasibility, cost_ratio, barriers, adoption_velocity, augmentation });
48+
49+const DEMO: DemoOccupation[] = [
50+ {
51+ code: "43-9021.00",
52+ title: "Data Entry Keyers",
53+ description:
54+ "Operate data entry devices to enter, verify and prepare data from source documents.",
55+ wageCents: 3_717_000_00 / 100,
56+ profile: p(5, 5, 5, 1, 4, 3),
57+ tasks: [
58+ { id: "T1", statement: "Enter account or customer data from source documents within time limits.", importance: 4.6 },
59+ { id: "T2", statement: "Verify entered data by reviewing, correcting or re-entering it.", importance: 4.2 },
60+ { id: "T3", statement: "Compile, sort and check accuracy of source documents before entry.", importance: 3.8, adjust: { automatability: -1 } },
61+ ],
62+ },
63+ {
64+ code: "43-4051.00",
65+ title: "Customer Service Representatives",
66+ description:
67+ "Interact with customers to handle inquiries, complaints and account questions.",
68+ wageCents: 3_933_000_00 / 100,
69+ profile: p(4, 4, 4, 2, 4, 4),
70+ tasks: [
71+ { id: "T1", statement: "Respond to routine customer inquiries about products, services and accounts.", importance: 4.7 },
72+ { id: "T2", statement: "Resolve complaints and escalate complex or sensitive cases.", importance: 4.1, adjust: { automatability: -1, barriers: 1 } },
73+ { id: "T3", statement: "Keep records of customer interactions and actions taken.", importance: 3.6, adjust: { automatability: 1 } },
74+ ],
75+ },
76+ {
77+ code: "27-3091.00",
78+ title: "Interpreters and Translators",
79+ description: "Translate or interpret written and spoken material between languages.",
80+ wageCents: 5_768_000_00 / 100,
81+ profile: p(4, 4, 4, 2, 3, 4),
82+ tasks: [
83+ { id: "T1", statement: "Translate written documents while preserving meaning, tone and register.", importance: 4.6 },
84+ { id: "T2", statement: "Interpret spoken exchanges in real time between parties.", importance: 4.0, adjust: { barriers: 1 } },
85+ { id: "T3", statement: "Proofread and quality-check translated content for accuracy.", importance: 3.7, adjust: { augmentation: 1 } },
86+ ],
87+ },
88+ {
89+ code: "15-1252.00",
90+ title: "Software Developers",
91+ description: "Design, build and maintain software systems and applications.",
92+ wageCents: 13_047_000_00 / 100,
93+ profile: p(4, 4, 3, 2, 5, 5),
94+ tasks: [
95+ { id: "T1", statement: "Write, test and debug application code to specification.", importance: 4.7 },
96+ { id: "T2", statement: "Design system architecture and negotiate technical trade-offs with stakeholders.", importance: 4.2, adjust: { automatability: -2, barriers: 1 } },
97+ { id: "T3", statement: "Review code, write documentation and maintain existing services.", importance: 3.9 },
98+ ],
99+ },
100+ {
101+ code: "13-2051.00",
102+ title: "Financial and Investment Analysts",
103+ description: "Analyze financial data to guide investment and business decisions.",
104+ wageCents: 9_918_000_00 / 100,
105+ profile: p(4, 3, 3, 3, 4, 5),
106+ tasks: [
107+ { id: "T1", statement: "Gather and consolidate financial data into models and reports.", importance: 4.5, adjust: { automatability: 1 } },
108+ { id: "T2", statement: "Assess valuation and risk to produce investment recommendations.", importance: 4.3, adjust: { barriers: 1 } },
109+ { id: "T3", statement: "Present findings and defend assumptions to clients and committees.", importance: 3.8, adjust: { automatability: -2 } },
110+ ],
111+ },
112+ {
113+ code: "23-1011.00",
114+ title: "Lawyers",
115+ description: "Advise and represent clients in legal matters and transactions.",
116+ wageCents: 14_531_000_00 / 100,
117+ profile: p(3, 3, 3, 5, 3, 4),
118+ tasks: [
119+ { id: "T1", statement: "Research case law and draft contracts, briefs and legal memoranda.", importance: 4.5, adjust: { automatability: 1, augmentation: 1 } },
120+ { id: "T2", statement: "Advise clients on legal strategy, risk and obligations.", importance: 4.4 },
121+ { id: "T3", statement: "Represent clients in negotiations, hearings and court proceedings.", importance: 4.0, adjust: { automatability: -1 } },
122+ ],
123+ },
124+ {
125+ code: "25-2021.00",
126+ title: "Elementary School Teachers",
127+ description: "Teach academic and social skills to students at the elementary level.",
128+ wageCents: 6_355_000_00 / 100,
129+ profile: p(2, 2, 2, 4, 2, 4),
130+ tasks: [
131+ { id: "T1", statement: "Prepare lesson plans, materials and assessments.", importance: 4.4, adjust: { automatability: 1, augmentation: 1 } },
132+ { id: "T2", statement: "Instruct and manage a classroom of students in person.", importance: 4.8, adjust: { automatability: -1, barriers: 1 } },
133+ { id: "T3", statement: "Grade assignments and track individual student progress.", importance: 3.9, adjust: { automatability: 1 } },
134+ ],
135+ },
136+ {
137+ code: "29-1141.00",
138+ title: "Registered Nurses",
139+ description: "Provide and coordinate patient care and educate patients about health conditions.",
140+ wageCents: 8_607_000_00 / 100,
141+ profile: p(2, 2, 2, 5, 3, 3),
142+ tasks: [
143+ { id: "T1", statement: "Assess patients and administer treatments and medications.", importance: 4.8, adjust: { automatability: -1 } },
144+ { id: "T2", statement: "Document care and maintain accurate patient records.", importance: 4.2, adjust: { automatability: 2, augmentation: 1 } },
145+ { id: "T3", statement: "Educate patients and families on conditions and care plans.", importance: 4.0 },
146+ ],
147+ },
148+ {
149+ code: "53-3032.00",
150+ title: "Heavy and Tractor-Trailer Truck Drivers",
151+ description: "Drive heavy trucks to transport goods over intercity routes.",
152+ wageCents: 5_411_000_00 / 100,
153+ profile: p(2, 2, 2, 4, 2, 2),
154+ tasks: [
155+ { id: "T1", statement: "Drive long-haul routes while complying with hours-of-service rules.", importance: 4.8, adjust: { adoption_velocity: 1 } },
156+ { id: "T2", statement: "Inspect vehicles and secure cargo before and after trips.", importance: 4.1 },
157+ { id: "T3", statement: "Plan routes and log trip records and fuel purchases.", importance: 3.5, adjust: { automatability: 2, augmentation: 1 } },
158+ ],
159+ },
160+ {
161+ code: "47-2111.00",
162+ title: "Electricians",
163+ description: "Install, maintain and repair electrical wiring and systems.",
164+ wageCents: 6_121_000_00 / 100,
165+ profile: p(1, 1, 2, 4, 2, 2),
166+ tasks: [
167+ { id: "T1", statement: "Install and repair wiring, fixtures and control systems on site.", importance: 4.8 },
168+ { id: "T2", statement: "Diagnose faults using test equipment and building plans.", importance: 4.3, adjust: { augmentation: 1 } },
169+ { id: "T3", statement: "Prepare estimates, order materials and document completed work.", importance: 3.4, adjust: { automatability: 2, augmentation: 1 } },
170+ ],
171+ },
172+];
173+
174+const clamp = (value: number): number => Math.max(1, Math.min(5, Math.round(value)));
175+
176+async function seedDemoOccupation(occupation: DemoOccupation): Promise<void> {
14 await prisma.occupation.upsert({ 177 await prisma.occupation.upsert({
15− where: { code }, 178+ where: { code: occupation.code },
16− update: { title }, 179+ update: { title: occupation.title, description: occupation.description, medianWageCents: Math.round(occupation.wageCents) },
17 create: { 180 create: {
18− code, 181+ code: occupation.code,
19− title, 182+ title: occupation.title,
20− description: 183+ description: occupation.description,
21− "Synthetic occupation from the published methodology example. Not real O*NET data.", 184+ medianWageCents: Math.round(occupation.wageCents),
22 }, 185 },
23 }); 186 });
24 187
25− for (const task of example.tasks) { 188+ for (const [taskIndex, task] of occupation.tasks.entries()) {
189+ const taskId = `${occupation.code}-${task.id}`;
26 await prisma.task.upsert({ 190 await prisma.task.upsert({
27− where: { id: `${code}-${task.taskId}` }, 191+ where: { id: taskId },
28− update: { importance: task.importance }, 192+ update: { statement: task.statement, importance: task.importance },
29 create: { 193 create: {
30− id: `${code}-${task.taskId}`, 194+ id: taskId,
31− occupationCode: code, 195+ occupationCode: occupation.code,
32− statement: `Synthetic task ${task.taskId} (see docs/methodology/examples/)`, 196+ statement: task.statement,
33 importance: task.importance, 197 importance: task.importance,
34 }, 198 },
35 }); 199 });
200+
201+ for (const [dimIndex, dimension] of DIMS.entries()) {
202+ const base = clamp(occupation.profile[dimension] + (task.adjust?.[dimension] ?? 0));
203+ // Deterministic panel disagreement: model B shifts by -1/0/+1 in a fixed pattern.
204+ const shift = ((dimIndex + taskIndex) % 3) - 1;
205+ const panel: Array<[string, number]> = [
206+ ["demo-panel-a", base],
207+ ["demo-panel-b", clamp(base + shift)],
208+ ];
209+ for (const [model, rating] of panel) {
210+ await prisma.taskRating.upsert({
211+ where: {
212+ taskId_dimension_model_promptVersion_sampleIndex: {
213+ taskId,
214+ dimension,
215+ model,
216+ promptVersion: "v1",
217+ sampleIndex: 0,
218+ },
219+ },
220+ update: { rating },
221+ create: {
222+ taskId,
223+ dimension,
224+ model,
225+ promptVersion: "v1",
226+ sampleIndex: 0,
227+ rating,
228+ rationale: "Demonstration rating (synthetic panel) — not an LLM output.",
229+ rawResponse: { demo: true },
230+ },
231+ });
232+ }
233+ }
36 } 234 }
235+}
37 236
38− console.log(`Seeded example occupation ${code} with ${example.tasks.length} tasks.`); 237+async function main(): Promise<void> {
238+ for (const occupation of DEMO) {
239+ await seedDemoOccupation(occupation);
240+ }
241+ console.log(
242+ `Seeded ${DEMO.length} demo occupations (${DEMO.reduce((n, o) => n + o.tasks.length, 0)} tasks) with synthetic panel ratings.`,
243+ );
244+ console.log("Next: RATER_MODELS=demo-panel-a,demo-panel-b pnpm score:recompute");
39 } 245 }
40 246
41 main() 247 main()
42 248
Menu

Theme