← back to Ventura Corridor

src/jobs/auto_dedupe_headlines.ts

113 lines

// Nightly template-buster. When qwen3 converges to identical headlines (3+
// features sharing the same headline), regenerate the runner-ups so we end
// with unique titles. Keeps the OLDEST copy of each group.
//
// Run manually: npm run magazine:auto-dedupe
// Cron-driven:  launchd 3:33 AM daily, after auto-publish + auto-cover.
//
// Safety:
//   - Only acts on groups with count ≥ 3 (skip noise).
//   - Caps at 5 regens per run (prevents long Mac1-busy windows).
//   - Skips features in 'spiked' state.
//   - Each regen is sequential; logs as it goes.

import { Pool } from 'pg';

const pool = new Pool({ host: '/tmp', database: 'ventura_corridor' });
const PORT = process.env.PORT || '9780';
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
const ADMIN_PASS = process.env.ADMIN_PASS || 'DWSecure2024!';
const MAX_REGENS_PER_RUN = 5;

async function main() {
  // Tier 1: exact-match headline groups
  const exact = await pool.query(`
    SELECT mf.headline,
           array_agg(mf.id ORDER BY mf.generated_at ASC) AS ids
    FROM magazine_features mf
    WHERE mf.headline IS NOT NULL AND mf.status != 'spiked'
    GROUP BY mf.headline
    HAVING count(*) >= 3
    ORDER BY count(*) DESC
  `);

  // Tier 2: trigram-templated headlines (e.g. "X in Y's Heart" recurring 3+ times).
  // Compute trigram counts in JS, then map back to feature IDs.
  const allHeadlines = await pool.query(`
    SELECT id, headline, generated_at FROM magazine_features
    WHERE headline IS NOT NULL AND status != 'spiked'
    ORDER BY generated_at ASC
  `);
  const STOP = new Set('a an the and or in of to for on at with by from is are be was were has have had this that these those'.split(/\s+/));
  type Pos = { id: number; ts: string };
  const triFeatures = new Map<string, Pos[]>();
  for (const row of allHeadlines.rows) {
    const tokens = String(row.headline).toLowerCase().replace(/[^a-z\s']/g, ' ').split(/\s+/).filter(t => t && !STOP.has(t) && t.length > 2);
    const triggered = new Set<string>();
    for (let i = 0; i + 2 < tokens.length; i++) {
      const tri = `${tokens[i]} ${tokens[i+1]} ${tokens[i+2]}`;
      if (triggered.has(tri)) continue;
      triggered.add(tri);
      if (!triFeatures.has(tri)) triFeatures.set(tri, []);
      triFeatures.get(tri)!.push({ id: row.id, ts: row.generated_at });
    }
  }
  const trigramGroups = [...triFeatures.entries()]
    .filter(([, arr]) => arr.length >= 3)
    .sort((a, b) => b[1].length - a[1].length);

  const exactCount = exact.rowCount ?? 0;
  if (exactCount === 0 && trigramGroups.length === 0) {
    console.log(`[auto-dedupe] no exact dupes, no trigram templates ≥3 — nothing to do`);
    await pool.end();
    return;
  }

  // Build target list: exact dupes first (priority), then trigram runner-ups.
  const targets: number[] = [];
  const seen = new Set<number>();
  const queue = (id: number) => { if (!seen.has(id) && targets.length < MAX_REGENS_PER_RUN) { seen.add(id); targets.push(id); } };

  for (const g of exact.rows) {
    const runners = (g.ids as number[]).slice(1);
    for (const id of runners) queue(id);
  }
  for (const [, arr] of trigramGroups) {
    // Skip the oldest member (the canonical use of the phrase) and queue newer ones
    const sorted = [...arr].sort((a, b) => +new Date(a.ts) - +new Date(b.ts));
    for (const p of sorted.slice(1)) queue(p.id);
  }

  console.log(`[auto-dedupe] exact-groups=${exactCount} · trigram-groups=${trigramGroups.length} · regenerating ${targets.length} runner-up(s) (cap=${MAX_REGENS_PER_RUN})`);
  if (trigramGroups.length) {
    console.log(`[auto-dedupe] top trigrams: ${trigramGroups.slice(0, 3).map(([t, arr]) => `"${t}" (${arr.length}×)`).join(', ')}`);
  }

  const auth = 'Basic ' + Buffer.from(`${ADMIN_USER}:${ADMIN_PASS}`).toString('base64');
  let ok = 0, fail = 0;
  for (const id of targets) {
    try {
      const t0 = Date.now();
      const r = await fetch(`http://127.0.0.1:${PORT}/api/magazine/${id}/regen`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: auth },
      });
      const took = ((Date.now() - t0) / 1000).toFixed(1);
      if (r.ok) {
        ok++;
        console.log(`[auto-dedupe] #${id} regen OK (${took}s)`);
      } else {
        fail++;
        console.log(`[auto-dedupe] #${id} regen FAIL ${r.status} (${took}s)`);
      }
    } catch (e: any) {
      fail++;
      console.log(`[auto-dedupe] #${id} regen ERROR: ${e.message}`);
    }
  }
  console.log(`[auto-dedupe] done — ${ok} regenerated, ${fail} failed`);
  await pool.end();
}

main().catch(e => { console.error('[auto-dedupe]', e); process.exit(1); });