← 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
M public/magazine.htmlM src/jobs/generate_features.tsM src/server/index.ts
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 →