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