β back to Ventura Corridor
iter 80: search box + table-of-contents view on /magazine.html β search-box input filters by name/headline/editorial/address/city/naics with 200ms debounce, 'π Table of contents' toggle reorganizes the issue into a centered single-column TOC grouped by category_tag with alphabetized list of features inside each group, dotted-underline rows linking to /magazine/:id reader, paid-ads-runners get a copper '$' marker; reverting iter 77 dual-model approach β empirically MS1 Ollama serializes hard at NUM_PARALLEL=1 and the retry-backoff was ballooning latency to 232s/feature (vs 25-30s baseline); now single-stream qwen3:14b only, fail-fast on 503; ~120 features/hour overnight = ~1500 by morning
d7cd146c5e0fd32377f5c6eb1aee2fad7ad0d720 Β· 2026-05-06 16:38:25 -0700 Β· SteveStudio2
Files touched
M public/magazine.htmlM src/jobs/generate_features.ts
Diff
commit d7cd146c5e0fd32377f5c6eb1aee2fad7ad0d720
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Wed May 6 16:38:25 2026 -0700
iter 80: search box + table-of-contents view on /magazine.html β search-box input filters by name/headline/editorial/address/city/naics with 200ms debounce, 'π Table of contents' toggle reorganizes the issue into a centered single-column TOC grouped by category_tag with alphabetized list of features inside each group, dotted-underline rows linking to /magazine/:id reader, paid-ads-runners get a copper '$' marker; reverting iter 77 dual-model approach β empirically MS1 Ollama serializes hard at NUM_PARALLEL=1 and the retry-backoff was ballooning latency to 232s/feature (vs 25-30s baseline); now single-stream qwen3:14b only, fail-fast on 503; ~120 features/hour overnight = ~1500 by morning
---
public/magazine.html | 53 +++++++++++++++++++++++++++++++--
src/jobs/generate_features.ts | 68 +++++++++++++++++--------------------------
2 files changed, 77 insertions(+), 44 deletions(-)
diff --git a/public/magazine.html b/public/magazine.html
index 1b171bb..8456ebb 100644
--- a/public/magazine.html
+++ b/public/magazine.html
@@ -219,10 +219,11 @@
<span style="color:var(--ink-mute);font-size:9px;letter-spacing:.22em;text-transform:uppercase;align-self:center;margin-right:8px">vertical β</span>
<!-- chips injected by JS -->
</div>
-<div class="chips" style="padding-top:0;border-bottom:1px solid var(--rule)">
+<div class="chips" style="padding-top:0;border-bottom:1px solid var(--rule);align-items:center">
<span style="color:var(--ink-mute);font-size:9px;letter-spacing:.22em;text-transform:uppercase;align-self:center;margin-right:8px">candidates β</span>
<button id="ads-only-btn" onclick="toggleAdsOnly()">$ paid-ad runners only</button>
- <span style="color:var(--ink-mute);font-size:9px;letter-spacing:.18em;align-self:center;margin-left:auto">businesses w/ detected ad pixels = sponsored-feature buyers</span>
+ <input id="search-box" type="search" placeholder="search businesses, headlinesβ¦" oninput="onSearch()" style="background:transparent;border:1px solid var(--rule);color:var(--ink);padding:5px 12px;font-size:11px;font-family:var(--sans);min-width:240px;margin-left:auto">
+ <button onclick="toggleView()" id="view-btn">π Table of contents</button>
</div>
<main class="spread" id="spread">
@@ -234,6 +235,18 @@ function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&
let currentStatus = 'all';
let currentCat = 'all';
let adsOnly = false;
+let searchTerm = '';
+let viewMode = 'cards'; // 'cards' | 'toc'
+let _searchT;
+function onSearch() {
+ clearTimeout(_searchT);
+ _searchT = setTimeout(() => { searchTerm = document.getElementById('search-box').value.trim().toLowerCase(); load(); }, 200);
+}
+function toggleView() {
+ viewMode = viewMode === 'cards' ? 'toc' : 'cards';
+ document.getElementById('view-btn').textContent = viewMode === 'cards' ? 'π Table of contents' : 'π° Card view';
+ load();
+}
function toggleAdsOnly() {
adsOnly = !adsOnly;
const btn = document.getElementById('ads-only-btn');
@@ -276,6 +289,12 @@ async function load() {
const data = await fetch(url).then(r => r.json());
let rows = data.rows || [];
if (adsOnly) rows = rows.filter(r => Number(r.paid_ads_count || 0) > 0);
+ if (searchTerm) {
+ rows = rows.filter(r => {
+ const hay = ((r.name || '') + ' ' + (r.headline || '') + ' ' + (r.editorial || '') + ' ' + (r.address || '') + ' ' + (r.city || '') + ' ' + (r.naics || '')).toLowerCase();
+ return hay.includes(searchTerm);
+ });
+ }
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;
@@ -287,6 +306,36 @@ async function load() {
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;
}
+ if (viewMode === 'toc') {
+ // Group by category, sorted alphabetically; show TOC list
+ const byCat = {};
+ for (const r of rows) {
+ const c = r.category_tag || 'uncategorized';
+ (byCat[c] = byCat[c] || []).push(r);
+ }
+ const cats = Object.keys(byCat).sort();
+ main.style.display = 'block';
+ main.style.maxWidth = '720px';
+ main.style.margin = '0 auto';
+ main.innerHTML = cats.map(cat => `
+ <div style="margin-bottom:36px">
+ <h3 style="font-family:var(--serif);font-style:italic;font-weight:500;font-size:24px;color:var(--metal);border-bottom:1px solid var(--rule);padding-bottom:6px;margin:0 0 14px;text-transform:capitalize">${escHtml(cat.replace(/-/g, ' '))} <span style="color:var(--ink-mute);font-style:normal;font-size:12px;letter-spacing:.2em;margin-left:10px">${byCat[cat].length}</span></h3>
+ ${byCat[cat].sort((a,b) => (a.name || '').localeCompare(b.name || '')).map(r => `
+ <a href="/magazine/${r.id}" style="display:grid;grid-template-columns:1fr auto;gap:14px;padding:7px 0;border-bottom:1px dotted var(--rule);text-decoration:none;color:var(--ink);font-size:14px">
+ <div>
+ <span style="font-family:var(--serif);font-style:italic;font-size:18px;color:var(--ink)">${escHtml(r.headline || r.name)}</span>
+ <span style="color:var(--ink-mute);font-size:11px;margin-left:8px">${escHtml(r.name)}${r.paid_ads_count > 0 ? ` <span style="color:var(--accent);font-weight:500">Β· $</span>` : ''}</span>
+ </div>
+ <span style="color:var(--ink-mute);font-family:var(--mono);font-size:10px;align-self:center">${escHtml((r.address || '').split(' ').slice(0, 2).join(' '))}</span>
+ </a>
+ `).join('')}
+ </div>
+ `).join('');
+ return;
+ }
+ main.style.display = '';
+ main.style.maxWidth = '';
+ main.style.margin = '';
main.innerHTML = rows.map(r => {
const cat = (r.category_tag || 'shop').toUpperCase();
const adsCount = Number(r.paid_ads_count || 0);
diff --git a/src/jobs/generate_features.ts b/src/jobs/generate_features.ts
index b627b47..97715db 100644
--- a/src/jobs/generate_features.ts
+++ b/src/jobs/generate_features.ts
@@ -10,10 +10,11 @@ import 'dotenv/config';
import { pool, query } from '../../db/pool.ts';
const OLLAMA = process.env.OLLAMA_URL || 'http://100.94.103.98:11434';
-// 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));
+// Single model, single in-flight request. Empirically MS1 Ollama queues only 1
+// request reliably β adding parallelism causes 503s and retry-backoff balloons latency.
+// At ~25-30s per feature this hits ~30 features/15min = ~120/hour overnight.
+const MODELS = (process.env.OLLAMA_MODELS || 'qwen3:14b').split(',').map(m => m.trim());
+const CONCURRENCY = 1;
const args = process.argv.slice(2);
const SPECIFIC = (args.find(a => a.startsWith('--biz=')) || '').replace('--biz=', '');
@@ -44,44 +45,27 @@ Building context: ${biz.bldg_address ? `Inside the multi-tenant building at ${bi
Write the feature.`;
- // 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');
+ // Single attempt β single-stream sequential rarely 503s.
+ // If it does, log and move on; the next loop iter will pick a different biz.
+ 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.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)}`); }
}
async function pickCandidates() {
β 7037e0a iter 79: ad-signal sponsored markers β /api/magazine joins b
Β·
back to Ventura Corridor
Β·
rename: magazine masthead The Boulevard β The Corridor β Ste 3836ac0 β