← back to Marketing Command Center
modules/engine/llm.js
356 lines
// Engine LLM — never-blocking local caption writer. Given a product, returns
// per-channel captions in the DW brand voice. Tries the local Ollama primary
// (Mac2), then the fallback (Mac1), then a deterministic template. EVERY caption
// is leak-cleaned + trimmed to the channel limit regardless of source, so output
// is always safe and non-empty.
const { clean } = require('../../lib/leak-deny');
const { CHANNEL_LIMITS } = require('./config');
const PRIMARY = process.env.OLLAMA_PRIMARY || 'http://127.0.0.1:11434';
const FALLBACK = process.env.OLLAMA_FALLBACK || 'http://192.168.1.133:11434';
const MODEL = process.env.ENGINE_MODEL || 'qwen3:14b';
const VISION_MODEL = process.env.ENGINE_VISION_MODEL || 'qwen2.5vl:7b';
const TIMEOUT_MS = 45000;
const VISION_TIMEOUT_MS = 90000;
const VISION_IMG_WIDTH = 640; // width-cap the fetched image for speed (Shopify CDN honors ?width=)
const CHANNELS = ['instagram', 'facebook', 'bluesky', 'youtube', 'tiktok', 'threads', 'linkedin'];
// ── DW brand-voice system prompt ─────────────────────────────────────────────
const SYSTEM = `You are the social copywriter for Designer Wallcoverings, a luxury wallcoverings retailer.
Voice rules (HARD):
- Say "wallcovering" — never "wallpaper" — when speaking generically.
- No generic superlatives: never "best", "amazing", "stunning", "gorgeous", "must-have".
- Use sensory, material language: texture, light, movement, hand, depth, sheen.
- Ground every caption in the VISUAL DESCRIPTION provided (what the product image
actually shows — motif, scale, color, texture). Describe only qualities present
in that description, the title, type, or tags. NEVER invent colorway names,
origins, or construction details you cannot see or that aren't in the input.
- If a DW SKU is provided, include it exactly as given, verbatim, in every
caption's text (a natural "· SKU <code>" tail is fine); never alter or
abbreviate it. If NO SKU is provided, do not write the word "SKU" at all.
- NEVER mention price, cost, sale, or discount.
Per-channel voice:
- instagram: polished and editorial, 5-8 hashtags.
- linkedin: professional, for the interior-design trade; craft and provenance angle; no emojis; 1-3 hashtags.
- facebook: warm and conversational, a full sentence or two.
- bluesky: witty and spare, <= 240 characters, no hashtags needed.
- youtube: a video TITLE (<= 90 characters) plus a 2-sentence description.
- tiktok: punchy and immediate, <= 120 characters, 3-5 hashtags.
- threads: conversational and human, <= 400 characters.
Return ONLY valid JSON, no prose, no code fences.`;
function userPrompt(product, brief) {
const p = {
title: product.title || '',
vendor: product.vendor || '',
productType: product.productType || '',
dwSku: product.dwSku || '',
tags: Array.isArray(product.tags) ? product.tags.slice(0, 12) : [],
};
const visual = (brief && String(brief).trim())
? `\nVISUAL DESCRIPTION of the actual product image (ground your copy in this):\n${String(brief).trim()}\n`
: '';
const skuLine = p.dwSku
? `\nHARD REQUIREMENT: include the exact SKU "${p.dwSku}" verbatim in every caption's text.\n`
: '';
return `Write social captions for this Designer Wallcoverings product:
${JSON.stringify(p, null, 2)}
${visual}${skuLine}
Output EXACTLY this JSON shape (fill every field, obey the per-channel voice + limits):
{
"instagram": { "caption": "...", "hashtags": ["...", "..."] },
"linkedin": { "caption": "...", "hashtags": ["..."] },
"facebook": { "caption": "..." },
"bluesky": { "caption": "..." },
"youtube": { "title": "...", "description": "..." },
"tiktok": { "caption": "...", "hashtags": ["...", "..."] },
"threads": { "caption": "..." }
}`;
}
// ── Vision pass: image → grounded visual brief ───────────────────────────────
// Fetches the product image, sends it to a local vision model, and returns a
// short factual description of what is ACTUALLY shown (motif, scale, color,
// texture). This grounds the captions in the real image instead of the title.
// Fully graceful: any failure (no image, fetch error, model down, non-Mac host
// that can't reach ollama) resolves to '' so text generation proceeds unchanged.
const VISION_PROMPT = `You are looking at a luxury wallcovering (or fabric) product image.
In 2-3 factual sentences describe ONLY what you can actually see:
- the motif / pattern and its scale (small repeat, large-scale, all-over, geometric, floral, textural plain, etc.)
- the dominant colors and tonal feel
- the surface texture or finish (matte, sheen, grasscloth, embossed, woven, metallic, flat, etc.)
Do NOT name a brand, collection, price, or a material you cannot visually confirm. No preamble — just the description.`;
async function fetchImageB64(url) {
if (!url) return null;
// Ask the CDN for a smaller render when it supports ?width= (Shopify/most CDNs do).
const sized = /\?/.test(url) ? `${url}&width=${VISION_IMG_WIDTH}` : `${url}?width=${VISION_IMG_WIDTH}`;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 20000);
try {
let res = await fetch(sized, { signal: ctrl.signal });
if (!res.ok) res = await fetch(url, { signal: ctrl.signal }); // fall back to the raw URL
if (!res.ok) throw new Error(`image HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
if (!buf.length) throw new Error('empty image body');
return buf.toString('base64');
} finally {
clearTimeout(timer);
}
}
async function visionBriefAt(base, b64) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), VISION_TIMEOUT_MS);
try {
const res = await fetch(`${base}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: ctrl.signal,
body: JSON.stringify({
model: VISION_MODEL,
stream: false,
options: { temperature: 0.3, num_predict: 180 },
messages: [{ role: 'user', content: VISION_PROMPT, images: [b64] }],
}),
});
if (!res.ok) throw new Error(`vision ${base} HTTP ${res.status}`);
const data = await res.json();
const content = data && data.message && data.message.content;
if (!content) throw new Error(`vision ${base} empty content`);
return String(content).trim();
} finally {
clearTimeout(timer);
}
}
// visualBrief(product) — primary → fallback → ''. Never rejects.
async function visualBrief(product) {
let b64;
try { b64 = await fetchImageB64(product && product.imageUrl); }
catch (e) { console.error('[engine vision] image fetch failed —', e.message); return ''; }
if (!b64) return '';
try { return await visionBriefAt(PRIMARY, b64); }
catch (e1) {
console.error('[engine vision] primary failed —', e1.message);
try { return await visionBriefAt(FALLBACK, b64); }
catch (e2) { console.error('[engine vision] fallback failed —', e2.message); return ''; }
}
}
// ── Ollama chat call ─────────────────────────────────────────────────────────
async function ollamaChat(base, product, brief) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
try {
const res = await fetch(`${base}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: ctrl.signal,
body: JSON.stringify({
model: MODEL,
think: false,
stream: false,
format: 'json',
options: { temperature: 0.8, num_predict: 900 },
messages: [
{ role: 'system', content: SYSTEM },
{ role: 'user', content: userPrompt(product, brief) },
],
}),
});
if (!res.ok) throw new Error(`ollama ${base} HTTP ${res.status}`);
const data = await res.json();
const content = data && data.message && data.message.content;
if (!content) throw new Error(`ollama ${base} empty content`);
return parseModelJson(content);
} finally {
clearTimeout(timer);
}
}
// Defensive JSON parse — strip code fences, pull the first {...} block.
function parseModelJson(raw) {
let s = String(raw).trim();
s = s.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
try { return JSON.parse(s); } catch {}
const start = s.indexOf('{');
const end = s.lastIndexOf('}');
if (start >= 0 && end > start) {
try { return JSON.parse(s.slice(start, end + 1)); } catch {}
}
throw new Error('unparseable model JSON');
}
// ── deterministic template fallback (never empty) ────────────────────────────
function hashtagsFor(product) {
const tags = [];
const push = (t) => {
const h = '#' + String(t).replace(/[^A-Za-z0-9]+/g, '');
if (h.length > 1 && !tags.includes(h)) tags.push(h);
};
push('DesignerWallcoverings');
if (product.productType) push(product.productType.replace(/\s+/g, ''));
for (const t of (product.tags || [])) {
const val = t.includes(':') ? t.split(':').slice(1).join(':').trim() : t;
if (val) push(val);
if (tags.length >= 8) break;
}
push('InteriorDesign');
push('LuxuryInteriors');
return tags.slice(0, 8);
}
function templateCaptions(product) {
const title = product.title || 'This design';
const vendor = product.vendor || '';
const type = product.productType || 'wallcovering';
const by = vendor ? ` by ${vendor}` : '';
const tags = hashtagsFor(product);
return {
instagram: {
caption: `New arrival — ${title}${by}. ${type} with presence: texture, light, movement.`,
hashtags: tags,
},
facebook: {
caption: `Just in: ${title}${by}. A ${type} that plays with light and texture across the wall — worth a closer look.`,
},
bluesky: {
caption: `${title}${by} — a ${type} built on texture, depth, and the way light moves across it.`,
},
youtube: {
title: `${title}${by} — ${type}`.slice(0, 90),
description: `A closer look at ${title}${by}, a ${type} where texture and light do the work. See how it shifts across the wall.`,
},
tiktok: {
caption: `${title}${by} — texture you can feel.`,
hashtags: tags.slice(0, 5),
},
threads: {
caption: `New in: ${title}${by}. A ${type} that reads differently as the light moves — texture, depth, hand. What room would you put it in?`,
},
linkedin: {
caption: `Now representing ${title}${by} — a ${type} for design professionals specifying with intent. Trade inquiries and sampling available through Designer Wallcoverings.`,
hashtags: tags.slice(0, 3),
},
};
}
// ── post-processing: strip quotes, leak-clean, trim to channel limit ─────────
function stripQuotes(s) {
return String(s == null ? '' : s).trim().replace(/^["'“”]+|["'“”]+$/g, '').trim();
}
// trim to `limit` chars at a word boundary (no mid-word cut); leak-cleaned first.
function tidy(text, limit) {
let s = clean(stripQuotes(text));
if (!limit || s.length <= limit) return s;
let cut = s.slice(0, limit);
const lastSpace = cut.lastIndexOf(' ');
if (lastSpace > limit * 0.5) cut = cut.slice(0, lastSpace);
return cut.trim();
}
function tidyHashtags(list) {
if (!Array.isArray(list)) return [];
return list
.map(h => clean(stripQuotes(h)).replace(/\s+/g, ''))
.map(h => (h && !h.startsWith('#') ? '#' + h : h))
.filter(h => h && h.length > 1);
}
// Guarantee the DW SKU appears in a caption without ever exceeding the channel
// limit: if the model already wrote the SKU (case-insensitive), just tidy; else
// reserve room for a " · SKU <code>" tail, tidy the body into the remaining space,
// and append. This is the deterministic backstop behind the prompt instruction —
// "always include our SKU" holds even when the model (or the template) omits it.
function withSku(text, limit, sku) {
// Strip any DANGLING "· SKU" tail the model may add when no real code follows
// (only matches SKU at the very end, so a valid "SKU DWTT-73419" is preserved).
const cleaned = String(text == null ? '' : text).replace(/\s*[·•|–—-]?\s*SKU\s*$/i, '').trim();
const base = tidy(cleaned, limit);
if (!sku) return base;
if (base.toLowerCase().includes(String(sku).toLowerCase())) return base;
const suffix = ` · SKU ${sku}`;
if (!limit) return (base + suffix).trim();
const room = Math.max(0, limit - suffix.length);
return (tidy(cleaned, room) + suffix).trim();
}
// Merge model output over the template so a missing channel/field is always
// filled, then post-process EVERY field regardless of source.
function finalize(modelOut, product) {
const tmpl = templateCaptions(product);
const m = modelOut && typeof modelOut === 'object' ? modelOut : {};
const pick = (ch, field) => {
const v = m[ch] && m[ch][field];
return (v != null && String(v).trim()) ? v : tmpl[ch][field];
};
const L = CHANNEL_LIMITS;
const sku = product && product.dwSku ? String(product.dwSku).trim() : '';
return {
instagram: {
caption: withSku(pick('instagram', 'caption'), L.instagram, sku),
hashtags: tidyHashtags((m.instagram && m.instagram.hashtags) || tmpl.instagram.hashtags),
},
facebook: {
caption: withSku(pick('facebook', 'caption'), L.facebook, sku),
},
bluesky: {
caption: withSku(pick('bluesky', 'caption'), L.bluesky, sku),
},
youtube: {
title: tidy(pick('youtube', 'title'), L.youtube),
description: withSku(pick('youtube', 'description'), 5000, sku),
},
tiktok: {
caption: withSku(pick('tiktok', 'caption'), L.tiktok, sku),
hashtags: tidyHashtags((m.tiktok && m.tiktok.hashtags) || tmpl.tiktok.hashtags).slice(0, 5),
},
threads: {
caption: withSku(pick('threads', 'caption'), L.threads, sku),
},
linkedin: {
caption: withSku(pick('linkedin', 'caption'), L.linkedin, sku),
hashtags: tidyHashtags((m.linkedin && m.linkedin.hashtags) || tmpl.linkedin.hashtags).slice(0, 3),
},
};
}
// captionsFor(product) — the public entry. Primary → fallback → template.
// Always resolves; never rejects.
async function captionsFor(product) {
let modelOut = null;
let source = 'template';
// Vision pass first — grounds the copy in the ACTUAL product image. Resolves to
// '' on any failure (no image, model down, or a host that can't reach ollama —
// e.g. Kamatera), in which case captions fall back to metadata-only text.
const brief = await visualBrief(product);
try {
modelOut = await ollamaChat(PRIMARY, product, brief);
source = `${MODEL}@mac2`;
} catch (e1) {
console.error('[engine llm] primary failed —', e1.message);
try {
modelOut = await ollamaChat(FALLBACK, product, brief);
source = `${MODEL}@mac1`;
} catch (e2) {
console.error('[engine llm] fallback failed —', e2.message);
modelOut = null;
source = 'template';
}
}
const out = finalize(modelOut, product);
out._source = brief ? `${source}+vision` : source;
out._brief = brief || null;
return out;
}
module.exports = { captionsFor };