SPB Git

spb/airiskindex

Public

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

feat: full O*NET universe — real ETL, deep detail pages, search, browse, rating scripts

- etl: OEWS wage download, O*NET 30.3 transform (occupations/tasks/importance),
  wages transform, psycopg loader; 1,016 occupations + 18,796 tasks loaded
- web: live search, /occupations browse grouped by SOC major group, home with
  hero stats + top/least exposed + group averages; detail page adds wages,
  employment, rank/percentile, weighted dimension breakdown, full per-task
  rater audit trail (model/rating/rationale), related occupations
- worker: one-shot rate script (batch submit/poll/ingest, --limit pilot mode),
  purge-demo script, thinking disabled for the rater
- layout: preview banner now driven by whether the latest run is demo-based

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

16 changed files +1,088 −196

modified apps/etl/Makefile +3 −2
@@ -7,12 +7,13 @@ pipeline: download transform load
7 7
8 download: 8 download:
9 $(PYTHON) -m airiskindex_etl.download_onet 9 $(PYTHON) -m airiskindex_etl.download_onet
10+ $(PYTHON) -m airiskindex_etl.download_oews
10 11
11 transform: 12 transform:
12− @echo "TODO: raw -> derived transforms (task statements + importance ratings -> data/derived/)" 13+ $(PYTHON) -m airiskindex_etl.transform
13 14
14 load: 15 load:
15− @echo "TODO: derived -> Postgres load (occupations + tasks via Prisma-compatible schema)" 16+ $(PYTHON) -m airiskindex_etl.load
16 17
17 test: 18 test:
18 $(PYTHON) -m pytest 19 $(PYTHON) -m pytest
modified apps/etl/pyproject.toml +2 −1
@@ -5,7 +5,8 @@ description = "AI Risk Index data pipeline: raw source dumps -> derived artifact
5 requires-python = ">=3.12" 5 requires-python = ">=3.12"
6 dependencies = [ 6 dependencies = [
7 "requests>=2.32", 7 "requests>=2.32",
8− "pandas>=2.2", 8+ "openpyxl>=3.1",
9+ "psycopg[binary]>=3.2",
9 ] 10 ]
10 11
11 [project.optional-dependencies] 12 [project.optional-dependencies]
added apps/etl/src/airiskindex_etl/download_oews.py +41 −0
@@ -0,0 +1,41 @@
1+"""Fetch BLS OEWS national wage data (May 2025) into data/raw/oews/.
2+
3+BLS blocks non-browser user agents — a descriptive UA with contact info works.
4+Source: https://www.bls.gov/oes/tables.htm (docs/research/02-data-sources.md).
5+"""
6+
7+from __future__ import annotations
8+
9+import sys
10+import zipfile
11+from pathlib import Path
12+
13+import requests
14+
15+OEWS_URL = "https://www.bls.gov/oes/special-requests/oesm25nat.zip"
16+USER_AGENT = "Mozilla/5.0 (compatible; airiskindex-etl/0.1; +https://www.airiskindex.io)"
17+
18+REPO_ROOT = Path(__file__).resolve().parents[4]
19+RAW_DIR = REPO_ROOT / "data" / "raw" / "oews"
20+
21+
22+def download() -> Path:
23+ RAW_DIR.mkdir(parents=True, exist_ok=True)
24+ archive = RAW_DIR / "oesm25nat.zip"
25+ if not archive.exists():
26+ print(f"downloading {OEWS_URL}")
27+ response = requests.get(OEWS_URL, headers={"User-Agent": USER_AGENT}, timeout=300)
28+ response.raise_for_status()
29+ archive.write_bytes(response.content)
30+ with zipfile.ZipFile(archive) as zf:
31+ zf.extractall(RAW_DIR)
32+ print(f"extracted to {RAW_DIR}")
33+ return archive
34+
35+
36+if __name__ == "__main__":
37+ try:
38+ download()
39+ except requests.RequestException as error:
40+ print(f"download failed: {error}", file=sys.stderr)
41+ sys.exit(1)
added apps/etl/src/airiskindex_etl/load.py +76 −0
@@ -0,0 +1,76 @@
1+"""Load derived O*NET + OEWS CSVs into Postgres (idempotent upserts).
2+
3+Occupations are keyed by O*NET-SOC code; wages join by SOC prefix
4+("15-1252.00" -> "15-1252"). Demo-seeded rows are overwritten by real data
5+where codes collide, which is the desired direction of truth.
6+"""
7+
8+from __future__ import annotations
9+
10+import csv
11+import os
12+from pathlib import Path
13+
14+import psycopg
15+
16+REPO_ROOT = Path(__file__).resolve().parents[4]
17+DERIVED = REPO_ROOT / "data" / "derived" / "onet"
18+
19+
20+def read(name: str) -> list[dict[str, str]]:
21+ with (DERIVED / name).open(encoding="utf-8", newline="") as fh:
22+ return list(csv.DictReader(fh))
23+
24+
25+def main() -> None:
26+ dsn = os.environ["DATABASE_URL"]
27+ occupations = read("occupations.csv")
28+ tasks = read("tasks.csv")
29+ wages = {row["soc"]: row for row in read("wages.csv")}
30+
31+ with psycopg.connect(dsn) as conn, conn.cursor() as cur:
32+ for occ in occupations:
33+ wage = wages.get(occ["code"].split(".")[0], {})
34+ median = wage.get("median_wage_cents") or None
35+ employment = wage.get("employment") or None
36+ cur.execute(
37+ """
38+ INSERT INTO "Occupation" (code, title, description, "medianWageCents",
39+ "wageCurrency", employment)
40+ VALUES (%s, %s, %s, %s, 'USD', %s)
41+ ON CONFLICT (code) DO UPDATE SET
42+ title = EXCLUDED.title,
43+ description = EXCLUDED.description,
44+ "medianWageCents" = COALESCE(EXCLUDED."medianWageCents", "Occupation"."medianWageCents"),
45+ employment = COALESCE(EXCLUDED.employment, "Occupation".employment)
46+ """,
47+ (occ["code"], occ["title"], occ["description"], median, employment),
48+ )
49+
50+ for task in tasks:
51+ cur.execute(
52+ """
53+ INSERT INTO "Task" (id, "occupationCode", statement, importance)
54+ VALUES (%s, %s, %s, %s)
55+ ON CONFLICT (id) DO UPDATE SET
56+ statement = EXCLUDED.statement,
57+ importance = EXCLUDED.importance
58+ """,
59+ (
60+ task["task_id"],
61+ task["code"],
62+ task["statement"],
63+ float(task["importance"]) if task["importance"] else None,
64+ ),
65+ )
66+
67+ cur.execute('SELECT count(*) FROM "Occupation"')
68+ occ_count = cur.fetchone()[0]
69+ cur.execute('SELECT count(*) FROM "Task"')
70+ task_count = cur.fetchone()[0]
71+
72+ print(f"loaded: {occ_count} occupations, {task_count} tasks (incl. pre-existing)")
73+
74+
75+if __name__ == "__main__":
76+ main()
added apps/etl/src/airiskindex_etl/transform.py +126 −0
@@ -0,0 +1,126 @@
1+"""Transform raw O*NET + OEWS dumps into derived CSVs with a manifest.
2+
3+Raw is never edited in place; derived payloads are gitignored, only the
4+manifest (hashes + row counts) is committed (CLAUDE.md §5).
5+"""
6+
7+from __future__ import annotations
8+
9+import csv
10+import hashlib
11+import json
12+from pathlib import Path
13+
14+from openpyxl import load_workbook
15+
16+REPO_ROOT = Path(__file__).resolve().parents[4]
17+RAW_ONET = REPO_ROOT / "data" / "raw" / "onet"
18+RAW_OEWS = REPO_ROOT / "data" / "raw" / "oews"
19+DERIVED = REPO_ROOT / "data" / "derived" / "onet"
20+
21+
22+def read_onet_table(name: str) -> list[dict[str, str]]:
23+ # The text dump extracts into a versioned subdirectory (db_30_3_text/).
24+ path = next(RAW_ONET.glob(f"**/{name}"))
25+ with path.open(encoding="utf-8", newline="") as fh:
26+ return list(csv.DictReader(fh, delimiter="\t"))
27+
28+
29+def transform_occupations() -> list[dict[str, str]]:
30+ rows = read_onet_table("Occupation Data.txt")
31+ return [
32+ {
33+ "code": row["O*NET-SOC Code"],
34+ "title": row["Title"],
35+ "description": row["Description"],
36+ }
37+ for row in rows
38+ ]
39+
40+
41+def transform_tasks() -> list[dict[str, str]]:
42+ statements = read_onet_table("Task Statements.txt")
43+ ratings = read_onet_table("Task Ratings.txt")
44+
45+ # Importance = Task Ratings rows with Scale ID "IM" (1–5), one per task.
46+ importance: dict[str, str] = {}
47+ for row in ratings:
48+ if row["Scale ID"] == "IM" and row.get("Recommend Suppress", "N") != "Y":
49+ importance[row["Task ID"]] = row["Data Value"]
50+
51+ return [
52+ {
53+ "task_id": row["Task ID"],
54+ "code": row["O*NET-SOC Code"],
55+ "statement": row["Task"],
56+ "task_type": row.get("Task Type", ""),
57+ "importance": importance.get(row["Task ID"], ""),
58+ }
59+ for row in statements
60+ ]
61+
62+
63+def transform_wages() -> list[dict[str, str]]:
64+ """OEWS national medians per detailed SOC: annual median (cents) + employment."""
65+ xlsx = next(RAW_OEWS.glob("**/national_M2025_dl.xlsx"), None) or next(
66+ RAW_OEWS.glob("**/*_dl.xlsx")
67+ )
68+ sheet = load_workbook(xlsx, read_only=True).active
69+ header = [str(cell.value).strip().upper() for cell in next(sheet.iter_rows(max_row=1))]
70+ idx = {name: header.index(name) for name in ("OCC_CODE", "O_GROUP", "TOT_EMP", "A_MEDIAN")}
71+
72+ out: list[dict[str, str]] = []
73+ for row in sheet.iter_rows(min_row=2, values_only=True):
74+ if str(row[idx["O_GROUP"]]).strip() != "detailed":
75+ continue
76+ soc = str(row[idx["OCC_CODE"]]).strip()
77+ median = row[idx["A_MEDIAN"]]
78+ employment = row[idx["TOT_EMP"]]
79+ # "#" = wage above the top-code (~$239,200); "*" / "**" = unavailable.
80+ if median == "#":
81+ median_cents = 23_920_000
82+ elif isinstance(median, (int, float)):
83+ median_cents = int(round(float(median) * 100))
84+ else:
85+ median_cents = ""
86+ out.append(
87+ {
88+ "soc": soc,
89+ "median_wage_cents": str(median_cents),
90+ "employment": str(int(employment)) if isinstance(employment, (int, float)) else "",
91+ }
92+ )
93+ return out
94+
95+
96+def write_csv(path: Path, rows: list[dict[str, str]]) -> dict[str, object]:
97+ path.parent.mkdir(parents=True, exist_ok=True)
98+ with path.open("w", encoding="utf-8", newline="") as fh:
99+ writer = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
100+ writer.writeheader()
101+ writer.writerows(rows)
102+ return {
103+ "file": path.name,
104+ "rows": len(rows),
105+ "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
106+ }
107+
108+
109+def main() -> None:
110+ onet_manifest = json.loads((RAW_ONET / "manifest.json").read_text())
111+ entries = [
112+ write_csv(DERIVED / "occupations.csv", transform_occupations()),
113+ write_csv(DERIVED / "tasks.csv", transform_tasks()),
114+ write_csv(DERIVED / "wages.csv", transform_wages()),
115+ ]
116+ (DERIVED / "manifest.json").write_text(
117+ json.dumps(
118+ {"source_onet_version": onet_manifest["version"], "outputs": entries}, indent=2
119+ )
120+ )
121+ for entry in entries:
122+ print(f"{entry['file']}: {entry['rows']} rows")
123+
124+
125+if __name__ == "__main__":
126+ main()
modified apps/web/app/layout.tsx +22 −5
@@ -1,23 +1,40 @@
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 type { ReactNode } from "react"; 3 import type { ReactNode } from "react";
4+import { prisma } from "@airiskindex/db";
4 import { INDEX_VERSION } from "@airiskindex/scoring"; 5 import { INDEX_VERSION } from "@airiskindex/scoring";
5 import "./globals.css"; 6 import "./globals.css";
6 7
8+async function isDemoRun(): Promise<boolean> {
9+ try {
10+ const run = await prisma.scoreRun.findFirst({ orderBy: { createdAt: "desc" } });
11+ return run ? run.raterModels.some((model) => model.startsWith("demo-")) : true;
12+ } catch {
13+ return true;
14+ }
15+}
16+
7 export const metadata: Metadata = { 17 export const metadata: Metadata = {
8 title: "AI Risk Index — task-based AI exposure scores for every occupation", 18 title: "AI Risk Index — task-based AI exposure scores for every occupation",
9 description: 19 description:
10 "Transparent, versioned, task-based scores of how occupations are exposed to AI — with separate exposure, substitution and augmentation sub-scores and confidence intervals. Adaptation guidance, not doom.", 20 "Transparent, versioned, task-based scores of how occupations are exposed to AI — with separate exposure, substitution and augmentation sub-scores and confidence intervals. Adaptation guidance, not doom.",
11 }; 21 };
12 22
13−export default function RootLayout({ children }: { children: ReactNode }): JSX.Element { 23+export default async function RootLayout({
24+ children,
25+}: {
26+ children: ReactNode;
27+}): Promise<JSX.Element> {
28+ const demo = await isDemoRun();
14 return ( 29 return (
15 <html lang="en"> 30 <html lang="en">
16 <body className="min-h-screen antialiased"> 31 <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)]"> 32+ {demo && (
18− Preview — scores below are computed from a demonstration dataset while the first 33+ <div className="border-b border-[var(--border)] bg-[var(--surface-1)] px-6 py-2 text-center text-xs text-[var(--ink-2)]">
19− published index run ({INDEX_VERSION.replace("-draft.1", "")}) is in progress. 34+ Preview — scores below are computed from a demonstration dataset while the first
20− </div> 35+ published index run ({INDEX_VERSION.replace("-draft.1", "")}) is in progress.
36+ </div>
37+ )}
21 <header className="border-b border-[var(--border)] bg-[var(--surface-1)]"> 38 <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"> 39 <nav className="mx-auto flex max-w-4xl items-center justify-between px-6 py-4">
23 <Link href="/" className="font-semibold tracking-tight"> 40 <Link href="/" className="font-semibold tracking-tight">
modified apps/web/app/occupations/[code]/page.tsx +301 −94
@@ -1,11 +1,36 @@
1 import Link from "next/link"; 1 import Link from "next/link";
2 import { notFound } from "next/navigation"; 2 import { notFound } from "next/navigation";
3 import { prisma } from "@airiskindex/db"; 3 import { prisma } from "@airiskindex/db";
4−import { HIGH_EXPOSURE_THRESHOLD } from "@airiskindex/scoring"; 4+import {
5+ DIMENSIONS,
6+ HIGH_EXPOSURE_THRESHOLD,
7+ INVERTED_DIMENSIONS,
8+ WEIGHTS,
9+ pressure,
10+ type DimensionKey,
11+} from "@airiskindex/scoring";
5 import { ScoreBar, ScoreTile, ShareMeter } from "@/components/score-marks"; 12 import { ScoreBar, ScoreTile, ShareMeter } from "@/components/score-marks";
13+import { formatWage, socGroupName } from "@/lib/soc-groups";
6 14
7 export const dynamic = "force-dynamic"; 15 export const dynamic = "force-dynamic";
8 16
17+const DIMENSION_LABELS: Record<DimensionKey | "augmentation", string> = {
18+ automatability: "Task automatability",
19+ feasibility: "Technical feasibility today",
20+ cost_ratio: "Cost vs. human wage",
21+ barriers: "Adoption barriers",
22+ adoption_velocity: "Sector adoption velocity",
23+ augmentation: "Augmentation potential",
24+};
25+
26+interface DimensionAggregate {
27+ dimension: DimensionKey;
28+ weight: number;
29+ inverted: boolean;
30+ pressure: number; // 0–100, orientation applied
31+ meanRating: number; // raw 1–5 panel mean
32+}
33+
9 async function loadOccupation(code: string) { 34 async function loadOccupation(code: string) {
10 const occupation = await prisma.occupation.findUnique({ 35 const occupation = await prisma.occupation.findUnique({
11 where: { code }, 36 where: { code },
@@ -19,13 +44,74 @@ async function loadOccupation(code: string) {
19 include: { run: true }, 44 include: { run: true },
20 }); 45 });
21 46
22− const taskScores = score 47+ const taskIds = occupation.tasks.map((task) => task.id);
23− ? await prisma.taskScore.findMany({ 48+ const [taskScores, ratings] = await Promise.all([
24− where: { runId: score.runId, taskId: { in: occupation.tasks.map((task) => task.id) } }, 49+ score
50+ ? prisma.taskScore.findMany({ where: { runId: score.runId, taskId: { in: taskIds } } })
51+ : Promise.resolve([]),
52+ prisma.taskRating.findMany({
53+ where: { taskId: { in: taskIds } },
54+ select: { taskId: true, dimension: true, model: true, rating: true, rationale: true },
55+ orderBy: [{ dimension: "asc" }, { model: "asc" }],
56+ }),
57+ ]);
58+
59+ let rank: number | null = null;
60+ let scoredTotal: number | null = null;
61+ if (score) {
62+ [rank, scoredTotal] = await Promise.all([
63+ prisma.occupationScore.count({
64+ where: { runId: score.runId, substitution: { gt: score.substitution } },
65+ }),
66+ prisma.occupationScore.count({ where: { runId: score.runId } }),
67+ ]);
68+ rank += 1;
69+ }
70+
71+ const related = score
72+ ? await prisma.occupationScore.findMany({
73+ where: {
74+ runId: score.runId,
75+ occupationCode: { startsWith: code.slice(0, 2), not: code },
76+ },
77+ orderBy: { substitution: "desc" },
78+ take: 5,
79+ include: { occupation: { select: { code: true, title: true } } },
25 }) 80 })
26 : []; 81 : [];
27 82
28− return { occupation, score, taskScores }; 83+ return { occupation, score, taskScores, ratings, rank, scoredTotal, related };
84+}
85+
86+function aggregateDimensions(
87+ tasks: Array<{ id: string; importance: number | null }>,
88+ ratings: Array<{ taskId: string; dimension: string; rating: number }>,
89+): DimensionAggregate[] {
90+ const out: DimensionAggregate[] = [];
91+ for (const dimension of DIMENSIONS) {
92+ let weightSum = 0;
93+ let ratingSum = 0;
94+ for (const task of tasks) {
95+ const values = ratings
96+ .filter((row) => row.taskId === task.id && row.dimension === dimension)
97+ .map((row) => row.rating);
98+ if (values.length === 0) continue;
99+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
100+ const weight = task.importance ?? 3;
101+ ratingSum += mean * weight;
102+ weightSum += weight;
103+ }
104+ if (weightSum === 0) continue;
105+ const meanRating = ratingSum / weightSum;
106+ out.push({
107+ dimension,
108+ weight: WEIGHTS[dimension],
109+ inverted: INVERTED_DIMENSIONS.has(dimension),
110+ pressure: 100 * pressure(dimension, meanRating),
111+ meanRating,
112+ });
113+ }
114+ return out;
29 } 115 }
30 116
31 export default async function OccupationPage({ 117 export default async function OccupationPage({
@@ -35,30 +121,69 @@ export default async function OccupationPage({
35 }): Promise<JSX.Element> { 121 }): Promise<JSX.Element> {
36 const data = await loadOccupation(params.code); 122 const data = await loadOccupation(params.code);
37 if (!data) notFound(); 123 if (!data) notFound();
38− const { occupation, score, taskScores } = data; 124+ const { occupation, score, taskScores, ratings, rank, scoredTotal, related } = data;
125+
39 const byTask = new Map(taskScores.map((entry) => [entry.taskId, entry])); 126 const byTask = new Map(taskScores.map((entry) => [entry.taskId, entry]));
127+ const ratingsByTask = new Map<string, typeof ratings>();
128+ for (const row of ratings) {
129+ const list = ratingsByTask.get(row.taskId) ?? [];
130+ list.push(row);
131+ ratingsByTask.set(row.taskId, list);
132+ }
40 const rankedTasks = [...occupation.tasks].sort( 133 const rankedTasks = [...occupation.tasks].sort(
41 (a, b) => (byTask.get(b.id)?.substitution ?? -1) - (byTask.get(a.id)?.substitution ?? -1), 134 (a, b) => (byTask.get(b.id)?.substitution ?? -1) - (byTask.get(a.id)?.substitution ?? -1),
42 ); 135 );
136+ const dimensions = aggregateDimensions(occupation.tasks, ratings);
137+ const wage = formatWage(occupation.medianWageCents);
43 138
44 return ( 139 return (
45 <main className="mx-auto max-w-4xl px-6 py-12"> 140 <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)]"> 141+ <nav className="text-sm text-[var(--ink-2)]">
47− ← Ranking 142+ <Link href="/#ranking" className="hover:text-[var(--ink)]">
48− </Link> 143+ Ranking
144+ </Link>{" "}
145+ /{" "}
146+ <Link
147+ href={`/occupations#g${occupation.code.slice(0, 2)}`}
148+ className="hover:text-[var(--ink)]"
149+ >
150+ {socGroupName(occupation.code)}
151+ </Link>
152+ </nav>
153+
49 <div className="mt-4 flex flex-wrap items-baseline gap-x-3 gap-y-1"> 154 <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> 155 <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)]"> 156 <span className="rounded-full border border-[var(--border)] px-2.5 py-0.5 text-xs text-[var(--muted)]">
52 {occupation.code} 157 {occupation.code}
53 </span> 158 </span>
54 </div> 159 </div>
160+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-[var(--ink-2)]">
161+ {wage && <span>Median wage {wage}</span>}
162+ {occupation.employment != null && (
163+ <span>{occupation.employment.toLocaleString("en-US")} employed (US)</span>
164+ )}
165+ {rank != null && scoredTotal != null && (
166+ <span>
167+ Rank <strong className="text-[var(--ink)]">#{rank}</strong> of {scoredTotal} scored ·
168+ top {Math.max(1, Math.round((rank / scoredTotal) * 100))}% by substitution
169+ </span>
170+ )}
171+ </div>
55 {occupation.description && ( 172 {occupation.description && (
56− <p className="mt-3 max-w-2xl text-[var(--ink-2)]">{occupation.description}</p> 173+ <p className="mt-3 max-w-2xl leading-relaxed text-[var(--ink-2)]">
174+ {occupation.description}
175+ </p>
57 )} 176 )}
58 177
59 {!score ? ( 178 {!score ? (
60 <div className="mt-10 rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-8 text-sm text-[var(--ink-2)]"> 179 <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. 180+ This occupation has not been scored yet — its {occupation.tasks.length} tasks are queued
181+ for the multi-model rater panel. Task statements are listed below.
182+ <ul className="mt-4 list-inside list-disc space-y-1">
183+ {occupation.tasks.slice(0, 20).map((task) => (
184+ <li key={task.id}>{task.statement}</li>
185+ ))}
186+ </ul>
62 </div> 187 </div>
63 ) : ( 188 ) : (
64 <> 189 <>
@@ -100,104 +225,186 @@ export default async function OccupationPage({
100 </p> 225 </p>
101 </section> 226 </section>
102 227
228+ {dimensions.length > 0 && (
229+ <section className="mt-12">
230+ <h2 className="text-xl font-semibold tracking-tight">Why this score</h2>
231+ <p className="mt-1 max-w-2xl text-sm text-[var(--ink-2)]">
232+ The five weighted dimensions of the composite, averaged across this occupation's
233+ tasks (importance-weighted, panel mean). Exact weights and formulas:{" "}
234+ <Link href="/api/v1/methodology" className="underline">
235+ /api/v1/methodology
236+ </Link>
237+ .
238+ </p>
239+ <div className="mt-5 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]">
240+ {dimensions.map((dim) => (
241+ <div key={dim.dimension} className="border-b border-[var(--grid)] px-5 py-3.5 last:border-b-0">
242+ <div className="flex items-baseline gap-2">
243+ <span className="text-sm font-medium">
244+ {DIMENSION_LABELS[dim.dimension]}
245+ </span>
246+ <span className="text-xs text-[var(--muted)]">
247+ weight {(dim.weight * 100).toFixed(0)}%
248+ {dim.inverted && " · inverted — strong barriers lower the score"}
249+ </span>
250+ <span className="ml-auto text-sm font-semibold tabular-nums">
251+ {dim.pressure.toFixed(0)}
252+ </span>
253+ </div>
254+ <div aria-hidden="true" className="mt-1.5 h-[6px] rounded-r-[3px] bg-[var(--seq-track)]">
255+ <div
256+ className="h-full rounded-r-[3px] bg-[var(--seq)]"
257+ style={{ width: `${Math.min(100, dim.pressure)}%` }}
258+ />
259+ </div>
260+ <p className="mt-1 text-xs text-[var(--muted)]">
261+ panel mean rating {dim.meanRating.toFixed(1)}/5
262+ {dim.inverted
263+ ? " (barrier strength) → substitution pressure "
264+ : " → substitution pressure "}
265+ {dim.pressure.toFixed(0)}/100
266+ </p>
267+ </div>
268+ ))}
269+ </div>
270+ </section>
271+ )}
272+
103 <section className="mt-12"> 273 <section className="mt-12">
104− <h2 className="text-xl font-semibold tracking-tight">Task breakdown</h2> 274+ <h2 className="text-xl font-semibold tracking-tight">
275+ Task breakdown{" "}
276+ <span className="text-sm font-normal text-[var(--muted)]">
277+ ({rankedTasks.length} tasks)
278+ </span>
279+ </h2>
105 <p className="mt-1 text-sm text-[var(--ink-2)]"> 280 <p className="mt-1 text-sm text-[var(--ink-2)]">
106− Substitution pressure per task, weighted by O*NET importance in the composite. Hover 281+ Substitution pressure per task, weighted by O*NET importance in the composite.
107− a bar for the full breakdown. 282+ Expand a task for the full rater audit trail — every rating, every model, every
283+ rationale.
108 </p> 284 </p>
109 <div className="mt-5 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]"> 285 <div className="mt-5 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]">
110 {rankedTasks.map((task) => { 286 {rankedTasks.map((task) => {
111 const ts = byTask.get(task.id); 287 const ts = byTask.get(task.id);
288+ const taskRatings = ratingsByTask.get(task.id) ?? [];
112 return ( 289 return (
113− <div 290+ <details key={task.id} className="group border-b border-[var(--grid)] last:border-b-0">
114− key={task.id} 291+ <summary className="cursor-pointer list-none px-5 py-4 hover:bg-[var(--wash)]">
115− className="group relative border-b border-[var(--grid)] px-5 py-4 last:border-b-0 hover:bg-[var(--wash)]" 292+ <div className="flex items-baseline gap-3">
116− > 293+ <p className="text-sm">{task.statement}</p>
117− <div className="flex items-baseline gap-3"> 294+ <span className="ml-auto shrink-0 pl-3 text-sm font-semibold tabular-nums">
118− <p className="truncate text-sm">{task.statement}</p> 295+ {ts ? ts.substitution.toFixed(0) : "—"}
119− <span className="ml-auto pl-3 text-sm font-semibold tabular-nums"> 296+ </span>
120− {ts ? ts.substitution.toFixed(0) : "—"} 297+ </div>
121− </span> 298+ <div className="mt-2">
122− </div> 299+ {ts ? (
123− <div className="mt-2"> 300+ <ScoreBar
124− {ts ? ( 301+ band={{
125− <ScoreBar 302+ low: ts.substitutionLow,
126− band={{ 303+ score: ts.substitution,
127− low: ts.substitutionLow, 304+ high: ts.substitutionHigh,
128− score: ts.substitution, 305+ }}
129− high: ts.substitutionHigh, 306+ thick={8}
130− }} 307+ />
131− thick={8} 308+ ) : (
132− /> 309+ <div className="h-[8px] rounded-[4px] bg-[var(--seq-track)]" />
133− ) : ( 310+ )}
134− <div className="h-[8px] rounded-[4px] bg-[var(--seq-track)]" /> 311+ </div>
312+ {ts && (
313+ <p className="mt-1.5 text-xs text-[var(--muted)]">
314+ CI {ts.substitutionLow.toFixed(0)}–{ts.substitutionHigh.toFixed(0)} ·
315+ exposure {ts.exposure.toFixed(0)} · augmentation{" "}
316+ {ts.augmentation.toFixed(0)}
317+ {task.importance != null && <> · importance {task.importance.toFixed(1)}/5</>}
318+ {taskRatings.length > 0 && <> · click for rater detail</>}
319+ </p>
135 )} 320 )}
136− </div> 321+ </summary>
137− {ts && ( 322+ {taskRatings.length > 0 && (
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"> 323+ <div className="border-t border-[var(--grid)] bg-[var(--page)] px-5 py-4">
139− CI {ts.substitutionLow.toFixed(0)}–{ts.substitutionHigh.toFixed(0)} · 324+ <table className="w-full text-left text-xs">
140− exposure {ts.exposure.toFixed(0)} · augmentation{" "} 325+ <caption className="sr-only">
141− {ts.augmentation.toFixed(0)} 326+ Panel ratings per dimension for this task
142− {task.importance != null && <> · importance {task.importance.toFixed(1)}/5</>} 327+ </caption>
328+ <thead className="text-[var(--muted)]">
329+ <tr>
330+ <th className="py-1 pr-3 font-medium">Dimension</th>
331+ <th className="py-1 pr-3 font-medium">Model</th>
332+ <th className="py-1 pr-3 font-medium">Rating</th>
333+ <th className="py-1 font-medium">Rationale</th>
334+ </tr>
335+ </thead>
336+ <tbody className="align-top">
337+ {taskRatings.map((row, index) => (
338+ <tr
339+ key={`${row.dimension}-${row.model}-${index}`}
340+ className="border-t border-[var(--grid)]"
341+ >
342+ <td className="py-1.5 pr-3 whitespace-nowrap">
343+ {DIMENSION_LABELS[row.dimension as DimensionKey] ?? row.dimension}
344+ </td>
345+ <td className="py-1.5 pr-3 whitespace-nowrap text-[var(--muted)]">
346+ {row.model}
347+ </td>
348+ <td className="py-1.5 pr-3 font-semibold tabular-nums">
349+ {row.rating}/5
350+ </td>
351+ <td className="py-1.5 text-[var(--ink-2)]">{row.rationale}</td>
352+ </tr>
353+ ))}
354+ </tbody>
355+ </table>
143 </div> 356 </div>
144 )} 357 )}
145− </div> 358+ </details>
146 ); 359 );
147 })} 360 })}
148 </div> 361 </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> 362 </section>
191 363
192− <section className="mt-12 rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-6"> 364+ {related.length > 0 && (
193− <h2 className="font-semibold">How to read this</h2> 365+ <section className="mt-12">
194− <p className="mt-2 text-sm leading-relaxed text-[var(--ink-2)]"> 366+ <h2 className="text-xl font-semibold tracking-tight">
195− A high substitution score does not mean this job disappears — it means a large share 367+ Related occupations — {socGroupName(occupation.code)}
196− of its current tasks face replacement pressure, so the mix of tasks is likely to 368+ </h2>
197− change. Occupations with high augmentation alongside substitution typically 369+ <ul className="mt-4 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]">
198− reorganize around the protected tasks. Wide confidence intervals mean the rater 370+ {related.map((row) => (
199− panel disagreed: treat those scores as open questions, not verdicts. 371+ <li key={row.occupationCode} className="border-b border-[var(--grid)] last:border-b-0">
200− </p> 372+ <Link
373+ href={`/occupations/${row.occupationCode}`}
374+ className="flex items-baseline gap-3 px-5 py-3 hover:bg-[var(--wash)]"
375+ >
376+ <span className="truncate text-sm">{row.occupation.title}</span>
377+ <span className="ml-auto text-sm font-semibold tabular-nums">
378+ {row.substitution.toFixed(0)}
379+ </span>
380+ </Link>
381+ </li>
382+ ))}
383+ </ul>
384+ </section>
385+ )}
386+
387+ <section className="mt-12 grid gap-4 sm:grid-cols-2">
388+ <div className="rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-6">
389+ <h2 className="font-semibold">How to read this</h2>
390+ <p className="mt-2 text-sm leading-relaxed text-[var(--ink-2)]">
391+ A high substitution score does not mean this job disappears — it means a large
392+ share of its current tasks face replacement pressure, so the mix of tasks is
393+ likely to change. High augmentation alongside substitution typically means the
394+ occupation reorganizes around the protected tasks. Wide confidence intervals mean
395+ the rater panel disagreed: treat those scores as open questions, not verdicts.
396+ </p>
397+ </div>
398+ <div className="rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-6">
399+ <h2 className="font-semibold">What would change this score</h2>
400+ <p className="mt-2 text-sm leading-relaxed text-[var(--ink-2)]">
401+ New model capabilities (automatability, feasibility), falling inference costs
402+ (cost ratio), regulation and licensing shifts (barriers), and measured sector
403+ adoption (velocity) all re-enter at every index release. Each release is
404+ recomputed, versioned and kept queryable — scores are claims with a date on them,
405+ not permanent labels.
406+ </p>
407+ </div>
201 </section> 408 </section>
202 </> 409 </>
203 )} 410 )}
added apps/web/app/occupations/page.tsx +86 −0
@@ -0,0 +1,86 @@
1+import Link from "next/link";
2+import { prisma } from "@airiskindex/db";
3+import { SOC_MAJOR_GROUPS } from "@/lib/soc-groups";
4+
5+export const dynamic = "force-dynamic";
6+
7+export const metadata = {
8+ title: "All occupations — AI Risk Index",
9+};
10+
11+export default async function OccupationsPage(): Promise<JSX.Element> {
12+ const run = await prisma.scoreRun.findFirst({ orderBy: { createdAt: "desc" } });
13+ const [occupations, scores] = await Promise.all([
14+ prisma.occupation.findMany({
15+ orderBy: { code: "asc" },
16+ select: { code: true, title: true },
17+ }),
18+ run
19+ ? prisma.occupationScore.findMany({
20+ where: { runId: run.id },
21+ select: { occupationCode: true, substitution: true },
22+ })
23+ : Promise.resolve([]),
24+ ]);
25+ const scoreByCode = new Map(scores.map((s) => [s.occupationCode, s.substitution]));
26+
27+ const groups = new Map<string, typeof occupations>();
28+ for (const occupation of occupations) {
29+ const prefix = occupation.code.slice(0, 2);
30+ const list = groups.get(prefix) ?? [];
31+ list.push(occupation);
32+ groups.set(prefix, list);
33+ }
34+
35+ return (
36+ <main className="mx-auto max-w-5xl px-6 py-12">
37+ <h1 className="text-3xl font-bold tracking-tight">All occupations</h1>
38+ <p className="mt-2 text-[var(--ink-2)]">
39+ {occupations.length.toLocaleString("en-US")} O*NET occupations across{" "}
40+ {groups.size} major groups. Occupations without a chip have not been scored in the latest
41+ run yet.
42+ </p>
43+
44+ <nav className="mt-6 flex flex-wrap gap-2 text-xs">
45+ {[...groups.keys()].sort().map((prefix) => (
46+ <a
47+ key={prefix}
48+ href={`#g${prefix}`}
49+ className="rounded-full border border-[var(--border)] bg-[var(--surface-1)] px-3 py-1 text-[var(--ink-2)] hover:border-[var(--seq)]"
50+ >
51+ {SOC_MAJOR_GROUPS[prefix] ?? prefix}
52+ </a>
53+ ))}
54+ </nav>
55+
56+ {[...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([prefix, list]) => (
57+ <section key={prefix} id={`g${prefix}`} className="mt-10 scroll-mt-6">
58+ <h2 className="text-lg font-semibold tracking-tight">
59+ {SOC_MAJOR_GROUPS[prefix] ?? prefix}{" "}
60+ <span className="text-sm font-normal text-[var(--muted)]">({list.length})</span>
61+ </h2>
62+ <ul className="mt-3 grid gap-x-8 sm:grid-cols-2">
63+ {list.map((occupation) => {
64+ const score = scoreByCode.get(occupation.code);
65+ return (
66+ <li key={occupation.code} className="border-b border-[var(--grid)]">
67+ <Link
68+ href={`/occupations/${occupation.code}`}
69+ className="flex items-baseline gap-2 py-2 hover:bg-[var(--wash)]"
70+ >
71+ <span className="truncate text-sm">{occupation.title}</span>
72+ {score !== undefined && (
73+ <span className="ml-auto shrink-0 rounded-full bg-[var(--seq-track)] px-2 py-0.5 text-xs font-semibold tabular-nums">
74+ {score.toFixed(0)}
75+ </span>
76+ )}
77+ </Link>
78+ </li>
79+ );
80+ })}
81+ </ul>
82+ </section>
83+ ))}
84+ </main>
85+ );
86+}
modified apps/web/app/page.tsx +168 −93
@@ -1,6 +1,8 @@
1 import Link from "next/link"; 1 import Link from "next/link";
2 import { prisma } from "@airiskindex/db"; 2 import { prisma } from "@airiskindex/db";
3+import { OccupationSearch } from "@/components/occupation-search";
3 import { ScoreBar } from "@/components/score-marks"; 4 import { ScoreBar } from "@/components/score-marks";
5+import { SOC_MAJOR_GROUPS } from "@/lib/soc-groups";
4 6
5 export const dynamic = "force-dynamic"; 7 export const dynamic = "force-dynamic";
6 8
@@ -22,26 +24,102 @@ const CONCEPTS = [
22 }, 24 },
23 ] as const; 25 ] as const;
24 26
25−async function loadRanking() { 27+interface RankRow {
28+ occupationCode: string;
29+ substitution: number;
30+ substitutionLow: number;
31+ substitutionHigh: number;
32+ exposure: number;
33+ augmentation: number;
34+ occupation: { code: string; title: string };
35+}
36+
37+async function loadHome() {
26 try { 38 try {
27− const run = await prisma.scoreRun.findFirst({ orderBy: { createdAt: "desc" } }); 39+ const [occupations, tasks, run] = await Promise.all([
28− if (!run) return { run: null, rows: [] as never[] }; 40+ prisma.occupation.count(),
29− const rows = await prisma.occupationScore.findMany({ 41+ prisma.task.count(),
30− where: { runId: run.id }, 42+ prisma.scoreRun.findFirst({ orderBy: { createdAt: "desc" } }),
31− orderBy: { substitution: "desc" }, 43+ ]);
32− include: { occupation: { select: { code: true, title: true } } }, 44+ if (!run) return { occupations, tasks, run: null, scored: 0, top: [], bottom: [], groups: [] };
33− }); 45+
34− return { run, rows }; 46+ const [scored, top, bottom, groups] = await Promise.all([
47+ prisma.occupationScore.count({ where: { runId: run.id } }),
48+ prisma.occupationScore.findMany({
49+ where: { runId: run.id },
50+ orderBy: { substitution: "desc" },
51+ take: 15,
52+ include: { occupation: { select: { code: true, title: true } } },
53+ }),
54+ prisma.occupationScore.findMany({
55+ where: { runId: run.id },
56+ orderBy: { substitution: "asc" },
57+ take: 15,
58+ include: { occupation: { select: { code: true, title: true } } },
59+ }),
60+ prisma.$queryRaw<Array<{ prefix: string; avg: number; n: bigint }>>`
61+ SELECT left("occupationCode", 2) AS prefix,
62+ avg(substitution)::float AS avg,
63+ count(*) AS n
64+ FROM "OccupationScore"
65+ WHERE "runId" = ${run.id}
66+ GROUP BY 1
67+ HAVING count(*) >= 3
68+ ORDER BY 2 DESC
69+ `,
70+ ]);
71+ return { occupations, tasks, run, scored, top, bottom, groups };
35 } catch { 72 } catch {
36− return { run: null, rows: [] as never[] }; 73+ return { occupations: 0, tasks: 0, run: null, scored: 0, top: [], bottom: [], groups: [] };
37 } 74 }
38 } 75 }
39 76
77+function RankList({ rows, startRank }: { rows: RankRow[]; startRank?: number }): JSX.Element {
78+ return (
79+ <ol className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]">
80+ {rows.map((row, index) => (
81+ <li key={row.occupationCode} className="border-b border-[var(--grid)] last:border-b-0">
82+ <Link
83+ href={`/occupations/${row.occupationCode}`}
84+ className="group relative block px-5 py-3.5 transition-colors hover:bg-[var(--wash)]"
85+ >
86+ <div className="flex items-baseline gap-3">
87+ {startRank !== undefined && (
88+ <span className="w-6 shrink-0 text-right text-sm tabular-nums text-[var(--muted)]">
89+ {startRank + index}
90+ </span>
91+ )}
92+ <span className="truncate font-medium">{row.occupation.title}</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 ${startRank !== undefined ? "pl-9" : ""}`}>
98+ <ScoreBar
99+ band={{
100+ low: row.substitutionLow,
101+ score: row.substitution,
102+ high: row.substitutionHigh,
103+ }}
104+ thick={8}
105+ />
106+ </div>
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)} · exposure{" "}
109+ {row.exposure.toFixed(0)} · augmentation {row.augmentation.toFixed(0)}
110+ </div>
111+ </Link>
112+ </li>
113+ ))}
114+ </ol>
115+ );
116+}
117+
40 export default async function HomePage(): Promise<JSX.Element> { 118 export default async function HomePage(): Promise<JSX.Element> {
41− const { run, rows } = await loadRanking(); 119+ const { occupations, tasks, run, scored, top, bottom, groups } = await loadHome();
42 120
43 return ( 121 return (
44− <main className="mx-auto max-w-4xl px-6 py-14"> 122+ <main className="mx-auto max-w-5xl px-6 py-14">
45 <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--muted)]"> 123 <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--muted)]">
46 The transparent, task-based AI job-exposure index 124 The transparent, task-based AI job-exposure index
47 </p> 125 </p>
@@ -49,103 +127,100 @@ export default async function HomePage(): Promise<JSX.Element> {
49 How is your occupation exposed to AI — task by task? 127 How is your occupation exposed to AI — task by task?
50 </h1> 128 </h1>
51 <p className="mt-4 max-w-2xl text-lg leading-relaxed text-[var(--ink-2)]"> 129 <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 130+ Every occupation is scored from its individual tasks by a multi-model rater panel — three
53 sub-scores, confidence intervals from rater disagreement, versioned methodology, public 131 sub-scores, confidence intervals from rater disagreement, versioned methodology, public
54− API. Built for adaptation planning — not headlines. 132+ API. Built for adaptation planning, not headlines.
55 </p> 133 </p>
56 134
57− <section id="ranking" className="mt-14"> 135+ <div className="mt-8">
58− <div className="flex items-baseline justify-between"> 136+ <OccupationSearch />
59− <h2 className="text-xl font-semibold tracking-tight">Substitution ranking</h2> 137+ </div>
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>
70 138
71− {rows.length === 0 ? ( 139+ <div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
72− <div className="mt-8 rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-8 text-sm text-[var(--ink-2)]"> 140+ {[
73− No score run published yet. Scores appear here after the first rating batch and{" "} 141+ [occupations.toLocaleString("en-US"), "occupations (O*NET 30.3)"],
74− <code>score:recompute</code>. 142+ [tasks.toLocaleString("en-US"), "task statements"],
143+ [scored.toLocaleString("en-US"), "occupations scored"],
144+ [run ? run.indexVersion : "—", "methodology version"],
145+ ].map(([value, label]) => (
146+ <div
147+ key={label as string}
148+ className="rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-4"
149+ >
150+ <p className="text-2xl font-semibold">{value}</p>
151+ <p className="mt-1 text-xs text-[var(--muted)]">{label}</p>
75 </div> 152 </div>
76− ) : ( 153+ ))}
77− <> 154+ </div>
78− <ol className="mt-6 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]"> 155+
79− {rows.map((row, index) => ( 156+ {run && top.length > 0 && (
80− <li key={row.occupationCode} className="border-b border-[var(--grid)] last:border-b-0"> 157+ <>
158+ <section id="ranking" className="mt-14">
159+ <div className="flex flex-wrap items-baseline justify-between gap-2">
160+ <h2 className="text-xl font-semibold tracking-tight">Substitution ranking</h2>
161+ <p className="text-xs text-[var(--muted)]">
162+ run {run.indexVersion} · {run.createdAt.toISOString().slice(0, 10)} ·{" "}
163+ <Link href="/occupations" className="underline hover:text-[var(--ink)]">
164+ browse all {scored.toLocaleString("en-US")} scored →
165+ </Link>
166+ </p>
167+ </div>
168+ <p className="mt-1 max-w-2xl text-sm text-[var(--ink-2)]">
169+ Composite substitution pressure, 0–100. The whisker marks the confidence interval
170+ from rater disagreement — a wide band is a claim we hold loosely.
171+ </p>
172+ <div className="mt-6 grid gap-6 lg:grid-cols-2">
173+ <div>
174+ <h3 className="mb-2 text-sm font-medium text-[var(--ink-2)]">Most exposed</h3>
175+ <RankList rows={top as RankRow[]} startRank={1} />
176+ </div>
177+ <div>
178+ <h3 className="mb-2 text-sm font-medium text-[var(--ink-2)]">Least exposed</h3>
179+ <RankList rows={bottom as RankRow[]} />
180+ </div>
181+ </div>
182+ </section>
183+
184+ {groups.length > 0 && (
185+ <section className="mt-14">
186+ <h2 className="text-xl font-semibold tracking-tight">By occupation group</h2>
187+ <p className="mt-1 text-sm text-[var(--ink-2)]">
188+ Mean substitution score across scored occupations in each SOC major group.
189+ </p>
190+ <div className="mt-5 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]">
191+ {groups.map((group) => (
81 <Link 192 <Link
82− href={`/occupations/${row.occupationCode}`} 193+ key={group.prefix}
83− className="group relative block px-5 py-4 transition-colors hover:bg-[var(--wash)]" 194+ href={`/occupations#g${group.prefix}`}
195+ className="block border-b border-[var(--grid)] px-5 py-3 last:border-b-0 hover:bg-[var(--wash)]"
84 > 196 >
85 <div className="flex items-baseline gap-3"> 197 <div className="flex items-baseline gap-3">
86− <span className="w-6 shrink-0 text-right text-sm tabular-nums text-[var(--muted)]"> 198+ <span className="truncate text-sm">
87− {index + 1} 199+ {SOC_MAJOR_GROUPS[group.prefix] ?? group.prefix}
88 </span> 200 </span>
89− <span className="truncate font-medium">{row.occupation.title}</span> 201+ <span className="ml-auto text-xs text-[var(--muted)]">
90− <span className="hidden text-xs text-[var(--muted)] sm:inline"> 202+ {Number(group.n)} occupations
91− {row.occupation.code}
92 </span> 203 </span>
93− <span className="ml-auto pl-3 text-sm font-semibold tabular-nums"> 204+ <span className="w-8 text-right text-sm font-semibold tabular-nums">
94− {row.substitution.toFixed(0)} 205+ {group.avg.toFixed(0)}
95 </span> 206 </span>
96 </div> 207 </div>
97− <div className="mt-2 pl-9"> 208+ <div
98− <ScoreBar 209+ aria-hidden="true"
99− band={{ 210+ className="mt-1.5 h-[6px] rounded-r-[3px] bg-[var(--seq-track)]"
100− low: row.substitutionLow, 211+ >
101− score: row.substitution, 212+ <div
102− high: row.substitutionHigh, 213+ className="h-full rounded-r-[3px] bg-[var(--seq)]"
103− }} 214+ style={{ width: `${Math.min(100, group.avg)}%` }}
104 /> 215 />
105 </div> 216 </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> 217 </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 ))} 218 ))}
144− </tbody> 219+ </div>
145− </table> 220+ </section>
146− </> 221+ )}
147− )} 222+ </>
148− </section> 223+ )}
149 224
150 <section id="concepts" className="mt-16"> 225 <section id="concepts" className="mt-16">
151 <h2 className="text-xl font-semibold tracking-tight"> 226 <h2 className="text-xl font-semibold tracking-tight">
added apps/web/components/occupation-search.tsx +82 −0
@@ -0,0 +1,82 @@
1+"use client";
2+
3+import Link from "next/link";
4+import { useEffect, useRef, useState } from "react";
5+
6+interface Item {
7+ code: string;
8+ title: string;
9+}
10+
11+/** Debounced live search over /api/v1/occupations. */
12+export function OccupationSearch(): JSX.Element {
13+ const [query, setQuery] = useState("");
14+ const [items, setItems] = useState<Item[]>([]);
15+ const [open, setOpen] = useState(false);
16+ const boxRef = useRef<HTMLDivElement>(null);
17+
18+ useEffect(() => {
19+ if (query.trim().length < 2) {
20+ setItems([]);
21+ return;
22+ }
23+ const controller = new AbortController();
24+ const timer = setTimeout(async () => {
25+ try {
26+ const response = await fetch(
27+ `/api/v1/occupations?q=${encodeURIComponent(query.trim())}&per_page=8`,
28+ { signal: controller.signal },
29+ );
30+ if (response.ok) {
31+ const data = (await response.json()) as { items: Item[] };
32+ setItems(data.items);
33+ setOpen(true);
34+ }
35+ } catch {
36+ /* aborted or offline — keep prior results */
37+ }
38+ }, 200);
39+ return () => {
40+ controller.abort();
41+ clearTimeout(timer);
42+ };
43+ }, [query]);
44+
45+ useEffect(() => {
46+ const onClick = (event: MouseEvent) => {
47+ if (!boxRef.current?.contains(event.target as Node)) setOpen(false);
48+ };
49+ document.addEventListener("mousedown", onClick);
50+ return () => document.removeEventListener("mousedown", onClick);
51+ }, []);
52+
53+ return (
54+ <div ref={boxRef} className="relative max-w-xl">
55+ <input
56+ type="search"
57+ value={query}
58+ onChange={(event) => setQuery(event.target.value)}
59+ onFocus={() => items.length > 0 && setOpen(true)}
60+ placeholder="Search 1,000+ occupations — e.g. paralegal, radiologist, roofer…"
61+ aria-label="Search occupations"
62+ className="w-full rounded-xl border border-[var(--border)] bg-[var(--surface-1)] px-4 py-3 text-base outline-none placeholder:text-[var(--muted)] focus:border-[var(--seq)]"
63+ />
64+ {open && items.length > 0 && (
65+ <ul className="absolute z-20 mt-2 w-full overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)] shadow-lg">
66+ {items.map((item) => (
67+ <li key={item.code} className="border-b border-[var(--grid)] last:border-b-0">
68+ <Link
69+ href={`/occupations/${item.code}`}
70+ className="flex items-baseline justify-between px-4 py-2.5 hover:bg-[var(--wash)]"
71+ onClick={() => setOpen(false)}
72+ >
73+ <span className="truncate">{item.title}</span>
74+ <span className="ml-3 shrink-0 text-xs text-[var(--muted)]">{item.code}</span>
75+ </Link>
76+ </li>
77+ ))}
78+ </ul>
79+ )}
80+ </div>
81+ );
82+}
added apps/web/lib/soc-groups.ts +35 −0
@@ -0,0 +1,35 @@
1+/** SOC 2018 major groups — first two digits of the O*NET-SOC code. */
2+export const SOC_MAJOR_GROUPS: Record<string, string> = {
3+ "11": "Management",
4+ "13": "Business & Financial Operations",
5+ "15": "Computer & Mathematical",
6+ "17": "Architecture & Engineering",
7+ "19": "Life, Physical & Social Science",
8+ "21": "Community & Social Service",
9+ "23": "Legal",
10+ "25": "Educational Instruction & Library",
11+ "27": "Arts, Design, Entertainment, Sports & Media",
12+ "29": "Healthcare Practitioners & Technical",
13+ "31": "Healthcare Support",
14+ "33": "Protective Service",
15+ "35": "Food Preparation & Serving",
16+ "37": "Building & Grounds Cleaning & Maintenance",
17+ "39": "Personal Care & Service",
18+ "41": "Sales & Related",
19+ "43": "Office & Administrative Support",
20+ "45": "Farming, Fishing & Forestry",
21+ "47": "Construction & Extraction",
22+ "49": "Installation, Maintenance & Repair",
23+ "51": "Production",
24+ "53": "Transportation & Material Moving",
25+ "55": "Military Specific",
26+};
27+
28+export function socGroupName(code: string): string {
29+ return SOC_MAJOR_GROUPS[code.slice(0, 2)] ?? "Other";
30+}
31+
32+export function formatWage(cents: number | null): string | null {
33+ if (cents == null) return null;
34+ return `$${Math.round(cents / 100).toLocaleString("en-US")}/yr`;
35+}
modified apps/worker/package.json +2 −0
@@ -7,6 +7,8 @@
7 "dev": "tsx watch src/index.ts", 7 "dev": "tsx watch src/index.ts",
8 "start": "tsx src/index.ts", 8 "start": "tsx src/index.ts",
9 "score:recompute": "tsx src/scripts/recompute.ts", 9 "score:recompute": "tsx src/scripts/recompute.ts",
10+ "rate": "tsx src/scripts/rate.ts",
11+ "purge:demo": "tsx src/scripts/purge-demo.ts",
10 "typecheck": "tsc --noEmit" 12 "typecheck": "tsc --noEmit"
11 }, 13 },
12 "dependencies": { 14 "dependencies": {
modified apps/worker/src/raters/batch.ts +3 −1
@@ -40,12 +40,14 @@ export async function submitRatingBatch(tasks: RatingTask[], model: string): Pro
40 content: `Occupation: ${task.occupationTitle}\nTask statement: ${task.statement}\n\nRate this task per the rubric.`, 40 content: `Occupation: ${task.occupationTitle}\nTask statement: ${task.statement}\n\nRate this task per the rubric.`,
41 }, 41 },
42 ], 42 ],
43− // Structured outputs (GA). Kept as an untyped extension so the code 43+ // Structured outputs (GA) + thinking disabled (Sonnet 5 thinks by
44+ // default and thinking bills as output). Untyped extension so the code
44 // compiles across SDK versions; see docs/research/03-llm-rater-api.md. 45 // compiles across SDK versions; see docs/research/03-llm-rater-api.md.
45 ...({ 46 ...({
46 output_config: { 47 output_config: {
47 format: { type: "json_schema", schema: RATING_OUTPUT_JSON_SCHEMA }, 48 format: { type: "json_schema", schema: RATING_OUTPUT_JSON_SCHEMA },
48 }, 49 },
50+ thinking: { type: "disabled" },
49 } as Record<string, unknown>), 51 } as Record<string, unknown>),
50 } as unknown as Anthropic.Messages.MessageCreateParamsNonStreaming, 52 } as unknown as Anthropic.Messages.MessageCreateParamsNonStreaming,
51 })), 53 })),
added apps/worker/src/scripts/purge-demo.ts +37 −0
@@ -0,0 +1,37 @@
1+import { prisma } from "@airiskindex/db";
2+
3+// Removes the demonstration dataset (synthetic tasks "XX-XXXX.00-Tn", demo
4+// panel ratings, and score runs computed from demo raters) once real ratings
5+// exist. Demo runs are preview artifacts, not published index history — the
6+// immutability rule (CLAUDE.md §9) applies to real runs.
7+
8+async function main(): Promise<void> {
9+ const demoRuns = await prisma.scoreRun.findMany({
10+ where: { raterModels: { hasSome: ["demo-panel-a", "demo-panel-b"] } },
11+ select: { id: true },
12+ });
13+ const runIds = demoRuns.map((run) => run.id);
14+
15+ const [taskScores, occScores, runs] = await prisma.$transaction([
16+ prisma.taskScore.deleteMany({ where: { runId: { in: runIds } } }),
17+ prisma.occupationScore.deleteMany({ where: { runId: { in: runIds } } }),
18+ prisma.scoreRun.deleteMany({ where: { id: { in: runIds } } }),
19+ ]);
20+
21+ const ratings = await prisma.taskRating.deleteMany({
22+ where: { model: { startsWith: "demo-" } },
23+ });
24+ const tasks = await prisma.task.deleteMany({ where: { id: { contains: "-T" } } });
25+ const occupations = await prisma.occupation.deleteMany({ where: { code: "99-9999.00" } });
26+
27+ console.log(
28+ `purged demo artifacts: ${runs.count} runs, ${occScores.count} occupation scores, ${taskScores.count} task scores, ${ratings.count} ratings, ${tasks.count} synthetic tasks, ${occupations.count} synthetic occupations`,
29+ );
30+}
31+
32+main()
33+ .catch((error) => {
34+ console.error(error);
35+ process.exitCode = 1;
36+ })
37+ .finally(() => prisma.$disconnect());
added apps/worker/src/scripts/rate.ts +84 −0
@@ -0,0 +1,84 @@
1+import { prisma } from "@airiskindex/db";
2+import { RATER_MODELS, RATER_PROMPT_VERSION } from "../config";
3+import {
4+ ingestBatchResults,
5+ isBatchComplete,
6+ submitRatingBatch,
7+ type RatingTask,
8+} from "../raters/batch";
9+import { ratingJobId } from "../raters/job-id";
10+
11+// One-shot rating run: submit a Message Batch per model for every task that
12+// lacks ratings under the current prompt version, poll to completion, ingest.
13+// Usage: tsx src/scripts/rate.ts [--limit N] [--poll-seconds S]
14+// Idempotent: already-rated tasks are excluded; re-running resumes cleanly.
15+
16+function argValue(flag: string): string | undefined {
17+ const index = process.argv.indexOf(flag);
18+ return index >= 0 ? process.argv[index + 1] : undefined;
19+}
20+
21+const limit = Number(argValue("--limit") ?? "0") || undefined;
22+const pollSeconds = Number(argValue("--poll-seconds") ?? "60") || 60;
23+
24+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
25+
26+async function runForModel(model: string): Promise<void> {
27+ const tasks = await prisma.task.findMany({
28+ where: {
29+ ratings: { none: { model, promptVersion: RATER_PROMPT_VERSION } },
30+ // exclude demo-seeded synthetic tasks (real O*NET task IDs are numeric)
31+ NOT: { id: { contains: "-T" } },
32+ },
33+ include: { occupation: { select: { title: true } } },
34+ orderBy: { id: "asc" },
35+ ...(limit ? { take: limit } : {}),
36+ });
37+ if (tasks.length === 0) {
38+ console.log(`[${model}] nothing to rate`);
39+ return;
40+ }
41+
42+ const ratingTasks: RatingTask[] = tasks.map((task) => ({
43+ taskId: task.id,
44+ occupationTitle: task.occupation.title,
45+ statement: task.statement,
46+ }));
47+ const jobIdToTaskId = new Map(
48+ ratingTasks.map((task) => [ratingJobId(task.taskId, model, RATER_PROMPT_VERSION), task.taskId]),
49+ );
50+
51+ console.log(`[${model}] submitting batch of ${ratingTasks.length} tasks…`);
52+ const batchId = await submitRatingBatch(ratingTasks, model);
53+ console.log(`[${model}] batch ${batchId} submitted; polling every ${pollSeconds}s`);
54+
55+ for (;;) {
56+ await sleep(pollSeconds * 1000);
57+ if (await isBatchComplete(batchId)) break;
58+ console.log(`[${model}] batch ${batchId} still processing…`);
59+ }
60+
61+ const { ingested, failed } = await ingestBatchResults(batchId, model, jobIdToTaskId);
62+ console.log(`[${model}] batch ${batchId} done: ${ingested} dimension ratings ingested, ${failed} requests failed`);
63+}
64+
65+async function main(): Promise<void> {
66+ if (RATER_MODELS.length < 2) {
67+ throw new Error("RATER_MODELS must list ≥2 models (multi-model rating, CLAUDE.md §6)");
68+ }
69+ console.log(
70+ `rating run — prompt ${RATER_PROMPT_VERSION}, models: ${RATER_MODELS.join(", ")}${limit ? `, limit ${limit}` : " (all unrated tasks)"}`,
71+ );
72+ // Sequential per model keeps memory/log output simple; batches themselves are parallel server-side.
73+ for (const model of RATER_MODELS) {
74+ await runForModel(model);
75+ }
76+ console.log("rating run complete — next: pnpm score:recompute");
77+}
78+
79+main()
80+ .catch((error) => {
81+ console.error(error);
82+ process.exitCode = 1;
83+ })
84+ .finally(() => prisma.$disconnect());
added data/derived/onet/manifest.json +20 −0
@@ -0,0 +1,20 @@
1+{
2+ "source_onet_version": "30_3",
3+ "outputs": [
4+ {
5+ "file": "occupations.csv",
6+ "rows": 1016,
7+ "sha256": "56b6466332003cc2445c5079f900b8b39ffc3bc5ac22b0308190b2714a026d15"
8+ },
9+ {
10+ "file": "tasks.csv",
11+ "rows": 18796,
12+ "sha256": "4d0e7a9ca3c4f16aba9dd3c386b19601957ec2e952a0abc0279e2ddc0b5d0263"
13+ },
14+ {
15+ "file": "wages.csv",
16+ "rows": 830,
17+ "sha256": "337e6561a5be9702c85c87b4be125b6173953a417f1511a5c840f611a3fa249e"
18+ }
19+ ]
20+}
\ No newline at end of file
21
Menu

Theme