feat(web): ranking home page + occupation detail pages with demo dataset
- ranking: substitution leaderboard with CI whiskers, hover tooltips, sr-only table fallback; sequential single-hue marks per dataviz specs - detail: sub-score stat tiles with interval strips, highly-exposed-tasks meter, per-task bars with CI, visible data-table fallback, adaptation-not-doom framing copy - seed: 10 real O*NET occupations x 3 tasks with deterministic synthetic two-model panel ratings (demo-panel-a/b); preview banner in layout - light + dark viz roles from the validated reference palette Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 changed files +756 −60
modified
apps/web/app/globals.css
+41 −0
@@ -1,3 +1,44 @@ | |||||
| 1 | @tailwind base; | 1 | @tailwind base; | ||
| 2 | @tailwind components; | 2 | @tailwind components; | ||
| 3 | @tailwind utilities; | 3 | @tailwind utilities; | ||
| 4 | + | ||||
| 5 | +/* Data-viz roles — reference palette (dataviz skill, references/palette.md). | ||||
| 6 | + Charts reference roles, never raw hex; dark steps are selected, not flipped. */ | ||||
| 7 | +:root { | ||||
| 8 | + color-scheme: light; | ||||
| 9 | + --page: #f9f9f7; | ||||
| 10 | + --surface-1: #fcfcfb; | ||||
| 11 | + --ink: #0b0b0b; | ||||
| 12 | + --ink-2: #52514e; | ||||
| 13 | + --muted: #898781; | ||||
| 14 | + --grid: #e1e0d9; | ||||
| 15 | + --baseline: #c3c2b7; | ||||
| 16 | + --border: rgba(11, 11, 11, 0.1); | ||||
| 17 | + --seq: #2a78d6; /* sequential blue 450 (light) */ | ||||
| 18 | + --seq-deep: #1c5cab; | ||||
| 19 | + --seq-track: #cde2fb; /* lighter step of the same ramp (meter/bar track) */ | ||||
| 20 | + --wash: rgba(42, 120, 214, 0.1); /* series hue at ~10% — area wash */ | ||||
| 21 | +} | ||||
| 22 | + | ||||
| 23 | +@media (prefers-color-scheme: dark) { | ||||
| 24 | + :root { | ||||
| 25 | + color-scheme: dark; | ||||
| 26 | + --page: #0d0d0d; | ||||
| 27 | + --surface-1: #1a1a19; | ||||
| 28 | + --ink: #ffffff; | ||||
| 29 | + --ink-2: #c3c2b7; | ||||
| 30 | + --muted: #898781; | ||||
| 31 | + --grid: #2c2c2a; | ||||
| 32 | + --baseline: #383835; | ||||
| 33 | + --border: rgba(255, 255, 255, 0.1); | ||||
| 34 | + --seq: #3987e5; /* sequential blue 400 (dark) */ | ||||
| 35 | + --seq-deep: #6da7ec; | ||||
| 36 | + --seq-track: #0d366b; /* recessive step of the same ramp on dark */ | ||||
| 37 | + --wash: rgba(57, 135, 229, 0.12); | ||||
| 38 | + } | ||||
| 39 | +} | ||||
| 40 | + | ||||
| 41 | +body { | ||||
| 42 | + background: var(--page); | ||||
| 43 | + color: var(--ink); | ||||
| 44 | +} | ||||
modified
apps/web/app/layout.tsx
+37 −1
@@ -1,5 +1,7 @@ | |||||
| 1 | import type { Metadata } from "next"; | 1 | import type { Metadata } from "next"; | ||
| 2 | +import Link from "next/link"; | ||||
| 2 | import type { ReactNode } from "react"; | 3 | import type { ReactNode } from "react"; | ||
| 4 | +import { INDEX_VERSION } from "@airiskindex/scoring"; | ||||
| 3 | import "./globals.css"; | 5 | import "./globals.css"; | ||
| 4 | 6 | ||||
| 5 | export const metadata: Metadata = { | 7 | export const metadata: Metadata = { | ||
@@ -11,7 +13,41 @@ export const metadata: Metadata = { | |||||
| 11 | export default function RootLayout({ children }: { children: ReactNode }): JSX.Element { | 13 | export default function RootLayout({ children }: { children: ReactNode }): JSX.Element { | ||
| 12 | return ( | 14 | return ( | ||
| 13 | <html lang="en"> | 15 | <html lang="en"> | ||
| 14 | − <body className="min-h-screen bg-white text-slate-900 antialiased">{children}</body> | 16 | + <body className="min-h-screen antialiased"> | ||
| 17 | + <div className="border-b border-[var(--border)] bg-[var(--surface-1)] px-6 py-2 text-center text-xs text-[var(--ink-2)]"> | ||||
| 18 | + Preview — scores below are computed from a demonstration dataset while the first | ||||
| 19 | + published index run ({INDEX_VERSION.replace("-draft.1", "")}) is in progress. | ||||
| 20 | + </div> | ||||
| 21 | + <header className="border-b border-[var(--border)] bg-[var(--surface-1)]"> | ||||
| 22 | + <nav className="mx-auto flex max-w-4xl items-center justify-between px-6 py-4"> | ||||
| 23 | + <Link href="/" className="font-semibold tracking-tight"> | ||||
| 24 | + AI Risk Index | ||||
| 25 | + </Link> | ||||
| 26 | + <div className="flex items-center gap-5 text-sm text-[var(--ink-2)]"> | ||||
| 27 | + <Link href="/#ranking" className="hover:text-[var(--ink)]"> | ||||
| 28 | + Ranking | ||||
| 29 | + </Link> | ||||
| 30 | + <Link href="/#concepts" className="hover:text-[var(--ink)]"> | ||||
| 31 | + Method | ||||
| 32 | + </Link> | ||||
| 33 | + <Link href="/api/v1/methodology" className="hover:text-[var(--ink)]"> | ||||
| 34 | + API | ||||
| 35 | + </Link> | ||||
| 36 | + </div> | ||||
| 37 | + </nav> | ||||
| 38 | + </header> | ||||
| 39 | + {children} | ||||
| 40 | + <footer className="mt-16 border-t border-[var(--border)] bg-[var(--surface-1)]"> | ||||
| 41 | + <div className="mx-auto max-w-4xl px-6 py-8 text-xs leading-relaxed text-[var(--muted)]"> | ||||
| 42 | + <p> | ||||
| 43 | + Methodology {INDEX_VERSION} · every score traces to a stored, immutable computation | ||||
| 44 | + run · weights, prompts and formulas are public. Occupation and task data from O*NET | ||||
| 45 | + (CC BY 4.0, U.S. Department of Labor). This index measures task-level pressure, not | ||||
| 46 | + certainty of job loss — read it as adaptation guidance. | ||||
| 47 | + </p> | ||||
| 48 | + </div> | ||||
| 49 | + </footer> | ||||
| 50 | + </body> | ||||
| 15 | </html> | 51 | </html> | ||
| 16 | ); | 52 | ); | ||
| 17 | } | 53 | } | ||
added
apps/web/app/occupations/[code]/page.tsx
+206 −0
@@ -0,0 +1,206 @@ | |||||
| 1 | +import Link from "next/link"; | ||||
| 2 | +import { notFound } from "next/navigation"; | ||||
| 3 | +import { prisma } from "@airiskindex/db"; | ||||
| 4 | +import { HIGH_EXPOSURE_THRESHOLD } from "@airiskindex/scoring"; | ||||
| 5 | +import { ScoreBar, ScoreTile, ShareMeter } from "@/components/score-marks"; | ||||
| 6 | + | ||||
| 7 | +export const dynamic = "force-dynamic"; | ||||
| 8 | + | ||||
| 9 | +async function loadOccupation(code: string) { | ||||
| 10 | + const occupation = await prisma.occupation.findUnique({ | ||||
| 11 | + where: { code }, | ||||
| 12 | + include: { tasks: { orderBy: { importance: "desc" } } }, | ||||
| 13 | + }); | ||||
| 14 | + if (!occupation) return null; | ||||
| 15 | + | ||||
| 16 | + const score = await prisma.occupationScore.findFirst({ | ||||
| 17 | + where: { occupationCode: code }, | ||||
| 18 | + orderBy: { run: { createdAt: "desc" } }, | ||||
| 19 | + include: { run: true }, | ||||
| 20 | + }); | ||||
| 21 | + | ||||
| 22 | + const taskScores = score | ||||
| 23 | + ? await prisma.taskScore.findMany({ | ||||
| 24 | + where: { runId: score.runId, taskId: { in: occupation.tasks.map((task) => task.id) } }, | ||||
| 25 | + }) | ||||
| 26 | + : []; | ||||
| 27 | + | ||||
| 28 | + return { occupation, score, taskScores }; | ||||
| 29 | +} | ||||
| 30 | + | ||||
| 31 | +export default async function OccupationPage({ | ||||
| 32 | + params, | ||||
| 33 | +}: { | ||||
| 34 | + params: { code: string }; | ||||
| 35 | +}): Promise<JSX.Element> { | ||||
| 36 | + const data = await loadOccupation(params.code); | ||||
| 37 | + if (!data) notFound(); | ||||
| 38 | + const { occupation, score, taskScores } = data; | ||||
| 39 | + const byTask = new Map(taskScores.map((entry) => [entry.taskId, entry])); | ||||
| 40 | + const rankedTasks = [...occupation.tasks].sort( | ||||
| 41 | + (a, b) => (byTask.get(b.id)?.substitution ?? -1) - (byTask.get(a.id)?.substitution ?? -1), | ||||
| 42 | + ); | ||||
| 43 | + | ||||
| 44 | + return ( | ||||
| 45 | + <main className="mx-auto max-w-4xl px-6 py-12"> | ||||
| 46 | + <Link href="/#ranking" className="text-sm text-[var(--ink-2)] hover:text-[var(--ink)]"> | ||||
| 47 | + ← Ranking | ||||
| 48 | + </Link> | ||||
| 49 | + <div className="mt-4 flex flex-wrap items-baseline gap-x-3 gap-y-1"> | ||||
| 50 | + <h1 className="text-3xl font-bold tracking-tight">{occupation.title}</h1> | ||||
| 51 | + <span className="rounded-full border border-[var(--border)] px-2.5 py-0.5 text-xs text-[var(--muted)]"> | ||||
| 52 | + {occupation.code} | ||||
| 53 | + </span> | ||||
| 54 | + </div> | ||||
| 55 | + {occupation.description && ( | ||||
| 56 | + <p className="mt-3 max-w-2xl text-[var(--ink-2)]">{occupation.description}</p> | ||||
| 57 | + )} | ||||
| 58 | + | ||||
| 59 | + {!score ? ( | ||||
| 60 | + <div className="mt-10 rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-8 text-sm text-[var(--ink-2)]"> | ||||
| 61 | + This occupation has not been scored yet — its tasks are queued for the rater panel. | ||||
| 62 | + </div> | ||||
| 63 | + ) : ( | ||||
| 64 | + <> | ||||
| 65 | + <section className="mt-10"> | ||||
| 66 | + <div className="grid gap-4 sm:grid-cols-3"> | ||||
| 67 | + <ScoreTile | ||||
| 68 | + label="Substitution" | ||||
| 69 | + band={{ | ||||
| 70 | + low: score.substitutionLow, | ||||
| 71 | + score: score.substitution, | ||||
| 72 | + high: score.substitutionHigh, | ||||
| 73 | + }} | ||||
| 74 | + hint="Composite pressure that AI replaces the human on this occupation's tasks — capability discounted by cost, barriers and adoption." | ||||
| 75 | + /> | ||||
| 76 | + <ScoreTile | ||||
| 77 | + label="Exposure" | ||||
| 78 | + band={{ low: score.exposureLow, score: score.exposure, high: score.exposureHigh }} | ||||
| 79 | + hint="Technical capability only: could AI perform these tasks, regardless of whether anyone deploys it." | ||||
| 80 | + /> | ||||
| 81 | + <ScoreTile | ||||
| 82 | + label="Augmentation" | ||||
| 83 | + band={{ | ||||
| 84 | + low: score.augmentationLow, | ||||
| 85 | + score: score.augmentation, | ||||
| 86 | + high: score.augmentationHigh, | ||||
| 87 | + }} | ||||
| 88 | + hint="How much AI assists a human doing this work. High augmentation with moderate substitution reads as a changing job, not a disappearing one." | ||||
| 89 | + /> | ||||
| 90 | + </div> | ||||
| 91 | + <div className="mt-4"> | ||||
| 92 | + <ShareMeter | ||||
| 93 | + share={score.highlyExposedTaskShare} | ||||
| 94 | + label={`Tasks with substitution ≥ ${HIGH_EXPOSURE_THRESHOLD}`} | ||||
| 95 | + /> | ||||
| 96 | + </div> | ||||
| 97 | + <p className="mt-3 text-xs text-[var(--muted)]"> | ||||
| 98 | + Run {score.run.indexVersion} · computed {score.run.createdAt.toISOString().slice(0, 10)} · | ||||
| 99 | + rater panel: {score.run.raterModels.join(", ")} · intervals span rater disagreement. | ||||
| 100 | + </p> | ||||
| 101 | + </section> | ||||
| 102 | + | ||||
| 103 | + <section className="mt-12"> | ||||
| 104 | + <h2 className="text-xl font-semibold tracking-tight">Task breakdown</h2> | ||||
| 105 | + <p className="mt-1 text-sm text-[var(--ink-2)]"> | ||||
| 106 | + Substitution pressure per task, weighted by O*NET importance in the composite. Hover | ||||
| 107 | + a bar for the full breakdown. | ||||
| 108 | + </p> | ||||
| 109 | + <div className="mt-5 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]"> | ||||
| 110 | + {rankedTasks.map((task) => { | ||||
| 111 | + const ts = byTask.get(task.id); | ||||
| 112 | + return ( | ||||
| 113 | + <div | ||||
| 114 | + key={task.id} | ||||
| 115 | + className="group relative border-b border-[var(--grid)] px-5 py-4 last:border-b-0 hover:bg-[var(--wash)]" | ||||
| 116 | + > | ||||
| 117 | + <div className="flex items-baseline gap-3"> | ||||
| 118 | + <p className="truncate text-sm">{task.statement}</p> | ||||
| 119 | + <span className="ml-auto pl-3 text-sm font-semibold tabular-nums"> | ||||
| 120 | + {ts ? ts.substitution.toFixed(0) : "—"} | ||||
| 121 | + </span> | ||||
| 122 | + </div> | ||||
| 123 | + <div className="mt-2"> | ||||
| 124 | + {ts ? ( | ||||
| 125 | + <ScoreBar | ||||
| 126 | + band={{ | ||||
| 127 | + low: ts.substitutionLow, | ||||
| 128 | + score: ts.substitution, | ||||
| 129 | + high: ts.substitutionHigh, | ||||
| 130 | + }} | ||||
| 131 | + thick={8} | ||||
| 132 | + /> | ||||
| 133 | + ) : ( | ||||
| 134 | + <div className="h-[8px] rounded-[4px] bg-[var(--seq-track)]" /> | ||||
| 135 | + )} | ||||
| 136 | + </div> | ||||
| 137 | + {ts && ( | ||||
| 138 | + <div className="pointer-events-none absolute right-5 top-1 z-10 hidden rounded-lg border border-[var(--border)] bg-[var(--surface-1)] px-3 py-2 text-xs text-[var(--ink-2)] shadow-sm group-hover:block"> | ||||
| 139 | + CI {ts.substitutionLow.toFixed(0)}–{ts.substitutionHigh.toFixed(0)} · | ||||
| 140 | + exposure {ts.exposure.toFixed(0)} · augmentation{" "} | ||||
| 141 | + {ts.augmentation.toFixed(0)} | ||||
| 142 | + {task.importance != null && <> · importance {task.importance.toFixed(1)}/5</>} | ||||
| 143 | + </div> | ||||
| 144 | + )} | ||||
| 145 | + </div> | ||||
| 146 | + ); | ||||
| 147 | + })} | ||||
| 148 | + </div> | ||||
| 149 | + | ||||
| 150 | + {/* Visible data table — chart fallback (CLAUDE.md §5) */} | ||||
| 151 | + <details className="mt-4"> | ||||
| 152 | + <summary className="cursor-pointer text-sm text-[var(--ink-2)] hover:text-[var(--ink)]"> | ||||
| 153 | + View as data table | ||||
| 154 | + </summary> | ||||
| 155 | + <div className="mt-3 overflow-x-auto rounded-xl border border-[var(--border)] bg-[var(--surface-1)]"> | ||||
| 156 | + <table className="w-full text-left text-sm"> | ||||
| 157 | + <caption className="sr-only"> | ||||
| 158 | + Per-task scores with confidence intervals for {occupation.title} | ||||
| 159 | + </caption> | ||||
| 160 | + <thead className="border-b border-[var(--grid)] text-xs uppercase tracking-wide text-[var(--muted)]"> | ||||
| 161 | + <tr> | ||||
| 162 | + <th className="px-4 py-3 font-medium">Task</th> | ||||
| 163 | + <th className="px-4 py-3 font-medium">Importance</th> | ||||
| 164 | + <th className="px-4 py-3 font-medium">Substitution (CI)</th> | ||||
| 165 | + <th className="px-4 py-3 font-medium">Exposure</th> | ||||
| 166 | + <th className="px-4 py-3 font-medium">Augmentation</th> | ||||
| 167 | + </tr> | ||||
| 168 | + </thead> | ||||
| 169 | + <tbody className="tabular-nums"> | ||||
| 170 | + {rankedTasks.map((task) => { | ||||
| 171 | + const ts = byTask.get(task.id); | ||||
| 172 | + return ( | ||||
| 173 | + <tr key={task.id} className="border-b border-[var(--grid)] last:border-b-0"> | ||||
| 174 | + <td className="max-w-md px-4 py-3">{task.statement}</td> | ||||
| 175 | + <td className="px-4 py-3">{task.importance?.toFixed(1) ?? "—"}</td> | ||||
| 176 | + <td className="px-4 py-3"> | ||||
| 177 | + {ts | ||||
| 178 | + ? `${ts.substitution.toFixed(1)} (${ts.substitutionLow.toFixed(1)}–${ts.substitutionHigh.toFixed(1)})` | ||||
| 179 | + : "—"} | ||||
| 180 | + </td> | ||||
| 181 | + <td className="px-4 py-3">{ts ? ts.exposure.toFixed(1) : "—"}</td> | ||||
| 182 | + <td className="px-4 py-3">{ts ? ts.augmentation.toFixed(1) : "—"}</td> | ||||
| 183 | + </tr> | ||||
| 184 | + ); | ||||
| 185 | + })} | ||||
| 186 | + </tbody> | ||||
| 187 | + </table> | ||||
| 188 | + </div> | ||||
| 189 | + </details> | ||||
| 190 | + </section> | ||||
| 191 | + | ||||
| 192 | + <section className="mt-12 rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-6"> | ||||
| 193 | + <h2 className="font-semibold">How to read this</h2> | ||||
| 194 | + <p className="mt-2 text-sm leading-relaxed text-[var(--ink-2)]"> | ||||
| 195 | + A high substitution score does not mean this job disappears — it means a large share | ||||
| 196 | + of its current tasks face replacement pressure, so the mix of tasks is likely to | ||||
| 197 | + change. Occupations with high augmentation alongside substitution typically | ||||
| 198 | + reorganize around the protected tasks. Wide confidence intervals mean the rater | ||||
| 199 | + panel disagreed: treat those scores as open questions, not verdicts. | ||||
| 200 | + </p> | ||||
| 201 | + </section> | ||||
| 202 | + </> | ||||
| 203 | + )} | ||||
| 204 | + </main> | ||||
| 205 | + ); | ||||
| 206 | +} | ||||
modified
apps/web/app/page.tsx
+138 −38
@@ -1,5 +1,8 @@ | |||||
| 1 | −import { ScoreBandPill } from "@airiskindex/ui"; | 1 | +import Link from "next/link"; | ||
| 2 | −import { INDEX_VERSION } from "@airiskindex/scoring"; | 2 | +import { prisma } from "@airiskindex/db"; | ||
| 3 | +import { ScoreBar } from "@/components/score-marks"; | ||||
| 4 | + | ||||
| 5 | +export const dynamic = "force-dynamic"; | ||||
| 3 | 6 | ||||
| 4 | const CONCEPTS = [ | 7 | const CONCEPTS = [ | ||
| 5 | { | 8 | { | ||
@@ -10,7 +13,7 @@ const CONCEPTS = [ | |||||
| 10 | { | 13 | { | ||
| 11 | name: "Substitution", | 14 | name: "Substitution", | ||
| 12 | description: | 15 | description: | ||
| 13 | − "AI actually replaces the human performing the task, once cost, adoption and real-world barriers are accounted for.", | 16 | + "AI actually replaces the human performing the task, once cost, adoption velocity and real-world barriers are accounted for. This is the headline score.", | ||
| 14 | }, | 17 | }, | ||
| 15 | { | 18 | { | ||
| 16 | name: "Augmentation", | 19 | name: "Augmentation", | ||
@@ -19,51 +22,148 @@ const CONCEPTS = [ | |||||
| 19 | }, | 22 | }, | ||
| 20 | ] as const; | 23 | ] as const; | ||
| 21 | 24 | ||||
| 22 | −export default function HomePage(): JSX.Element { | 25 | +async function loadRanking() { | ||
| 26 | + try { | ||||
| 27 | + const run = await prisma.scoreRun.findFirst({ orderBy: { createdAt: "desc" } }); | ||||
| 28 | + if (!run) return { run: null, rows: [] as never[] }; | ||||
| 29 | + const rows = await prisma.occupationScore.findMany({ | ||||
| 30 | + where: { runId: run.id }, | ||||
| 31 | + orderBy: { substitution: "desc" }, | ||||
| 32 | + include: { occupation: { select: { code: true, title: true } } }, | ||||
| 33 | + }); | ||||
| 34 | + return { run, rows }; | ||||
| 35 | + } catch { | ||||
| 36 | + return { run: null, rows: [] as never[] }; | ||||
| 37 | + } | ||||
| 38 | +} | ||||
| 39 | + | ||||
| 40 | +export default async function HomePage(): Promise<JSX.Element> { | ||||
| 41 | + const { run, rows } = await loadRanking(); | ||||
| 42 | + | ||||
| 23 | return ( | 43 | return ( | ||
| 24 | − <main className="mx-auto max-w-3xl px-6 py-16"> | 44 | + <main className="mx-auto max-w-4xl px-6 py-14"> | ||
| 25 | − <p className="text-sm font-medium uppercase tracking-wide text-slate-500"> | 45 | + <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--muted)]"> | ||
| 26 | − AI Risk Index · methodology {INDEX_VERSION} | 46 | + The transparent, task-based AI job-exposure index | ||
| 27 | </p> | 47 | </p> | ||
| 28 | − <h1 className="mt-2 text-4xl font-bold tracking-tight"> | 48 | + <h1 className="mt-3 max-w-2xl text-4xl font-bold tracking-tight sm:text-5xl"> | ||
| 29 | How is your occupation exposed to AI — task by task? | 49 | How is your occupation exposed to AI — task by task? | ||
| 30 | </h1> | 50 | </h1> | ||
| 31 | − <p className="mt-4 text-lg text-slate-600"> | 51 | + <p className="mt-4 max-w-2xl text-lg leading-relaxed text-[var(--ink-2)]"> | ||
| 32 | − We score occupations from their individual tasks, report three separate sub-scores with | 52 | + Occupations are scored from their individual tasks by a multi-model rater panel. Three | ||
| 33 | − confidence intervals, and publish every weight, prompt and formula. Built for adaptation | 53 | + sub-scores, confidence intervals from rater disagreement, versioned methodology, public | ||
| 34 | − planning — not headlines. | 54 | + API. Built for adaptation planning — not headlines. | ||
| 35 | </p> | 55 | </p> | ||
| 36 | 56 | ||||
| 37 | − <div className="mt-8 flex flex-wrap gap-3"> | 57 | + <section id="ranking" className="mt-14"> | ||
| 38 | − {/* Example occupation from the published methodology example */} | 58 | + <div className="flex items-baseline justify-between"> | ||
| 39 | − <ScoreBandPill label="Substitution" low={40.9} score={56.7} high={68.4} /> | 59 | + <h2 className="text-xl font-semibold tracking-tight">Substitution ranking</h2> | ||
| 40 | − <ScoreBandPill label="Exposure" low={42.0} score={57.1} high={74.1} /> | 60 | + {run && ( | ||
| 41 | − <ScoreBandPill label="Augmentation" low={46.9} score={71.9} high={84.4} /> | 61 | + <p className="text-xs text-[var(--muted)]"> | ||
| 42 | − </div> | 62 | + run {run.indexVersion} · {run.createdAt.toISOString().slice(0, 10)} | ||
| 63 | + </p> | ||||
| 64 | + )} | ||||
| 65 | + </div> | ||||
| 66 | + <p className="mt-1 text-sm text-[var(--ink-2)]"> | ||||
| 67 | + Composite substitution pressure, 0–100. The whisker marks the confidence interval from | ||||
| 68 | + rater disagreement — a wide band is a claim we hold loosely. | ||||
| 69 | + </p> | ||||
| 43 | 70 | ||||
| 44 | − <section className="mt-12 grid gap-6 sm:grid-cols-3"> | 71 | + {rows.length === 0 ? ( | ||
| 45 | − {CONCEPTS.map((concept) => ( | 72 | + <div className="mt-8 rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-8 text-sm text-[var(--ink-2)]"> | ||
| 46 | − <div key={concept.name} className="rounded-lg border border-slate-200 p-5"> | 73 | + No score run published yet. Scores appear here after the first rating batch and{" "} | ||
| 47 | − <h2 className="font-semibold">{concept.name}</h2> | 74 | + <code>score:recompute</code>. | ||
| 48 | − <p className="mt-2 text-sm text-slate-600">{concept.description}</p> | ||||
| 49 | </div> | 75 | </div> | ||
| 50 | − ))} | 76 | + ) : ( | ||
| 77 | + <> | ||||
| 78 | + <ol className="mt-6 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]"> | ||||
| 79 | + {rows.map((row, index) => ( | ||||
| 80 | + <li key={row.occupationCode} className="border-b border-[var(--grid)] last:border-b-0"> | ||||
| 81 | + <Link | ||||
| 82 | + href={`/occupations/${row.occupationCode}`} | ||||
| 83 | + className="group relative block px-5 py-4 transition-colors hover:bg-[var(--wash)]" | ||||
| 84 | + > | ||||
| 85 | + <div className="flex items-baseline gap-3"> | ||||
| 86 | + <span className="w-6 shrink-0 text-right text-sm tabular-nums text-[var(--muted)]"> | ||||
| 87 | + {index + 1} | ||||
| 88 | + </span> | ||||
| 89 | + <span className="truncate font-medium">{row.occupation.title}</span> | ||||
| 90 | + <span className="hidden text-xs text-[var(--muted)] sm:inline"> | ||||
| 91 | + {row.occupation.code} | ||||
| 92 | + </span> | ||||
| 93 | + <span className="ml-auto pl-3 text-sm font-semibold tabular-nums"> | ||||
| 94 | + {row.substitution.toFixed(0)} | ||||
| 95 | + </span> | ||||
| 96 | + </div> | ||||
| 97 | + <div className="mt-2 pl-9"> | ||||
| 98 | + <ScoreBar | ||||
| 99 | + band={{ | ||||
| 100 | + low: row.substitutionLow, | ||||
| 101 | + score: row.substitution, | ||||
| 102 | + high: row.substitutionHigh, | ||||
| 103 | + }} | ||||
| 104 | + /> | ||||
| 105 | + </div> | ||||
| 106 | + {/* hover tooltip */} | ||||
| 107 | + <div className="pointer-events-none absolute right-5 top-1 z-10 hidden rounded-lg border border-[var(--border)] bg-[var(--surface-1)] px-3 py-2 text-xs text-[var(--ink-2)] shadow-sm group-hover:block"> | ||||
| 108 | + CI {row.substitutionLow.toFixed(0)}–{row.substitutionHigh.toFixed(0)} · | ||||
| 109 | + exposure {row.exposure.toFixed(0)} · augmentation{" "} | ||||
| 110 | + {row.augmentation.toFixed(0)} | ||||
| 111 | + </div> | ||||
| 112 | + </Link> | ||||
| 113 | + </li> | ||||
| 114 | + ))} | ||||
| 115 | + </ol> | ||||
| 116 | + | ||||
| 117 | + {/* Accessible table fallback (CLAUDE.md §5) */} | ||||
| 118 | + <table className="sr-only"> | ||||
| 119 | + <caption>Occupations ranked by substitution score, with sub-scores</caption> | ||||
| 120 | + <thead> | ||||
| 121 | + <tr> | ||||
| 122 | + <th>Rank</th> | ||||
| 123 | + <th>Occupation</th> | ||||
| 124 | + <th>Substitution (CI)</th> | ||||
| 125 | + <th>Exposure</th> | ||||
| 126 | + <th>Augmentation</th> | ||||
| 127 | + </tr> | ||||
| 128 | + </thead> | ||||
| 129 | + <tbody> | ||||
| 130 | + {rows.map((row, index) => ( | ||||
| 131 | + <tr key={row.occupationCode}> | ||||
| 132 | + <td>{index + 1}</td> | ||||
| 133 | + <td> | ||||
| 134 | + {row.occupation.title} ({row.occupation.code}) | ||||
| 135 | + </td> | ||||
| 136 | + <td> | ||||
| 137 | + {row.substitution.toFixed(1)} ({row.substitutionLow.toFixed(1)}– | ||||
| 138 | + {row.substitutionHigh.toFixed(1)}) | ||||
| 139 | + </td> | ||||
| 140 | + <td>{row.exposure.toFixed(1)}</td> | ||||
| 141 | + <td>{row.augmentation.toFixed(1)}</td> | ||||
| 142 | + </tr> | ||||
| 143 | + ))} | ||||
| 144 | + </tbody> | ||||
| 145 | + </table> | ||||
| 146 | + </> | ||||
| 147 | + )} | ||||
| 51 | </section> | 148 | </section> | ||
| 52 | 149 | ||||
| 53 | − <section className="mt-12 text-sm text-slate-600"> | 150 | + <section id="concepts" className="mt-16"> | ||
| 54 | − <h2 className="text-base font-semibold text-slate-900">Public API</h2> | 151 | + <h2 className="text-xl font-semibold tracking-tight"> | ||
| 55 | − <ul className="mt-2 list-inside list-disc space-y-1"> | 152 | + Three scores, never collapsed into one | ||
| 56 | − <li> | 153 | + </h2> | ||
| 57 | − <code>/api/v1/occupations</code> — search occupations | 154 | + <div className="mt-5 grid gap-4 sm:grid-cols-3"> | ||
| 58 | − </li> | 155 | + {CONCEPTS.map((concept) => ( | ||
| 59 | − <li> | 156 | + <div | ||
| 60 | − <code>/api/v1/occupations/:code</code> — full score breakdown with sub-scores and CI | 157 | + key={concept.name} | ||
| 61 | − bounds | 158 | + className="rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-5" | ||
| 62 | − </li> | 159 | + > | ||
| 63 | − <li> | 160 | + <h3 className="font-semibold">{concept.name}</h3> | ||
| 64 | − <code>/api/v1/methodology</code> — machine-readable weights and version metadata | 161 | + <p className="mt-2 text-sm leading-relaxed text-[var(--ink-2)]"> | ||
| 65 | − </li> | 162 | + {concept.description} | ||
| 66 | − </ul> | 163 | + </p> | ||
| 164 | + </div> | ||||
| 165 | + ))} | ||||
| 166 | + </div> | ||||
| 67 | </section> | 167 | </section> | ||
| 68 | </main> | 168 | </main> | ||
| 69 | ); | 169 | ); | ||
added
apps/web/components/score-marks.tsx
+107 −0
@@ -0,0 +1,107 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Chart marks for 0–100 scores with confidence bounds. | ||||
| 3 | + * Specs per the dataviz skill: thin bars (≤24px) with a 4px rounded data-end | ||||
| 4 | + * and a square baseline, track = lighter step of the same ramp, CI whisker in | ||||
| 5 | + * muted ink, values in text tokens (never the series color). | ||||
| 6 | + */ | ||||
| 7 | + | ||||
| 8 | +export interface Band { | ||||
| 9 | + low: number; | ||||
| 10 | + score: number; | ||||
| 11 | + high: number; | ||||
| 12 | +} | ||||
| 13 | + | ||||
| 14 | +const pct = (value: number): string => `${Math.max(0, Math.min(100, value))}%`; | ||||
| 15 | + | ||||
| 16 | +/** Horizontal score bar with CI whisker on a 0–100 track. */ | ||||
| 17 | +export function ScoreBar({ band, thick = 10 }: { band: Band; thick?: number }): JSX.Element { | ||||
| 18 | + return ( | ||||
| 19 | + <div | ||||
| 20 | + aria-hidden="true" | ||||
| 21 | + className="relative w-full rounded-r-[4px] bg-[var(--seq-track)]" | ||||
| 22 | + style={{ height: thick }} | ||||
| 23 | + > | ||||
| 24 | + <div | ||||
| 25 | + className="absolute inset-y-0 left-0 rounded-r-[4px] bg-[var(--seq)]" | ||||
| 26 | + style={{ width: pct(band.score) }} | ||||
| 27 | + /> | ||||
| 28 | + {/* CI whisker: hairline + end ticks, muted ink over the track */} | ||||
| 29 | + <div | ||||
| 30 | + className="absolute top-1/2 h-px -translate-y-1/2 bg-[var(--muted)]" | ||||
| 31 | + style={{ left: pct(band.low), width: pct(band.high - band.low) }} | ||||
| 32 | + /> | ||||
| 33 | + <div | ||||
| 34 | + className="absolute top-1/2 h-[7px] w-px -translate-y-1/2 bg-[var(--muted)]" | ||||
| 35 | + style={{ left: pct(band.low) }} | ||||
| 36 | + /> | ||||
| 37 | + <div | ||||
| 38 | + className="absolute top-1/2 h-[7px] w-px -translate-y-1/2 bg-[var(--muted)]" | ||||
| 39 | + style={{ left: pct(band.high) }} | ||||
| 40 | + /> | ||||
| 41 | + </div> | ||||
| 42 | + ); | ||||
| 43 | +} | ||||
| 44 | + | ||||
| 45 | +/** Compact interval strip for stat tiles: band wash + point marker. */ | ||||
| 46 | +export function IntervalStrip({ band }: { band: Band }): JSX.Element { | ||||
| 47 | + return ( | ||||
| 48 | + <div aria-hidden="true" className="relative h-[6px] w-full rounded-[3px] bg-[var(--seq-track)]"> | ||||
| 49 | + <div | ||||
| 50 | + className="absolute inset-y-0 rounded-[3px] bg-[var(--seq)] opacity-40" | ||||
| 51 | + style={{ left: pct(band.low), width: pct(band.high - band.low) }} | ||||
| 52 | + /> | ||||
| 53 | + {/* point marker ≥8px with a 2px surface ring */} | ||||
| 54 | + <div | ||||
| 55 | + className="absolute top-1/2 h-[10px] w-[10px] -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[var(--surface-1)] bg-[var(--seq)]" | ||||
| 56 | + style={{ left: pct(band.score) }} | ||||
| 57 | + /> | ||||
| 58 | + </div> | ||||
| 59 | + ); | ||||
| 60 | +} | ||||
| 61 | + | ||||
| 62 | +/** Stat tile: label · value · CI, with the interval strip underneath. */ | ||||
| 63 | +export function ScoreTile({ | ||||
| 64 | + label, | ||||
| 65 | + band, | ||||
| 66 | + hint, | ||||
| 67 | +}: { | ||||
| 68 | + label: string; | ||||
| 69 | + band: Band; | ||||
| 70 | + hint: string; | ||||
| 71 | +}): JSX.Element { | ||||
| 72 | + return ( | ||||
| 73 | + <div className="rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-5"> | ||||
| 74 | + <p className="text-sm font-medium text-[var(--ink-2)]">{label}</p> | ||||
| 75 | + <p className="mt-1 text-4xl font-semibold"> | ||||
| 76 | + {band.score.toFixed(0)} | ||||
| 77 | + <span className="ml-2 align-middle text-sm font-normal text-[var(--muted)]"> | ||||
| 78 | + CI {band.low.toFixed(0)}–{band.high.toFixed(0)} | ||||
| 79 | + </span> | ||||
| 80 | + </p> | ||||
| 81 | + <div className="mt-3"> | ||||
| 82 | + <IntervalStrip band={band} /> | ||||
| 83 | + </div> | ||||
| 84 | + <p className="mt-3 text-xs leading-relaxed text-[var(--muted)]">{hint}</p> | ||||
| 85 | + </div> | ||||
| 86 | + ); | ||||
| 87 | +} | ||||
| 88 | + | ||||
| 89 | +/** Meter: fill + same-ramp track (marks-and-anatomy §Figures). */ | ||||
| 90 | +export function ShareMeter({ share, label }: { share: number; label: string }): JSX.Element { | ||||
| 91 | + const value = Math.round(share * 100); | ||||
| 92 | + return ( | ||||
| 93 | + <div className="rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-5"> | ||||
| 94 | + <div className="flex items-baseline justify-between"> | ||||
| 95 | + <p className="text-sm font-medium text-[var(--ink-2)]">{label}</p> | ||||
| 96 | + <p className="text-2xl font-semibold">{value}%</p> | ||||
| 97 | + </div> | ||||
| 98 | + <div className="mt-3 h-[10px] w-full rounded-[4px] bg-[var(--seq-track)]"> | ||||
| 99 | + <div | ||||
| 100 | + className="h-full rounded-[4px] bg-[var(--seq)]" | ||||
| 101 | + style={{ width: `${value}%` }} | ||||
| 102 | + aria-hidden="true" | ||||
| 103 | + /> | ||||
| 104 | + </div> | ||||
| 105 | + </div> | ||||
| 106 | + ); | ||||
| 107 | +} | ||||
modified
packages/db/prisma/seed.ts
+227 −21
@@ -1,41 +1,247 @@ | |||||
| 1 | −import { readFileSync } from "node:fs"; | ||||
| 2 | import { PrismaClient } from "@prisma/client"; | 1 | import { PrismaClient } from "@prisma/client"; | ||
| 3 | 2 | ||||
| 4 | −// Seeds the synthetic methodology example occupation so the app and API have | 3 | +// Demo seed: 10 real O*NET-SOC occupations with plausible hand-authored task | ||
| 5 | −// demo data before the real O*NET ETL runs. Real data: `cd apps/etl && make pipeline`. | 4 | +// ratings from a synthetic two-model panel ("demo-panel-a/b"). This exists so | ||
| 5 | +// the site has data before the real O*NET ETL + LLM rating batch runs; the web | ||||
| 6 | +// layout shows a "Preview — demonstration dataset" banner, and every score | ||||
| 7 | +// still traces to a ScoreRun whose raterModels make the provenance explicit. | ||||
| 8 | +// Deterministic on purpose — reseeding yields identical ratings. | ||||
| 9 | + | ||||
| 6 | const prisma = new PrismaClient(); | 10 | const prisma = new PrismaClient(); | ||
| 7 | 11 | ||||
| 8 | −const example = JSON.parse( | 12 | +const DIMS = [ | ||
| 9 | − readFileSync(new URL("../../../docs/methodology/examples/example-analyst.json", import.meta.url), "utf8"), | 13 | + "automatability", | ||
| 10 | −); | 14 | + "feasibility", | ||
| 15 | + "cost_ratio", | ||||
| 16 | + "barriers", | ||||
| 17 | + "adoption_velocity", | ||||
| 18 | + "augmentation", | ||||
| 19 | +] as const; | ||||
| 11 | 20 | ||||
| 12 | −async function main(): Promise<void> { | 21 | +type Dim = (typeof DIMS)[number]; | ||
| 13 | − const { code, title } = example.occupation; | 22 | +type Profile = Record<Dim, number>; | ||
| 23 | + | ||||
| 24 | +interface DemoTask { | ||||
| 25 | + id: string; | ||||
| 26 | + statement: string; | ||||
| 27 | + importance: number; | ||||
| 28 | + adjust?: Partial<Profile>; | ||||
| 29 | +} | ||||
| 30 | + | ||||
| 31 | +interface DemoOccupation { | ||||
| 32 | + code: string; | ||||
| 33 | + title: string; | ||||
| 34 | + description: string; | ||||
| 35 | + wageCents: number; // median annual, USD cents | ||||
| 36 | + profile: Profile; | ||||
| 37 | + tasks: DemoTask[]; | ||||
| 38 | +} | ||||
| 39 | + | ||||
| 40 | +const p = ( | ||||
| 41 | + automatability: number, | ||||
| 42 | + feasibility: number, | ||||
| 43 | + cost_ratio: number, | ||||
| 44 | + barriers: number, | ||||
| 45 | + adoption_velocity: number, | ||||
| 46 | + augmentation: number, | ||||
| 47 | +): Profile => ({ automatability, feasibility, cost_ratio, barriers, adoption_velocity, augmentation }); | ||||
| 48 | + | ||||
| 49 | +const DEMO: DemoOccupation[] = [ | ||||
| 50 | + { | ||||
| 51 | + code: "43-9021.00", | ||||
| 52 | + title: "Data Entry Keyers", | ||||
| 53 | + description: | ||||
| 54 | + "Operate data entry devices to enter, verify and prepare data from source documents.", | ||||
| 55 | + wageCents: 3_717_000_00 / 100, | ||||
| 56 | + profile: p(5, 5, 5, 1, 4, 3), | ||||
| 57 | + tasks: [ | ||||
| 58 | + { id: "T1", statement: "Enter account or customer data from source documents within time limits.", importance: 4.6 }, | ||||
| 59 | + { id: "T2", statement: "Verify entered data by reviewing, correcting or re-entering it.", importance: 4.2 }, | ||||
| 60 | + { id: "T3", statement: "Compile, sort and check accuracy of source documents before entry.", importance: 3.8, adjust: { automatability: -1 } }, | ||||
| 61 | + ], | ||||
| 62 | + }, | ||||
| 63 | + { | ||||
| 64 | + code: "43-4051.00", | ||||
| 65 | + title: "Customer Service Representatives", | ||||
| 66 | + description: | ||||
| 67 | + "Interact with customers to handle inquiries, complaints and account questions.", | ||||
| 68 | + wageCents: 3_933_000_00 / 100, | ||||
| 69 | + profile: p(4, 4, 4, 2, 4, 4), | ||||
| 70 | + tasks: [ | ||||
| 71 | + { id: "T1", statement: "Respond to routine customer inquiries about products, services and accounts.", importance: 4.7 }, | ||||
| 72 | + { id: "T2", statement: "Resolve complaints and escalate complex or sensitive cases.", importance: 4.1, adjust: { automatability: -1, barriers: 1 } }, | ||||
| 73 | + { id: "T3", statement: "Keep records of customer interactions and actions taken.", importance: 3.6, adjust: { automatability: 1 } }, | ||||
| 74 | + ], | ||||
| 75 | + }, | ||||
| 76 | + { | ||||
| 77 | + code: "27-3091.00", | ||||
| 78 | + title: "Interpreters and Translators", | ||||
| 79 | + description: "Translate or interpret written and spoken material between languages.", | ||||
| 80 | + wageCents: 5_768_000_00 / 100, | ||||
| 81 | + profile: p(4, 4, 4, 2, 3, 4), | ||||
| 82 | + tasks: [ | ||||
| 83 | + { id: "T1", statement: "Translate written documents while preserving meaning, tone and register.", importance: 4.6 }, | ||||
| 84 | + { id: "T2", statement: "Interpret spoken exchanges in real time between parties.", importance: 4.0, adjust: { barriers: 1 } }, | ||||
| 85 | + { id: "T3", statement: "Proofread and quality-check translated content for accuracy.", importance: 3.7, adjust: { augmentation: 1 } }, | ||||
| 86 | + ], | ||||
| 87 | + }, | ||||
| 88 | + { | ||||
| 89 | + code: "15-1252.00", | ||||
| 90 | + title: "Software Developers", | ||||
| 91 | + description: "Design, build and maintain software systems and applications.", | ||||
| 92 | + wageCents: 13_047_000_00 / 100, | ||||
| 93 | + profile: p(4, 4, 3, 2, 5, 5), | ||||
| 94 | + tasks: [ | ||||
| 95 | + { id: "T1", statement: "Write, test and debug application code to specification.", importance: 4.7 }, | ||||
| 96 | + { id: "T2", statement: "Design system architecture and negotiate technical trade-offs with stakeholders.", importance: 4.2, adjust: { automatability: -2, barriers: 1 } }, | ||||
| 97 | + { id: "T3", statement: "Review code, write documentation and maintain existing services.", importance: 3.9 }, | ||||
| 98 | + ], | ||||
| 99 | + }, | ||||
| 100 | + { | ||||
| 101 | + code: "13-2051.00", | ||||
| 102 | + title: "Financial and Investment Analysts", | ||||
| 103 | + description: "Analyze financial data to guide investment and business decisions.", | ||||
| 104 | + wageCents: 9_918_000_00 / 100, | ||||
| 105 | + profile: p(4, 3, 3, 3, 4, 5), | ||||
| 106 | + tasks: [ | ||||
| 107 | + { id: "T1", statement: "Gather and consolidate financial data into models and reports.", importance: 4.5, adjust: { automatability: 1 } }, | ||||
| 108 | + { id: "T2", statement: "Assess valuation and risk to produce investment recommendations.", importance: 4.3, adjust: { barriers: 1 } }, | ||||
| 109 | + { id: "T3", statement: "Present findings and defend assumptions to clients and committees.", importance: 3.8, adjust: { automatability: -2 } }, | ||||
| 110 | + ], | ||||
| 111 | + }, | ||||
| 112 | + { | ||||
| 113 | + code: "23-1011.00", | ||||
| 114 | + title: "Lawyers", | ||||
| 115 | + description: "Advise and represent clients in legal matters and transactions.", | ||||
| 116 | + wageCents: 14_531_000_00 / 100, | ||||
| 117 | + profile: p(3, 3, 3, 5, 3, 4), | ||||
| 118 | + tasks: [ | ||||
| 119 | + { id: "T1", statement: "Research case law and draft contracts, briefs and legal memoranda.", importance: 4.5, adjust: { automatability: 1, augmentation: 1 } }, | ||||
| 120 | + { id: "T2", statement: "Advise clients on legal strategy, risk and obligations.", importance: 4.4 }, | ||||
| 121 | + { id: "T3", statement: "Represent clients in negotiations, hearings and court proceedings.", importance: 4.0, adjust: { automatability: -1 } }, | ||||
| 122 | + ], | ||||
| 123 | + }, | ||||
| 124 | + { | ||||
| 125 | + code: "25-2021.00", | ||||
| 126 | + title: "Elementary School Teachers", | ||||
| 127 | + description: "Teach academic and social skills to students at the elementary level.", | ||||
| 128 | + wageCents: 6_355_000_00 / 100, | ||||
| 129 | + profile: p(2, 2, 2, 4, 2, 4), | ||||
| 130 | + tasks: [ | ||||
| 131 | + { id: "T1", statement: "Prepare lesson plans, materials and assessments.", importance: 4.4, adjust: { automatability: 1, augmentation: 1 } }, | ||||
| 132 | + { id: "T2", statement: "Instruct and manage a classroom of students in person.", importance: 4.8, adjust: { automatability: -1, barriers: 1 } }, | ||||
| 133 | + { id: "T3", statement: "Grade assignments and track individual student progress.", importance: 3.9, adjust: { automatability: 1 } }, | ||||
| 134 | + ], | ||||
| 135 | + }, | ||||
| 136 | + { | ||||
| 137 | + code: "29-1141.00", | ||||
| 138 | + title: "Registered Nurses", | ||||
| 139 | + description: "Provide and coordinate patient care and educate patients about health conditions.", | ||||
| 140 | + wageCents: 8_607_000_00 / 100, | ||||
| 141 | + profile: p(2, 2, 2, 5, 3, 3), | ||||
| 142 | + tasks: [ | ||||
| 143 | + { id: "T1", statement: "Assess patients and administer treatments and medications.", importance: 4.8, adjust: { automatability: -1 } }, | ||||
| 144 | + { id: "T2", statement: "Document care and maintain accurate patient records.", importance: 4.2, adjust: { automatability: 2, augmentation: 1 } }, | ||||
| 145 | + { id: "T3", statement: "Educate patients and families on conditions and care plans.", importance: 4.0 }, | ||||
| 146 | + ], | ||||
| 147 | + }, | ||||
| 148 | + { | ||||
| 149 | + code: "53-3032.00", | ||||
| 150 | + title: "Heavy and Tractor-Trailer Truck Drivers", | ||||
| 151 | + description: "Drive heavy trucks to transport goods over intercity routes.", | ||||
| 152 | + wageCents: 5_411_000_00 / 100, | ||||
| 153 | + profile: p(2, 2, 2, 4, 2, 2), | ||||
| 154 | + tasks: [ | ||||
| 155 | + { id: "T1", statement: "Drive long-haul routes while complying with hours-of-service rules.", importance: 4.8, adjust: { adoption_velocity: 1 } }, | ||||
| 156 | + { id: "T2", statement: "Inspect vehicles and secure cargo before and after trips.", importance: 4.1 }, | ||||
| 157 | + { id: "T3", statement: "Plan routes and log trip records and fuel purchases.", importance: 3.5, adjust: { automatability: 2, augmentation: 1 } }, | ||||
| 158 | + ], | ||||
| 159 | + }, | ||||
| 160 | + { | ||||
| 161 | + code: "47-2111.00", | ||||
| 162 | + title: "Electricians", | ||||
| 163 | + description: "Install, maintain and repair electrical wiring and systems.", | ||||
| 164 | + wageCents: 6_121_000_00 / 100, | ||||
| 165 | + profile: p(1, 1, 2, 4, 2, 2), | ||||
| 166 | + tasks: [ | ||||
| 167 | + { id: "T1", statement: "Install and repair wiring, fixtures and control systems on site.", importance: 4.8 }, | ||||
| 168 | + { id: "T2", statement: "Diagnose faults using test equipment and building plans.", importance: 4.3, adjust: { augmentation: 1 } }, | ||||
| 169 | + { id: "T3", statement: "Prepare estimates, order materials and document completed work.", importance: 3.4, adjust: { automatability: 2, augmentation: 1 } }, | ||||
| 170 | + ], | ||||
| 171 | + }, | ||||
| 172 | +]; | ||||
| 173 | + | ||||
| 174 | +const clamp = (value: number): number => Math.max(1, Math.min(5, Math.round(value))); | ||||
| 175 | + | ||||
| 176 | +async function seedDemoOccupation(occupation: DemoOccupation): Promise<void> { | ||||
| 14 | await prisma.occupation.upsert({ | 177 | await prisma.occupation.upsert({ | ||
| 15 | − where: { code }, | 178 | + where: { code: occupation.code }, | ||
| 16 | − update: { title }, | 179 | + update: { title: occupation.title, description: occupation.description, medianWageCents: Math.round(occupation.wageCents) }, | ||
| 17 | create: { | 180 | create: { | ||
| 18 | − code, | 181 | + code: occupation.code, | ||
| 19 | − title, | 182 | + title: occupation.title, | ||
| 20 | − description: | 183 | + description: occupation.description, | ||
| 21 | − "Synthetic occupation from the published methodology example. Not real O*NET data.", | 184 | + medianWageCents: Math.round(occupation.wageCents), | ||
| 22 | }, | 185 | }, | ||
| 23 | }); | 186 | }); | ||
| 24 | 187 | ||||
| 25 | − for (const task of example.tasks) { | 188 | + for (const [taskIndex, task] of occupation.tasks.entries()) { | ||
| 189 | + const taskId = `${occupation.code}-${task.id}`; | ||||
| 26 | await prisma.task.upsert({ | 190 | await prisma.task.upsert({ | ||
| 27 | − where: { id: `${code}-${task.taskId}` }, | 191 | + where: { id: taskId }, | ||
| 28 | − update: { importance: task.importance }, | 192 | + update: { statement: task.statement, importance: task.importance }, | ||
| 29 | create: { | 193 | create: { | ||
| 30 | − id: `${code}-${task.taskId}`, | 194 | + id: taskId, | ||
| 31 | − occupationCode: code, | 195 | + occupationCode: occupation.code, | ||
| 32 | − statement: `Synthetic task ${task.taskId} (see docs/methodology/examples/)`, | 196 | + statement: task.statement, | ||
| 33 | importance: task.importance, | 197 | importance: task.importance, | ||
| 34 | }, | 198 | }, | ||
| 35 | }); | 199 | }); | ||
| 200 | + | ||||
| 201 | + for (const [dimIndex, dimension] of DIMS.entries()) { | ||||
| 202 | + const base = clamp(occupation.profile[dimension] + (task.adjust?.[dimension] ?? 0)); | ||||
| 203 | + // Deterministic panel disagreement: model B shifts by -1/0/+1 in a fixed pattern. | ||||
| 204 | + const shift = ((dimIndex + taskIndex) % 3) - 1; | ||||
| 205 | + const panel: Array<[string, number]> = [ | ||||
| 206 | + ["demo-panel-a", base], | ||||
| 207 | + ["demo-panel-b", clamp(base + shift)], | ||||
| 208 | + ]; | ||||
| 209 | + for (const [model, rating] of panel) { | ||||
| 210 | + await prisma.taskRating.upsert({ | ||||
| 211 | + where: { | ||||
| 212 | + taskId_dimension_model_promptVersion_sampleIndex: { | ||||
| 213 | + taskId, | ||||
| 214 | + dimension, | ||||
| 215 | + model, | ||||
| 216 | + promptVersion: "v1", | ||||
| 217 | + sampleIndex: 0, | ||||
| 218 | + }, | ||||
| 219 | + }, | ||||
| 220 | + update: { rating }, | ||||
| 221 | + create: { | ||||
| 222 | + taskId, | ||||
| 223 | + dimension, | ||||
| 224 | + model, | ||||
| 225 | + promptVersion: "v1", | ||||
| 226 | + sampleIndex: 0, | ||||
| 227 | + rating, | ||||
| 228 | + rationale: "Demonstration rating (synthetic panel) — not an LLM output.", | ||||
| 229 | + rawResponse: { demo: true }, | ||||
| 230 | + }, | ||||
| 231 | + }); | ||||
| 232 | + } | ||||
| 233 | + } | ||||
| 36 | } | 234 | } | ||
| 235 | +} | ||||
| 37 | 236 | ||||
| 38 | − console.log(`Seeded example occupation ${code} with ${example.tasks.length} tasks.`); | 237 | +async function main(): Promise<void> { | ||
| 238 | + for (const occupation of DEMO) { | ||||
| 239 | + await seedDemoOccupation(occupation); | ||||
| 240 | + } | ||||
| 241 | + console.log( | ||||
| 242 | + `Seeded ${DEMO.length} demo occupations (${DEMO.reduce((n, o) => n + o.tasks.length, 0)} tasks) with synthetic panel ratings.`, | ||||
| 243 | + ); | ||||
| 244 | + console.log("Next: RATER_MODELS=demo-panel-a,demo-panel-b pnpm score:recompute"); | ||||
| 39 | } | 245 | } | ||
| 40 | 246 | ||||
| 41 | main() | 247 | main() | ||
| 42 | 248 | ||||