SPB Git

spb/airiskindex Public

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

TypeScript 88% Python 6.1% SQL 2.7% CSS 1.2% JavaScript 0.9% Shell 0.8%

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 6 days ago (Aug 5, 2026) parent 61f6dac

Showing 6 changed files with +756 and −60

modified apps/web/app/globals.css +41 −0
@@ -1,3 +1,44 @@
1 1 @tailwind base;
2 2 @tailwind components;
3 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 1 import type { Metadata } from "next";
2 +import Link from "next/link";
2 3 import type { ReactNode } from "react";
4 +import { INDEX_VERSION } from "@airiskindex/scoring";
3 5 import "./globals.css";
4 6
5 7 export const metadata: Metadata = {
@@ -11,7 +13,41 @@ export const metadata: Metadata = {
11 13 export default function RootLayout({ children }: { children: ReactNode }): JSX.Element {
12 14 return (
13 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 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";
2 import { INDEX_VERSION } from "@airiskindex/scoring";
1 +import Link from "next/link";
2 +import { prisma } from "@airiskindex/db";
3 +import { ScoreBar } from "@/components/score-marks";
4 +
5 +export const dynamic = "force-dynamic";
3 6
4 7 const CONCEPTS = [
5 8 {
@@ -10,7 +13,7 @@ const CONCEPTS = [
10 13 {
11 14 name: "Substitution",
12 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 19 name: "Augmentation",
@@ -19,51 +22,148 @@ const CONCEPTS = [
19 22 },
20 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 43 return (
24 <main className="mx-auto max-w-3xl px-6 py-16">
25 <p className="text-sm font-medium uppercase tracking-wide text-slate-500">
26 AI Risk Index · methodology {INDEX_VERSION}
44 + <main className="mx-auto max-w-4xl px-6 py-14">
45 + <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--muted)]">
46 + The transparent, task-based AI job-exposure index
27 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 49 How is your occupation exposed to AI — task by task?
30 50 </h1>
31 <p className="mt-4 text-lg text-slate-600">
32 We score occupations from their individual tasks, report three separate sub-scores with
33 confidence intervals, and publish every weight, prompt and formula. Built for adaptation
34 planning — not headlines.
51 + <p className="mt-4 max-w-2xl text-lg leading-relaxed text-[var(--ink-2)]">
52 + Occupations are scored from their individual tasks by a multi-model rater panel. Three
53 + sub-scores, confidence intervals from rater disagreement, versioned methodology, public
54 + API. Built for adaptation planning — not headlines.
35 55 </p>
36 56
37 <div className="mt-8 flex flex-wrap gap-3">
38 {/* Example occupation from the published methodology example */}
39 <ScoreBandPill label="Substitution" low={40.9} score={56.7} high={68.4} />
40 <ScoreBandPill label="Exposure" low={42.0} score={57.1} high={74.1} />
41 <ScoreBandPill label="Augmentation" low={46.9} score={71.9} high={84.4} />
42 </div>
57 + <section id="ranking" className="mt-14">
58 + <div className="flex items-baseline justify-between">
59 + <h2 className="text-xl font-semibold tracking-tight">Substitution ranking</h2>
60 + {run && (
61 + <p className="text-xs text-[var(--muted)]">
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">
45 {CONCEPTS.map((concept) => (
46 <div key={concept.name} className="rounded-lg border border-slate-200 p-5">
47 <h2 className="font-semibold">{concept.name}</h2>
48 <p className="mt-2 text-sm text-slate-600">{concept.description}</p>
71 + {rows.length === 0 ? (
72 + <div className="mt-8 rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-8 text-sm text-[var(--ink-2)]">
73 + No score run published yet. Scores appear here after the first rating batch and{" "}
74 + <code>score:recompute</code>.
49 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 148 </section>
52 149
53 <section className="mt-12 text-sm text-slate-600">
54 <h2 className="text-base font-semibold text-slate-900">Public API</h2>
55 <ul className="mt-2 list-inside list-disc space-y-1">
56 <li>
57 <code>/api/v1/occupations</code> — search occupations
58 </li>
59 <li>
60 <code>/api/v1/occupations/:code</code> — full score breakdown with sub-scores and CI
61 bounds
62 </li>
63 <li>
64 <code>/api/v1/methodology</code> — machine-readable weights and version metadata
65 </li>
66 </ul>
150 + <section id="concepts" className="mt-16">
151 + <h2 className="text-xl font-semibold tracking-tight">
152 + Three scores, never collapsed into one
153 + </h2>
154 + <div className="mt-5 grid gap-4 sm:grid-cols-3">
155 + {CONCEPTS.map((concept) => (
156 + <div
157 + key={concept.name}
158 + className="rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-5"
159 + >
160 + <h3 className="font-semibold">{concept.name}</h3>
161 + <p className="mt-2 text-sm leading-relaxed text-[var(--ink-2)]">
162 + {concept.description}
163 + </p>
164 + </div>
165 + ))}
166 + </div>
67 167 </section>
68 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 1 import { PrismaClient } from "@prisma/client";
3 2
4 // Seeds the synthetic methodology example occupation so the app and API have
5 // demo data before the real O*NET ETL runs. Real data: `cd apps/etl && make pipeline`.
3 +// Demo seed: 10 real O*NET-SOC occupations with plausible hand-authored task
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 10 const prisma = new PrismaClient();
7 11
8 const example = JSON.parse(
9 readFileSync(new URL("../../../docs/methodology/examples/example-analyst.json", import.meta.url), "utf8"),
10 );
12 +const DIMS = [
13 + "automatability",
14 + "feasibility",
15 + "cost_ratio",
16 + "barriers",
17 + "adoption_velocity",
18 + "augmentation",
19 +] as const;
11 20
12 async function main(): Promise<void> {
13 const { code, title } = example.occupation;
21 +type Dim = (typeof DIMS)[number];
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 177 await prisma.occupation.upsert({
15 where: { code },
16 update: { title },
178 + where: { code: occupation.code },
179 + update: { title: occupation.title, description: occupation.description, medianWageCents: Math.round(occupation.wageCents) },
17 180 create: {
18 code,
19 title,
20 description:
21 "Synthetic occupation from the published methodology example. Not real O*NET data.",
181 + code: occupation.code,
182 + title: occupation.title,
183 + description: occupation.description,
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 190 await prisma.task.upsert({
27 where: { id: `${code}-${task.taskId}` },
28 update: { importance: task.importance },
191 + where: { id: taskId },
192 + update: { statement: task.statement, importance: task.importance },
29 193 create: {
30 id: `${code}-${task.taskId}`,
31 occupationCode: code,
32 statement: `Synthetic task ${task.taskId} (see docs/methodology/examples/)`,
194 + id: taskId,
195 + occupationCode: occupation.code,
196 + statement: task.statement,
33 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 247 main()
42 248