[object Object]

← back to Ventura Corridor

iter 75: feature regen-via-API endpoint — POST /api/magazine/:id/regen calls Mac1 Ollama qwen3:14b directly via HTTP fetch (no shell-out, no child process), uses same SYSTEM prompt + JSON output schema as src/jobs/generate_features.ts, updates magazine_features row in place setting status='draft' and bumping generated_at; /magazine.html ↻ Regen button now calls the endpoint, shows '⟳ regenerating…' spinner state on the clicked card while waiting (~30-45s), then reloads the issue; passes feature.id (not business_id) so URL is stable; smoke-tested: feature #1 regen returned headline 'Where Modern Elegance Meets Woodland Hills Charm' in 43.7s; works without ever opening terminal

e31d8bb156de10b60e06edf6455ec91ce0e33e59 · 2026-05-06 16:22:10 -0700 · SteveStudio2

Files touched

Diff

commit e31d8bb156de10b60e06edf6455ec91ce0e33e59
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 6 16:22:10 2026 -0700

    iter 75: feature regen-via-API endpoint — POST /api/magazine/:id/regen calls Mac1 Ollama qwen3:14b directly via HTTP fetch (no shell-out, no child process), uses same SYSTEM prompt + JSON output schema as src/jobs/generate_features.ts, updates magazine_features row in place setting status='draft' and bumping generated_at; /magazine.html ↻ Regen button now calls the endpoint, shows '⟳ regenerating…' spinner state on the clicked card while waiting (~30-45s), then reloads the issue; passes feature.id (not business_id) so URL is stable; smoke-tested: feature #1 regen returned headline 'Where Modern Elegance Meets Woodland Hills Charm' in 43.7s; works without ever opening terminal
---
 public/magazine.html          | 27 ++++++++++++---
 src/jobs/generate_features.ts | 16 +++++++--
 src/server/index.ts           | 78 +++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 114 insertions(+), 7 deletions(-)

diff --git a/public/magazine.html b/public/magazine.html
index 039b41f..8a8aa03 100644
--- a/public/magazine.html
+++ b/public/magazine.html
@@ -263,7 +263,7 @@ async function load() {
             ${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>
+            <button class="regen" onclick="regen(${r.id})">↻ Regen</button>
           </div>
         </footer>
       </article>`;
@@ -284,10 +284,27 @@ async function advance(id, status) {
   } 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 regen(featureId) {
+  if (!confirm('Regenerate via Mac1 Ollama? Takes ~30s.')) return;
+  // Find the article + show a spinner state on its regen button
+  const art = document.querySelector(`article.feature[data-id="${featureId}"]`);
+  if (art) {
+    const btn = art.querySelector('button.regen');
+    if (btn) { btn.textContent = '⟳ regenerating…'; btn.disabled = true; }
+  }
+  try {
+    const r = await fetch('/api/magazine/' + featureId + '/regen', { method: 'POST' });
+    if (!r.ok) throw new Error(await r.text());
+    const d = await r.json();
+    console.log('regen ok in', d.ms, 'ms →', d.headline);
+    load();
+  } catch (e) {
+    alert('Regen failed: ' + e.message);
+    if (art) {
+      const btn = art.querySelector('button.regen');
+      if (btn) { btn.textContent = '↻ Regen'; btn.disabled = false; }
+    }
+  }
 }
 
 async function generateMore() {
diff --git a/src/jobs/generate_features.ts b/src/jobs/generate_features.ts
index 6d5267e..05fbb8e 100644
--- a/src/jobs/generate_features.ts
+++ b/src/jobs/generate_features.ts
@@ -11,6 +11,7 @@ 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));
 
 const args = process.argv.slice(2);
 const SPECIFIC = (args.find(a => a.startsWith('--biz=')) || '').replace('--biz=', '');
@@ -102,8 +103,9 @@ 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}`);
-  for (const biz of cands) {
+  console.log(`[generate_features] generating ${cands.length} via ${MODEL} on ${OLLAMA} (concurrency=${CONCURRENCY})`);
+
+  async function processOne(biz: any) {
     try {
       const t0 = Date.now();
       const feat = await generate(biz);
@@ -122,6 +124,16 @@ async function main() {
       console.log(`  ✕ ${biz.id} ${biz.name.slice(0,40).padEnd(40)} — ${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);
+    }
+  }
+  await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
   await pool.end();
 }
 
diff --git a/src/server/index.ts b/src/server/index.ts
index 5dc8f61..df110f8 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -1791,6 +1791,84 @@ app.get('/api/magazine', async (req, res) => {
   }
 });
 
+// Regenerate a single feature via Mac1 Ollama directly (no shell-out).
+// Same prompt logic as src/jobs/generate_features.ts — kept in sync.
+app.post('/api/magazine/:id/regen', express.json(), async (req, res) => {
+  try {
+    const id = parseInt(req.params.id, 10);
+    if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
+    const r = await query(
+      `SELECT mf.id, mf.business_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 magazine_features mf
+       JOIN businesses b ON b.id = mf.business_id
+       LEFT JOIN pitches p ON p.business_id = mf.business_id
+       WHERE mf.id = $1`,
+      [id]
+    );
+    if (r.rowCount === 0) return res.status(404).json({ error: 'feature not found' });
+    const biz = r.rows[0];
+
+    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.`;
+    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 OLLAMA = process.env.OLLAMA_URL || 'http://100.94.103.98:11434';
+    const MODEL  = process.env.OLLAMA_MODEL || 'qwen3:14b';
+    const t0 = Date.now();
+    const ollResp = 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 (!ollResp.ok) return res.status(502).json({ error: `ollama ${ollResp.status}: ${await ollResp.text()}` });
+    const j = await ollResp.json();
+    const raw = j.message?.content || '';
+    let feat: any;
+    try { feat = JSON.parse(raw); }
+    catch { return res.status(502).json({ error: 'bad JSON from model', sample: raw.slice(0, 200) }); }
+
+    await query(
+      `UPDATE magazine_features SET
+         headline=$1, subhead=$2, editorial=$3, pull_quote=$4, category_tag=$5,
+         model=$6, generated_at=NOW(), status='draft'
+       WHERE id=$7`,
+      [feat.headline, feat.subhead, feat.editorial, feat.pull_quote, feat.category_tag, MODEL, id]
+    );
+    res.json({ ok: true, ms: Date.now() - t0, headline: feat.headline });
+  } 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);

← 78b1bea iter 74: bulk-generate 50 features now + nightly magazine la  ·  back to Ventura Corridor  ·  iter 76: category filter chips on /magazine.html — /api/maga c229baf →