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>
150 lines
4.6 KiB
TypeScript
150 lines
4.6 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|