/** * Deterministic content checks (768w.16.10.1) — pure + framework-free. * * A Foundry tenant reviews AI-generated content before accepting it. The n8n * pipeline that produced the content also ran a set of deterministic * auto-checks (word-count bands, hype-word scan, approved-source allow-list) * and stored their result on the record. This module re-implements the SAME * logic so the client can RECOMPUTE the checks against the reviewer's *edited* * content — validation reflects the current draft, not the stale original. * * Keep this identical to the n8n judge's deterministic logic so client + * pipeline always agree. No React, no DOM — importable from a Node/LLM route. */ import { verbatimOverlap } from './overlap'; /** A single check's outcome. Green / amber / red, encoded redundantly in the UI. */ export type CheckStatus = 'pass' | 'warn' | 'fail'; /** * Where a check's problem lives, so a reviewer UI can jump straight to it * (768w.16.15.3) instead of making the reader hunt for what "7/8" means. */ export interface CheckLocation { /** The offending field — lets a UI open the right channel/tab first. */ field: keyof ContentFields; /** * Exact substrings to highlight within that field, in the order the check * found them. Omitted when the whole field IS the problem (a word count is * not a span), so `matches === undefined` means "jump to the field, don't * highlight anything inside it". */ matches?: string[]; } /** One deterministic check result. */ export interface ContentCheck { /** Stable id for the check (e.g. `blog-words`, `no-hype`). */ id: string; /** Human label shown in the checklist. */ label: string; status: CheckStatus; /** Short human explanation (e.g. "462 words", "unapproved: reddit.com"). */ detail?: string; /** * Where the problem is. Present ONLY when the check is not passing — a green * check has nothing to jump to. `no-hype` can span several fields, hence an * array. `detail` stays the human sentence; this is the machine-readable * counterpart of the same finding. */ locations?: CheckLocation[]; } /** * The content fields a record can expose for checking. All optional — a check * is only emitted for a field that is present (an app without a `blog` field * never gets a spurious blog check). `tags` and `sources` are flat strings * (comma-separated tags, whitespace/URL-bearing sources) to match how the * pipeline stores them. */ export interface ContentFields { blog?: string; linkedin?: string; headline?: string; tags?: string; metaDescription?: string; sources?: string; } /** An inclusive `[min, max]` band. */ export type Band = [min: number, max: number]; /** * Tunable thresholds. Everything has a sensible default EXCEPT `approvedHosts`: * the source allow-list is deliberately app-injected (no hardcoded list here), * so the approved-sources check is skipped entirely when it is omitted. */ export interface ContentChecksConfig { /** Blog word-count band (default 400–500). */ blogWords?: Band; /** LinkedIn word-count band (default 150–250). */ linkedinWords?: Band; /** Headline word-count band (default 8–12). */ headlineWords?: Band; /** Tag-count band (default 4–6). */ tagsCount?: Band; /** Meta-description character band (default 150–160). */ metaChars?: Band; /** Require a `[link]` placeholder in the LinkedIn post (default true). */ requireLink?: boolean; /** Override the hype-word blocklist. */ hypeWords?: string[]; /** Approved source hosts (no `www.`). REQUIRED to run the source check. */ approvedHosts?: string[]; /** * The cited source articles' TEXT. REQUIRED to run the verbatim-overlap check — * the guardrail against copying too much from a source. Omit it (or pass []) and * the check is skipped (nothing to compare against). */ sourceTexts?: string[]; /** Longest verbatim run (words) that FAILS the overlap check (default 15). */ overlapFailRun?: number; /** Longest verbatim run (words) that WARNS (default 8). */ overlapWarnRun?: number; /** Copied fraction (0–1) that FAILS regardless of run length (default 0.25). */ overlapFailRatio?: number; } /** * The default hype/marketing-fluff blocklist. If ANY appears in the content the * hype check FAILS. Matches the n8n judge's list — keep the two in sync. */ export const DEFAULT_HYPE_WORDS: string[] = [ 'game-changing', 'gamechanging', 'revolutionary', 'world-leading', 'world leading', 'unprecedented', 'undisputed', 'transformational', 'groundbreaking', 'ground-breaking', 'best-in-class', 'unmatched', 'unrivalled', 'unrivaled', 'on earth', 'most attractive', ]; /** Forum / social hosts that are never an acceptable source, allow-list or not. */ const FORUM_SOCIAL = /reddit|quora|facebook|twitter|forum/i; const DEFAULTS = { blogWords: [400, 500] as Band, linkedinWords: [150, 250] as Band, headlineWords: [8, 12] as Band, tagsCount: [4, 6] as Band, metaChars: [150, 160] as Band, }; /** Count words in a string — whitespace-delimited, empty → 0. */ export function wordCount(text: string | null | undefined): number { if (!text) return 0; return text.trim().split(/\s+/).filter(Boolean).length; } /** Count comma-separated tags in a flat string, ignoring empties. */ export function tagCount(tags: string | null | undefined): number { if (!tags) return 0; return tags .split(',') .map((t) => t.trim()) .filter(Boolean).length; } /** pass when `n` is inside the inclusive band, else warn. */ function bandStatus(n: number, [min, max]: Band): CheckStatus { return n >= min && n <= max ? 'pass' : 'warn'; } /** * `locations` for a whole-field problem (word/char counts, a missing * placeholder): the field itself is the finding, so there is no span to * highlight. Passing checks get nothing — there is nowhere to jump to. */ function fieldLocation(status: CheckStatus, field: keyof ContentFields): Pick { return status === 'pass' ? {} : { locations: [{ field }] }; } /** Pull http(s) URLs out of a free-text sources string. */ export function extractUrls(text: string | null | undefined): string[] { if (!text) return []; return text.match(/https?:\/\/[^\s"'<>)\]]+/gi) ?? []; } /** Normalise a URL to its bare host (lowercased, `www.` stripped). '' if unparseable. */ export function hostOf(url: string): string { try { return new URL(url).hostname.toLowerCase().replace(/^www\./, ''); } catch { return ''; } } /** * Recompute every deterministic content check over `fields`. Pure. A check is * emitted only when its underlying field is present; the approved-sources check * is emitted only when `config.approvedHosts` is supplied. */ export function runContentChecks(fields: ContentFields, config: ContentChecksConfig = {}): ContentCheck[] { const checks: ContentCheck[] = []; const { blogWords = DEFAULTS.blogWords, linkedinWords = DEFAULTS.linkedinWords, headlineWords = DEFAULTS.headlineWords, tagsCount = DEFAULTS.tagsCount, metaChars = DEFAULTS.metaChars, requireLink = true, hypeWords = DEFAULT_HYPE_WORDS, approvedHosts, sourceTexts, overlapFailRun = 15, overlapWarnRun = 8, overlapFailRatio = 0.25, } = config; // Word-count bands. if (fields.headline !== undefined) { const n = wordCount(fields.headline); const status = bandStatus(n, headlineWords); checks.push({ id: 'headline-words', label: 'Headline length', status, detail: `${n} words`, ...fieldLocation(status, 'headline') }); } if (fields.blog !== undefined) { const n = wordCount(fields.blog); const status = bandStatus(n, blogWords); checks.push({ id: 'blog-words', label: 'Blog word count', status, detail: `${n} words`, ...fieldLocation(status, 'blog') }); } if (fields.linkedin !== undefined) { const n = wordCount(fields.linkedin); const status = bandStatus(n, linkedinWords); checks.push({ id: 'linkedin-words', label: 'LinkedIn word count', status, detail: `${n} words`, ...fieldLocation(status, 'linkedin') }); } // Tag count. if (fields.tags !== undefined) { const n = tagCount(fields.tags); const status = bandStatus(n, tagsCount); checks.push({ id: 'tags-count', label: 'Tag count', status, detail: `${n} tags`, ...fieldLocation(status, 'tags') }); } // Meta description length (characters). if (fields.metaDescription !== undefined) { const len = fields.metaDescription.trim().length; const status = bandStatus(len, metaChars); checks.push({ id: 'meta-length', label: 'Meta description length', status, detail: `${len} chars`, ...fieldLocation(status, 'metaDescription') }); } // `[link]` placeholder present in the LinkedIn post. if (requireLink && fields.linkedin !== undefined) { const has = fields.linkedin.includes('[link]'); const status: CheckStatus = has ? 'pass' : 'warn'; checks.push({ id: 'link-placeholder', label: '[link] placeholder in LinkedIn', status, detail: has ? undefined : 'no [link] found', // The problem is an ABSENCE, so there is no span to highlight — only a // field to open. ...fieldLocation(status, 'linkedin'), }); } // No hype words — scan headline + blog + linkedin, lowercased. const proseFields: Array<[keyof ContentFields, string | undefined]> = [ ['headline', fields.headline], ['blog', fields.blog], ['linkedin', fields.linkedin], ]; const hasProse = proseFields.some(([, text]) => text !== undefined); if (hasProse) { const haystack = proseFields .map(([, text]) => text) .filter((s): s is string => typeof s === 'string') .join('\n') .toLowerCase(); const found = hypeWords.filter((w) => haystack.includes(w.toLowerCase())); // Re-scan per field so a UI can jump to the RIGHT channel: the joined // haystack above tells us a word exists, not which post it's in. const locations: CheckLocation[] = []; if (found.length > 0) { for (const [field, text] of proseFields) { if (typeof text !== 'string') continue; const hay = text.toLowerCase(); const matches = found.filter((w) => hay.includes(w.toLowerCase())); if (matches.length > 0) locations.push({ field, matches }); } } checks.push({ id: 'no-hype', label: 'No hype words', status: found.length > 0 ? 'fail' : 'pass', detail: found.length > 0 ? found.join(', ') : undefined, ...(locations.length > 0 ? { locations } : {}), }); } // Approved sources — only when the app supplied an allow-list. if (approvedHosts !== undefined) { const approved = new Set(approvedHosts.map((h) => h.toLowerCase().replace(/^www\./, ''))); const urls = extractUrls(fields.sources); const hosts = urls.map(hostOf).filter(Boolean); if (hosts.length === 0) { checks.push({ id: 'approved-sources', label: 'Approved sources', status: 'warn', detail: 'no sources to check', locations: [{ field: 'sources' }], }); } else { const bad = Array.from(new Set(hosts.filter((h) => !approved.has(h) || FORUM_SOCIAL.test(h)))); // Highlight the offending URLs as they appear in the sources text — the // reviewer pastes URLs, not bare hosts, so matching on the host alone // would highlight nothing. const badUrls = urls.filter((u) => bad.includes(hostOf(u))); checks.push({ id: 'approved-sources', label: 'Approved sources', status: bad.length > 0 ? 'fail' : 'pass', detail: bad.length > 0 ? `unapproved: ${bad.join(', ')}` : `${hosts.length} source${hosts.length === 1 ? '' : 's'} ok`, ...(bad.length > 0 ? { locations: [{ field: 'sources' as const, matches: badUrls }] } : {}), }); } } // Verbatim overlap — the anti-plagiarism guardrail. Only when the caller supplied // the cited source article TEXT. Scans blog + linkedin against the sources and // reports the WORST offender (longest copied run / highest copied fraction). if (sourceTexts !== undefined && sourceTexts.length > 0) { const targets: Array<[keyof ContentFields, string | undefined]> = [ ['blog', fields.blog], ['linkedin', fields.linkedin], ]; let worstRun = 0; let worstRatio = 0; let worstField: keyof ContentFields | null = null; let worstSample = ''; for (const [field, text] of targets) { if (typeof text !== 'string' || !text.trim()) continue; if (worstField === null) worstField = field; // at least one field present → emit (even a clean pass) const o = verbatimOverlap(text, sourceTexts); if (o.longestRunWords > worstRun) { worstRun = o.longestRunWords; worstField = field; worstSample = o.sample; } if (o.overlapRatio > worstRatio) worstRatio = o.overlapRatio; } if (worstField !== null) { const status: CheckStatus = worstRun >= overlapFailRun || worstRatio >= overlapFailRatio ? 'fail' : worstRun >= overlapWarnRun ? 'warn' : 'pass'; checks.push({ id: 'source-overlap', label: 'Original wording (not copied from sources)', status, detail: status === 'pass' ? `${worstRun}-word max run` : `copies ${worstRun} consecutive words (${Math.round(worstRatio * 100)}% overlap)`, ...(status !== 'pass' ? { locations: [{ field: worstField, matches: worstSample ? [worstSample] : undefined }] } : {}), }); } } return checks; } /** * Roll a list of checks up to one status: fail if any fails, else warn if any * warns, else pass (empty → pass). */ export function overallStatus(checks: ContentCheck[]): CheckStatus { if (checks.some((c) => c.status === 'fail')) return 'fail'; if (checks.some((c) => c.status === 'warn')) return 'warn'; return 'pass'; }