← back to Interiordesignershowroom
feat: auto guide generator — gen-guide.js builds a room (shared lib/roomgen) + authors an editorial guide around its exact pieces (local qwen3:14b, template fallback); room-type rotation for variety; 5 guides published locally
213ca8b2add64e3621f03460fd9071ba1530f3eb · 2026-08-02 19:38:29 -0700 · Steve Abrams
Files touched
A lib/roomgen.jsA scripts/gen-guide.jsM scripts/gen-room-setting.js
Diff
commit 213ca8b2add64e3621f03460fd9071ba1530f3eb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Aug 2 19:38:29 2026 -0700
feat: auto guide generator — gen-guide.js builds a room (shared lib/roomgen) + authors an editorial guide around its exact pieces (local qwen3:14b, template fallback); room-type rotation for variety; 5 guides published locally
---
lib/roomgen.js | 84 +++++++++++++++++++++++++++++
scripts/gen-guide.js | 127 ++++++++++++++++++++++++++++++++++++++++++++
scripts/gen-room-setting.js | 85 ++---------------------------
3 files changed, 216 insertions(+), 80 deletions(-)
diff --git a/lib/roomgen.js b/lib/roomgen.js
new file mode 100644
index 0000000..0da0f50
--- /dev/null
+++ b/lib/roomgen.js
@@ -0,0 +1,84 @@
+// Core room-generation pipeline, extracted from scripts/gen-room-setting.js so
+// both the standalone room cron AND the guide generator (scripts/gen-guide.js)
+// build rooms the same way: pick a coherent room (room-type + style + a diverse
+// set of pieces) -> render an AD-style scene (Gemini image) -> vision-locate each
+// piece so the room is shoppable (Gemini Flash) -> save it PUBLIC.
+const path = require('path');
+const rooms = require('./rooms');
+const scene = require('./scene');
+const hotspots = require('./hotspots');
+
+const ROOM_TYPES = ['living-room', 'bedroom', 'dining', 'office'];
+const STYLES = ['modern', 'mid-century', 'coastal', 'traditional', 'scandinavian', 'boho', 'industrial', 'farmhouse'];
+const pick = (a) => a[Math.floor(Math.random() * a.length)];
+const cap = (s) => (s || '').replace(/\b\w/g, (c) => c.toUpperCase()).replace(/-/g, ' ');
+
+// Choose a diverse, coordinated set of pieces: at most one per "category" bucket so
+// a room reads like a designed space (a sofa + a table + lighting + a rug), not 5 sofas.
+const BUCKETS = [
+ /sofa|sectional|loveseat|settee/i, /bed\b|headboard/i, /desk/i,
+ /coffee table|side table|end table|console|dining table|\btable\b/i,
+ /chair|stool|bench/i, /lamp|light|sconce|pendant|chandelier/i,
+ /rug/i, /art|print|mirror|wall/i, /shelf|bookcase|cabinet|dresser|credenza|sideboard|buffet|console|hutch/i,
+ /vase|planter|decor|throw|pillow|cushion/i,
+];
+function diverseSet(pool, n = 6) {
+ const used = new Set(), out = [];
+ for (const p of pool) {
+ const b = BUCKETS.findIndex((rx) => rx.test(p.title));
+ const key = b === -1 ? `x${out.length}` : b;
+ if (used.has(key)) continue;
+ used.add(key); out.push(p);
+ if (out.length >= n) break;
+ }
+ // top up if we couldn't fill from distinct buckets
+ for (const p of pool) { if (out.length >= n) break; if (!out.includes(p)) out.push(p); }
+ return out;
+}
+
+// loosen filters until we land a populated, coherent pool (style+room -> room -> any)
+async function candidatePool(wantRoom, wantStyle) {
+ const room = wantRoom || pick(ROOM_TYPES);
+ const style = wantStyle || pick(STYLES);
+ for (const attempt of [{ room, style }, { room }, {}]) {
+ const rows = await rooms.searchProducts({ ...attempt, limit: 40 });
+ if (rows.length >= 4) {
+ rows.sort(() => Math.random() - 0.5); // light shuffle for variety across runs
+ return { room, style: attempt.style || rows[0].style || null, pool: rows };
+ }
+ }
+ return { room, style: null, pool: [] };
+}
+
+// Generate + save one public shoppable room. Returns everything the caller needs
+// (incl. run cost) or throws when inventory can't fill a room.
+async function generateRoom({ room, style, log = () => {} } = {}) {
+ const t0 = Date.now();
+ const picked = await candidatePool(room, style);
+ if (picked.pool.length < 4) throw new Error('not enough inventory to build a room');
+ const products = diverseSet(picked.pool, 6);
+ const title = `${cap(picked.style) ? cap(picked.style) + ' ' : ''}${cap(picked.room)}`;
+ log(`[room-gen] "${title}" — ${products.length} pieces from ${new Set(products.map((p) => p.advertiser)).size} advertiser(s). Est cost ~$0.040`);
+
+ // 1) render the scene (paid Gemini image)
+ const out = await scene.generateScene({ style: picked.style, room_type: picked.room, products });
+ let cost = out.cost || 0;
+ log(` ✓ scene ${out.url} ($${cost.toFixed(3)}, ${out.refs} refs)`);
+
+ // 2) vision-locate the pieces so the room is shoppable (paid Gemini Flash)
+ const imgPath = path.join(__dirname, '..', 'public', out.url);
+ const loc = await hotspots.locateProducts(imgPath, products);
+ cost += loc.cost || 0;
+ log(` ✓ hotspots ${loc.hotspots.length}/${products.length} located${loc.error ? ' (err: ' + loc.error + ')' : ''} ($${(loc.cost || 0).toFixed(3)})`);
+
+ // 3) save the room PUBLIC + shoppable
+ const slug = await rooms.createRoom({
+ title, room_type: picked.room, style: picked.style,
+ product_ids: products.map((p) => p.id),
+ scene_image: out.url, hotspots: loc.hotspots, created_by: 'auto',
+ });
+ log(`[room-gen] saved /room/${slug} | run cost $${cost.toFixed(3)} | ${((Date.now() - t0) / 1000).toFixed(1)}s`);
+ return { slug, title, room: picked.room, style: picked.style, products, scene_image: out.url, hotspots: loc.hotspots, cost };
+}
+
+module.exports = { generateRoom, ROOM_TYPES, STYLES, cap };
diff --git a/scripts/gen-guide.js b/scripts/gen-guide.js
new file mode 100644
index 0000000..bf6adcc
--- /dev/null
+++ b/scripts/gen-guide.js
@@ -0,0 +1,127 @@
+// Auto-generate ONE editorial buying guide built around a REAL generated room:
+// 1) build a fresh shoppable room via lib/roomgen (Gemini scene + hotspots, ~$0.04),
+// varying the (room-type x style) combo away from the most recent guides so
+// consecutive guides show different styled pieces;
+// 2) author the guide body with LOCAL Ollama (qwen3:14b, $0) — template fallback
+// if Ollama is unreachable so a cron run never dies;
+// 3) publish the guide: hero = the room's actual scene render, "Shop this guide"
+// = the exact pieces IN the scene, body links to the shoppable /room/<slug>.
+//
+// Cost per run ≈ $0.04 (Gemini scene+hotspots) + $0 (local LLM text). Shown every run.
+// Run once: node scripts/gen-guide.js Batch: node scripts/gen-guide.js --count 5
+// Env: OLLAMA_URL (default http://127.0.0.1:11434; on Kamatera use the Mac2
+// tailnet endpoint http://100.82.17.107:11434), OLLAMA_MODEL (default qwen3:14b).
+require('dotenv').config();
+const db = require('../lib/db');
+const roomsLib = require('../lib/rooms');
+const { generateRoom, ROOM_TYPES, STYLES, cap } = require('../lib/roomgen');
+
+const OLLAMA_URL = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
+const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3:14b';
+const COUNT = Math.max(1, parseInt((process.argv.find((a) => a.startsWith('--count')) || '').split('=')[1] || process.argv[process.argv.indexOf('--count') + 1] || '1', 10) || 1);
+
+// Rotating editorial title shapes so 48 guides/day don't all read the same.
+const TITLE_SHAPES = [
+ (s, r) => `The ${s} ${r} Edit`,
+ (s, r) => `Shop the Look: A ${s} ${r}`,
+ (s, r) => `How to Build a ${s} ${r}`,
+ (s, r) => `${s} ${r}, Piece by Piece`,
+ (s, r) => `One Room, Done Right: ${s} ${r}`,
+ (s, r) => `The ${s} ${r} Formula`,
+];
+
+// Pick a (room, style) combo not used by the most recent guides — this is what
+// keeps the styled pieces varied run-to-run.
+async function pickCombo() {
+ const { rows } = await db.query(
+ `SELECT room, style FROM guides WHERE published ORDER BY created_at DESC LIMIT 12`);
+ const recent = new Set(rows.map((g) => `${g.room}|${g.style}`));
+ // hard-rotate the room type: never reuse a room from the last 3 guides, so even
+ // when thin style inventory loosens to "modern", consecutive guides differ.
+ const recentRooms = new Set(rows.slice(0, 3).map((g) => g.room));
+ const combos = [];
+ for (const r of ROOM_TYPES) for (const s of STYLES) combos.push([r, s]);
+ combos.sort(() => Math.random() - 0.5);
+ const fresh =
+ combos.find(([r, s]) => !recentRooms.has(r) && !recent.has(`${r}|${s}`)) ||
+ combos.find(([r, s]) => !recent.has(`${r}|${s}`)) || combos[0];
+ return { room: fresh[0], style: fresh[1] };
+}
+
+function stripThink(t) { return (t || '').replace(/<think>[\s\S]*?<\/think>/g, '').trim(); }
+
+async function ollamaBody({ title, style, room, products }) {
+ const list = products.map((p) => `- ${p.title}${p.price ? ` ($${p.price})` : ''}`).join('\n');
+ const prompt = `You are the senior editor of an interior-design shopping magazine. Write the body of a buying guide titled "${title}" about designing a ${style} ${room.replace(/-/g, ' ')}. The guide is built around ONE real designed room whose exact pieces are:\n${list}\n\nRules: 450-650 words of markdown. Use 3-5 "## " section headings. Practical, specific, designer-voice advice (dimensions, materials, color logic, layout rules) woven around those actual pieces — refer to several of them by name. No intro fluff like "In this guide", no conclusion heading, no links, no images, no emoji, no bullet list of the products (they are shown separately). American English.`;
+ const res = await fetch(`${OLLAMA_URL}/api/generate`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: OLLAMA_MODEL, prompt, stream: false, options: { temperature: 0.8, num_predict: 1400 } }),
+ signal: AbortSignal.timeout(300000),
+ });
+ if (!res.ok) throw new Error(`ollama ${res.status}`);
+ const j = await res.json();
+ const body = stripThink(j.response);
+ if (body.length < 400) throw new Error('ollama body too short');
+ return body;
+}
+
+// Deterministic fallback so a scheduled run always publishes something coherent.
+function templateBody({ style, room, products }) {
+ const S = cap(style), R = cap(room);
+ const names = products.map((p) => p.title);
+ return [
+ `## The idea`,
+ `This room started with one decision: commit to ${S.toLowerCase()} and let every piece earn its place. The anchor here is the ${names[0] || 'main piece'}, and everything else — ${names.slice(1, 4).join(', ')} — is chosen to support it rather than compete with it.`,
+ `## Get the anchor right first`,
+ `In a ${R.toLowerCase()}, the largest piece sets the scale for the whole space. Place it first, leave 30–36 inches of walkway around it, and only then size the secondary pieces. If the anchor feels heavy, lift it visually: exposed legs, lighter textiles, or a rug that extends at least 6 inches beyond its footprint on every side.`,
+ `## Layer the supporting pieces`,
+ `A designed room is one anchor, one or two mid-weight pieces, and a handful of accents at different heights. Mix at least two materials (wood + metal, boucle + stone) and keep the palette to three colors plus one accent so the ${S.toLowerCase()} character reads as intentional.`,
+ `## Light it in threes`,
+ `Never rely on the overhead alone — three sources at three heights (floor, table, ceiling), all warm 2700K on dimmers, is what makes the finished room in the photo feel inhabitable rather than staged.`,
+ `## Shop it as a set or steal the formula`,
+ `Every piece below is the exact item in the rendered room — take the whole formula, or swap any single piece and keep the proportions.`,
+ ].join('\n\n');
+}
+
+async function uniqueSlug(base) {
+ let slug = roomsLib.slugify(base), n = 1;
+ while (true) {
+ const { rows } = await db.query(`SELECT 1 FROM guides WHERE slug=$1`, [slug]);
+ if (!rows.length) return slug;
+ slug = `${roomsLib.slugify(base)}-${++n}`;
+ }
+}
+
+async function genOne(i) {
+ const combo = await pickCombo();
+ console.log(`[guide-gen] #${i + 1} combo: ${combo.style} ${combo.room}`);
+ const roomOut = await generateRoom({ ...combo, log: (m) => console.log(' ' + m) });
+ const S = cap(roomOut.style || combo.style), R = cap(roomOut.room);
+ const title = TITLE_SHAPES[Math.floor(Math.random() * TITLE_SHAPES.length)](S, R);
+ const dek = `A complete ${S.toLowerCase()} ${R.toLowerCase()}, rendered as one real shoppable scene — the exact ${roomOut.products.length} pieces, why each one works, and the layout rules that make them read as a designed room.`;
+
+ let body, author = 'ollama:' + OLLAMA_MODEL;
+ try {
+ body = await ollamaBody({ title, style: roomOut.style || combo.style, room: roomOut.room, products: roomOut.products });
+ } catch (e) {
+ console.log(` ! ollama unavailable (${e.message}) — using template body`);
+ body = templateBody({ style: roomOut.style || combo.style, room: roomOut.room, products: roomOut.products });
+ author = 'template';
+ }
+ body += `\n\n## See the room\n\n[Step inside the shoppable room this guide is built on →](/room/${roomOut.slug}) — every hotspot in the scene is one of the pieces below.`;
+
+ const slug = await uniqueSlug(title);
+ await db.query(
+ `INSERT INTO guides (slug, title, dek, hero_image, body_md, product_ids, room, style, published)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,TRUE)`,
+ [slug, title, dek, roomOut.scene_image, body, roomOut.products.map((p) => p.id), roomOut.room, roomOut.style || combo.style]);
+ console.log(`[guide-gen] published /guides/${slug} (body: ${author}, ${body.length} chars) | run cost $${roomOut.cost.toFixed(3)} (Gemini) + $0 (local text)`);
+ return roomOut.cost;
+}
+
+(async () => {
+ let total = 0;
+ for (let i = 0; i < COUNT; i++) total += await genOne(i);
+ console.log(`[guide-gen] done — ${COUNT} guide(s), total cost $${total.toFixed(3)}`);
+ process.exit(0);
+})().catch((e) => { console.error('[guide-gen]', e.message); process.exit(1); });
diff --git a/scripts/gen-room-setting.js b/scripts/gen-room-setting.js
index bc44a9c..11769ec 100644
--- a/scripts/gen-room-setting.js
+++ b/scripts/gen-room-setting.js
@@ -1,86 +1,11 @@
-// Auto-generate ONE shoppable room setting from the live CJ inventory:
-// pick a coherent room (room-type + style + a diverse set of pieces) -> render an
-// Architectural-Digest-style scene (Gemini image) -> vision-locate each piece so
-// the room is shoppable (Gemini Flash) -> save it PUBLIC. Meant to run on a timer
-// (launchd, every INTERVAL_MIN). Uses ANY advertiser (approved or not) — fine for
-// testing the room-generation pipeline before CJ links go live.
-//
-// Cost per run ≈ $0.039 (scene) + $0.001 (hotspots) = ~$0.04. Shown every run.
+// Auto-generate ONE shoppable room setting from the live inventory. Thin CLI over
+// lib/roomgen.js (shared with scripts/gen-guide.js). Meant to run on a timer
+// (cron/launchd, every INTERVAL_MIN). Cost per run ≈ $0.04, shown every run.
// Run once: node scripts/gen-room-setting.js
require('dotenv').config();
-const path = require('path');
-const db = require('./../lib/db');
-const rooms = require('./../lib/rooms');
-const scene = require('./../lib/scene');
-const hotspots = require('./../lib/hotspots');
-
-const ROOM_TYPES = ['living-room', 'bedroom', 'dining', 'office'];
-const STYLES = ['modern', 'mid-century', 'coastal', 'traditional', 'scandinavian', 'boho', 'industrial', 'farmhouse'];
-const pick = (a) => a[Math.floor(Math.random() * a.length)];
-const cap = (s) => (s || '').replace(/\b\w/g, (c) => c.toUpperCase()).replace(/-/g, ' ');
-
-// Choose a diverse, coordinated set of pieces: at most one per "category" bucket so
-// a room reads like a designed space (a sofa + a table + lighting + a rug), not 5 sofas.
-const BUCKETS = [
- /sofa|sectional|loveseat|settee/i, /bed\b|headboard/i, /desk/i,
- /coffee table|side table|end table|console|dining table|\btable\b/i,
- /chair|stool|bench/i, /lamp|light|sconce|pendant|chandelier/i,
- /rug/i, /art|print|mirror|wall/i, /shelf|bookcase|cabinet|dresser|credenza|sideboard|buffet|console|hutch/i,
- /vase|planter|decor|throw|pillow|cushion/i,
-];
-function diverseSet(pool, n = 6) {
- const used = new Set(), out = [];
- for (const p of pool) {
- const b = BUCKETS.findIndex((rx) => rx.test(p.title));
- const key = b === -1 ? `x${out.length}` : b;
- if (used.has(key)) continue;
- used.add(key); out.push(p);
- if (out.length >= n) break;
- }
- // top up if we couldn't fill from distinct buckets
- for (const p of pool) { if (out.length >= n) break; if (!out.includes(p)) out.push(p); }
- return out;
-}
-
-async function candidatePool() {
- const room = pick(ROOM_TYPES);
- // loosen filters until we land a populated, coherent pool (style+room -> room -> any)
- for (const attempt of [{ room, style: pick(STYLES) }, { room }, {}]) {
- const rows = await rooms.searchProducts({ ...attempt, limit: 40 });
- if (rows.length >= 4) {
- // light shuffle for variety across runs
- rows.sort(() => Math.random() - 0.5);
- return { room, style: attempt.style || rows[0].style || null, pool: rows };
- }
- }
- return { room, style: null, pool: [] };
-}
+const { generateRoom } = require('../lib/roomgen');
(async () => {
- const t0 = Date.now();
- const { room, style, pool } = await candidatePool();
- if (pool.length < 4) { console.error('[room-gen] not enough inventory to build a room'); process.exit(1); }
- const products = diverseSet(pool, 6);
- const title = `${cap(style) ? cap(style) + ' ' : ''}${cap(room)}`;
- console.log(`[room-gen] "${title}" — ${products.length} pieces from ${new Set(products.map((p) => p.advertiser)).size} advertiser(s). Est cost ~$0.040`);
-
- // 1) render the scene (paid Gemini image)
- const out = await scene.generateScene({ style, room_type: room, products });
- let cost = out.cost || 0;
- console.log(` ✓ scene ${out.url} ($${cost.toFixed(3)}, ${out.refs} refs)`);
-
- // 2) vision-locate the pieces so the room is shoppable (paid Gemini Flash)
- const imgPath = path.join(__dirname, '..', 'public', out.url);
- const loc = await hotspots.locateProducts(imgPath, products);
- cost += loc.cost || 0;
- console.log(` ✓ hotspots ${loc.hotspots.length}/${products.length} located${loc.error ? ' (err: ' + loc.error + ')' : ''} ($${(loc.cost || 0).toFixed(3)})`);
-
- // 3) save the room PUBLIC + shoppable
- const slug = await rooms.createRoom({
- title, room_type: room, style,
- product_ids: products.map((p) => p.id),
- scene_image: out.url, hotspots: loc.hotspots, created_by: 'auto',
- });
- console.log(`[room-gen] saved /room/${slug} | run cost $${cost.toFixed(3)} | ${((Date.now() - t0) / 1000).toFixed(1)}s`);
+ await generateRoom({ log: console.log });
process.exit(0);
})().catch((e) => { console.error('[room-gen]', e.message); process.exit(1); });
← 325e399 add tracked-link coverage canary: samples live /go redirects
·
back to Interiordesignershowroom
·
docs: go-live runbook for auto-guides (gated) 8898a07 →