SPB Git forge

spb/airiskindex

Public

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

21commits 1branches 0releases
251.0 KBsize
maindefault branch
20 days agolast push
TypeScript 88% Python 6.1% SQL 2.7% CSS 1.2% JavaScript 0.9% Shell 0.8%

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 1 mo ago (Aug 5, 2026) parent b73fd7d

16 changed files +1,088 −196

modified apps/etl/Makefile +3 −2
@@ -7,12 +7,13 @@ pipeline: download transform load
7 7
8 8 download:
9 9 $(PYTHON) -m airiskindex_etl.download_onet
10 + $(PYTHON) -m airiskindex_etl.download_oews
10 11
11 12 transform:
12 − @echo "TODO: raw -> derived transforms (task statements + importance ratings -> data/derived/)"
13 + $(PYTHON) -m airiskindex_etl.transform
13 14
14 15 load:
15 − @echo "TODO: derived -> Postgres load (occupations + tasks via Prisma-compatible schema)"
16 + $(PYTHON) -m airiskindex_etl.load
16 17
17 18 test:
18 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 5 requires-python = ">=3.12"
6 6 dependencies = [
7 7 "requests>=2.32",
8 − "pandas>=2.2",
8 + "openpyxl>=3.1",
9 + "psycopg[binary]>=3.2",
9 10 ]
10 11
11 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 1 import type { Metadata } from "next";
2 2 import Link from "next/link";
3 3 import type { ReactNode } from "react";
4 +import { prisma } from "@airiskindex/db";
4 5 import { INDEX_VERSION } from "@airiskindex/scoring";
5 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 17 export const metadata: Metadata = {
8 18 title: "AI Risk Index — task-based AI exposure scores for every occupation",
9 19 description:
10 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 29 return (
15 30 <html lang="en">
16 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)]">
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>
32 + {demo && (
33 + <div className="border-b border-[var(--border)] bg-[var(--surface-1)] px-6 py-2 text-center text-xs text-[var(--ink-2)]">
34 + Preview — scores below are computed from a demonstration dataset while the first
35 + published index run ({INDEX_VERSION.replace("-draft.1", "")}) is in progress.
36 + </div>
37 + )}
21 38 <header className="border-b border-[var(--border)] bg-[var(--surface-1)]">
22 39 <nav className="mx-auto flex max-w-4xl items-center justify-between px-6 py-4">
23 40 <Link href="/" className="font-semibold tracking-tight">
modified apps/web/app/occupations/[code]/page.tsx +301 −94
@@ -1,11 +1,36 @@
1 1 import Link from "next/link";
2 2 import { notFound } from "next/navigation";
3 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 12 import { ScoreBar, ScoreTile, ShareMeter } from "@/components/score-marks";
13 +import { formatWage, socGroupName } from "@/lib/soc-groups";
6 14
7 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 34 async function loadOccupation(code: string) {
10 35 const occupation = await prisma.occupation.findUnique({
11 36 where: { code },
@@ -19,13 +44,74 @@ async function loadOccupation(code: string) {
19 44 include: { run: true },
20 45 });
21 46
22 − const taskScores = score
23 − ? await prisma.taskScore.findMany({
24 − where: { runId: score.runId, taskId: { in: occupation.tasks.map((task) => task.id) } },
47 + const taskIds = occupation.tasks.map((task) => task.id);
48 + const [taskScores, ratings] = await Promise.all([
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 117 export default async function OccupationPage({
@@ -35,30 +121,69 @@ export default async function OccupationPage({
35 121 }): Promise<JSX.Element> {
36 122 const data = await loadOccupation(params.code);
37 123 if (!data) notFound();
38 − const { occupation, score, taskScores } = data;
124 + const { occupation, score, taskScores, ratings, rank, scoredTotal, related } = data;
125 +
39 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 133 const rankedTasks = [...occupation.tasks].sort(
41 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 139 return (
45 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)]">
47 − ← Ranking
48 − </Link>
141 + <nav className="text-sm text-[var(--ink-2)]">
142 + <Link href="/#ranking" className="hover:text-[var(--ink)]">
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 154 <div className="mt-4 flex flex-wrap items-baseline gap-x-3 gap-y-1">
50 155 <h1 className="text-3xl font-bold tracking-tight">{occupation.title}</h1>
51 156 <span className="rounded-full border border-[var(--border)] px-2.5 py-0.5 text-xs text-[var(--muted)]">
52 157 {occupation.code}
53 158 </span>
54 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 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 178 {!score ? (
60 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 187 </div>
63 188 ) : (
64 189 <>
@@ -100,104 +225,186 @@ export default async function OccupationPage({
100 225 </p>
101 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 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 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
107 − a bar for the full breakdown.
281 + Substitution pressure per task, weighted by O*NET importance in the composite.
282 + Expand a task for the full rater audit trail — every rating, every model, every
283 + rationale.
108 284 </p>
109 285 <div className="mt-5 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]">
110 286 {rankedTasks.map((task) => {
111 287 const ts = byTask.get(task.id);
288 + const taskRatings = ratingsByTask.get(task.id) ?? [];
112 289 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)]" />
290 + <details key={task.id} className="group border-b border-[var(--grid)] last:border-b-0">
291 + <summary className="cursor-pointer list-none px-5 py-4 hover:bg-[var(--wash)]">
292 + <div className="flex items-baseline gap-3">
293 + <p className="text-sm">{task.statement}</p>
294 + <span className="ml-auto shrink-0 pl-3 text-sm font-semibold tabular-nums">
295 + {ts ? ts.substitution.toFixed(0) : "—"}
296 + </span>
297 + </div>
298 + <div className="mt-2">
299 + {ts ? (
300 + <ScoreBar
301 + band={{
302 + low: ts.substitutionLow,
303 + score: ts.substitution,
304 + high: ts.substitutionHigh,
305 + }}
306 + thick={8}
307 + />
308 + ) : (
309 + <div className="h-[8px] rounded-[4px] bg-[var(--seq-track)]" />
310 + )}
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>
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</>}
321 + </summary>
322 + {taskRatings.length > 0 && (
323 + <div className="border-t border-[var(--grid)] bg-[var(--page)] px-5 py-4">
324 + <table className="w-full text-left text-xs">
325 + <caption className="sr-only">
326 + Panel ratings per dimension for this task
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 356 </div>
144 357 )}
145 − </div>
358 + </details>
146 359 );
147 360 })}
148 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 362 </section>
191 363
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>
364 + {related.length > 0 && (
365 + <section className="mt-12">
366 + <h2 className="text-xl font-semibold tracking-tight">
367 + Related occupations — {socGroupName(occupation.code)}
368 + </h2>
369 + <ul className="mt-4 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]">
370 + {related.map((row) => (
371 + <li key={row.occupationCode} className="border-b border-[var(--grid)] last:border-b-0">
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 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 1 import Link from "next/link";
2 2 import { prisma } from "@airiskindex/db";
3 +import { OccupationSearch } from "@/components/occupation-search";
3 4 import { ScoreBar } from "@/components/score-marks";
5 +import { SOC_MAJOR_GROUPS } from "@/lib/soc-groups";
4 6
5 7 export const dynamic = "force-dynamic";
6 8
@@ -22,26 +24,102 @@ const CONCEPTS = [
22 24 },
23 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 38 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 };
39 + const [occupations, tasks, run] = await Promise.all([
40 + prisma.occupation.count(),
41 + prisma.task.count(),
42 + prisma.scoreRun.findFirst({ orderBy: { createdAt: "desc" } }),
43 + ]);
44 + if (!run) return { occupations, tasks, run: null, scored: 0, top: [], bottom: [], groups: [] };
45 +
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 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 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 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 123 <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--muted)]">
46 124 The transparent, task-based AI job-exposure index
47 125 </p>
@@ -49,103 +127,100 @@ export default async function HomePage(): Promise<JSX.Element> {
49 127 How is your occupation exposed to AI — task by task?
50 128 </h1>
51 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 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 133 </p>
56 134
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>
135 + <div className="mt-8">
136 + <OccupationSearch />
137 + </div>
70 138
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>.
139 + <div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
140 + {[
141 + [occupations.toLocaleString("en-US"), "occupations (O*NET 30.3)"],
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 152 </div>
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">
153 + ))}
154 + </div>
155 +
156 + {run && top.length > 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 192 <Link
82 − href={`/occupations/${row.occupationCode}`}
83 − className="group relative block px-5 py-4 transition-colors hover:bg-[var(--wash)]"
193 + key={group.prefix}
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 197 <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}
198 + <span className="truncate text-sm">
199 + {SOC_MAJOR_GROUPS[group.prefix] ?? group.prefix}
88 200 </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}
201 + <span className="ml-auto text-xs text-[var(--muted)]">
202 + {Number(group.n)} occupations
92 203 </span>
93 − <span className="ml-auto pl-3 text-sm font-semibold tabular-nums">
94 − {row.substitution.toFixed(0)}
204 + <span className="w-8 text-right text-sm font-semibold tabular-nums">
205 + {group.avg.toFixed(0)}
95 206 </span>
96 207 </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 − }}
208 + <div
209 + aria-hidden="true"
210 + className="mt-1.5 h-[6px] rounded-r-[3px] bg-[var(--seq-track)]"
211 + >
212 + <div
213 + className="h-full rounded-r-[3px] bg-[var(--seq)]"
214 + style={{ width: `${Math.min(100, group.avg)}%` }}
104 215 />
105 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 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>
145 − </table>
146 − </>
147 − )}
148 − </section>
219 + </div>
220 + </section>
221 + )}
222 + </>
223 + )}
149 224
150 225 <section id="concepts" className="mt-16">
151 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 7 "dev": "tsx watch src/index.ts",
8 8 "start": "tsx src/index.ts",
9 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 12 "typecheck": "tsc --noEmit"
11 13 },
12 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 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 45 // compiles across SDK versions; see docs/research/03-llm-rater-api.md.
45 46 ...({
46 47 output_config: {
47 48 format: { type: "json_schema", schema: RATING_OUTPUT_JSON_SCHEMA },
48 49 },
50 + thinking: { type: "disabled" },
49 51 } as Record<string, unknown>),
50 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