/** * backfill-fingerprints.ts * * Generates aggregate_fingerprint for analyzer_analyses rows that are missing * one. Reads triage_response + sonnet_response (+ optional opus_response) from * the legacy model_traces JSONB column on each analysis, runs Stage 6, and * writes the result back via updateAnalysisFingerprint. * * Idempotent — analyses with a fingerprint already in place are skipped at * the SQL filter level, so re-running is safe. * * Usage: * npx tsx scripts/backfill-fingerprints.ts # process all * npx tsx scripts/backfill-fingerprints.ts --limit=50 # cap work * npx tsx scripts/backfill-fingerprints.ts --dry-run # show what would run * * Spec: docs/ticket-analyzer-phase2-spec.md → Section A.3 */ import { config } from 'dotenv'; import { resolve } from 'path'; config({ path: resolve(__dirname, '../.env.local') }); config({ path: resolve(__dirname, '../.env') }); // When run from the host (not inside the docker network), POSTGRES_HOST is // 'postgres' which won't resolve. Fall back to localhost. if (process.env.POSTGRES_HOST === 'postgres') { process.env.POSTGRES_HOST = 'localhost'; } import postgresClient from '../lib/services/postgres-client'; import { runFingerprintStage } from '../lib/services/analyzer/stages/stage6-fingerprint'; import { updateAnalysisFingerprint } from '../lib/services/analyzer/persistence'; import { DeepAnalysisResponse, OpusResponse, TriageResponse, } from '../lib/types/analyzer'; interface BackfillRow { id: string; ticket_number: string; analysis_version: number; model_traces: Record | null; } const BATCH_SIZE = 10; function parseArgs() { const args = process.argv.slice(2); const dryRun = args.includes('--dry-run'); const limitArg = args.find((a) => a.startsWith('--limit=')); const limit = limitArg ? Math.max(0, Number(limitArg.split('=')[1])) : Number.POSITIVE_INFINITY; return { dryRun, limit }; } async function main() { const { dryRun, limit } = parseArgs(); console.log( `[backfill-fingerprints] starting (dryRun=${dryRun}, limit=${ Number.isFinite(limit) ? limit : 'unlimited' })` ); let processed = 0; let succeeded = 0; let skipped = 0; let failed = 0; let totalCost = 0; // Loop in batches until we run out of work or hit the limit. while (processed < limit) { const remaining = Math.min(BATCH_SIZE, limit - processed); const res = await postgresClient.query( `SELECT id::text AS id, ticket_number, analysis_version, model_traces FROM analyzer_analyses WHERE aggregate_fingerprint IS NULL AND status = 'complete' ORDER BY triggered_at ASC LIMIT $1`, [remaining] ); if (res.rowCount === 0) break; for (const row of res.rows) { processed++; const tag = `${row.ticket_number} v${row.analysis_version}`; const traces = row.model_traces ?? {}; const tRaw = (traces as Record).triage_response; const sRaw = (traces as Record).sonnet_response; const oRaw = (traces as Record).opus_response; if (!tRaw || !sRaw) { console.log(`[skip] ${tag} — model_traces missing triage/sonnet`); skipped++; continue; } let triage, sonnet, opus; try { triage = TriageResponse.parse(tRaw); sonnet = DeepAnalysisResponse.parse(sRaw); opus = oRaw ? OpusResponse.parse(oRaw) : null; } catch (err) { console.log( `[skip] ${tag} — model_traces shape unrecognized: ${ err instanceof Error ? err.message : String(err) }` ); skipped++; continue; } if (dryRun) { console.log(`[dry] would fingerprint ${tag}`); continue; } try { const fp = await runFingerprintStage({ triage, sonnet, opus }); await updateAnalysisFingerprint(row.id, fp.data); totalCost += fp.estimated_cost_usd; succeeded++; console.log( `[ok] ${tag} (cost $${fp.estimated_cost_usd.toFixed(4)}, total $${totalCost.toFixed(4)})` ); } catch (err) { failed++; console.error( `[err] ${tag}: ${err instanceof Error ? err.message : String(err)}` ); } } } console.log( `[backfill-fingerprints] done. processed=${processed} succeeded=${succeeded} skipped=${skipped} failed=${failed} totalCost=$${totalCost.toFixed(4)}` ); process.exit(failed > 0 && succeeded === 0 ? 1 : 0); } main().catch((err) => { console.error('[backfill-fingerprints] unhandled error:', err); process.exit(1); });