[object Object]

← back to Ventura Corridor

iter 77+78: dual-model alternating generation + /magazine/:id full-page reader — generator now rotates between qwen3:14b (~25s) and qwen3:8b (~12s) per feature for ~30% throughput gain without server-config changes; retry-with-exponential-backoff handles MS1 503 'server busy' (8 attempts, 2s base * 1.6^n + jitter); 2000-feature overnight run kicked off; /magazine/:id reader page renders one feature in full-page magazine layout (Cormorant Garamond 64px italic headline, drop-cap lede, copper-tone pull-quote, dl meta with business/address/trade/status/generated_at/model/views), back-to-issue + print + pipeline-link toolbar; views counter auto-increments on each load; click any headline on /magazine.html now opens its reader page

34f78f5a03d8123abe67276cdb5fe5bb45ce1b84 · 2026-05-06 16:34:34 -0700 · SteveStudio2

Files touched

Diff

commit 34f78f5a03d8123abe67276cdb5fe5bb45ce1b84
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 6 16:34:34 2026 -0700

    iter 77+78: dual-model alternating generation + /magazine/:id full-page reader — generator now rotates between qwen3:14b (~25s) and qwen3:8b (~12s) per feature for ~30% throughput gain without server-config changes; retry-with-exponential-backoff handles MS1 503 'server busy' (8 attempts, 2s base * 1.6^n + jitter); 2000-feature overnight run kicked off; /magazine/:id reader page renders one feature in full-page magazine layout (Cormorant Garamond 64px italic headline, drop-cap lede, copper-tone pull-quote, dl meta with business/address/trade/status/generated_at/model/views), back-to-issue + print + pipeline-link toolbar; views counter auto-increments on each load; click any headline on /magazine.html now opens its reader page
---
 public/magazine.html          |  2 +-
 src/jobs/generate_features.ts | 95 ++++++++++++++++++++++++++-----------------
 src/server/index.ts           | 76 ++++++++++++++++++++++++++++++++++
 3 files changed, 134 insertions(+), 39 deletions(-)

diff --git a/public/magazine.html b/public/magazine.html
index de28927..d90d729 100644
--- a/public/magazine.html
+++ b/public/magazine.html
@@ -283,7 +283,7 @@ async function load() {
           <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>
+        <h2><a href="/magazine/${r.id}" style="color:inherit;text-decoration:none">${escHtml(r.headline || '(untitled)')}</a></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>` : ''}
diff --git a/src/jobs/generate_features.ts b/src/jobs/generate_features.ts
index 05fbb8e..b627b47 100644
--- a/src/jobs/generate_features.ts
+++ b/src/jobs/generate_features.ts
@@ -10,8 +10,10 @@ 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 CONCURRENCY = Math.max(1, parseInt(process.env.CONCURRENCY || '1', 10));
+// Round-robin across these models. Both fit in MS1 unified memory; Ollama serializes
+// per-model but parallelizes across different models, so 2 models = ~2x throughput.
+const MODELS = (process.env.OLLAMA_MODELS || 'qwen3:14b,qwen3:8b').split(',').map(m => m.trim());
+const CONCURRENCY = Math.max(1, parseInt(process.env.CONCURRENCY || String(MODELS.length), 10));
 
 const args = process.argv.slice(2);
 const SPECIFIC = (args.find(a => a.startsWith('--biz=')) || '').replace('--biz=', '');
@@ -34,7 +36,7 @@ Rules:
 - Avoid superlatives ("the BEST"). Prefer concrete sensory details.
 - Output ONLY the JSON. No preamble, no markdown fences.`;
 
-async function generate(biz: any): Promise<any> {
+async function generate(biz: any, model: string): Promise<any> {
   const userPrompt = `Business: ${biz.name}
 Address: ${biz.address || 'unknown'}, ${biz.city || ''} ${biz.zip || ''}
 Category: ${biz.naics || biz.pitch_type || biz.category || 'unknown'}
@@ -42,28 +44,44 @@ Building context: ${biz.bldg_address ? `Inside the multi-tenant building at ${bi
 
 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)}`);
+  // Retry on 503 "server busy" with exponential backoff. MS1 Ollama queues
+  // briefly then rejects — backoff lets the queue drain.
+  let lastErr: any;
+  for (let attempt = 0; attempt < 8; attempt++) {
+    try {
+      const r = await fetch(`${OLLAMA}/api/chat`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          model,
+          messages: [
+            { role: 'system', content: SYSTEM },
+            { role: 'user',   content: userPrompt }
+          ],
+          stream: false,
+          options: { temperature: 0.7 },
+          format: 'json'
+        })
+      });
+      if (r.status === 503) {
+        const wait = 2000 * Math.pow(1.6, attempt) + Math.random() * 500;
+        await new Promise(res => setTimeout(res, wait));
+        lastErr = new Error('503 server busy');
+        continue;
+      }
+      if (!r.ok) throw new Error(`ollama ${r.status}: ${(await r.text()).slice(0, 120)}`);
+      const j = await r.json();
+      const raw = j.message?.content || '';
+      try { return JSON.parse(raw); }
+      catch { throw new Error(`bad JSON from model: ${raw.slice(0, 120)}`); }
+    } catch (e: any) {
+      lastErr = e;
+      if (!/503|busy|fetch failed|ECONNRESET/.test(e.message)) throw e;
+      const wait = 2000 * Math.pow(1.6, attempt) + Math.random() * 500;
+      await new Promise(res => setTimeout(res, wait));
+    }
   }
+  throw lastErr || new Error('exhausted retries');
 }
 
 async function pickCandidates() {
@@ -103,12 +121,12 @@ async function pickCandidates() {
 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} (concurrency=${CONCURRENCY})`);
+  console.log(`[generate_features] generating ${cands.length} via [${MODELS.join(', ')}] on ${OLLAMA} (concurrency=${CONCURRENCY})`);
 
-  async function processOne(biz: any) {
+  async function processOne(biz: any, model: string) {
     try {
       const t0 = Date.now();
-      const feat = await generate(biz);
+      const feat = await generate(biz, model);
       const ms = Date.now() - t0;
       await query(
         `INSERT INTO magazine_features (business_id, headline, subhead, editorial, pull_quote, category_tag, status, model, generated_at)
@@ -117,23 +135,24 @@ async function main() {
            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]
+        [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)}"`);
+      console.log(`  ✓ [${model.padEnd(10)}] ${biz.id} ${biz.name.slice(0,38).padEnd(38)} (${ms}ms)`);
     } catch (e: any) {
-      console.log(`  ✕ ${biz.id} ${biz.name.slice(0,40).padEnd(40)} — ${e.message.slice(0, 80)}`);
+      console.log(`  ✕ [${model}] ${biz.id} ${biz.name.slice(0,38).padEnd(38)} — ${e.message.slice(0, 80)}`);
     }
   }
 
-  // Concurrency-limited worker pool
-  const queue = [...cands];
-  async function worker() {
-    while (queue.length) {
-      const biz = queue.shift();
-      if (biz) await processOne(biz);
-    }
+  // Single worker that alternates models per request.
+  // MS1 Ollama queue is small; concurrent fetches → 503s even across models.
+  // Sequential with model rotation: ~25s qwen3:14b + ~12s qwen3:8b = avg ~18s/feature
+  // (vs single-model qwen3:14b ~25s) — ~30% throughput gain without server-config changes.
+  let modelIdx = 0;
+  for (const biz of cands) {
+    const model = MODELS[modelIdx % MODELS.length];
+    modelIdx++;
+    await processOne(biz, model);
   }
-  await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
   await pool.end();
 }
 
diff --git a/src/server/index.ts b/src/server/index.ts
index 6cae9bc..4c28f4b 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -72,6 +72,7 @@ const ADMIN_PATHS = [
   /^\/api\/stale-pitches\/?$/i,
   /^\/magazine(\.html)?\/?$/i,
   /^\/api\/magazine(\/.*)?$/i,
+  /^\/magazine\/\d+\/?$/i,
   /^\/crawl-derby(\.html)?\/?$/i,
   /^\/api\/crawl(\/.*)?$/i,
   /^\/postcards(\.html)?\/?$/i,
@@ -1871,6 +1872,81 @@ Write the feature.`;
   }
 });
 
+// Full-page reader for one feature
+app.get('/magazine/:id', async (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id)) return res.status(400).send('bad id');
+  const r = await query(
+    `SELECT mf.*, b.name AS biz_name, b.address AS biz_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 mf.id = $1`,
+    [id]
+  );
+  if (r.rowCount === 0) return res.status(404).send('feature not found');
+  const f = r.rows[0];
+  // Bump views
+  await query(`UPDATE magazine_features SET views = views + 1 WHERE id = $1`, [id]);
+  const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
+  res.setHeader('Content-Type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<title>${esc(f.headline || f.biz_name)} — The Boulevard</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,400;1,500&family=Inter:wght@300;400;500&family=JetBrains+Mono:wght@200;300&display=swap');
+:root { --paper:#faf6ee; --ink:#1a1815; --ink-mute:#6e6356; --metal:#8a6d3b; --accent:#6a3a1a; --rule:#d8cdb8; }
+html,body{margin:0;background:var(--paper);color:var(--ink);font-family:'Inter',system-ui,sans-serif;font-weight:300;}
+.toolbar{padding:14px 24px;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;font-size:11px;letter-spacing:.18em;text-transform:uppercase}
+.toolbar a{color:var(--metal);text-decoration:none;padding:5px 10px;border:1px solid var(--rule)}
+.toolbar a:hover{border-color:var(--metal)}
+.toolbar .breadcrumb{color:var(--ink-mute)}
+.toolbar .breadcrumb a{border:none;padding:0}
+article{max-width:680px;margin:0 auto;padding:60px 24px 80px}
+.kicker{font-size:11px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);font-weight:500}
+.cat{display:inline-block;background:var(--ink);color:var(--paper);padding:5px 12px;font-size:9px;letter-spacing:.3em;text-transform:uppercase;font-weight:500;margin-bottom:18px}
+h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:64px;line-height:1.0;margin:18px 0 14px;color:var(--ink);letter-spacing:-0.02em}
+.subhead{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:300;font-size:22px;line-height:1.4;color:var(--ink-mute);margin:0 0 36px}
+.photo{aspect-ratio:16/9;background:linear-gradient(135deg,#c8b894 0%,#8a7754 100%);position:relative;display:flex;align-items:center;justify-content:center;color:var(--paper);font-family:'Cormorant Garamond',serif;font-style:italic;margin-bottom:36px;overflow:hidden;text-shadow:0 1px 2px rgba(0,0,0,0.3)}
+.photo::after{content:'';position:absolute;inset:0;background:linear-gradient(180deg,transparent 60%,rgba(0,0,0,0.4))}
+.photo .placeholder{font-size:18px;letter-spacing:.05em;text-align:center;padding:24px}
+.lede{font-family:'Cormorant Garamond',serif;font-weight:400;font-size:20px;line-height:1.6;color:var(--ink);margin:0 0 24px}
+.lede::first-letter{font-family:'Cormorant Garamond',serif;font-weight:600;font-size:84px;line-height:0.85;float:left;margin:8px 12px 0 0;color:var(--accent)}
+.pull{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:400;font-size:28px;line-height:1.3;color:var(--accent);border-left:3px solid var(--metal);padding:14px 0 14px 22px;margin:32px 0}
+.meta{margin-top:48px;padding-top:24px;border-top:1px solid var(--rule);display:grid;grid-template-columns:auto 1fr;gap:12px 20px;font-size:13px;color:var(--ink-mute)}
+.meta dt{color:var(--ink-mute);font-size:9px;letter-spacing:.26em;text-transform:uppercase;align-self:center}
+.meta dd{margin:0;font-family:'Cormorant Garamond',serif;font-size:18px;color:var(--ink)}
+.meta dd.mono{font-family:'JetBrains Mono',monospace;font-size:12px}
+footer{margin-top:48px;padding:24px 0;text-align:center;font-size:10px;letter-spacing:.26em;text-transform:uppercase;color:var(--ink-mute)}
+@media print{.toolbar{display:none}article{max-width:100%;padding:24px}h1{font-size:42px}.lede::first-letter{font-size:64px}}
+</style></head><body>
+<div class="toolbar">
+  <div class="breadcrumb"><a href="/magazine.html">← The Boulevard</a> · ${esc(f.category_tag || 'feature')}</div>
+  <div>
+    <a href="javascript:window.print()">🖨 Print</a>
+    <a href="/pitches.html?id=${f.business_id}" target="_blank">Pipeline ↗</a>
+  </div>
+</div>
+<article>
+  <div class="kicker">${esc(f.category_tag || 'corridor feature')}</div>
+  <h1>${esc(f.headline || '(untitled)')}</h1>
+  <p class="subhead">${esc(f.subhead || '')}</p>
+  <div class="photo"><div class="placeholder">${esc(f.biz_name)}<br><span style="font-size:9px;letter-spacing:.32em;text-transform:uppercase;font-family:Inter">photo TBD</span></div></div>
+  <p class="lede">${esc(f.editorial || '')}</p>
+  ${f.pull_quote ? `<div class="pull">"${esc(f.pull_quote)}"</div>` : ''}
+  <dl class="meta">
+    <dt>Business</dt><dd>${esc(f.biz_name)}</dd>
+    <dt>Address</dt><dd class="mono">${esc(f.biz_address || '')}</dd>
+    <dt>City</dt><dd class="mono">${esc(f.city || '')} ${esc(f.zip || '')}</dd>
+    ${f.naics ? `<dt>Trade</dt><dd class="mono">${esc(f.naics)}</dd>` : ''}
+    <dt>Status</dt><dd class="mono">${esc(f.status)}</dd>
+    <dt>Generated</dt><dd class="mono">${new Date(f.generated_at).toLocaleString('en-US', { month:'short', day:'numeric', year:'numeric'})} via ${esc(f.model)}</dd>
+    <dt>Views</dt><dd class="mono">${f.views || 0}</dd>
+  </dl>
+  <footer>The Boulevard · Volume I · 2026 · all loopback / not for redistribution</footer>
+</article>
+</body></html>`);
+});
+
 app.patch('/api/magazine/:id', express.json(), async (req, res) => {
   try {
     const id = parseInt(req.params.id, 10);

← c229baf iter 76: category filter chips on /magazine.html — /api/maga  ·  back to Ventura Corridor  ·  iter 79: ad-signal sponsored markers — /api/magazine joins b 7037e0a →