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>
Showing 6 changed files with +756 and −60
modified
apps/web/app/globals.css
+41 −0
@@ -1,3 +1,44 @@ | ||
| 1 | 1 | @tailwind base; |
| 2 | 2 | @tailwind components; |
| 3 | 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 | 1 | import type { Metadata } from "next"; |
| 2 | +import Link from "next/link"; | |
| 2 | 3 | import type { ReactNode } from "react"; |
| 4 | +import { INDEX_VERSION } from "@airiskindex/scoring"; | |
| 3 | 5 | import "./globals.css"; |
| 4 | 6 | |
| 5 | 7 | export const metadata: Metadata = { |
@@ -11,7 +13,41 @@ export const metadata: Metadata = { | ||
| 11 | 13 | export default function RootLayout({ children }: { children: ReactNode }): JSX.Element { |
| 12 | 14 | return ( |
| 13 | 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 | 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"; | |
| 2 | −import { INDEX_VERSION } from "@airiskindex/scoring"; | |
| 1 | +import Link from "next/link"; | |
| 2 | +import { prisma } from "@airiskindex/db"; | |
| 3 | +import { ScoreBar } from "@/components/score-marks"; | |
| 4 | + | |
| 5 | +export const dynamic = "force-dynamic"; | |
| 3 | 6 | |
| 4 | 7 | const CONCEPTS = [ |
| 5 | 8 | { |
@@ -10,7 +13,7 @@ const CONCEPTS = [ | ||
| 10 | 13 | { |
| 11 | 14 | name: "Substitution", |
| 12 | 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 | 19 | name: "Augmentation", |
@@ -19,51 +22,148 @@ const CONCEPTS = [ | ||
| 19 | 22 | }, |
| 20 | 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 | 43 | return ( |
| 24 | − <main className="mx-auto max-w-3xl px-6 py-16"> | |
| 25 | − <p className="text-sm font-medium uppercase tracking-wide text-slate-500"> | |
| 26 | − AI Risk Index · methodology {INDEX_VERSION} | |
| 44 | + <main className="mx-auto max-w-4xl px-6 py-14"> | |
| 45 | + <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--muted)]"> | |
| 46 | + The transparent, task-based AI job-exposure index | |
| 27 | 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 | 49 | How is your occupation exposed to AI — task by task? |
| 30 | 50 | </h1> |
| 31 | − <p className="mt-4 text-lg text-slate-600"> | |
| 32 | − We score occupations from their individual tasks, report three separate sub-scores with | |
| 33 | − confidence intervals, and publish every weight, prompt and formula. Built for adaptation | |
| 34 | − planning — not headlines. | |
| 51 | + <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 | |
| 53 | + sub-scores, confidence intervals from rater disagreement, versioned methodology, public | |
| 54 | + API. Built for adaptation planning — not headlines. | |
| 35 | 55 | </p> |
| 36 | 56 | |
| 37 | − <div className="mt-8 flex flex-wrap gap-3"> | |
| 38 | − {/* Example occupation from the published methodology example */} | |
| 39 | − <ScoreBandPill label="Substitution" low={40.9} score={56.7} high={68.4} /> | |
| 40 | − <ScoreBandPill label="Exposure" low={42.0} score={57.1} high={74.1} /> | |
| 41 | − <ScoreBandPill label="Augmentation" low={46.9} score={71.9} high={84.4} /> | |
| 42 | − </div> | |
| 57 | + <section id="ranking" className="mt-14"> | |
| 58 | + <div className="flex items-baseline justify-between"> | |
| 59 | + <h2 className="text-xl font-semibold tracking-tight">Substitution ranking</h2> | |
| 60 | + {run && ( | |
| 61 | + <p className="text-xs text-[var(--muted)]"> | |
| 62 | + run {run.indexVersion} · {run.createdAt.toISOString().slice(0, 10)} | |
| 63 | + </p> | |
| 64 | + )} | |
| 65 | + </div> | |
| 66 | + <p className="mt-1 text-sm text-[var(--ink-2)]"> | |
| 67 | + Composite substitution pressure, 0–100. The whisker marks the confidence interval from | |
| 68 | + rater disagreement — a wide band is a claim we hold loosely. | |
| 69 | + </p> | |
| 43 | 70 | |
| 44 | − <section className="mt-12 grid gap-6 sm:grid-cols-3"> | |
| 45 | − {CONCEPTS.map((concept) => ( | |
| 46 | − <div key={concept.name} className="rounded-lg border border-slate-200 p-5"> | |
| 47 | − <h2 className="font-semibold">{concept.name}</h2> | |
| 48 | − <p className="mt-2 text-sm text-slate-600">{concept.description}</p> | |
| 71 | + {rows.length === 0 ? ( | |
| 72 | + <div className="mt-8 rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-8 text-sm text-[var(--ink-2)]"> | |
| 73 | + No score run published yet. Scores appear here after the first rating batch and{" "} | |
| 74 | + <code>score:recompute</code>. | |
| 49 | 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 | 148 | </section> |
| 52 | 149 | |
| 53 | − <section className="mt-12 text-sm text-slate-600"> | |
| 54 | − <h2 className="text-base font-semibold text-slate-900">Public API</h2> | |
| 55 | − <ul className="mt-2 list-inside list-disc space-y-1"> | |
| 56 | − <li> | |
| 57 | − <code>/api/v1/occupations</code> — search occupations | |
| 58 | − </li> | |
| 59 | − <li> | |
| 60 | − <code>/api/v1/occupations/:code</code> — full score breakdown with sub-scores and CI | |
| 61 | − bounds | |
| 62 | − </li> | |
| 63 | − <li> | |
| 64 | − <code>/api/v1/methodology</code> — machine-readable weights and version metadata | |
| 65 | − </li> | |
| 66 | − </ul> | |
| 150 | + <section id="concepts" className="mt-16"> | |
| 151 | + <h2 className="text-xl font-semibold tracking-tight"> | |
| 152 | + Three scores, never collapsed into one | |
| 153 | + </h2> | |
| 154 | + <div className="mt-5 grid gap-4 sm:grid-cols-3"> | |
| 155 | + {CONCEPTS.map((concept) => ( | |
| 156 | + <div | |
| 157 | + key={concept.name} | |
| 158 | + className="rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-5" | |
| 159 | + > | |
| 160 | + <h3 className="font-semibold">{concept.name}</h3> | |
| 161 | + <p className="mt-2 text-sm leading-relaxed text-[var(--ink-2)]"> | |
| 162 | + {concept.description} | |
| 163 | + </p> | |
| 164 | + </div> | |
| 165 | + ))} | |
| 166 | + </div> | |
| 67 | 167 | </section> |
| 68 | 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 | 1 | import { PrismaClient } from "@prisma/client"; |
| 3 | 2 | |
| 4 | −// Seeds the synthetic methodology example occupation so the app and API have | |
| 5 | −// demo data before the real O*NET ETL runs. Real data: `cd apps/etl && make pipeline`. | |
| 3 | +// Demo seed: 10 real O*NET-SOC occupations with plausible hand-authored task | |
| 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 | 10 | const prisma = new PrismaClient(); |
| 7 | 11 | |
| 8 | −const example = JSON.parse( | |
| 9 | − readFileSync(new URL("../../../docs/methodology/examples/example-analyst.json", import.meta.url), "utf8"), | |
| 10 | −); | |
| 12 | +const DIMS = [ | |
| 13 | + "automatability", | |
| 14 | + "feasibility", | |
| 15 | + "cost_ratio", | |
| 16 | + "barriers", | |
| 17 | + "adoption_velocity", | |
| 18 | + "augmentation", | |
| 19 | +] as const; | |
| 11 | 20 | |
| 12 | −async function main(): Promise<void> { | |
| 13 | − const { code, title } = example.occupation; | |
| 21 | +type Dim = (typeof DIMS)[number]; | |
| 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 | 177 | await prisma.occupation.upsert({ |
| 15 | − where: { code }, | |
| 16 | − update: { title }, | |
| 178 | + where: { code: occupation.code }, | |
| 179 | + update: { title: occupation.title, description: occupation.description, medianWageCents: Math.round(occupation.wageCents) }, | |
| 17 | 180 | create: { |
| 18 | − code, | |
| 19 | − title, | |
| 20 | − description: | |
| 21 | − "Synthetic occupation from the published methodology example. Not real O*NET data.", | |
| 181 | + code: occupation.code, | |
| 182 | + title: occupation.title, | |
| 183 | + description: occupation.description, | |
| 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 | 190 | await prisma.task.upsert({ |
| 27 | − where: { id: `${code}-${task.taskId}` }, | |
| 28 | − update: { importance: task.importance }, | |
| 191 | + where: { id: taskId }, | |
| 192 | + update: { statement: task.statement, importance: task.importance }, | |
| 29 | 193 | create: { |
| 30 | − id: `${code}-${task.taskId}`, | |
| 31 | − occupationCode: code, | |
| 32 | − statement: `Synthetic task ${task.taskId} (see docs/methodology/examples/)`, | |
| 194 | + id: taskId, | |
| 195 | + occupationCode: occupation.code, | |
| 196 | + statement: task.statement, | |
| 33 | 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 | 247 | main() |
| 42 | 248 | |