[object Object]

← back to Ventura Corridor

iter 173: auto-dedupe now also catches trigram-templated headlines (3+ shared 3-word phrases) on top of exact dupes

88857c90281201aa17580ce1cfad239ef24cf488 · 2026-05-06 20:22:50 -0700 · SteveStudio2

Files touched

Diff

commit 88857c90281201aa17580ce1cfad239ef24cf488
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 6 20:22:50 2026 -0700

    iter 173: auto-dedupe now also catches trigram-templated headlines (3+ shared 3-word phrases) on top of exact dupes
---
 src/jobs/auto_dedupe_headlines.ts | 56 +++++++++++++++++++++++++++++++--------
 1 file changed, 45 insertions(+), 11 deletions(-)

diff --git a/src/jobs/auto_dedupe_headlines.ts b/src/jobs/auto_dedupe_headlines.ts
index cbfa2f1..7d0383a 100644
--- a/src/jobs/auto_dedupe_headlines.ts
+++ b/src/jobs/auto_dedupe_headlines.ts
@@ -20,7 +20,8 @@ const ADMIN_PASS = process.env.ADMIN_PASS || 'DWSecure2024!';
 const MAX_REGENS_PER_RUN = 5;
 
 async function main() {
-  const groups = await pool.query(`
+  // 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
@@ -30,24 +31,57 @@ async function main() {
     ORDER BY count(*) DESC
   `);
 
-  if (groups.rowCount === 0) {
-    console.log(`[auto-dedupe] no template groups with ≥3 dupes — nothing to do`);
+  // 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[] = [];
-  for (const g of groups.rows) {
-    // Drop the oldest (keep it), queue the rest
+  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) {
-      if (targets.length >= MAX_REGENS_PER_RUN) break;
-      targets.push(id);
-    }
-    if (targets.length >= MAX_REGENS_PER_RUN) break;
+    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] ${groups.rowCount} template group(s) found, regenerating ${targets.length} runner-up(s) (cap=${MAX_REGENS_PER_RUN})`);
+  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;

← 317fdcf iter 172: /api/magazine/similar/:id — pg_trgm word_similarit  ·  back to Ventura Corridor  ·  iter 174: fix /api/magazine/:id/regen — no longer demotes pu 02da0a2 →