Brand kit and caption checks per client

- Client page → Brand kit: hashtag sets, approved phrases/CTAs, words to avoid, voice notes
- New post: one-click insert of hashtag sets and phrases, live checks while typing
- Checks: agency named in a client post (refused when saving; the agency's own account is exempt),
  avoid list, generic AI-sounding phrases, too many hashtags or emoji, platform length limits
- Brand warnings also appear on drafts; MCP tools brand_kit and caption_check for the AI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-23 10:19:04 -04:00
parent 9e263cd1f8
commit 7e780549ea
8 changed files with 221 additions and 2 deletions

71
connector/src/brandkit.js Normal file
View File

@ -0,0 +1,71 @@
// Brand kit per client: saved hashtag sets, approved phrases/CTAs, words to avoid.
// checkCaption() flags problems before a post goes for approval (shared by the dashboard, the queue and the MCP tools).
import fs from "node:fs";
import path from "node:path";
import { PROJECT_ROOT } from "./env.js";
const FILE = process.env.QUORLA_BRANDKIT_FILE || path.join(PROJECT_ROOT, "data", "brandkit.json");
const CLIENTS = path.join(PROJECT_ROOT, "clients");
fs.mkdirSync(path.dirname(FILE), { recursive: true });
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
// Phrases that read as AI-written or generic (project writing rules).
const GENERIC = ["elevate your", "unlock", "in today's fast-paced world", "look no further", "game-changer", "game changer", "take it to the next level", "dive into", "seamless", "revolutionize", "embark on"];
// White-label: the agency must never appear in a client's public post.
const AGENCY_WORDS = ["metatroncube", "metatron cube", "quorla"];
const LIMITS = { instagram: 2200, facebook: 63206, tiktok: 2200, youtube: 5000, gbp: 1500, linkedin: 3000, threads: 500, pinterest: 500, reddit: 40000 };
function checkClient(slug) {
if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || "")) || !fs.existsSync(path.join(CLIENTS, slug))) throw err("Unknown client.", 404);
return slug;
}
const tagList = (v) => [...new Set(String(Array.isArray(v) ? v.join(" ") : v || "").split(/[\s,]+/).map((t) => t.trim()).filter(Boolean).map((t) => `#${t.replace(/^#+/, "")}`).filter((t) => /^#[\p{L}\p{N}_]{1,60}$/u.test(t)))];
const lines = (v) => (Array.isArray(v) ? v : String(v || "").split(/\n/)).map((x) => String(x).trim()).filter(Boolean).slice(0, 200);
export function get(client) {
checkClient(client);
const k = read()[client] || {};
return { hashtagSets: k.hashtagSets || [], phrases: k.phrases || [], avoid: k.avoid || [], notes: k.notes || "", updatedAt: k.updatedAt || null, updatedBy: k.updatedBy || null };
}
export function save(client, patch, by = "team") {
checkClient(client);
const db = read();
const cur = get(client);
const next = {
hashtagSets: patch.hashtagSets !== undefined ? (patch.hashtagSets || []).slice(0, 30).map((s) => ({ name: String(s.name || "").trim().slice(0, 60) || "Set", tags: tagList(s.tags).slice(0, 30) })).filter((s) => s.tags.length) : cur.hashtagSets,
phrases: patch.phrases !== undefined ? lines(patch.phrases).map((x) => x.slice(0, 300)) : cur.phrases,
avoid: patch.avoid !== undefined ? lines(patch.avoid).map((x) => x.slice(0, 80)) : cur.avoid,
notes: patch.notes !== undefined ? String(patch.notes || "").slice(0, 3000) : cur.notes,
updatedAt: new Date().toISOString(), updatedBy: by,
};
db[client] = next;
const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db, null, 1)); fs.renameSync(tmp, FILE);
return next;
}
const EMOJI = /\p{Extended_Pictographic}/gu;
/**
* Problems in a caption for this client: { level: "bad" | "warn", text }.
* bad = must fix (agency named in a client post, over the platform limit); warn = worth a second look.
*/
export function checkCaption(client, caption, { platforms = [] } = {}) {
const text = String(caption || "");
const low = text.toLowerCase();
const out = [];
let kit = { avoid: [] };
try { kit = get(client); } catch { /* unknown client: generic checks only */ }
// The agency's own account (its client name contains the agency name) may of course mention itself.
let ownName = "";
try { ownName = JSON.parse(fs.readFileSync(path.join(CLIENTS, client, "client.json"), "utf8")).name || ""; } catch { /* no client.json */ }
const isAgencyItself = AGENCY_WORDS.some((w) => `${ownName} ${client}`.toLowerCase().replace(/[-_]/g, " ").includes(w));
if (!isAgencyItself) for (const w of AGENCY_WORDS) if (low.includes(w)) out.push({ level: "bad", text: `Mentions “${w}”. Client posts must never name the agency.` });
for (const p of platforms) if (LIMITS[p] && text.length > LIMITS[p]) out.push({ level: "bad", text: `Too long for ${p}: ${text.length} of ${LIMITS[p]} characters.` });
for (const w of kit.avoid) if (w && low.includes(w.toLowerCase())) out.push({ level: "warn", text: `Uses “${w}”, which is on this client's avoid list.` });
for (const g of GENERIC) if (low.includes(g)) out.push({ level: "warn", text: `“${g}” sounds generic. Say it the way the client would.` });
const tags = text.match(/#[\p{L}\p{N}_]+/gu) || [];
if (tags.length > 10) out.push({ level: "warn", text: `${tags.length} hashtags. 3–8 relevant ones usually do better.` });
if ((text.match(EMOJI) || []).length > 8) out.push({ level: "warn", text: "Lots of emoji. Keep a few that add meaning." });
return out;
}

View File

@ -9,6 +9,7 @@ import { PROJECT_ROOT } from "./env.js";
import * as meta from "./meta.js";
import * as extra from "./platforms/index.js";
import * as quota from "./platforms/quota.js";
import * as brandkit from "./brandkit.js";
const META_PLATFORMS = ["instagram", "facebook"];
export const ALL_PLATFORMS = [...META_PLATFORMS, ...extra.PLATFORM_IDS];
@ -90,6 +91,19 @@ function quotaAccount(client, platform) {
}
/** Warnings (not errors) when a day's planned posts exceed a platform's limit; extra posts then wait for the reset. */
/** Brand-kit and writing checks on every caption a post will use. Naming the agency is refused outright. */
function brandWarnings(post) {
const captions = [[null, post.caption], ["facebook", post.fbCaption], ...Object.entries(post.options || {}).map(([pl, o]) => [pl, o?.caption])].filter(([, c]) => c);
const out = [];
for (const [pl, c] of captions) {
for (const w of brandkit.checkCaption(post.client, c, { platforms: pl ? [pl] : post.platforms })) {
if (/never name the agency/.test(w.text)) throw err(`${pl ? `${pl} caption: ` : ""}${w.text}`);
out.push(`${pl ? `${pl} caption: ` : ""}${w.text}`);
}
}
return [...new Set(out)];
}
function quotaWarnings(post, items) {
const at = new Date(post.publishAt).getTime();
const out = [];
@ -137,7 +151,7 @@ export function createPost({ client, platforms = ["instagram"], type = "IMAGE",
results: {}, errors: {},
};
const items = read();
post.warnings = quotaWarnings(post, items);
post.warnings = [...quotaWarnings(post, items), ...brandWarnings(post)];
items.push(post); write(items);
emit("draft_created", post);
return post;
@ -151,7 +165,7 @@ export function updatePost(id, patch, by = "team") {
for (const pl of p.platforms) if (!META_PLATFORMS.includes(pl)) extra.validate(pl, p.client, p);
if (patch.localTime) { const t = zonedToUtc(patch.localTime, p.timezone); p.publishAt = t.toISOString(); p.localTime = formatInTz(t, p.timezone); }
p.status = "draft"; p.updatedBy = by; p.updatedAt = new Date().toISOString();
p.warnings = quotaWarnings(p, items);
p.warnings = [...quotaWarnings(p, items), ...brandWarnings(p)];
// Changed after the client looked at it: ask them again.
if (p.clientReview && p.clientReview.status !== "pending") p.clientReview = { ...p.clientReview, status: "pending", previous: p.clientReview.status, requestedAt: p.updatedAt, note: "", by: null };
write(items); return p;

View File

@ -12,6 +12,7 @@ import * as sched from "./schedule.js";
import * as lib from "./library.js";
import * as plan from "./plan.js";
import * as competitors from "./competitors.js";
import * as brandkit from "./brandkit.js";
import * as extra from "./platforms/index.js";
const server = new McpServer({ name: "social-manager", version: "0.1.0" });
@ -214,4 +215,11 @@ server.tool("competitor_add", "Start tracking an Instagram competitor for a clie
{ client: z.string(), username: z.string(), note: z.string().default("") },
wrap(async ({ client, username, note }) => competitors.add(client, username, { by: "agent", source: "suggested", note })));
server.tool("brand_kit", "A client's saved hashtag sets, approved phrases/CTAs and words to avoid. Read it before writing captions for that client.",
{ client: z.string() }, wrap(async ({ client }) => brandkit.get(client)));
server.tool("caption_check", "Check a caption against the client's brand kit and our writing rules before scheduling (agency name, avoid list, generic phrases, hashtag count, platform length).",
{ client: z.string(), caption: z.string(), platforms: z.array(z.string()).default([]) },
wrap(async ({ client, caption, platforms }) => brandkit.checkCaption(client, caption, { platforms })));
await server.connect(new StdioServerTransport());

View File

@ -0,0 +1,24 @@
// Brand kit and caption checks (offline, temp file).
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
process.env.QUORLA_BRANDKIT_FILE = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "quorla-bk-")), "bk.json");
const bk = await import("../src/brandkit.js");
const { PROJECT_ROOT } = await import("../src/env.js");
const client = fs.readdirSync(path.join(PROJECT_ROOT, "clients")).find((d) => !d.startsWith("_") && !/metatron/i.test(d) && fs.existsSync(path.join(PROJECT_ROOT, "clients", d, "client.json")));
test("brand kit: saves clean hashtag sets and lists; caption checks", { skip: !client && "no client folder" }, () => {
const k = bk.save(client, { hashtagSets: [{ name: "Local", tags: "waterloo, #kitchener #KW bad-tag!" }], phrases: "Order online\n\nBook a table", avoid: "cheap\nbest in town" });
assert.deepEqual(k.hashtagSets[0].tags, ["#waterloo", "#kitchener", "#KW"]);
assert.deepEqual(k.phrases, ["Order online", "Book a table"]);
const w = bk.checkCaption(client, "The best in town, made by Quorla. Unlock flavour! " + "#a ".repeat(12), { platforms: ["threads"] }).map((x) => x.level + ":" + x.text.slice(0, 25));
assert.ok(w.some((x) => x.startsWith("bad:Mentions “quorla”")));
assert.ok(w.some((x) => x.startsWith("warn:Uses “best in town”")));
assert.ok(w.some((x) => x.startsWith("warn:“unlock”")));
assert.ok(w.some((x) => x.startsWith("warn:12 hashtags")));
assert.equal(bk.checkCaption(client, "x".repeat(600), { platforms: ["threads"] })[0].level, "bad");
assert.deepEqual(bk.checkCaption(client, "Fresh focaccia at 8. First 20 people get a free cortado.", { platforms: ["instagram"] }), []);
});

View File

@ -21,6 +21,7 @@ import * as sched from "../../connector/src/schedule.js";
import * as lib from "../../connector/src/library.js";
import * as plan from "../../connector/src/plan.js";
import * as competitors from "../../connector/src/competitors.js";
import * as brandkit from "../../connector/src/brandkit.js";
import * as extra from "../../connector/src/platforms/index.js";
import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js";
import { DATA_DIR as _DD } from "./config.js";
@ -333,6 +334,11 @@ app.put("/api/clients/:slug/monthly", requireAdmin, h(async (req) => ({ settings
app.post("/api/clients/:slug/monthly/build", h(async (req) => monthly.buildWithPdf(req.params.slug, String(req.body?.month || monthly.lastMonth()))));
app.post("/api/clients/:slug/monthly/email", requireAdmin, h(async (req) => monthly.emailReport(req.params.slug, String(req.body?.month || monthly.lastMonth()))));
// ---------- brand kit: hashtag sets, phrases, words to avoid; caption checks ----------
app.get("/api/brandkit/:client", h(async (req) => brandkit.get(req.params.client)));
app.put("/api/brandkit/:client", h(async (req) => brandkit.save(req.params.client, req.body || {}, who(req))));
app.post("/api/brandkit/:client/check", h(async (req) => ({ warnings: brandkit.checkCaption(req.params.client, req.body?.caption, { platforms: req.body?.platforms || [] }) })));
// ---------- notifications ----------
app.get("/api/notifications", h(async (req) => notify.listFor(req.user.id)));
app.post("/api/notifications/read", h(async (req) => { notify.markRead(req.user.id, Array.isArray(req.body?.ids) ? req.body.ids : null); return notify.listFor(req.user.id); }));

View File

@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react";
import { Plus, Save, Trash2 } from "lucide-react";
import { api } from "../api.js";
import { Button, ErrorNote, Field, Panel, Spinner, timeAgo } from "../ui.jsx";
/** Client page tab: hashtag sets, approved phrases/CTAs, words to avoid, voice notes. */
export default function BrandKit({ slug }) {
const [kit, setKit] = useState(null);
const [sets, setSets] = useState([]);
const [phrases, setPhrases] = useState("");
const [avoid, setAvoid] = useState("");
const [notes, setNotes] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [saved, setSaved] = useState(false);
const apply = (k) => { setKit(k); setSets(k.hashtagSets.map((s) => ({ name: s.name, tags: s.tags.join(" ") }))); setPhrases(k.phrases.join("\n")); setAvoid(k.avoid.join("\n")); setNotes(k.notes); };
useEffect(() => { api(`/api/brandkit/${slug}`).then(apply).catch(setError); }, [slug]);
const save = async () => {
setBusy(true); setError(null); setSaved(false);
try { apply(await api(`/api/brandkit/${slug}`, { method: "PUT", body: { hashtagSets: sets, phrases, avoid, notes } })); setSaved(true); } catch (e) { setError(e); }
setBusy(false);
};
if (!kit) return error ? <ErrorNote error={error} /> : <Spinner />;
return (
<div className="space-y-4">
<Panel title="Hashtag sets" subtitle="Saved groups the team can drop into a caption with one click. 3–8 relevant tags per post work best.">
<div className="space-y-3">
{sets.map((s, i) => (
<div key={i} className="grid sm:grid-cols-[180px_1fr_auto] gap-2 items-start">
<label htmlFor={`bk-n-${i}`} className="sr-only">Set name</label>
<input id={`bk-n-${i}`} className="input" value={s.name} placeholder="e.g. Local" onChange={(e) => setSets(sets.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))} />
<label htmlFor={`bk-t-${i}`} className="sr-only">Hashtags</label>
<input id={`bk-t-${i}`} className="input" value={s.tags} placeholder="#waterloo #kitchenerwaterloo #kwbakery" onChange={(e) => setSets(sets.map((x, j) => (j === i ? { ...x, tags: e.target.value } : x)))} />
<button type="button" onClick={() => setSets(sets.filter((_, j) => j !== i))} className="p-2 rounded hover:bg-bad-soft text-muted hover:text-bad" aria-label={`Remove set ${s.name || i + 1}`}><Trash2 className="size-4" /></button>
</div>
))}
<Button size="sm" icon={Plus} onClick={() => setSets([...sets, { name: "", tags: "" }])}>Add a set</Button>
</div>
</Panel>
<div className="grid lg:grid-cols-2 gap-4">
<Panel title="Approved phrases and calls to action" subtitle="One per line. Offered while writing captions.">
<label htmlFor="bk-phrases" className="sr-only">Phrases</label>
<textarea id="bk-phrases" className="input h-40 py-2" value={phrases} onChange={(e) => setPhrases(e.target.value)} placeholder={"Order online at the link in bio\nBook your table: 519-…\nFresh every morning from 8 a.m."} />
</Panel>
<Panel title="Words to avoid" subtitle="One per line. Captions using them get a warning before approval.">
<label htmlFor="bk-avoid" className="sr-only">Words to avoid</label>
<textarea id="bk-avoid" className="input h-40 py-2" value={avoid} onChange={(e) => setAvoid(e.target.value)} placeholder={"cheap\nbest in town\ndiscount (say 'offer')"} />
</Panel>
</div>
<Panel title="Voice notes" subtitle="Anything else about how this client sounds. The AI reads this with brand.md.">
<label htmlFor="bk-notes" className="sr-only">Voice notes</label>
<textarea id="bk-notes" className="input h-24 py-2" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Warm, a little playful. Always sign off with the owner's first name on Fridays." />
</Panel>
<div className="flex items-center gap-3">
<Button variant="primary" icon={Save} loading={busy} onClick={save}>Save brand kit</Button>
{saved && <span className="text-[12.5px] text-ok">Saved.</span>}
{kit.updatedAt && !saved && <span className="text-[12px] text-muted">Last changed {timeAgo(kit.updatedAt)} by {kit.updatedBy}</span>}
</div>
<ErrorNote error={error} />
</div>
);
}
/** Under a caption box: insert hashtag sets/phrases and show live brand checks. */
export function CaptionHelper({ client, caption, platforms, onInsert }) {
const [kit, setKit] = useState(null);
const [warnings, setWarnings] = useState([]);
const timer = useRef(null);
useEffect(() => { setKit(null); if (client) api(`/api/brandkit/${client}`).then(setKit).catch(() => {}); }, [client]);
useEffect(() => {
clearTimeout(timer.current);
if (!client || !caption?.trim()) { setWarnings([]); return; }
timer.current = setTimeout(() => api(`/api/brandkit/${client}/check`, { method: "POST", body: { caption, platforms } }).then((r) => setWarnings(r.warnings)).catch(() => {}), 500);
return () => clearTimeout(timer.current);
}, [client, caption, platforms.join(",")]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="space-y-1.5 mt-1.5">
{kit && (kit.hashtagSets.length > 0 || kit.phrases.length > 0) && (
<div className="flex flex-wrap gap-1.5 items-center text-[12px]">
<span className="text-muted">Insert:</span>
{kit.hashtagSets.map((s) => <button key={`h-${s.name}`} type="button" onClick={() => onInsert(`\n\n${s.tags.join(" ")}`)} className="h-7 px-2 rounded border border-line hover:bg-canvas" title={s.tags.join(" ")}># {s.name}</button>)}
{kit.phrases.slice(0, 6).map((p) => <button key={`p-${p}`} type="button" onClick={() => onInsert(`\n${p}`)} className="h-7 px-2 rounded border border-line hover:bg-canvas max-w-[220px] truncate" title={p}>{p}</button>)}
</div>
)}
{warnings.map((w) => <div key={w.text} className={w.level === "bad" ? "text-[12px] text-bad" : "text-[12px] text-warn"}>{w.level === "bad" ? "Fix: " : ""}{w.text}</div>)}
</div>
);
}

View File

@ -5,6 +5,7 @@ import { ArrowLeft, ArrowUpRight, ClipboardCheck, CalendarRange, FileBarChart, F
import { api } from "../api.js";
import ClientPortal from "./ClientPortal.jsx";
import MonthlyReport from "./MonthlyReport.jsx";
import BrandKit from "./BrandKit.jsx";
import { useAuth } from "../App.jsx";
import {
Badge, Button, cx, Dialog, Empty, ErrorNote, fmt, fmtDate, fmtDateTime, Panel, Spinner, Stat, StatCell, StatRow, STATUS_TONE, Tabs, timeAgo,
@ -99,6 +100,7 @@ export default function Client() {
{ value: "performance", label: "Performance" },
{ value: "docs", label: "Documents", count: data.docs.filter((d) => d.kind !== "setup").length },
{ value: "runs", label: "AI runs", count: data.jobs.length },
{ value: "brand", label: "Brand kit" },
{ value: "portal", label: "Client approval" },
]}
/>
@ -115,6 +117,7 @@ export default function Client() {
))}
{tab === "docs" && <div className="space-y-5"><MonthlyReport slug={slug} onBuilt={load} /><Documents slug={slug} docs={data.docs} reload={load} /></div>}
{tab === "runs" && <ClientRuns jobs={data.jobs} />}
{tab === "brand" && <BrandKit slug={slug} />}
{tab === "portal" && <ClientPortal slug={slug} clientName={client.name} />}
<RunDialog open={runOpen} onClose={() => setRunOpen(false)} client={client} onStarted={(job) => nav(`/runs/${job.id}`)} />

View File

@ -3,6 +3,7 @@ import { CalendarClock, Check, Image as ImageIcon, Plus, RotateCcw, Upload, X, S
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner } from "../ui.jsx";
import { CaptionHelper } from "./BrandKit.jsx";
import PlatformPicker, { defaultPlatforms, selectionFor, useClientPlatforms } from "../PlatformPicker.jsx";
const REVIEW = { pending: { tone: "warn", label: "With client" }, approved: { tone: "ok", label: "Client approved" }, changes: { tone: "bad", label: "Client wants changes" } };
@ -194,6 +195,7 @@ function Compose({ clients, onClose, onSaved }) {
)}
<Field label="Caption" hint="Plain text works best for search. Hashtags at the end.">
<textarea id="c-caption" className="input w-full h-28 py-2" value={f.caption} onChange={(e) => set("caption", e.target.value)} />
<CaptionHelper client={f.client} caption={f.caption} platforms={sel.platforms} onInsert={(t) => set("caption", `${f.caption.trimEnd()}${t}`)} />
</Field>
<Field label="Post to"><PlatformPicker client={f.client} type={f.type} caption={f.caption} value={sel} onChange={setSel} available={available} media={f.media} /></Field>
{sel.platforms.includes("facebook") && (