SPB Git

spb/airiskindex Public

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

TypeScript 88% Python 6.1% SQL 2.7% CSS 1.2% JavaScript 0.9% Shell 0.8%
5.1 KB · 159 lines typescript
Raw Blame History
1//  File:    recompute.ts2//  Path:    apps/worker/src/scripts/recompute.ts3//  Project: AI Risk Index — airiskindex.io4//  Author:  Simon-Pierre Boucher5//  Contact: contact@spboucher.ai6//  Copyright © 2026 Simon-Pierre Boucher. All rights reserved.7//8//  Description: Full index recomputation: panel bands + overrides -> new immutable score run.910import { prisma } from "@airiskindex/db";11import {12  INDEX_VERSION,13  scoreOccupation,14  type RatingBand,15  type TaskInput,16  type TaskRatings,17} from "@airiskindex/scoring";18import { RATED_DIMENSIONS } from "../raters/schema";19import { RATER_MODELS, RATER_PROMPT_VERSION } from "../config";2021// Full index recomputation (`pnpm score:recompute`): builds rating bands from22// the multi-model panel (min/mean/max across models — METHODOLOGY.md §3),23// applies expert overrides, scores every occupation, and writes ONE new24// immutable ScoreRun. Historical runs are never mutated (CLAUDE.md §9).2526function bandFromPanel(values: number[]): RatingBand {27  const low = Math.min(...values);28  const high = Math.max(...values);29  const mid = values.reduce((sum, value) => sum + value, 0) / values.length;30  return { low, mid, high };31}3233async function main(): Promise<void> {34  // Load occupations lazily, one at a time, selecting only the rating fields35  // the computation needs — a full include would materialize every raw API36  // response (~1 GB of JSON) in one query and overflow the engine bridge.37  const occupationCodes = await prisma.occupation.findMany({ select: { code: true } });3839  const run = await prisma.scoreRun.create({40    data: {41      indexVersion: INDEX_VERSION,42      raterPromptVersion: RATER_PROMPT_VERSION,43      raterModels: [...RATER_MODELS],44    },45  });4647  let scoredOccupations = 0;48  let skippedTasks = 0;4950  for (const { code } of occupationCodes) {51    const occupation = await prisma.occupation.findUniqueOrThrow({52      where: { code },53      select: {54        code: true,55        tasks: {56          select: {57            id: true,58            importance: true,59            ratings: {60              where: { promptVersion: RATER_PROMPT_VERSION },61              select: { dimension: true, rating: true },62            },63            overrides: {64              select: { dimension: true, ratingLow: true, ratingMid: true, ratingHigh: true },65            },66          },67        },68      },69    });70    const inputs: TaskInput[] = [];7172    for (const task of occupation.tasks) {73      const bands: Partial<Record<(typeof RATED_DIMENSIONS)[number], RatingBand>> = {};74      let complete = true;7576      for (const dimension of RATED_DIMENSIONS) {77        const override = task.overrides.find((entry) => entry.dimension === dimension);78        if (override) {79          bands[dimension] = {80            low: override.ratingLow,81            mid: override.ratingMid,82            high: override.ratingHigh,83          };84          continue;85        }86        const values = task.ratings87          .filter((entry) => entry.dimension === dimension)88          .map((entry) => entry.rating);89        if (values.length === 0) {90          complete = false;91          break;92        }93        bands[dimension] = bandFromPanel(values);94      }9596      if (!complete) {97        skippedTasks += 1;98        continue;99      }100      inputs.push({101        taskId: task.id,102        importance: task.importance ?? undefined,103        ratings: bands as TaskRatings,104      });105    }106107    if (inputs.length === 0) continue;108109    const scores = scoreOccupation(inputs);110    await prisma.$transaction([111      prisma.occupationScore.create({112        data: {113          runId: run.id,114          occupationCode: occupation.code,115          substitutionLow: scores.substitution.low,116          substitution: scores.substitution.score,117          substitutionHigh: scores.substitution.high,118          exposureLow: scores.exposure.low,119          exposure: scores.exposure.score,120          exposureHigh: scores.exposure.high,121          augmentationLow: scores.augmentation.low,122          augmentation: scores.augmentation.score,123          augmentationHigh: scores.augmentation.high,124          highlyExposedTaskShare: scores.highlyExposedTaskShare,125        },126      }),127      ...scores.tasks.map((task) =>128        prisma.taskScore.create({129          data: {130            runId: run.id,131            taskId: task.taskId,132            substitutionLow: task.substitution.low,133            substitution: task.substitution.score,134            substitutionHigh: task.substitution.high,135            exposureLow: task.exposure.low,136            exposure: task.exposure.score,137            exposureHigh: task.exposure.high,138            augmentationLow: task.augmentation.low,139            augmentation: task.augmentation.score,140            augmentationHigh: task.augmentation.high,141          },142        }),143      ),144    ]);145    scoredOccupations += 1;146  }147148  console.log(149    `Run ${run.id} (index ${INDEX_VERSION}, prompt ${RATER_PROMPT_VERSION}): scored ${scoredOccupations}/${occupationCodes.length} occupations; skipped ${skippedTasks} unrated tasks.`,150  );151}152153main()154  .catch((error) => {155    console.error(error);156    process.exitCode = 1;157  })158  .finally(() => prisma.$disconnect());159