[object Object]

← back to Ventura Corridor

iter 87+88: stale-magazine alert + deterministic category normalizer — /api/magazine/stale?days=N returns drafts older than N days (default 7) sorted oldest-first; src/jobs/normalize_categories.ts deterministically re-maps category_tag from pitch_type using a 30-key lookup (restaurant/food-service/cafe/bar→restaurant, salon-spa/beauty-salon→salon, medical-clinic/dental/chiropractic/therapy→medical, fitness-studio/gym/yoga→fitness, law-office/accountant/cpa→professional, etc.); applied: 21 of 49 features re-categorized — fixed qwen3's over-tagging of hospitality as restaurant (1→7) and miscategorization of medical (4→14); npm run magazine:normalize

e6c8c011fcb837250d084824a0adaf7bc7dc1bc9 · 2026-05-06 17:03:14 -0700 · SteveStudio2

Files touched

Diff

commit e6c8c011fcb837250d084824a0adaf7bc7dc1bc9
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 6 17:03:14 2026 -0700

    iter 87+88: stale-magazine alert + deterministic category normalizer — /api/magazine/stale?days=N returns drafts older than N days (default 7) sorted oldest-first; src/jobs/normalize_categories.ts deterministically re-maps category_tag from pitch_type using a 30-key lookup (restaurant/food-service/cafe/bar→restaurant, salon-spa/beauty-salon→salon, medical-clinic/dental/chiropractic/therapy→medical, fitness-studio/gym/yoga→fitness, law-office/accountant/cpa→professional, etc.); applied: 21 of 49 features re-categorized — fixed qwen3's over-tagging of hospitality as restaurant (1→7) and miscategorization of medical (4→14); npm run magazine:normalize
---
 package.json                     |  3 +-
 src/jobs/normalize_categories.ts | 72 ++++++++++++++++++++++++++++++++++++++++
 src/server/index.ts              | 21 ++++++++++++
 3 files changed, 95 insertions(+), 1 deletion(-)

diff --git a/package.json b/package.json
index 38e3cd8..9dd1d93 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,8 @@
     "digest": "tsx src/jobs/weekly_digest.ts",
     "magazine:gen": "tsx src/jobs/generate_features.ts",
     "magazine:photos": "tsx src/jobs/find_photos.ts",
-    "magazine:watchdog": "tsx src/jobs/magazine_watchdog.ts"
+    "magazine:watchdog": "tsx src/jobs/magazine_watchdog.ts",
+    "magazine:normalize": "tsx src/jobs/normalize_categories.ts"
   },
   "dependencies": {
     "dotenv": "^16.4.5",
diff --git a/src/jobs/normalize_categories.ts b/src/jobs/normalize_categories.ts
new file mode 100644
index 0000000..6513259
--- /dev/null
+++ b/src/jobs/normalize_categories.ts
@@ -0,0 +1,72 @@
+/**
+ * Backfill / fix category_tag on magazine_features using a deterministic
+ * pitch_type → category_tag mapping (instead of trusting qwen3's pick).
+ * Runs idempotently; only updates rows where the deterministic value differs.
+ *
+ *   $ npx tsx src/jobs/normalize_categories.ts          # apply to all
+ *   $ npx tsx src/jobs/normalize_categories.ts --dry-run
+ */
+import 'dotenv/config';
+import { pool, query } from '../../db/pool.ts';
+
+const DRY = process.argv.includes('--dry-run');
+
+const MAP: Record<string, string> = {
+  'restaurant':         'restaurant',
+  'food-service':       'restaurant',
+  'cafe':               'restaurant',
+  'bar':                'restaurant',
+  'salon-spa':          'salon',
+  'beauty-salon':       'salon',
+  'beauty-spa':         'beauty',
+  'nail-salon':         'beauty',
+  'medical-clinic':     'medical',
+  'dental-office':      'medical',
+  'chiropractic':       'medical',
+  'therapy':            'medical',
+  'fitness-studio':     'fitness',
+  'gym':                'fitness',
+  'yoga':               'fitness',
+  'law-office':         'professional',
+  'accountant':         'professional',
+  'cpa':                'professional',
+  'real-estate':        'real-estate',
+  'real-estate-agent':  'real-estate',
+  'realty':             'real-estate',
+  'retail-storefront':  'shop',
+  'boutique':           'shop',
+  'gift-shop':          'shop',
+  'office-tenant':      'professional',
+  'building-neighbor':  'professional',
+  'hospitality':        'hospitality',
+  'hotel':              'hospitality',
+  'automotive':         'automotive',
+  'auto-repair':        'automotive',
+  'car-wash':           'automotive',
+  'beauty':             'beauty',
+};
+
+async function main() {
+  const r = await query(`
+    SELECT mf.id, mf.category_tag, p.pitch_type
+    FROM magazine_features mf
+    JOIN pitches p ON p.business_id = mf.business_id
+    WHERE p.pitch_type IS NOT NULL
+  `);
+  let toUpdate = 0;
+  for (const row of r.rows) {
+    const expected = MAP[row.pitch_type];
+    if (!expected) continue;
+    if (row.category_tag === expected) continue;
+    if (DRY) {
+      console.log(`  [dry] ${row.id} ${row.pitch_type} : ${row.category_tag} → ${expected}`);
+    } else {
+      await query(`UPDATE magazine_features SET category_tag = $1 WHERE id = $2`, [expected, row.id]);
+    }
+    toUpdate++;
+  }
+  console.log(`[normalize_categories] ${DRY ? 'would update' : 'updated'} ${toUpdate} of ${r.rowCount} features`);
+  await pool.end();
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/src/server/index.ts b/src/server/index.ts
index e647a50..ff8e881 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -1746,6 +1746,27 @@ app.get('/api/data-freshness', async (_req, res) => {
 });
 
 // Stable per-day feature pick: use day-of-year as seed so it's deterministic for "today"
+app.get('/api/magazine/stale', async (req, res) => {
+  try {
+    const days = Math.max(parseInt(String(req.query.days ?? '7'), 10) || 7, 1);
+    const r = await query(
+      `SELECT mf.id, mf.headline, mf.category_tag, mf.generated_at,
+              FLOOR(EXTRACT(EPOCH FROM (NOW() - mf.generated_at))/86400)::int AS days_old,
+              b.name AS biz_name
+       FROM magazine_features mf
+       JOIN businesses b ON b.id = mf.business_id
+       WHERE mf.status = 'draft'
+         AND mf.generated_at < NOW() - ($1 || ' days')::interval
+       ORDER BY mf.generated_at ASC
+       LIMIT 30`,
+      [days]
+    );
+    res.json({ count: r.rowCount, days_threshold: days, rows: r.rows });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 app.get('/api/magazine/feature-of-the-day', async (_req, res) => {
   try {
     const r = await query(`

← c666532 iter 86: nav surfacing for /issue + /scoreboard — adds links  ·  back to Ventura Corridor  ·  iter 89+90: per-vertical /issue nav strip + bulk-review-draf 082198f →