[object Object]

← 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

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 →