← 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
M public/magazine.htmlM src/jobs/generate_features.tsM src/server/index.ts
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →