← 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 };