← back to Marketing Command Center
modules/copy/index.js
772 lines
// copy — Suggested Copy module for the Marketing Command Center.
// On-brand marketing copy generator for Designer Wallcoverings (luxury
// wallcoverings). LLM-backed via Anthropic when ANTHROPIC_API_KEY is set;
// otherwise falls back to well-crafted local templates seeded with brand.js.
//
// Routes (mounted at /api/copy by the shell):
// POST /generate {kind, topic, product, audience, tone, n} -> {variants:[…], mock?}
// POST /score {kind, text} -> {score, openClass, tone:{reassurance,storytelling,urgency},
// personalization:{tokens, missing, recommended, coverage},
// flags:[…], notes, source}
// GET /presets -> {kinds, tones, audiences, products, ctas}
//
// No outbound sends here — this only DRAFTS copy. CAN-SPAM footer is flagged as
// a required manual add for the 'email' kind (compliance lives in brand.js).
const brand = require('../../lib/brand.js');
const KINDS = [
{ id: 'subject-lines', label: 'Email subject lines' },
{ id: 'email', label: 'Email body' },
{ id: 'instagram', label: 'Instagram caption' },
{ id: 'tiktok', label: 'TikTok caption' },
{ id: 'sms', label: 'SMS / text' },
{ id: 'headline', label: 'Web / ad headline' },
];
const TONES = ['editorial', 'warm', 'bold', 'minimal', 'playful', 'authoritative'];
// ── helpers ──────────────────────────────────────────────────────────────────
const cap = s => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
const pick = (arr, i) => arr[((i % arr.length) + arr.length) % arr.length];
const clampN = n => Math.max(1, Math.min(8, parseInt(n, 10) || 3));
const A = arr => arr[Math.floor(Math.random() * arr.length)];
// A product phrase that reads naturally in a sentence.
function productPhrase(product) {
if (!product || product === 'any' || product === 'all') return 'wallcoverings';
return /grasscloth|silk|cork|linen|mural|metallic/i.test(product)
? `${product} wallcoverings`
: `${product} wallcoverings`;
}
function audiencePhrase(audience) {
if (!audience || audience === 'any' || audience === 'all') return 'designers and discerning homeowners';
return audience;
}
function hashtags(product) {
const base = ['#DesignerWallcoverings', '#LuxuryInteriors', '#InteriorDesign', '#WallcoveringDesign'];
const prod = product && product !== 'any'
? '#' + product.replace(/[^a-z0-9]/gi, '').replace(/^./, c => c.toUpperCase())
: '#Grasscloth';
return [...base, prod, '#TradeOnly', '#MemoSamples'];
}
// ── local template generators (fallback / mock mode) ─────────────────────────
function tplSubjectLines(ctx, n) {
const p = productPhrase(ctx.product);
const t = ctx.topic || 'the new collection';
const ctaWords = brand.cta;
const formulas = [
() => `${cap(t)} — now in ${p}`,
() => `A closer look: ${t}`,
() => `${cap(p)}, reimagined for ${audiencePhrase(ctx.audience).split('/')[0].trim()}`,
() => `Inside ${ctx.name || brand.name}: ${t}`,
() => `${cap(t)}. ${A(ctaWords)}.`,
() => `The room starts here — ${t}`,
() => `Limited memo: ${t}`,
() => `${cap(p)} worth specifying`,
];
return Array.from({ length: n }, (_, i) => ({
text: pick(formulas, i)(),
meta: 'subject line · keep under ~50 chars for inbox preview',
}));
}
function tplEmail(ctx, n) {
const p = productPhrase(ctx.product);
const aud = audiencePhrase(ctx.audience);
const topic = ctx.topic || 'our latest collection';
const cta = A(brand.cta);
return Array.from({ length: n }, (_, i) => {
const opener = pick([
`There is a quiet luxury in ${p} — the kind that rewards a second look.`,
`Some surfaces simply belong in extraordinary rooms. ${cap(topic)} is one of them.`,
`For the ${aud} who specify with intention, ${topic} deserves a place on the board.`,
], i);
const body =
`${opener}
Designed and curated by ${brand.name}, ${topic} brings ${p} into focus — tactile, considered, and built for spaces that matter. ${brand.tagline}.
We invite you to ${cta.toLowerCase()} and see how it reads at scale.`;
return {
text: `Subject: ${cap(topic)} — ${p}\n\n${body}\n\n[ ${cta} ]`,
meta: 'email body · ADD CAN-SPAM footer (physical mailing address + working unsubscribe) before sending',
footerRequired: true,
};
});
}
function tplInstagram(ctx, n) {
const p = productPhrase(ctx.product);
const topic = ctx.topic || 'the new collection';
const tags = hashtags(ctx.product);
return Array.from({ length: n }, (_, i) => {
const hook = pick([
`Texture you can feel across the room. ✨`,
`Where the wall becomes the moment.`,
`${cap(topic)} — for spaces that earn a second glance.`,
], i);
const line = `${hook}\n\n${cap(p)} from ${brand.name}. ${A(brand.cta)} — link in bio.`;
return { text: `${line}\n\n${tags.join(' ')}`, meta: 'IG caption · first line is the hook' };
});
}
function tplTiktok(ctx, n) {
const p = productPhrase(ctx.product);
const topic = ctx.topic || 'this wall transformation';
const tags = hashtags(ctx.product);
return Array.from({ length: n }, (_, i) => {
const hook = pick([
`POV: you specified the ${p.replace(' wallcoverings', '')} and the whole room changed.`,
`Watch ${topic} come together →`,
`Designers, this one's for your moodboard.`,
], i);
return {
text: `${hook}\n\n${cap(topic)} · ${brand.name}\n${A(brand.cta)} 👇\n\n${tags.slice(0, 5).join(' ')}`,
meta: 'TikTok caption · hook in first 1–2s, keep it punchy',
};
});
}
function tplSms(ctx, n) {
const p = productPhrase(ctx.product);
const topic = ctx.topic || 'New arrivals';
const cta = A(brand.cta);
return Array.from({ length: n }, (_, i) => {
const body = pick([
`${brand.name}: ${cap(topic)} in ${p} just landed. ${cta} →`,
`${cap(topic)} — ${p} you'll want to spec. ${cta}.`,
`New from ${brand.name}: ${topic}. ${cta} →`,
], i);
return { text: `${body} Reply STOP to opt out.`, meta: 'SMS · ~160 chars · STOP opt-out included' };
});
}
function tplHeadline(ctx, n) {
const p = productPhrase(ctx.product);
const topic = ctx.topic || brand.tagline;
return Array.from({ length: n }, (_, i) => ({
text: pick([
`${cap(p)} for extraordinary spaces`,
cap(topic),
`Specify the surface that defines the room`,
`${cap(p)}, made to be remembered`,
`The art of the wall — ${p}`,
`${cap(topic)}, in ${p}`,
], i),
meta: 'headline · pairs with a single supporting line + CTA',
}));
}
const GENERATORS = {
'subject-lines': tplSubjectLines,
email: tplEmail,
instagram: tplInstagram,
tiktok: tplTiktok,
sms: tplSms,
headline: tplHeadline,
};
function localGenerate(ctx, n) {
const gen = GENERATORS[ctx.kind] || tplHeadline;
return gen(ctx, n);
}
// ── LLM path (Anthropic Messages API via global fetch) ───────────────────────
function brandSystemPrompt() {
return [
`You are the senior copywriter for ${brand.name} — a luxury wallcovering house.`,
`Tagline: "${brand.tagline}".`,
`Brand voice: ${brand.voice.join(', ')}. Write elevated, editorial, tactile copy. Never gimmicky, never salesy clichés, no exclamation-point spam.`,
`Product lines you may reference: ${brand.productLines.join(', ')}.`,
`Audiences: ${brand.audiences.join(', ')}.`,
`Preferred calls to action: ${brand.cta.join('; ')}.`,
`Compliance reminder: ${brand.compliance} (You DRAFT only — never invent a fake address or unsubscribe link; flag that the footer must be added.)`,
`Always return ONLY valid JSON of the exact shape {"variants":[{"text":"…","meta":"…"}]} with no markdown fences, no prose before or after.`,
].join('\n');
}
function userPromptFor(ctx, n) {
const lines = [
`Write ${n} distinct ${ctx.kind} variant(s) for ${brand.name}.`,
ctx.topic ? `Topic / campaign: ${ctx.topic}.` : '',
ctx.product && ctx.product !== 'any' ? `Featured product line: ${ctx.product}.` : '',
ctx.audience && ctx.audience !== 'any' ? `Primary audience: ${ctx.audience}.` : '',
ctx.tone ? `Tone: ${ctx.tone}.` : '',
].filter(Boolean);
if (ctx.kind === 'subject-lines') lines.push('Each variant is one subject line, under ~50 characters.');
if (ctx.kind === 'email') lines.push('Each variant is a short email: a subject line, then 2–3 tight paragraphs, then a CTA in [brackets]. Do NOT include a footer — note in meta that the CAN-SPAM footer (physical address + unsubscribe) must be added before sending.');
if (ctx.kind === 'instagram') lines.push('Each variant: a strong first-line hook, a 1–2 sentence body, then a set of on-brand hashtags.');
if (ctx.kind === 'tiktok') lines.push('Each variant: a scroll-stopping hook for the first 1–2 seconds, punchy, plus a few hashtags.');
if (ctx.kind === 'sms') lines.push('Each variant: one SMS under ~160 chars, include a STOP opt-out.');
if (ctx.kind === 'headline') lines.push('Each variant is a single web/ad headline.');
lines.push('Put a short helpful note in each variant\'s "meta" field (length tips, footer reminders, etc.).');
return lines.join('\n');
}
async function llmGenerate(ctx, n) {
const key = process.env.ANTHROPIC_API_KEY;
const resp = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': key,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-fable-5',
max_tokens: 1500,
system: brandSystemPrompt(),
messages: [{ role: 'user', content: userPromptFor(ctx, n) }],
}),
});
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
throw new Error(`anthropic ${resp.status}: ${detail.slice(0, 300)}`);
}
const data = await resp.json();
const text = (data.content || []).map(b => (b && b.type === 'text' ? b.text : '')).join('').trim();
return parseVariants(text, n);
}
// Gemini path (Google Generative Language API). Preferred when GEMINI_API_KEY is
// set — same brand system prompt, parsed by the shared parseVariants().
async function geminiGenerate(ctx, n) {
const key = process.env.GEMINI_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY;
const model = process.env.GEMINI_MODEL || 'gemini-2.0-flash';
const resp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
systemInstruction: { parts: [{ text: brandSystemPrompt() }] },
contents: [{ role: 'user', parts: [{ text: userPromptFor(ctx, n) }] }],
generationConfig: { maxOutputTokens: 1500, temperature: 0.9 },
}),
});
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
throw new Error(`gemini ${resp.status}: ${detail.slice(0, 300)}`);
}
const data = await resp.json();
const text = (data.candidates?.[0]?.content?.parts || []).map(p => p.text || '').join('').trim();
return parseVariants(text, n);
}
// Parse the model's response into clean variants. Tolerant of JSON, fenced
// JSON, or plain newline/numbered lists.
function parseVariants(raw, n) {
if (!raw) return [];
let body = raw.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
// Try a clean JSON object first, then the first {...} block.
const tryJson = str => {
try {
const o = JSON.parse(str);
const arr = Array.isArray(o) ? o : o.variants;
if (Array.isArray(arr)) {
return arr.map(v => (typeof v === 'string'
? { text: v.trim(), meta: '' }
: { text: String(v.text || v.copy || '').trim(), meta: String(v.meta || '').trim() }))
.filter(v => v.text);
}
} catch { /* fall through */ }
return null;
};
let out = tryJson(body);
if (!out) {
const m = body.match(/\{[\s\S]*\}/);
if (m) out = tryJson(m[0]);
}
if (!out || !out.length) {
// Plain-text fallback: split on blank lines or numbered list markers.
out = body
.split(/\n\s*\n|\n(?=\d+[.)]\s)/)
.map(s => s.replace(/^\s*\d+[.)]\s*/, '').trim())
.filter(Boolean)
.map(s => ({ text: s, meta: '' }));
}
return out.slice(0, n);
}
// ── scoring ──────────────────────────────────────────────────────────────────
// Inline copy/subject-line scoring. Returns a predicted-open score (0–100), a
// luxury-tone breakdown (reassurance / storytelling / urgency, summing to 100),
// and a personalization-coverage check that hunts for merge tags across the
// dialects we see in the wild (Constant Contact, Mailchimp, Klaviyo, Liquid).
//
// Heuristic-first: this is the baseline and always runs so the panel works
// without external creds. If GEMINI_API_KEY is set we additionally ask Gemini
// for a second opinion, average the two predicted-open scores, and pass through
// Gemini's notes. On any Gemini failure we fall back silently to the heuristic.
const PERSONALIZATION_PATTERNS = [
// {{first_name}}, {{ firstName }}, {{customer.first_name}}
/\{\{\s*([a-z0-9_.]+)\s*\}\}/gi,
// [firstName], [first_name]
/\[\s*([a-z0-9_.]+)\s*\]/gi,
// %FIRSTNAME%
/%([A-Z0-9_]+)%/g,
// Mailchimp-style *|FNAME|*
/\*\|([A-Z0-9_]+)\|\*/g,
// Constant Contact / generic [[token]]
/\[\[\s*([a-z0-9_.]+)\s*\]\]/gi,
];
// Canonical merge-tag buckets we look for. Each entry: bucket label + a regex
// matched against the lowercased, normalized token name extracted above.
const PERSONALIZATION_BUCKETS = [
{ id: 'first_name', label: 'First name', match: /(^|[_.])f(irst)?[_.]?name$|^fname$|^firstname$/i },
{ id: 'last_name', label: 'Last name', match: /(^|[_.])l(ast)?[_.]?name$|^lname$|^lastname$/i },
{ id: 'company', label: 'Firm / company', match: /^(company|firm|studio|business|organization|org)$/i },
{ id: 'role', label: 'Designer role', match: /^(role|title|job|jobtitle|persona|segment)$/i },
{ id: 'city', label: 'City / region', match: /^(city|region|state|territory|market)$/i },
];
// Word banks tuned to luxury-wallcovering voice. Each is matched word-boundary,
// case-insensitive. Counts are clamped before being normalized.
const TONE_BANKS = {
reassurance: [
'handcrafted', 'handmade', 'curated', 'considered', 'trade-only', 'trade only',
'memo', 'samples', 'archive', 'heritage', 'house', 'guarantee', 'guaranteed',
'trusted', 'made to order', 'made-to-order', 'workroom', 'atelier', 'crafted',
'designed', 'specified', 'spec', 'quiet luxury', 'timeless', 'enduring',
'authentic', 'pedigree', 'provenance', 'since',
],
storytelling: [
'imagine', 'picture', 'envision', 'consider', 'inside', 'closer look',
'inside look', 'the story', 'story', 'journey', 'origin', 'inspired',
'inspiration', 'tactile', 'texture', 'light', 'shadow', 'soft', 'silken',
'grain', 'hand', 'weave', 'thread', 'fiber', 'patina', 'depth', 'whisper',
'glow', 'mood', 'atmosphere', 'rooms that', 'room that', 'walls that',
'wall that', 'a closer look', 'between',
],
urgency: [
'today', 'now', 'limited', 'last chance', 'final', 'ending', 'ends',
'closes', 'closing', 'this week', 'this month', 'while supplies last',
"don't miss", 'just dropped', 'just landed', 'new', 'just in', 'hurry',
'before', 'expires', 'expiring', 'only', 'exclusive', 'this weekend',
'tonight', 'tomorrow', 'last', 'final hours',
],
};
// Subject-line predicted-open heuristics. Each rule contributes a delta to a
// baseline. The result is clamped to [0,100]. Tuned against ESP best-practice
// guidance for luxury B2B/B2C senders (designers + trade).
function scoreSubjectLine(text) {
const t = String(text || '');
const lower = t.toLowerCase();
const len = t.trim().length;
const words = t.trim().split(/\s+/).filter(Boolean);
const reasons = [];
let s = 56;
// length sweet spot (30–55 chars)
if (len === 0) { reasons.push({ d: -56, why: 'empty subject' }); s -= 56; }
else if (len < 18) { reasons.push({ d: -8, why: `short (${len} chars)` }); s -= 8; }
else if (len <= 55) { reasons.push({ d: +10, why: `length on target (${len} chars)` }); s += 10; }
else if (len <= 70) { reasons.push({ d: -4, why: `getting long (${len} chars)` }); s -= 4; }
else { reasons.push({ d: -14, why: `too long for inbox preview (${len} chars)` }); s -= 14; }
// SHOUTING is brand-off and lowers opens for premium senders
const upperWords = words.filter(w => w.length >= 3 && w === w.toUpperCase() && /[A-Z]/.test(w));
if (upperWords.length >= 2) { reasons.push({ d: -10, why: 'ALL-CAPS reads shouty' }); s -= 10; }
// exclamation spam
const bangs = (t.match(/!/g) || []).length;
if (bangs >= 2) { reasons.push({ d: -8, why: 'multiple exclamation points' }); s -= 8; }
else if (bangs === 1) { reasons.push({ d: -2, why: 'avoid exclamation in luxury voice' }); s -= 2; }
// common spam triggers (luxury-relevant subset)
const spamTriggers = ['free', 'free!!', 'guarantee', 'cheap', '100%', 'act now', 'click here',
'$$$', 'winner', 'congratulations', 'risk-free', 'no obligation', 'discount', 'sale!!'];
const hits = spamTriggers.filter(w => lower.includes(w));
if (hits.length) {
reasons.push({ d: -6 * hits.length, why: `spam-trigger words: ${hits.slice(0,3).join(', ')}` });
s -= 6 * hits.length;
}
// personalization bumps opens materially
const merged = extractMergeTokens(t).list.length;
if (merged >= 1) {
reasons.push({ d: +8, why: 'personalization token present' });
s += 8;
}
// numerals / specificity ("5 grasscloths", "12 walls"), modest bump
if (/\b\d{1,3}\b/.test(t)) { reasons.push({ d: +3, why: 'specific number' }); s += 3; }
// luxury / curiosity words
const lux = ['inside', 'closer look', 'memo', 'curated', 'editor', 'trade', 'collection',
'archive', 'atelier', 'limited', 'private', 'first look', 'preview'];
if (lux.some(w => lower.includes(w))) { reasons.push({ d: +4, why: 'editorial / curiosity word' }); s += 4; }
// questions perform well in B2B
if (/\?$/.test(t.trim())) { reasons.push({ d: +3, why: 'question framing' }); s += 3; }
return { score: clamp(Math.round(s), 0, 100), reasons };
}
// Body / caption / SMS scoring. Slightly different signal set: rewards
// concrete CTA presence, specificity, sensory language, and presence of merge
// tags; penalizes wall-of-text and missing CTA.
function scoreBody(text, kind) {
const t = String(text || '');
const lower = t.toLowerCase();
const len = t.trim().length;
const reasons = [];
let s = 55;
if (len === 0) { reasons.push({ d: -55, why: 'empty copy' }); return { score: 0, reasons }; }
// length expectations differ by kind
const targets = {
email: { min: 220, ideal: [320, 900], max: 1600 },
instagram: { min: 90, ideal: [130, 320], max: 600 },
tiktok: { min: 60, ideal: [80, 220], max: 400 },
sms: { min: 40, ideal: [70, 160], max: 220 },
headline: { min: 16, ideal: [22, 60], max: 90 },
'subject-lines': { min: 18, ideal: [30, 55], max: 70 },
}[kind] || { min: 80, ideal: [180, 500], max: 1200 };
if (len < targets.min) { reasons.push({ d: -10, why: `short for ${kind} (${len} chars)` }); s -= 10; }
else if (len > targets.max) { reasons.push({ d: -12, why: `long for ${kind} (${len} chars)` }); s -= 12; }
else if (len >= targets.ideal[0] && len <= targets.ideal[1]) {
reasons.push({ d: +8, why: `length on target (${len} chars)` }); s += 8;
}
// CTA presence — match brand CTAs + common verbs
const ctaWords = [...brand.cta.map(c => c.toLowerCase()), 'request', 'explore', 'book',
'reserve', 'browse', 'shop', 'see', 'order memo', 'order samples', 'view', 'learn more'];
const hasCTA = ctaWords.some(w => lower.includes(w));
if (hasCTA) { reasons.push({ d: +8, why: 'CTA present' }); s += 8; }
else { reasons.push({ d: -10, why: 'no clear CTA' }); s -= 10; }
// personalization tokens
const merged = extractMergeTokens(t).list.length;
if (merged >= 1) { reasons.push({ d: +5, why: 'personalization token present' }); s += 5; }
// sensory / brand-voice words (rewards luxury copy)
const sensoryHits = TONE_BANKS.storytelling.filter(w => lower.includes(w)).length;
if (sensoryHits >= 2) { reasons.push({ d: +6, why: 'sensory / editorial language' }); s += 6; }
// SHOUTING penalty
const bangs = (t.match(/!/g) || []).length;
if (bangs >= 3) { reasons.push({ d: -8, why: 'exclamation-heavy' }); s -= 8; }
const wordsArr = t.split(/\s+/).filter(Boolean);
const upperWords = wordsArr.filter(w => w.length >= 3 && w === w.toUpperCase() && /[A-Z]/.test(w));
if (upperWords.length >= 3) { reasons.push({ d: -8, why: 'ALL-CAPS bursts' }); s -= 8; }
// big wall of unbroken text reads poorly on mobile
const longestParagraph = Math.max(0, ...t.split(/\n\s*\n/).map(p => p.length));
if (longestParagraph > 600) { reasons.push({ d: -6, why: 'paragraph too long for mobile' }); s -= 6; }
// SMS-specific: STOP opt-out
if (kind === 'sms' && !/stop/i.test(lower)) {
reasons.push({ d: -10, why: 'SMS missing STOP opt-out' });
s -= 10;
}
// hashtags help social
if ((kind === 'instagram' || kind === 'tiktok') && (t.match(/#\w+/g) || []).length < 3) {
reasons.push({ d: -4, why: 'social caption needs more hashtags' });
s -= 4;
}
return { score: clamp(Math.round(s), 0, 100), reasons };
}
function clamp(n, lo, hi) { return Math.min(hi, Math.max(lo, n)); }
function openClassOf(score) {
if (score >= 80) return 'excellent';
if (score >= 65) return 'strong';
if (score >= 50) return 'fair';
if (score >= 35) return 'weak';
return 'poor';
}
// Tone breakdown — count word-bank hits per bucket, normalize to a 100% split.
// If nothing matches, we report a neutral 33/33/34 with `confidence: 'low'`.
function toneBreakdown(text) {
const lower = String(text || '').toLowerCase();
const count = bank => bank.reduce((acc, w) => {
const re = new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi');
const m = lower.match(re);
return acc + (m ? m.length : 0);
}, 0);
const raw = {
reassurance: count(TONE_BANKS.reassurance),
storytelling: count(TONE_BANKS.storytelling),
urgency: count(TONE_BANKS.urgency),
};
const total = raw.reassurance + raw.storytelling + raw.urgency;
if (total === 0) return { reassurance: 33, storytelling: 33, urgency: 34, confidence: 'low', hits: raw };
// normalize, then round preserving sum = 100
const pct = {
reassurance: (raw.reassurance / total) * 100,
storytelling: (raw.storytelling / total) * 100,
urgency: (raw.urgency / total) * 100,
};
const r = { reassurance: Math.round(pct.reassurance), storytelling: Math.round(pct.storytelling), urgency: Math.round(pct.urgency) };
const drift = 100 - (r.reassurance + r.storytelling + r.urgency);
if (drift !== 0) {
const key = Object.keys(pct).sort((a, b) => pct[b] - pct[a])[0];
r[key] += drift;
}
return { ...r, confidence: total >= 3 ? 'high' : 'medium', hits: raw };
}
// Pull every merge token out of the text, normalize the name, and label it.
function extractMergeTokens(text) {
const list = [];
const seen = new Set();
const s = String(text || '');
for (const re of PERSONALIZATION_PATTERNS) {
let m;
re.lastIndex = 0;
while ((m = re.exec(s)) !== null) {
const raw = m[0];
const name = (m[1] || '').trim();
if (!name) continue;
const key = name.toLowerCase();
if (seen.has(raw)) continue;
seen.add(raw);
const bucket = PERSONALIZATION_BUCKETS.find(b => b.match.test(key)) || null;
list.push({ raw, name, bucket: bucket ? bucket.id : 'other', label: bucket ? bucket.label : name });
}
}
return { list };
}
function personalizationCoverage(text, kind) {
const { list } = extractMergeTokens(text);
const covered = new Set(list.map(t => t.bucket).filter(b => b !== 'other'));
// What we recommend by kind. Luxury subject lines benefit most from first name
// + firm; email body adds role; social/SMS typically skip personalization.
const recommendedMap = {
'subject-lines': ['first_name'],
email: ['first_name', 'company'],
headline: [],
instagram: [],
tiktok: [],
sms: ['first_name'],
};
const recommendedIds = recommendedMap[kind] || ['first_name'];
const recommended = recommendedIds.map(id => {
const def = PERSONALIZATION_BUCKETS.find(b => b.id === id);
return { id, label: def ? def.label : id, present: covered.has(id) };
});
const missing = recommended.filter(r => !r.present).map(r => r.id);
const coverage = recommended.length === 0
? 1
: recommended.filter(r => r.present).length / recommended.length;
return { tokens: list, recommended, missing, coverage: Math.round(coverage * 100) };
}
function heuristicScore({ kind, text }) {
const k = String(kind || '').toLowerCase();
const isSubject = k === 'subject-lines' || k === 'subject' || k === 'subject-line';
const head = isSubject ? scoreSubjectLine(text) : scoreBody(text, k);
const tone = toneBreakdown(text);
const personalization = personalizationCoverage(text, isSubject ? 'subject-lines' : k);
const flags = [];
if (k === 'email' && !/unsubscribe/i.test(text)) flags.push('email draft has no unsubscribe phrasing — CAN-SPAM footer still required before sending');
if (k === 'sms' && !/stop/i.test(text)) flags.push('SMS draft is missing STOP opt-out');
if (isSubject && text.trim().length > 70) flags.push('subject likely truncated in most inboxes');
if (text.length > 8000) flags.push('text is unusually long — was a whole thread pasted in?');
// tone-shape notes
const dominant = ['reassurance', 'storytelling', 'urgency'].sort((a, b) => tone[b] - tone[a])[0];
const notes = [];
notes.push(`Predicted-open class: ${openClassOf(head.score)} (heuristic).`);
notes.push(`Tone leans ${dominant} (${tone[dominant]}%).`);
if (personalization.missing.length) {
notes.push(`Add merge token(s): ${personalization.missing.join(', ')}.`);
} else if (personalization.tokens.length) {
notes.push(`Personalization coverage: ${personalization.coverage}%.`);
}
return {
score: head.score,
openClass: openClassOf(head.score),
tone,
personalization,
reasons: head.reasons,
flags,
notes: notes.join(' '),
source: 'heuristic',
};
}
// Optional Gemini second-opinion. Returns null on any failure so the caller
// uses the heuristic result unchanged.
async function geminiScore({ kind, text }) {
const key = process.env.GEMINI_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY;
if (!key) return null;
const model = process.env.GEMINI_MODEL || 'gemini-2.0-flash';
const sys = [
`You are a senior email & social copy reviewer for ${brand.name}, a luxury wallcovering house.`,
`Brand voice: ${brand.voice.join(', ')}. Audience: ${brand.audiences.join(', ')}.`,
`Score the supplied ${kind} copy. Return ONLY valid JSON of shape`,
`{"predicted_open":0-100,"tone":{"reassurance":0-100,"storytelling":0-100,"urgency":0-100},"notes":"…"}`,
`Tone keys MUST sum to 100. No markdown, no prose.`,
].join('\n');
const user = `KIND: ${kind}\nCOPY:\n${String(text || '').slice(0, 4000)}`;
let resp;
try {
resp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
systemInstruction: { parts: [{ text: sys }] },
contents: [{ role: 'user', parts: [{ text: user }] }],
generationConfig: { maxOutputTokens: 400, temperature: 0.2 },
}),
});
} catch (e) {
console.warn(`[copy/score] gemini network: ${e.message}`);
return null;
}
if (!resp.ok) {
console.warn(`[copy/score] gemini ${resp.status}`);
return null;
}
let data;
try { data = await resp.json(); } catch { return null; }
const raw = (data.candidates?.[0]?.content?.parts || []).map(p => p.text || '').join('').trim();
let obj;
try {
const cleaned = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
obj = JSON.parse(cleaned);
} catch {
const m = raw.match(/\{[\s\S]*\}/);
if (!m) return null;
try { obj = JSON.parse(m[0]); } catch { return null; }
}
if (!obj || typeof obj.predicted_open !== 'number') return null;
const tone = obj.tone || {};
return {
predictedOpen: clamp(Math.round(obj.predicted_open), 0, 100),
tone: {
reassurance: clamp(Math.round(+tone.reassurance || 0), 0, 100),
storytelling: clamp(Math.round(+tone.storytelling || 0), 0, 100),
urgency: clamp(Math.round(+tone.urgency || 0), 0, 100),
},
notes: String(obj.notes || '').slice(0, 600),
};
}
// ── module ───────────────────────────────────────────────────────────────────
module.exports = {
id: 'copy',
title: 'Suggested Copy',
icon: '✍️',
mount(router) {
router.get('/presets', (_req, res) => {
res.json({
kinds: KINDS,
tones: TONES,
audiences: brand.audiences,
products: brand.productLines,
ctas: brand.cta,
voice: brand.voice,
tagline: brand.tagline,
compliance: brand.compliance,
llm: !!(process.env.GEMINI_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.ANTHROPIC_API_KEY),
});
});
router.post('/generate', async (req, res) => {
const b = req.body || {};
const kind = KINDS.some(k => k.id === b.kind) ? b.kind : 'headline';
const ctx = {
kind,
topic: (b.topic || '').toString().slice(0, 240).trim(),
product: (b.product || 'any').toString().trim(),
audience: (b.audience || 'any').toString().trim(),
tone: (b.tone || 'editorial').toString().trim(),
name: brand.name,
};
const n = clampN(b.n);
// LLM path when keyed — Gemini preferred, Anthropic next; gracefully fall
// back to templates on any failure so we never return nothing.
if (process.env.GEMINI_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
try {
const variants = await geminiGenerate(ctx, n);
if (variants.length) return res.json({ variants, kind, mock: false, source: 'gemini' });
} catch (e) { console.warn(`[copy] gemini failed, trying next: ${e.message}`); }
}
if (process.env.ANTHROPIC_API_KEY) {
try {
const variants = await llmGenerate(ctx, n);
if (variants.length) return res.json({ variants, kind, mock: false, source: 'anthropic' });
} catch (e) { console.warn(`[copy] anthropic failed, using templates: ${e.message}`); }
}
const variants = localGenerate(ctx, n);
res.json({ variants, kind, mock: true, source: 'templates' });
});
// POST /api/copy/score — inline scoring for a subject line or copy block.
// Always runs the heuristic; if Gemini is keyed, averages predicted-open
// with Gemini's read and blends the tone breakdown (heuristic weight 0.6,
// Gemini 0.4). Returns the same shape either way so the panel stays simple.
router.post('/score', async (req, res) => {
const b = req.body || {};
const kind = (b.kind || 'subject-lines').toString().trim().toLowerCase();
const text = (b.text || '').toString().slice(0, 12000);
if (!text.trim()) {
return res.status(400).json({ error: 'empty text' });
}
const base = heuristicScore({ kind, text });
let source = 'heuristic';
let gem = null;
if (process.env.GEMINI_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
try { gem = await geminiScore({ kind, text }); }
catch (e) { console.warn(`[copy/score] gemini failed: ${e.message}`); }
}
if (gem) {
source = 'gemini+heuristic';
const blendedScore = Math.round(base.score * 0.6 + gem.predictedOpen * 0.4);
// weighted tone blend, then normalize to sum=100
const blendTone = {
reassurance: base.tone.reassurance * 0.6 + gem.tone.reassurance * 0.4,
storytelling: base.tone.storytelling * 0.6 + gem.tone.storytelling * 0.4,
urgency: base.tone.urgency * 0.6 + gem.tone.urgency * 0.4,
};
const sum = blendTone.reassurance + blendTone.storytelling + blendTone.urgency || 1;
const norm = {
reassurance: Math.round((blendTone.reassurance / sum) * 100),
storytelling: Math.round((blendTone.storytelling / sum) * 100),
urgency: Math.round((blendTone.urgency / sum) * 100),
};
const drift = 100 - (norm.reassurance + norm.storytelling + norm.urgency);
if (drift !== 0) {
const top = ['reassurance', 'storytelling', 'urgency']
.sort((a, b) => norm[b] - norm[a])[0];
norm[top] += drift;
}
return res.json({
score: blendedScore,
openClass: openClassOf(blendedScore),
tone: { ...norm, confidence: 'high', hits: base.tone.hits },
personalization: base.personalization,
reasons: base.reasons,
flags: base.flags,
notes: [base.notes, gem.notes].filter(Boolean).join(' '),
source,
detail: {
heuristic: { score: base.score, tone: base.tone },
gemini: { score: gem.predictedOpen, tone: gem.tone },
},
});
}
res.json({ ...base, source });
});
},
};