|
| 1 | +// One-shot reprocess: re-run the β filer's persistence step against an |
| 2 | +// already-existing mutation_results_pg row. Used to validate the type-coercion |
| 3 | +// bug fix without burning another Sonnet call. |
| 4 | +// |
| 5 | +// Usage: node scripts/_reprocess_spotter_filer.mjs <run_id> |
| 6 | + |
| 7 | +import { withClient } from "/app/dist/pg_pool.js"; |
| 8 | +import { randomUUID } from "node:crypto"; |
| 9 | + |
| 10 | +const runId = process.argv[2]; |
| 11 | +if (!runId) { |
| 12 | + console.error("usage: node _reprocess_spotter_filer.mjs <run_id>"); |
| 13 | + process.exit(1); |
| 14 | +} |
| 15 | + |
| 16 | +// 1. Fetch the existing mutation_results row. |
| 17 | +const row = await withClient(async (c) => { |
| 18 | + const r = await c.query( |
| 19 | + `SELECT result_id, bodies, bodies_hash, candidate_count |
| 20 | + FROM mutation_results_pg |
| 21 | + WHERE mutation_id = $1 |
| 22 | + ORDER BY created_at DESC LIMIT 1`, |
| 23 | + [runId], |
| 24 | + ); |
| 25 | + return r.rows[0] ?? null; |
| 26 | +}); |
| 27 | +if (!row) { |
| 28 | + console.error(`No mutation_results row for run_id=${runId}`); |
| 29 | + process.exit(1); |
| 30 | +} |
| 31 | + |
| 32 | +console.log(`Found result_id=${row.result_id} with ${row.candidate_count} bodies`); |
| 33 | + |
| 34 | +// 2. Decode bodies as decisions, applying the new lenient validation. |
| 35 | +const bodies = JSON.parse(row.bodies); |
| 36 | +const VALID_OUTCOMES = new Set([ |
| 37 | + "filed_candidate", "rejected_low_signal", "rejected_not_procedural", |
| 38 | + "rejected_fits_in_prompt", "rejected_duplicate", "rejected_variable_instances", |
| 39 | +]); |
| 40 | + |
| 41 | +const decisions = []; |
| 42 | +for (const body of bodies) { |
| 43 | + let parsed; |
| 44 | + try { parsed = JSON.parse(body.candidate_body); } |
| 45 | + catch (e) { console.warn("malformed body, skip:", e.message); continue; } |
| 46 | + const coercedId = typeof parsed.signal_id === "number" |
| 47 | + ? parsed.signal_id |
| 48 | + : (typeof parsed.signal_id === "string" && /^-?\d+$/.test(parsed.signal_id) |
| 49 | + ? parseInt(parsed.signal_id, 10) |
| 50 | + : NaN); |
| 51 | + if (!Number.isFinite(coercedId)) { |
| 52 | + console.warn(`skip: bad signal_id=${JSON.stringify(parsed.signal_id)}`); |
| 53 | + continue; |
| 54 | + } |
| 55 | + if (!VALID_OUTCOMES.has(parsed.outcome)) { |
| 56 | + console.warn(`skip: bad outcome=${parsed.outcome}`); |
| 57 | + continue; |
| 58 | + } |
| 59 | + decisions.push({ ...parsed, signal_id: coercedId }); |
| 60 | +} |
| 61 | + |
| 62 | +console.log(`Decoded ${decisions.length} valid decisions`); |
| 63 | + |
| 64 | +// 3. Apply decisions to skill_spotter_signals_pg + skill_candidates_pg. |
| 65 | +const byOutcome = { |
| 66 | + filed_candidate: 0, rejected_low_signal: 0, rejected_not_procedural: 0, |
| 67 | + rejected_fits_in_prompt: 0, rejected_duplicate: 0, rejected_variable_instances: 0, |
| 68 | +}; |
| 69 | +let candidatesFiled = 0; |
| 70 | + |
| 71 | +for (const d of decisions) { |
| 72 | + byOutcome[d.outcome]++; |
| 73 | + if (d.outcome === "filed_candidate" && d.candidate) { |
| 74 | + const candidateId = randomUUID(); |
| 75 | + const cand = d.candidate; |
| 76 | + await withClient(async (c) => { |
| 77 | + await c.query( |
| 78 | + `INSERT INTO skill_candidates_pg ( |
| 79 | + candidate_id, project_hash, target_role, rejection_count, |
| 80 | + first_rejection_at, last_rejection_at, rejection_outcomes, |
| 81 | + headline, proposed_skill_body, proposed_at, status |
| 82 | + ) VALUES ($1, $2, $3, $4, now(), now(), $5::jsonb, |
| 83 | + $6, $7, now(), 'ready')`, |
| 84 | + [ |
| 85 | + candidateId, "spotter-global", "developer", 0, |
| 86 | + JSON.stringify({ source: "skill-spotter", signal_id: d.signal_id, scope: cand.scope, result_id: row.result_id, bodies_hash: row.bodies_hash }), |
| 87 | + `[spotter] ${cand.skill_name}: ${(cand.description ?? "").slice(0, 140)}`, |
| 88 | + `---\nname: ${cand.skill_name}\ndescription: |\n ${cand.description}\nscope: ${cand.scope}\n---\n\n${cand.proposed_skill_body}`, |
| 89 | + ], |
| 90 | + ); |
| 91 | + await c.query( |
| 92 | + `UPDATE skill_spotter_signals_pg |
| 93 | + SET outcome = 'filed_candidate', outcome_reason = $2, candidate_id = $3::uuid |
| 94 | + WHERE signal_id = $1`, |
| 95 | + [d.signal_id, d.outcome_reason ?? "", candidateId], |
| 96 | + ); |
| 97 | + }); |
| 98 | + candidatesFiled++; |
| 99 | + console.log(` filed: signal_id=${d.signal_id} as ${cand.skill_name}`); |
| 100 | + } else { |
| 101 | + await withClient(async (c) => { |
| 102 | + await c.query( |
| 103 | + `UPDATE skill_spotter_signals_pg |
| 104 | + SET outcome = $2, outcome_reason = $3 |
| 105 | + WHERE signal_id = $1`, |
| 106 | + [d.signal_id, d.outcome, d.outcome_reason ?? ""], |
| 107 | + ); |
| 108 | + }); |
| 109 | + console.log(` signal_id=${d.signal_id}: ${d.outcome}`); |
| 110 | + } |
| 111 | +} |
| 112 | + |
| 113 | +// 4. Update the spotter run row. |
| 114 | +await withClient(async (c) => { |
| 115 | + await c.query( |
| 116 | + `UPDATE skill_spotter_runs_pg |
| 117 | + SET mode = 'llm-proposed', candidates_filed = $2 |
| 118 | + WHERE run_id = $1`, |
| 119 | + [runId, candidatesFiled], |
| 120 | + ); |
| 121 | +}); |
| 122 | + |
| 123 | +console.log(""); |
| 124 | +console.log("=== REPROCESS COMPLETE ==="); |
| 125 | +console.log(`signals_processed: ${decisions.length}`); |
| 126 | +console.log(`candidates_filed: ${candidatesFiled}`); |
| 127 | +console.log("by_outcome: ", JSON.stringify(byOutcome)); |
| 128 | +process.exit(0); |
0 commit comments