[object Object]

← 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

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 β†’