wulf-pulse/scripts/backfill-fingerprints.ts

151 lines
4.6 KiB
TypeScript
Raw Permalink Normal View History

feat(analyzer): Phase 2 — full stage persistence, fingerprints, aggregate reports, cost guards Eight sub-phases per docs/ticket-analyzer-phase2-spec.md: 2.1 Schema (migration 070): analyzer_stage_executions table; source_snapshot, aggregate_fingerprint, fingerprint_generated_at columns on analyzer_analyses. model_traces marked LEGACY (kept for back-compat). 2.2 Every pipeline stage records a row to analyzer_stage_executions, success or failure. Worker persists a status='failed' analyzer_analyses row when the pipeline throws so partial stage records have a parent. Pipeline exposes raw triage/sonnet/opus responses for downstream stages. 2.3 Stage 3 prompt updated with markdown formatting rules + banned filler phrases. Added react-markdown + remark-gfm + @tailwindcss/typography. New <AnalysisMarkdown> component replaces <ProseText>; coerces stray headers to bold paragraphs. 2.4 Stage 6 fingerprint (Haiku) runs after persistence, failure-tolerant. scripts/backfill-fingerprints.ts reconstructs Stage 6 input from the legacy model_traces blob. 2.5 Browse UI rebuild at /analyzer/tickets: multi-select for client/issue/ queue/status/priority/assignee, sticky filter bar, active-filter chips, bulk selection persisted via localStorage, "Analyze N selected" + "Generate aggregate report" actions. New <MultiSelect> primitive. Staleness uses last_activity_date > completed_at heuristic per spec C.1. 2.6 Aggregate reports (migration 071): runner is fire-and-forget, persists SQL distributions immediately so UI shows partial state during the Sonnet reduce call. Three endpoints, three pages (/analyzer/reports[/new /:id]). IT Glue context fetcher capped at 200 doc titles. 2.7 Cost guards (migration 072): per-request $5 confirmation, soft-warn at $20/day, hard-block at $50/day with ANALYZER_DAILY_COST_OVERRIDE_USERS override. Every gating decision audited. 2.8 Runbook + build notes updated. 128 vitest tests passing, tsc clean. Migrations 070/071/072 idempotent (IF NOT EXISTS). model_traces double-write retained — drop in a future migration once aggregate reports have soaked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:00:22 -04:00
/**
* 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<string, unknown> | 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<BackfillRow>(
`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<string, unknown>).triage_response;
const sRaw = (traces as Record<string, unknown>).sonnet_response;
const oRaw = (traces as Record<string, unknown>).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);
});