[object Object]

← back to Ventura Corridor

iter 73: PIVOT to corridor magazine MVP — migration 016 adds magazine_features table (business_id UNIQUE, headline, subhead, editorial, pull_quote, category_tag, photo_url, status draft/reviewed/published/spiked, model, generated_at, reviewed_at, published_at, views, notes); src/jobs/generate_features.ts uses Mac1 Ollama qwen3:14b (per Steve's local-LLM rule) to write 80-130-word magazine features w/ JSON output (headline + subhead + editorial + pull_quote + category_tag), Conde-Nast-Traveler tone, no superlatives, lean on address as sense of place; npm run magazine:gen [N] generates N fresh; --biz=ID regenerates one specific; /api/magazine + PATCH endpoints; /magazine.html viewer w/ Cormorant Garamond serif masthead 'The Boulevard' Vol I Spring 2026, double-rule under masthead, 4-column grid of feature cards, photo-frame placeholder w/ category-tag overlay + first-letter drop cap on lede + pull-quote callouts in copper accent + per-card actions (Mark reviewed → Publish → Spike, ↻ Regen); 5 first features generated successfully (Le Petit Corner, Kelli Rust, Point to Wellness, etc.) at ~25-31s each on MS1 Ollama

1daefa653c72bd16562f8cbbb309fe856d9049c6 · 2026-05-06 16:03:09 -0700 · SteveStudio2

Files touched

Diff

commit 1daefa653c72bd16562f8cbbb309fe856d9049c6
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 6 16:03:09 2026 -0700

    iter 73: PIVOT to corridor magazine MVP — migration 016 adds magazine_features table (business_id UNIQUE, headline, subhead, editorial, pull_quote, category_tag, photo_url, status draft/reviewed/published/spiked, model, generated_at, reviewed_at, published_at, views, notes); src/jobs/generate_features.ts uses Mac1 Ollama qwen3:14b (per Steve's local-LLM rule) to write 80-130-word magazine features w/ JSON output (headline + subhead + editorial + pull_quote + category_tag), Conde-Nast-Traveler tone, no superlatives, lean on address as sense of place; npm run magazine:gen [N] generates N fresh; --biz=ID regenerates one specific; /api/magazine + PATCH endpoints; /magazine.html viewer w/ Cormorant Garamond serif masthead 'The Boulevard' Vol I Spring 2026, double-rule under masthead, 4-column grid of feature cards, photo-frame placeholder w/ category-tag overlay + first-letter drop cap on lede + pull-quote callouts in copper accent + per-card actions (Mark reviewed → Publish → Spike, ↻ Regen); 5 first features generated successfully (Le Petit Corner, Kelli Rust, Point to Wellness, etc.) at ~25-31s each on MS1 Ollama
---
 db/migrations/016_magazine.sql |  28 ++++
 package.json                   |   3 +-
 public/magazine.html           | 309 +++++++++++++++++++++++++++++++++++++++++
 src/jobs/generate_features.ts  | 128 +++++++++++++++++
 src/server/index.ts            |  52 +++++++
 5 files changed, 519 insertions(+), 1 deletion(-)

diff --git a/db/migrations/016_magazine.sql b/db/migrations/016_magazine.sql
new file mode 100644
index 0000000..57cc87a
--- /dev/null
+++ b/db/migrations/016_magazine.sql
@@ -0,0 +1,28 @@
+-- Migration 016 — magazine of corridor businesses with AI-generated editorial
+-- Pivot from outreach pipeline → corridor shopping guide / "magazine of who's here"
+-- Each business gets a glowing AI-written feature; ad-signal-detected businesses prioritized
+-- (they're proven ad-buyers, natural targets for a paid feature placement).
+
+CREATE TABLE IF NOT EXISTS magazine_features (
+  id            BIGSERIAL PRIMARY KEY,
+  business_id   BIGINT       NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+  headline      TEXT,                          -- 4-8 word punchy headline
+  subhead       TEXT,                          -- 10-20 word descriptor
+  editorial     TEXT,                          -- 80-150 word feature paragraph
+  pull_quote    TEXT,                          -- optional callout (≤20 words)
+  category_tag  TEXT,                          -- 'restaurant' | 'professional' | 'beauty' | 'shop' | etc
+  photo_url     TEXT,                          -- placeholder until photos sourced
+  status        TEXT NOT NULL DEFAULT 'draft', -- 'draft' | 'reviewed' | 'published' | 'spiked'
+  model         TEXT,                          -- 'qwen3:14b' | 'claude' | etc
+  prompt_hash   TEXT,                          -- so we can re-gen if prompt changes
+  generated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  reviewed_at   TIMESTAMPTZ,
+  published_at  TIMESTAMPTZ,
+  views         INTEGER NOT NULL DEFAULT 0,
+  notes         TEXT,
+  UNIQUE (business_id)                          -- one feature per business at a time
+);
+
+CREATE INDEX IF NOT EXISTS idx_magfeat_status         ON magazine_features (status);
+CREATE INDEX IF NOT EXISTS idx_magfeat_published_at   ON magazine_features (published_at DESC) WHERE status='published';
+CREATE INDEX IF NOT EXISTS idx_magfeat_category       ON magazine_features (category_tag);
diff --git a/package.json b/package.json
index 4e9dcd2..78153be 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,8 @@
     "enrich:adstransparency": "tsx src/enrich/ads_transparency.ts",
     "migrate": "tsx db/migrate.ts",
     "snapshot": "tsx src/jobs/daily_snapshot.ts",
-    "digest": "tsx src/jobs/weekly_digest.ts"
+    "digest": "tsx src/jobs/weekly_digest.ts",
+    "magazine:gen": "tsx src/jobs/generate_features.ts"
   },
   "dependencies": {
     "dotenv": "^16.4.5",
diff --git a/public/magazine.html b/public/magazine.html
new file mode 100644
index 0000000..039b41f
--- /dev/null
+++ b/public/magazine.html
@@ -0,0 +1,309 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Ventura Corridor — the magazine</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+  @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400;1,500&family=Inter:wght@200;300;400;500;600&family=JetBrains+Mono:wght@200;300;400&display=swap');
+  :root {
+    --paper: #faf6ee;
+    --ink: #1a1815;
+    --ink-mute: #6e6356;
+    --metal: #8a6d3b;
+    --metal-glow: #b89968;
+    --accent: #6a3a1a;
+    --rule: #d8cdb8;
+    --serif: 'Cormorant Garamond', 'Times New Roman', serif;
+    --sans: 'Inter', -apple-system, system-ui, sans-serif;
+    --mono: 'JetBrains Mono', ui-monospace, monospace;
+  }
+  html, body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); font-weight: 300; min-height: 100vh; }
+  * { box-sizing: border-box; }
+
+  /* Masthead */
+  header.masthead {
+    text-align: center;
+    padding: 56px 24px 32px;
+    border-bottom: 4px double var(--ink);
+    background: var(--paper);
+  }
+  header.masthead .kicker {
+    font-family: var(--sans); font-weight: 500;
+    font-size: 10px; letter-spacing: 0.4em; text-transform: uppercase;
+    color: var(--metal); margin-bottom: 14px;
+  }
+  header.masthead h1 {
+    font-family: var(--serif); font-style: italic; font-weight: 500;
+    font-size: 72px; line-height: 0.95; margin: 0; letter-spacing: -0.02em;
+    color: var(--ink);
+  }
+  header.masthead h1 em { font-style: normal; color: var(--metal); }
+  header.masthead .deck {
+    font-family: var(--serif); font-weight: 300; font-style: italic;
+    font-size: 17px; color: var(--ink-mute); margin-top: 14px;
+    max-width: 540px; margin-left: auto; margin-right: auto; line-height: 1.5;
+  }
+  header.masthead .issue {
+    margin-top: 20px;
+    font-family: var(--mono); font-size: 9px; letter-spacing: 0.3em;
+    text-transform: uppercase; color: var(--ink-mute);
+  }
+
+  /* Navigation rail (admin links — collapsed by default) */
+  nav.admin-rail {
+    background: rgba(0,0,0,0.04);
+    padding: 10px 24px;
+    text-align: center;
+    border-bottom: 1px solid var(--rule);
+  }
+  nav.admin-rail a, nav.admin-rail span {
+    font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase;
+    color: var(--ink-mute); margin: 0 8px; text-decoration: none;
+  }
+  nav.admin-rail a:hover { color: var(--metal); }
+
+  /* Filter chips */
+  .chips {
+    display: flex; gap: 8px; flex-wrap: wrap; justify-content: center;
+    padding: 18px 24px; border-bottom: 1px solid var(--rule);
+  }
+  .chips button {
+    background: transparent; border: 1px solid var(--rule);
+    color: var(--ink-mute); font-size: 9px; letter-spacing: 0.22em;
+    text-transform: uppercase; padding: 5px 12px; cursor: pointer;
+    font-family: var(--sans);
+  }
+  .chips button.active { color: var(--ink); border-color: var(--metal); background: rgba(184,153,104,0.08); }
+  .chips button:hover { color: var(--ink); border-color: var(--metal); }
+
+  /* Magazine grid */
+  main.spread {
+    max-width: 1240px; margin: 0 auto; padding: 32px 24px 80px;
+    display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 36px;
+  }
+
+  article.feature {
+    background: var(--paper);
+    padding: 8px 0;
+    position: relative;
+    break-inside: avoid;
+  }
+  article.feature .photo-frame {
+    aspect-ratio: 4/3;
+    background:
+      repeating-linear-gradient(45deg, rgba(0,0,0,0.04) 0 8px, transparent 8px 16px),
+      linear-gradient(135deg, #c8b894 0%, #8a7754 100%);
+    border: 1px solid var(--rule);
+    display: flex; align-items: center; justify-content: center;
+    color: var(--paper);
+    font-family: var(--serif); font-style: italic; font-size: 18px;
+    margin-bottom: 14px;
+    position: relative;
+    overflow: hidden;
+  }
+  article.feature .photo-frame .placeholder-text {
+    text-align: center; padding: 12px; opacity: 0.85;
+    text-shadow: 0 1px 2px rgba(0,0,0,0.3);
+    font-size: 13px; letter-spacing: 0.05em;
+  }
+  article.feature .photo-frame::after {
+    content: '';
+    position: absolute; inset: 0;
+    background: linear-gradient(180deg, transparent 60%, rgba(0,0,0,0.4));
+  }
+  article.feature .photo-frame .cat-tag {
+    position: absolute; top: 12px; left: 12px;
+    background: var(--ink); color: var(--paper);
+    padding: 4px 10px; font-size: 8px; letter-spacing: 0.3em;
+    text-transform: uppercase; font-family: var(--sans); font-weight: 500;
+    z-index: 2;
+  }
+
+  article.feature h2 {
+    font-family: var(--serif); font-weight: 500; font-style: italic;
+    font-size: 30px; line-height: 1.05; margin: 0 0 8px;
+    letter-spacing: -0.01em;
+    color: var(--ink);
+  }
+  article.feature .subhead {
+    font-family: var(--serif); font-weight: 300; font-style: italic;
+    font-size: 16px; color: var(--ink-mute); margin: 0 0 16px;
+    line-height: 1.4;
+  }
+  article.feature .lede {
+    font-family: var(--serif); font-weight: 400;
+    font-size: 16px; line-height: 1.65;
+    color: var(--ink); margin: 0 0 16px;
+  }
+  article.feature .lede::first-letter {
+    font-family: var(--serif); font-weight: 600;
+    font-size: 52px; line-height: 0.9; float: left;
+    margin: 4px 8px 0 0; color: var(--accent);
+  }
+  article.feature .pull {
+    font-family: var(--serif); font-style: italic; font-weight: 400;
+    font-size: 19px; line-height: 1.35;
+    color: var(--accent);
+    border-left: 2px solid var(--metal);
+    padding: 8px 0 8px 16px;
+    margin: 14px 0;
+  }
+  article.feature footer {
+    margin-top: 16px; padding-top: 12px;
+    border-top: 1px solid var(--rule);
+    display: flex; justify-content: space-between; align-items: baseline;
+    gap: 12px; flex-wrap: wrap;
+  }
+  article.feature footer .biz {
+    font-family: var(--sans); font-weight: 500; font-size: 11px;
+    color: var(--ink); letter-spacing: 0.02em;
+  }
+  article.feature footer .addr {
+    font-family: var(--mono); font-size: 9px; letter-spacing: 0.08em;
+    color: var(--ink-mute);
+  }
+  article.feature footer .actions {
+    display: flex; gap: 6px;
+  }
+  article.feature footer .actions button {
+    background: transparent; border: 1px solid var(--rule); color: var(--ink-mute);
+    font-size: 8px; letter-spacing: 0.22em; text-transform: uppercase;
+    padding: 3px 9px; cursor: pointer; font-family: var(--sans);
+  }
+  article.feature footer .actions button:hover { color: var(--metal); border-color: var(--metal); }
+  article.feature footer .actions button.publish { color: var(--accent); border-color: var(--accent); }
+  article.feature footer .actions button.regen { color: var(--metal); }
+
+  article.feature.s-published { border-top: 3px solid var(--accent); padding-top: 14px; }
+  article.feature.s-spiked { opacity: 0.4; }
+
+  .empty { text-align: center; padding: 80px 24px; color: var(--ink-mute); font-style: italic; font-family: var(--serif); font-size: 22px; }
+  .loading { text-align: center; padding: 60px; color: var(--ink-mute); font-size: 11px; letter-spacing: .26em; text-transform: uppercase; }
+
+  /* Print */
+  @media print {
+    nav.admin-rail, .chips, article.feature footer .actions { display: none; }
+    article.feature { break-inside: avoid; page-break-inside: avoid; }
+  }
+</style>
+</head>
+<body>
+
+<header class="masthead">
+  <div class="kicker">Volume I · Spring Issue · 2026</div>
+  <h1>The <em>Boulevard</em></h1>
+  <div class="deck">A magazine of who's here. The corridor's restaurants, professionals, ateliers, and ateliers-disguised-as-storefronts — written warm.</div>
+  <div class="issue" id="issue-stat">— · — · —</div>
+</header>
+
+<nav class="admin-rail">
+  <span style="color:var(--ink-mute)">admin:</span>
+  <a href="/today.html">today</a>
+  <a href="/pitches.html">pitches</a>
+  <a href="/buildings.html">buildings</a>
+  <a href="/walk-route.html">walk route</a>
+  <a href="/responses.html">responses</a>
+  <a href="/postcards.html">postcards</a>
+  <a href="/magazine.html" style="color:var(--metal-glow);font-weight:500">magazine</a>
+  <a href="/crawl-derby.html">🏇 derby</a>
+</nav>
+
+<div class="chips">
+  <button data-status="all" class="active">All features</button>
+  <button data-status="draft">⚙ Drafts (need review)</button>
+  <button data-status="reviewed">✓ Reviewed</button>
+  <button data-status="published">★ Published</button>
+  <button onclick="generateMore()">+ Generate 5 more (Mac1 Ollama)</button>
+</div>
+
+<main class="spread" id="spread">
+  <div class="loading">Loading the issue…</div>
+</main>
+
+<script>
+function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
+let currentStatus = 'all';
+
+async function load() {
+  try {
+    const url = currentStatus === 'all' ? '/api/magazine' : '/api/magazine?status=' + currentStatus;
+    const data = await fetch(url).then(r => r.json());
+    const rows = data.rows || [];
+    const counts = data.by_status || [];
+    const total = counts.reduce((a, x) => a + Number(x.n), 0);
+    const draftN = (counts.find(x => x.status === 'draft') || {}).n || 0;
+    const pubN   = (counts.find(x => x.status === 'published') || {}).n || 0;
+    document.getElementById('issue-stat').textContent =
+      `${total} features · ${draftN} drafts awaiting review · ${pubN} published`;
+    const main = document.getElementById('spread');
+    if (!rows.length) {
+      main.innerHTML = `<div class="empty">No features yet.<br><br><span style="font-size:14px;font-style:normal;color:var(--metal)">Click "+ Generate 5 more" to seed the issue.</span></div>`;
+      return;
+    }
+    main.innerHTML = rows.map(r => {
+      const cat = (r.category_tag || 'shop').toUpperCase();
+      const photoTxt = `${escHtml(r.name)}\n${escHtml((r.address||'').split(',')[0])}`;
+      return `
+      <article class="feature s-${r.status}" data-id="${r.id}">
+        <div class="photo-frame">
+          <span class="cat-tag">${escHtml(cat)}</span>
+          <div class="placeholder-text">${escHtml(r.name)}<br><span style="font-size:10px;letter-spacing:.18em;text-transform:uppercase">photo TBD</span></div>
+        </div>
+        <h2>${escHtml(r.headline || '(untitled)')}</h2>
+        <div class="subhead">${escHtml(r.subhead || '')}</div>
+        <p class="lede">${escHtml(r.editorial || '')}</p>
+        ${r.pull_quote ? `<div class="pull">"${escHtml(r.pull_quote)}"</div>` : ''}
+        <footer>
+          <div>
+            <div class="biz">${escHtml(r.name)}</div>
+            <div class="addr">${escHtml(r.address || '')} · ${escHtml(r.city || '')}</div>
+          </div>
+          <div class="actions">
+            ${r.status === 'draft' ? `<button onclick="advance(${r.id}, 'reviewed')">✓ Mark reviewed</button>` : ''}
+            ${r.status === 'reviewed' ? `<button class="publish" onclick="advance(${r.id}, 'published')">★ Publish</button>` : ''}
+            ${r.status !== 'spiked' ? `<button onclick="advance(${r.id}, 'spiked')">✕ Spike</button>` : `<button onclick="advance(${r.id}, 'draft')">↶ Restore</button>`}
+            <button class="regen" onclick="regen(${r.business_id})">↻ Regen</button>
+          </div>
+        </footer>
+      </article>`;
+    }).join('');
+  } catch (e) {
+    document.getElementById('spread').innerHTML = `<div class="empty">load failed: ${e.message}</div>`;
+  }
+}
+
+async function advance(id, status) {
+  try {
+    const r = await fetch('/api/magazine/' + id, {
+      method: 'PATCH', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ status })
+    });
+    if (!r.ok) throw new Error(await r.text());
+    load();
+  } catch (e) { alert('failed: ' + e.message); }
+}
+
+async function regen(business_id) {
+  if (!confirm('Regenerate the editorial via Mac1 Ollama? Takes ~30s.')) return;
+  // POST to a tiny endpoint that respawns the generator for one biz
+  alert('Run this in terminal:\n\nnpx tsx src/jobs/generate_features.ts --biz=' + business_id + '\n\nThen click any chip to refresh.');
+}
+
+async function generateMore() {
+  alert('Run in terminal:\n\nnpm run magazine:gen 5\n\nTakes ~2 minutes for 5 features. Then click any chip to refresh.');
+}
+
+document.querySelectorAll('.chips button[data-status]').forEach(b => {
+  b.addEventListener('click', () => {
+    document.querySelectorAll('.chips button[data-status]').forEach(x => x.classList.remove('active'));
+    b.classList.add('active');
+    currentStatus = b.dataset.status;
+    load();
+  });
+});
+
+load();
+</script>
+</body>
+</html>
diff --git a/src/jobs/generate_features.ts b/src/jobs/generate_features.ts
new file mode 100644
index 0000000..6d5267e
--- /dev/null
+++ b/src/jobs/generate_features.ts
@@ -0,0 +1,128 @@
+/**
+ * Generate AI-written magazine features for corridor businesses.
+ * Uses Mac1 Ollama qwen3:14b (per Steve's "local LLM for non-DW work" rule).
+ *
+ *   $ npx tsx src/jobs/generate_features.ts          # generate 5 fresh features
+ *   $ npx tsx src/jobs/generate_features.ts 20       # generate 20
+ *   $ npx tsx src/jobs/generate_features.ts --biz=461  # regenerate one specific
+ */
+import 'dotenv/config';
+import { pool, query } from '../../db/pool.ts';
+
+const OLLAMA = process.env.OLLAMA_URL || 'http://100.94.103.98:11434';
+const MODEL  = process.env.OLLAMA_MODEL || 'qwen3:14b';
+
+const args = process.argv.slice(2);
+const SPECIFIC = (args.find(a => a.startsWith('--biz=')) || '').replace('--biz=', '');
+const COUNT = parseInt(args.find(a => /^\d+$/.test(a)) || '5', 10);
+
+const SYSTEM = `You are a corridor editor writing flattering, magazine-style ~100-word features for businesses on Ventura Boulevard in Sherman Oaks / Encino / Tarzana. Your tone is warm but never gushing — think Conde Nast Traveler shopping guide. You highlight what makes the business feel like part of the neighborhood.
+
+Output a JSON object with EXACTLY these fields:
+{
+  "headline":   "6-10 word title",
+  "subhead":    "12-18 word subtitle",
+  "editorial":  "80-130 word feature paragraph",
+  "pull_quote": "8-15 word callout",
+  "category_tag": "one of: restaurant, professional, beauty, shop, fitness, medical, real-estate, hospitality, salon, automotive"
+}
+
+Rules:
+- Never invent specific prices, opening dates, owner names, or claims you can't verify.
+- Lean on the address as a sense of place ("just east of Sepulveda" / "in the white-stone tower at 15821 Ventura").
+- Avoid superlatives ("the BEST"). Prefer concrete sensory details.
+- Output ONLY the JSON. No preamble, no markdown fences.`;
+
+async function generate(biz: any): Promise<any> {
+  const userPrompt = `Business: ${biz.name}
+Address: ${biz.address || 'unknown'}, ${biz.city || ''} ${biz.zip || ''}
+Category: ${biz.naics || biz.pitch_type || biz.category || 'unknown'}
+Building context: ${biz.bldg_address ? `Inside the multi-tenant building at ${biz.bldg_address}` : 'Storefront on Ventura'}
+
+Write the feature.`;
+
+  const r = await fetch(`${OLLAMA}/api/chat`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      model: MODEL,
+      messages: [
+        { role: 'system', content: SYSTEM },
+        { role: 'user',   content: userPrompt }
+      ],
+      stream: false,
+      options: { temperature: 0.7 },
+      format: 'json'
+    })
+  });
+  if (!r.ok) throw new Error(`ollama ${r.status}: ${await r.text()}`);
+  const j = await r.json();
+  const raw = j.message?.content || '';
+  try {
+    return JSON.parse(raw);
+  } catch (e) {
+    throw new Error(`bad JSON from model: ${raw.slice(0, 200)}`);
+  }
+}
+
+async function pickCandidates() {
+  if (SPECIFIC) {
+    const r = await query(
+      `SELECT b.id, b.name, b.address, b.city, b.zip, b.category,
+              b.raw->>'primary_naics_description' AS naics,
+              p.pitch_type,
+              TRIM(regexp_replace(b.address, '\\s*(SUITE|STE|UNIT|#).*$', '', 'i')) AS bldg_address
+       FROM businesses b LEFT JOIN pitches p ON p.business_id = b.id
+       WHERE b.id = $1`,
+      [parseInt(SPECIFIC, 10)]
+    );
+    return r.rows;
+  }
+  // Skip businesses already featured; prefer those with a pitch (better metadata)
+  const r = await query(
+    `SELECT b.id, b.name, b.address, b.city, b.zip, b.category,
+            b.raw->>'primary_naics_description' AS naics,
+            p.pitch_type,
+            TRIM(regexp_replace(b.address, '\\s*(SUITE|STE|UNIT|#).*$', '', 'i')) AS bldg_address
+     FROM businesses b
+     LEFT JOIN pitches p ON p.business_id = b.id
+     WHERE b.on_corridor
+       AND b.merged_into IS NULL
+       AND b.address IS NOT NULL
+       AND b.name NOT ILIKE '%LLC' AND b.name NOT ILIKE '%INC' AND b.name NOT ILIKE '%CORP'
+       AND length(b.name) BETWEEN 4 AND 60
+       AND NOT EXISTS (SELECT 1 FROM magazine_features mf WHERE mf.business_id = b.id)
+     ORDER BY (p.pitch_type IS NOT NULL) DESC, random()
+     LIMIT $1`,
+    [COUNT]
+  );
+  return r.rows;
+}
+
+async function main() {
+  const cands = await pickCandidates();
+  if (!cands.length) { console.log('[generate_features] no candidates'); await pool.end(); return; }
+  console.log(`[generate_features] generating ${cands.length} via ${MODEL} on ${OLLAMA}`);
+  for (const biz of cands) {
+    try {
+      const t0 = Date.now();
+      const feat = await generate(biz);
+      const ms = Date.now() - t0;
+      await query(
+        `INSERT INTO magazine_features (business_id, headline, subhead, editorial, pull_quote, category_tag, status, model, generated_at)
+         VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7, NOW())
+         ON CONFLICT (business_id) DO UPDATE SET
+           headline=EXCLUDED.headline, subhead=EXCLUDED.subhead, editorial=EXCLUDED.editorial,
+           pull_quote=EXCLUDED.pull_quote, category_tag=EXCLUDED.category_tag,
+           model=EXCLUDED.model, generated_at=NOW(), status='draft'`,
+        [biz.id, feat.headline, feat.subhead, feat.editorial, feat.pull_quote, feat.category_tag, MODEL]
+      );
+      console.log(`  ✓ ${biz.id} ${biz.name.slice(0,40).padEnd(40)} (${ms}ms) "${(feat.headline || '').slice(0,50)}"`);
+    } catch (e: any) {
+      console.log(`  ✕ ${biz.id} ${biz.name.slice(0,40).padEnd(40)} — ${e.message.slice(0, 80)}`);
+    }
+  }
+  await pool.end();
+}
+
+main().catch(e => { console.error('[generate_features] fatal:', e); process.exit(1); });
diff --git a/src/server/index.ts b/src/server/index.ts
index 6259a89..851ad0c 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -70,6 +70,8 @@ const ADMIN_PATHS = [
   /^\/api\/activity\/?$/i,
   /^\/api\/data-freshness\/?$/i,
   /^\/api\/stale-pitches\/?$/i,
+  /^\/magazine(\.html)?\/?$/i,
+  /^\/api\/magazine(\/.*)?$/i,
   /^\/crawl-derby(\.html)?\/?$/i,
   /^\/api\/crawl(\/.*)?$/i,
   /^\/postcards(\.html)?\/?$/i,
@@ -1740,6 +1742,56 @@ app.get('/api/data-freshness', async (_req, res) => {
   }
 });
 
+// ─── Magazine: AI-generated features for corridor businesses ────────
+app.get('/api/magazine', async (req, res) => {
+  try {
+    const status = String(req.query.status || 'all'); // all | draft | reviewed | published
+    const where: string[] = [];
+    const params: any[] = [];
+    if (status !== 'all') { params.push(status); where.push(`mf.status = $${params.length}`); }
+    const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200);
+    params.push(limit);
+    const r = await query(
+      `SELECT mf.id, mf.business_id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote,
+              mf.category_tag, mf.photo_url, mf.status, mf.model, mf.generated_at,
+              mf.reviewed_at, mf.published_at, mf.views, mf.notes,
+              b.name, b.address, b.city, b.zip,
+              b.raw->>'primary_naics_description' AS naics
+       FROM magazine_features mf
+       JOIN businesses b ON b.id = mf.business_id
+       ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
+       ORDER BY mf.generated_at DESC
+       LIMIT $${params.length}`,
+      params
+    );
+    const counts = await query(`SELECT status, count(*) AS n FROM magazine_features GROUP BY status`);
+    res.json({ count: r.rowCount, rows: r.rows, by_status: counts.rows });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+app.patch('/api/magazine/:id', express.json(), async (req, res) => {
+  try {
+    const id = parseInt(req.params.id, 10);
+    const allowed = ['headline','subhead','editorial','pull_quote','category_tag','photo_url','status','notes'];
+    const sets: string[] = [];
+    const params: any[] = [];
+    for (const k of allowed) if (k in (req.body || {})) {
+      params.push(req.body[k] === '' ? null : req.body[k]);
+      sets.push(`${k} = $${params.length}`);
+    }
+    if (req.body?.status === 'reviewed') sets.push('reviewed_at = NOW()');
+    if (req.body?.status === 'published') sets.push('published_at = COALESCE(published_at, NOW())');
+    if (!sets.length) return res.status(400).json({ error: 'no fields' });
+    params.push(id);
+    await query(`UPDATE magazine_features SET ${sets.join(', ')} WHERE id = $${params.length}`, params);
+    res.json({ ok: true });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // ─── Stale pitches: sent >N days ago, no reply, not closed ─────────────
 // Powers the "Cold leads" card on /today.html.
 app.get('/api/stale-pitches', async (req, res) => {

← 67f2256 iter 72: weekly digest email job — src/jobs/weekly_digest.ts  ·  back to Ventura Corridor  ·  iter 74: bulk-generate 50 features now + nightly magazine la 78b1bea →