SPB Git

spb/airiskindex Public

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

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

feat: 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 5 days ago (Aug 5, 2026) parent b73fd7d

Showing 16 changed files with +1,088 and −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 yetits tasks are queued for the rater panel.
180 + This occupation has not been scored yetits {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 disagreementa 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