← 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
M package.jsonA src/jobs/normalize_categories.tsM src/server/index.ts
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 →