← back to Wallco Ai
feat(scripts): learn-and-purge-flagged.js — headless equivalent of /admin/ghost-review bulk Delete & learn
b362f0f4d8ea77bccef66816a7729a068e59b153 · 2026-05-24 08:25:40 -0700 · Steve Abrams
Reads data/ghost-scan-flagged.jsonl, keeps only is_published=TRUE rows,
appends each one's prompt+motifs+category to data/bad-aesthetic-patterns.jsonl
(the learning file the next-build generators read to blocklist the
aesthetic), moves PNG to quarantine, then UPDATEs PG.
Mirrors POST /api/ghost-review/bulk-delete server-side with no HTTP
round-trip. Safe to run directly on Kamatera against prod PG.
Dry-run by default; --apply to execute; --no-quarantine to skip PNG move;
--limit N to cap items.
Reversible via: node scripts/soft-delete-ghost-flagged.js --restore --apply
Files touched
A scripts/learn-and-purge-flagged.js
Diff
commit b362f0f4d8ea77bccef66816a7729a068e59b153
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun May 24 08:25:40 2026 -0700
feat(scripts): learn-and-purge-flagged.js — headless equivalent of /admin/ghost-review bulk Delete & learn
Reads data/ghost-scan-flagged.jsonl, keeps only is_published=TRUE rows,
appends each one's prompt+motifs+category to data/bad-aesthetic-patterns.jsonl
(the learning file the next-build generators read to blocklist the
aesthetic), moves PNG to quarantine, then UPDATEs PG.
Mirrors POST /api/ghost-review/bulk-delete server-side with no HTTP
round-trip. Safe to run directly on Kamatera against prod PG.
Dry-run by default; --apply to execute; --no-quarantine to skip PNG move;
--limit N to cap items.
Reversible via: node scripts/soft-delete-ghost-flagged.js --restore --apply
---
scripts/learn-and-purge-flagged.js | 167 +++++++++++++++++++++++++++++++++++++
1 file changed, 167 insertions(+)
diff --git a/scripts/learn-and-purge-flagged.js b/scripts/learn-and-purge-flagged.js
new file mode 100755
index 0000000..561bf25
--- /dev/null
+++ b/scripts/learn-and-purge-flagged.js
@@ -0,0 +1,167 @@
+#!/usr/bin/env node
+// learn-and-purge-flagged — headless equivalent of the /admin/ghost-review
+// "🗑 Delete & learn" bulk action. Reads data/ghost-scan-flagged.jsonl,
+// keeps only ids that are STILL is_published=TRUE in PG, writes each one's
+// prompt + motifs + category to data/bad-aesthetic-patterns.jsonl (the
+// learning file the next-build generators read to blocklist the aesthetic),
+// moves the PNG to quarantine, then UPDATE spoon_all_designs SET
+// is_published=FALSE, user_removed=TRUE on the prod row.
+//
+// Mirrors POST /api/ghost-review/bulk-delete server-side, but with no HTTP
+// round-trip — safe to run directly on Kamatera against the prod PG.
+//
+// Reversible via:
+// node scripts/soft-delete-ghost-flagged.js --restore --apply
+//
+// Usage:
+// node scripts/learn-and-purge-flagged.js # dry-run (default)
+// node scripts/learn-and-purge-flagged.js --apply # execute
+// node scripts/learn-and-purge-flagged.js --limit 500 # cap items
+// node scripts/learn-and-purge-flagged.js --apply --no-quarantine # skip PNG move
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const { psqlQuery } = require('../lib/db.js');
+
+const ROOT = path.join(__dirname, '..');
+const FLAGGED = path.join(ROOT, 'data', 'ghost-scan-flagged.jsonl');
+const LEARN_LOG = path.join(ROOT, 'data', 'bad-aesthetic-patterns.jsonl');
+const PURGE_LOG = path.join(ROOT, 'data', 'ghost-purge.jsonl');
+const GEN_DIR = path.join(ROOT, 'data', 'generated');
+const QUARANTINE = path.join(ROOT, 'data', 'generated_ghost_quarantine');
+
+const ARGS = process.argv.slice(2);
+const APPLY = ARGS.includes('--apply');
+const NO_QUARANTINE = ARGS.includes('--no-quarantine');
+const LIMIT = (() => {
+ const i = ARGS.indexOf('--limit');
+ return i >= 0 ? Math.max(1, parseInt(ARGS[i + 1], 10) || 0) : 0;
+})();
+
+function loadJsonl(file) {
+ if (!fs.existsSync(file)) return [];
+ return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean)
+ .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+}
+
+function chunked(arr, n) {
+ const out = [];
+ for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
+ return out;
+}
+
+function main() {
+ if (!fs.existsSync(FLAGGED)) {
+ console.error(`[learn-purge] flagged file missing: ${FLAGGED}`);
+ process.exit(1);
+ }
+
+ const flagged = loadJsonl(FLAGGED);
+ const ids = [...new Set(flagged.map(r => r.id).filter(Number.isFinite))];
+ if (!ids.length) { console.log('[learn-purge] nothing to do'); return; }
+
+ console.log(`[learn-purge] mode=${APPLY ? 'APPLY' : 'DRY-RUN'}`);
+ console.log(`[learn-purge] flagged.jsonl unique ids: ${ids.length}`);
+
+ // Pull prompt + motifs + local_path + is_published for every id, chunked
+ // so the SQL stays under the command-line length cap.
+ const rowsById = new Map();
+ for (const chunk of chunked(ids, 1000)) {
+ const raw = psqlQuery(
+ `SELECT row_to_json(t) FROM (
+ SELECT id, category, prompt, motifs, local_path, is_published, user_removed
+ FROM spoon_all_designs WHERE id IN (${chunk.join(',')})
+ ) t;`
+ );
+ for (const line of raw.split('\n').filter(Boolean)) {
+ try { const r = JSON.parse(line); rowsById.set(r.id, r); } catch {}
+ }
+ }
+ console.log(`[learn-purge] matched in PG: ${rowsById.size}`);
+
+ // Filter to rows STILL is_published=TRUE (the actual work).
+ let queue = [...rowsById.values()].filter(r => r.is_published === true);
+ if (LIMIT > 0) queue = queue.slice(0, LIMIT);
+ console.log(`[learn-purge] still-published targets: ${queue.length}`);
+
+ // Sample the first 5 so you can sanity-check before --apply.
+ console.log('[learn-purge] sample (first 5):');
+ for (const r of queue.slice(0, 5)) {
+ console.log(` id=${r.id} cat=${r.category} prompt="${(r.prompt || '').slice(0, 70)}…"`);
+ }
+
+ if (!APPLY) {
+ console.log('\n[learn-purge] DRY-RUN complete. Re-run with --apply to execute.');
+ return;
+ }
+
+ // ── apply ──
+ if (!NO_QUARANTINE) fs.mkdirSync(QUARANTINE, { recursive: true });
+
+ const ts = new Date().toISOString();
+ let learned = 0, moved = 0, alreadyMoved = 0, missingPng = 0;
+ const updateIds = [];
+
+ for (const r of queue) {
+ // 1. Learning file — append prompt + motifs so the next-build generator
+ // can blocklist that aesthetic.
+ if (r.prompt) {
+ fs.appendFileSync(LEARN_LOG, JSON.stringify({
+ id: r.id,
+ category: r.category,
+ prompt: r.prompt,
+ motifs: r.motifs,
+ reason: 'ghost-layer (learn-and-purge headless cleanup)',
+ ts,
+ }) + '\n');
+ learned++;
+ }
+
+ // 2. PNG → quarantine (optional)
+ let qPath = null, oPath = null, hadPng = false;
+ if (!NO_QUARANTINE && r.local_path) {
+ oPath = r.local_path;
+ qPath = path.join(QUARANTINE, path.basename(r.local_path));
+ try {
+ if (fs.existsSync(oPath)) { fs.renameSync(oPath, qPath); moved++; hadPng = true; }
+ else if (fs.existsSync(qPath)) { alreadyMoved++; hadPng = true; }
+ else { missingPng++; }
+ } catch { missingPng++; }
+ }
+
+ // 3. Audit log
+ fs.appendFileSync(PURGE_LOG, JSON.stringify({
+ id: r.id, category: r.category, confidence: 1,
+ original_local_path: oPath, quarantine_path: qPath,
+ had_png: hadPng, source: 'learn-and-purge-flagged', ts,
+ }) + '\n');
+
+ updateIds.push(r.id);
+ }
+
+ // 4. PG unpublish — chunked
+ console.log(`[learn-purge] unpublishing ${updateIds.length} PG rows in chunks of 500 …`);
+ let updated = 0;
+ for (const chunk of chunked(updateIds, 500)) {
+ psqlQuery(
+ `UPDATE spoon_all_designs
+ SET is_published=FALSE, user_removed=TRUE
+ WHERE id IN (${chunk.join(',')});`
+ );
+ updated += chunk.length;
+ }
+
+ console.log('\n[learn-purge] DONE');
+ console.log(` learned (prompts saved): ${learned}`);
+ console.log(` moved-to-quarantine: ${moved}`);
+ console.log(` already-quarantined: ${alreadyMoved}`);
+ console.log(` missing-png: ${missingPng}`);
+ console.log(` pg-rows-unpublished: ${updated}`);
+ console.log(` learning file: ${LEARN_LOG}`);
+ console.log(` audit log: ${PURGE_LOG}`);
+ console.log(` reverse with: node scripts/soft-delete-ghost-flagged.js --restore --apply`);
+}
+
+main();
← 5575ce7 Stage 2: composition-detector + smart-fix retry gate
·
back to Wallco Ai
·
feat(regenerate-whole): create sibling design instead of rep 631a8c5 →