@coppica/persuasion-mcp 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/data/LICENSE.md +8 -0
- package/data/catalog.json +1 -0
- package/data/categories.json +1 -0
- package/data/trait-evidence.json +187 -0
- package/dist/data.d.ts +81 -0
- package/dist/data.js +207 -0
- package/dist/format.d.ts +28 -0
- package/dist/format.js +50 -0
- package/dist/hookcheck/index.d.ts +98 -0
- package/dist/hookcheck/index.js +294 -0
- package/dist/http.d.ts +12 -0
- package/dist/http.js +62 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/roles.d.ts +68 -0
- package/dist/roles.js +188 -0
- package/dist/schemas.d.ts +7 -0
- package/dist/schemas.js +13 -0
- package/dist/scorers/claim-framework.d.ts +52 -0
- package/dist/scorers/claim-framework.js +363 -0
- package/dist/scorers/flow-framework.d.ts +69 -0
- package/dist/scorers/flow-framework.js +708 -0
- package/dist/scorers/hook-framework.d.ts +58 -0
- package/dist/scorers/hook-framework.js +447 -0
- package/dist/scorers/string-utils.d.ts +1 -0
- package/dist/scorers/string-utils.js +10 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +71 -0
- package/dist/stats.d.ts +22 -0
- package/dist/stats.js +112 -0
- package/dist/tools/catalog.d.ts +2 -0
- package/dist/tools/catalog.js +84 -0
- package/dist/tools/craft.d.ts +2 -0
- package/dist/tools/craft.js +129 -0
- package/dist/tools/diagnose.d.ts +5 -0
- package/dist/tools/diagnose.js +269 -0
- package/dist/tools/plan.d.ts +4 -0
- package/dist/tools/plan.js +99 -0
- package/dist/tools/testing.d.ts +2 -0
- package/dist/tools/testing.js +145 -0
- package/dist/tools/why.d.ts +2 -0
- package/dist/tools/why.js +63 -0
- package/dist/voice.d.ts +33 -0
- package/dist/voice.js +139 -0
- package/package.json +73 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
// Copied from persuasion-ecosystem lib/flow-framework.ts on 2026-10-03. Pure module: no I/O, no AI calls.
|
|
2
|
+
// Keep in sync with the source until both import a shared package.
|
|
3
|
+
import { detectAISmell } from './hook-framework.js';
|
|
4
|
+
import { safeSlice } from './string-utils.js';
|
|
5
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
6
|
+
// Tunables
|
|
7
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
8
|
+
/** Layer 2/3 do not fire on these contexts — they violate the gates by design. */
|
|
9
|
+
const NON_PROSE_CONTEXTS = new Set([
|
|
10
|
+
'bullets',
|
|
11
|
+
'header',
|
|
12
|
+
'subject',
|
|
13
|
+
'tldr',
|
|
14
|
+
]);
|
|
15
|
+
/** Above this word count, sample 3 random 1.5k-word windows + first/last paragraph. */
|
|
16
|
+
const MAX_FULL_WORDS = 8000;
|
|
17
|
+
const SAMPLE_WINDOW_WORDS = 1500;
|
|
18
|
+
const SAMPLE_WINDOW_COUNT = 3;
|
|
19
|
+
/** Negative-parallelism cap: at most 1 per N words (default), unless allow_antithesis. */
|
|
20
|
+
const NEGATIVE_PARALLELISM_PER_WORDS = 500;
|
|
21
|
+
/** Em-dash density: max em-dashes per 150 words. */
|
|
22
|
+
const EM_DASH_DENSITY_THRESHOLD = 1.0;
|
|
23
|
+
/** Triad-ratio: 3-item adjective stacks / total adjective stacks. */
|
|
24
|
+
const TRIAD_RATIO_THRESHOLD = 0.4;
|
|
25
|
+
/** Burstiness target: stdev of sentence word counts in [8, 14]. Flag if < 6. */
|
|
26
|
+
const BURSTINESS_TARGET_MIN = 8;
|
|
27
|
+
const BURSTINESS_FAIL_BELOW = 6;
|
|
28
|
+
/** Threading ratio target: at least 0.5 of sentences thread to the prior. */
|
|
29
|
+
const THREADING_TARGET = 0.5;
|
|
30
|
+
const THREADING_FAIL_BELOW = 0.4;
|
|
31
|
+
/** Bucket-brigade density target: > 1 per 300 words in long-form. */
|
|
32
|
+
const BUCKET_BRIGADE_PER_WORDS_LONG_FORM = 300;
|
|
33
|
+
/** Closing-recap noun overlap fail threshold. */
|
|
34
|
+
const RECAP_OVERLAP_THRESHOLD = 0.7;
|
|
35
|
+
const RECAP_NEW_NOUN_THRESHOLD = 2;
|
|
36
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
37
|
+
// Tokenization helpers (deliberately lightweight, no POS tagger)
|
|
38
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
39
|
+
// Sentence splitter. A sentence ends at terminal punctuation (. ! ? or …, maybe
|
|
40
|
+
// followed by a closing quote or bracket) before whitespace and a capital, quote
|
|
41
|
+
// or bracket, or at a line break before one, since ad copy often runs one
|
|
42
|
+
// thought per line with no full stop. A negative lookbehind for the most common
|
|
43
|
+
// abbreviations + initials keeps "Mr. Smith" / "U.S. Air Force" / "e.g. Smith"
|
|
44
|
+
// as ONE sentence instead of emitting a 2-word fragment that would drag
|
|
45
|
+
// burstiness σ toward zero.
|
|
46
|
+
//
|
|
47
|
+
// REGRESSION (fixed 2026-10-03): the abbreviation fix described below dropped
|
|
48
|
+
// the `(?<=[.!?])` requirement altogether, so the splitter broke at EVERY
|
|
49
|
+
// capitalized word or quote. "This is the Persuasion Taxonomy, from Coppica."
|
|
50
|
+
// came out as four sentences, which skewed burstiness, band distribution,
|
|
51
|
+
// threading and recap checks on any copy with a brand name or a quote in it.
|
|
52
|
+
// Terminal punctuation is required again, through its own positive lookbehind.
|
|
53
|
+
//
|
|
54
|
+
// CRITICAL: the trailing `\.` must live INSIDE the lookbehind alternation, not
|
|
55
|
+
// be inferred from `(?<=[.!?])` separately. For "Mr. Smith" the cursor sits
|
|
56
|
+
// AFTER the period, so the chars "before the cursor" are "Mr." (4 chars
|
|
57
|
+
// including the period). The previous version used `(?<!\bMr)(?<=[.!?])`
|
|
58
|
+
// which checked "are the last 2 chars 'Mr'?" — but the last 2 chars are "r."
|
|
59
|
+
// not "Mr", so the assertion always passed and the regex never excluded any
|
|
60
|
+
// abbreviation. The fix is `(?<!\bMr\.)\s+` which correctly checks "are the
|
|
61
|
+
// last 3 chars 'Mr.'?". JS regex supports variable-width lookbehind (Node
|
|
62
|
+
// 16+, V8) so the alternation can have entries of different lengths.
|
|
63
|
+
//
|
|
64
|
+
// Pure-JS approximation — a real NLP tagger would catch more edge cases at
|
|
65
|
+
// the cost of a 250kb dep we explicitly avoid.
|
|
66
|
+
// At a line break only titles that come before a name are guarded, so "Dr." wrapped onto a new
|
|
67
|
+
// line before "Smith" stays one sentence, while "Acme Inc." or "etc." at the end of a line still
|
|
68
|
+
// ends that line's thought.
|
|
69
|
+
const NOT_AFTER_ABBREVIATION = String.raw `(?<!\b(?:Mr|Mrs|Ms|Dr|St|Sr|Jr|Prof|Rev|Hon|U\.S|U\.K|U\.S\.A|e\.g|i\.e|etc|vs|cf|ca|approx|incl|excl|fig|ref|Inc|Ltd|Co|Corp)\.)`;
|
|
70
|
+
const NOT_AFTER_TITLE = String.raw `(?<!\b(?:Mr|Mrs|Ms|Dr|Prof|Rev|Hon|St)\.)`;
|
|
71
|
+
const SENTENCE_SPLIT = new RegExp(String.raw `(?<=[.!?…]["'’”)\]]?)${NOT_AFTER_ABBREVIATION}\s+(?=[A-Z"'“‘(])` +
|
|
72
|
+
String.raw `|${NOT_AFTER_TITLE}[^\S\n]*\n\s*(?=[A-Z"'“‘(])`, 'g');
|
|
73
|
+
const WORD_SPLIT = /\s+/;
|
|
74
|
+
export function splitSentences(text) {
|
|
75
|
+
// Collapse whitespace runs first. A long run made the split regex backtrack
|
|
76
|
+
// quadratically (60k spaces took ~3 s); paragraphs are split separately, from
|
|
77
|
+
// the raw text, so nothing downstream needs the original spacing.
|
|
78
|
+
return text
|
|
79
|
+
.replace(/[^\S\n]+/g, ' ')
|
|
80
|
+
.replace(/ ?\n\s*/g, '\n')
|
|
81
|
+
.split(SENTENCE_SPLIT)
|
|
82
|
+
.map((s) => s.trim())
|
|
83
|
+
.filter((s) => s.length > 0);
|
|
84
|
+
}
|
|
85
|
+
function splitParagraphs(text) {
|
|
86
|
+
return text
|
|
87
|
+
.split(/\n{2,}/)
|
|
88
|
+
.map((p) => p.trim())
|
|
89
|
+
.filter((p) => p.length > 0);
|
|
90
|
+
}
|
|
91
|
+
function countWords(text) {
|
|
92
|
+
if (!text)
|
|
93
|
+
return 0;
|
|
94
|
+
const trimmed = text.trim();
|
|
95
|
+
if (!trimmed)
|
|
96
|
+
return 0;
|
|
97
|
+
return trimmed.split(WORD_SPLIT).length;
|
|
98
|
+
}
|
|
99
|
+
function tokenizeWords(text) {
|
|
100
|
+
return text
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.replace(/[^a-z0-9'\s-]/g, ' ')
|
|
103
|
+
.split(WORD_SPLIT)
|
|
104
|
+
.filter((w) => w.length >= 4);
|
|
105
|
+
}
|
|
106
|
+
// Words too generic to count as "shared content nouns" between sentences.
|
|
107
|
+
const STOPWORDS = new Set([
|
|
108
|
+
'this', 'that', 'these', 'those', 'with', 'from', 'will', 'have', 'were',
|
|
109
|
+
'been', 'they', 'them', 'their', 'there', 'which', 'when', 'where', 'what',
|
|
110
|
+
'about', 'into', 'over', 'than', 'then', 'just', 'only', 'every', 'some',
|
|
111
|
+
'most', 'more', 'other', 'such', 'also', 'even', 'much', 'very', 'still',
|
|
112
|
+
'because', 'while', 'after', 'before', 'between', 'across', 'through',
|
|
113
|
+
'something', 'someone', 'nothing', 'really', 'should', 'would', 'could',
|
|
114
|
+
'going', 'made', 'make', 'many', 'know', 'like', 'good', 'best', 'work',
|
|
115
|
+
'works', 'working', 'used', 'using',
|
|
116
|
+
]);
|
|
117
|
+
function contentTokens(text) {
|
|
118
|
+
const out = new Set();
|
|
119
|
+
for (const w of tokenizeWords(text)) {
|
|
120
|
+
if (!STOPWORDS.has(w))
|
|
121
|
+
out.add(w);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
function stdev(values) {
|
|
126
|
+
if (values.length === 0)
|
|
127
|
+
return 0;
|
|
128
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
129
|
+
const variance = values.reduce((acc, v) => acc + (v - mean) * (v - mean), 0) / values.length;
|
|
130
|
+
return Math.sqrt(variance);
|
|
131
|
+
}
|
|
132
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
133
|
+
// Sampling for length-capped inputs
|
|
134
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
135
|
+
function sampleWindows(text) {
|
|
136
|
+
const words = text.trim().split(WORD_SPLIT);
|
|
137
|
+
if (words.length <= MAX_FULL_WORDS) {
|
|
138
|
+
return { sampledText: text, sampledWindowCount: 0 };
|
|
139
|
+
}
|
|
140
|
+
const windows = [];
|
|
141
|
+
// First and last paragraphs always included for opening/closing scoring.
|
|
142
|
+
const paragraphs = splitParagraphs(text);
|
|
143
|
+
if (paragraphs.length >= 2) {
|
|
144
|
+
windows.push(paragraphs[0]);
|
|
145
|
+
windows.push(paragraphs[paragraphs.length - 1]);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
// Single-paragraph 8k+ word block: rare. Fall back to word-slice but
|
|
149
|
+
// pad to nearest sentence boundary on both ends.
|
|
150
|
+
windows.push(words.slice(0, 200).join(' '));
|
|
151
|
+
windows.push(words.slice(-200).join(' '));
|
|
152
|
+
}
|
|
153
|
+
// Sample SAMPLE_WINDOW_COUNT windows by paragraph index (NOT word offset).
|
|
154
|
+
// Word-slicing at random offsets cuts sentences mid-stream and creates
|
|
155
|
+
// 1-3 word fragments at boundaries that pollute burstiness/threading.
|
|
156
|
+
// Paragraphs are the natural structural unit — keep them intact.
|
|
157
|
+
if (paragraphs.length > 4) {
|
|
158
|
+
// Each window: ~SAMPLE_WINDOW_WORDS worth of contiguous paragraphs.
|
|
159
|
+
const interiorParagraphs = paragraphs.slice(1, -1);
|
|
160
|
+
const avgPara = interiorParagraphs.reduce((s, p) => s + countWords(p), 0) / interiorParagraphs.length;
|
|
161
|
+
const parasPerWindow = Math.max(1, Math.round(SAMPLE_WINDOW_WORDS / Math.max(1, avgPara)));
|
|
162
|
+
for (let i = 0; i < SAMPLE_WINDOW_COUNT; i++) {
|
|
163
|
+
const maxStart = Math.max(0, interiorParagraphs.length - parasPerWindow);
|
|
164
|
+
const startIdx = Math.floor(Math.random() * (maxStart + 1));
|
|
165
|
+
const slice = interiorParagraphs.slice(startIdx, startIdx + parasPerWindow);
|
|
166
|
+
if (slice.length > 0)
|
|
167
|
+
windows.push(slice.join('\n\n'));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
sampledText: windows.join('\n\n'),
|
|
172
|
+
sampledWindowCount: windows.length,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
176
|
+
// Layer 1 — SURFACE
|
|
177
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
178
|
+
const NEGATIVE_PARALLELISM_REGEX = /\bit'?s\s+not\s+(just\s+)?[^.!?]{1,60}[,.]?\s+it'?s\s+[^.!?]{1,60}[.!?]/gi;
|
|
179
|
+
function layerSurface(text, options) {
|
|
180
|
+
const gates = [];
|
|
181
|
+
const violations = [];
|
|
182
|
+
const wordCount = countWords(text);
|
|
183
|
+
// 1a. Banned words (delegates to hook-framework's lexicon for SSOT).
|
|
184
|
+
const smell = detectAISmell(text);
|
|
185
|
+
if (smell.hasSmells) {
|
|
186
|
+
gates.push({
|
|
187
|
+
name: 'ai_smell_lexicon',
|
|
188
|
+
layer: 'surface',
|
|
189
|
+
status: 'fail',
|
|
190
|
+
detail: smell.violations.slice(0, 5).join('; '),
|
|
191
|
+
});
|
|
192
|
+
for (const v of smell.violations) {
|
|
193
|
+
violations.push({ pattern: 'banned_word', span: v, reason: 'AI-smell vocabulary' });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
gates.push({ name: 'ai_smell_lexicon', layer: 'surface', status: 'pass' });
|
|
198
|
+
}
|
|
199
|
+
// 1b. Em-dash density: max EM_DASH_DENSITY_THRESHOLD per 150 words.
|
|
200
|
+
const emDashCount = (text.match(/—/g) || []).length;
|
|
201
|
+
const emDashDensity = emDashCount / Math.max(1, wordCount / 150);
|
|
202
|
+
if (emDashDensity > EM_DASH_DENSITY_THRESHOLD) {
|
|
203
|
+
gates.push({
|
|
204
|
+
name: 'em_dash_density',
|
|
205
|
+
layer: 'surface',
|
|
206
|
+
status: 'fail',
|
|
207
|
+
detail: 'Too many em-dashes for this word count — humans use them less often.',
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
gates.push({ name: 'em_dash_density', layer: 'surface', status: 'pass' });
|
|
212
|
+
}
|
|
213
|
+
// 1c. Negative parallelism cap (the "Five bots. Ten principles." tic).
|
|
214
|
+
const matches = text.match(NEGATIVE_PARALLELISM_REGEX) || [];
|
|
215
|
+
const allowance = Math.max(1, Math.floor(wordCount / NEGATIVE_PARALLELISM_PER_WORDS));
|
|
216
|
+
const cap = options.allowAntithesis ? Math.max(allowance * 3, 5) : allowance;
|
|
217
|
+
if (matches.length > cap) {
|
|
218
|
+
gates.push({
|
|
219
|
+
name: 'negative_parallelism_cap',
|
|
220
|
+
layer: 'surface',
|
|
221
|
+
status: 'fail',
|
|
222
|
+
detail: `Too many "it's not X, it's Y" constructions for this length${options.allowAntithesis ? ' (you set deliberate=true, but still over the deliberate cap)' : ' — cut to one assertion'}.`,
|
|
223
|
+
});
|
|
224
|
+
for (const m of matches.slice(0, 5)) {
|
|
225
|
+
violations.push({
|
|
226
|
+
pattern: 'negative_parallelism',
|
|
227
|
+
span: m.trim(),
|
|
228
|
+
reason: 'Antithesis construction is the highest-frequency AI-smell rhythm. Cut to one assertion.',
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
gates.push({
|
|
234
|
+
name: 'negative_parallelism_cap',
|
|
235
|
+
layer: 'surface',
|
|
236
|
+
status: 'pass',
|
|
237
|
+
detail: `${matches.length}/${cap}`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
// 1d. Triad density — three-adjective stacks attached to abstract corporate
|
|
241
|
+
// nouns ("innovative, scalable, and robust platform"). Tightened from a
|
|
242
|
+
// generic three-item list pattern (which false-positived on legitimate
|
|
243
|
+
// lists like "apples, oranges, and bananas") to require an abstract closer
|
|
244
|
+
// noun. Mirrors the hook-framework `tripled_adjectives` pattern.
|
|
245
|
+
const triadMatches = text.match(/\b(\w+ly\s+)?\w+,\s+\w+,?\s+and\s+\w+\s+(experience|approach|solution|platform|system|journey|landscape|ecosystem|framework|method)\b/gi) || [];
|
|
246
|
+
const triadDensity = triadMatches.length / Math.max(1, wordCount / 200);
|
|
247
|
+
if (triadDensity > TRIAD_RATIO_THRESHOLD) {
|
|
248
|
+
gates.push({
|
|
249
|
+
name: 'triad_density',
|
|
250
|
+
layer: 'surface',
|
|
251
|
+
status: 'warn',
|
|
252
|
+
detail: 'Multiple three-adjective stacks attached to abstract nouns — humans pile up modifiers less often.',
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
gates.push({ name: 'triad_density', layer: 'surface', status: 'pass' });
|
|
257
|
+
}
|
|
258
|
+
return { gates, violations };
|
|
259
|
+
}
|
|
260
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
261
|
+
// Layer 2 — CADENCE
|
|
262
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
263
|
+
function layerCadence(text) {
|
|
264
|
+
const gates = [];
|
|
265
|
+
const violations = [];
|
|
266
|
+
const sentences = splitSentences(text);
|
|
267
|
+
const paragraphs = splitParagraphs(text);
|
|
268
|
+
if (sentences.length < 4) {
|
|
269
|
+
gates.push({
|
|
270
|
+
name: 'burstiness',
|
|
271
|
+
layer: 'cadence',
|
|
272
|
+
status: 'pass',
|
|
273
|
+
detail: 'too few sentences to score',
|
|
274
|
+
});
|
|
275
|
+
return { gates, violations };
|
|
276
|
+
}
|
|
277
|
+
// 2a. Sentence-length burstiness (the single best statistical AI tell).
|
|
278
|
+
const sentenceLengths = sentences.map(countWords);
|
|
279
|
+
const sigma = stdev(sentenceLengths);
|
|
280
|
+
if (sigma < BURSTINESS_FAIL_BELOW) {
|
|
281
|
+
gates.push({
|
|
282
|
+
name: 'burstiness',
|
|
283
|
+
layer: 'cadence',
|
|
284
|
+
status: 'fail',
|
|
285
|
+
detail: 'Sentences are too uniform in length — humans vary. Drop a 3-word punch next to a 25-word build.',
|
|
286
|
+
});
|
|
287
|
+
violations.push({
|
|
288
|
+
pattern: 'low_burstiness',
|
|
289
|
+
span: '',
|
|
290
|
+
reason: 'Mix sentence lengths. Drop a 3-word punch next to a 25-word build.',
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
else if (sigma < BURSTINESS_TARGET_MIN) {
|
|
294
|
+
gates.push({
|
|
295
|
+
name: 'burstiness',
|
|
296
|
+
layer: 'cadence',
|
|
297
|
+
status: 'warn',
|
|
298
|
+
detail: 'Sentence lengths are still pretty uniform — vary more.',
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
gates.push({
|
|
303
|
+
name: 'burstiness',
|
|
304
|
+
layer: 'cadence',
|
|
305
|
+
status: 'pass',
|
|
306
|
+
detail: 'ok',
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
// 2b. Paragraph-length variance.
|
|
310
|
+
if (paragraphs.length >= 5) {
|
|
311
|
+
const paragraphLengths = paragraphs.map(countWords);
|
|
312
|
+
const paragraphSigma = stdev(paragraphLengths);
|
|
313
|
+
if (paragraphSigma < 1.5) {
|
|
314
|
+
gates.push({
|
|
315
|
+
name: 'paragraph_variance',
|
|
316
|
+
layer: 'cadence',
|
|
317
|
+
status: 'warn',
|
|
318
|
+
detail: 'Paragraphs are similar in length — vary the rhythm.',
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
gates.push({
|
|
323
|
+
name: 'paragraph_variance',
|
|
324
|
+
layer: 'cadence',
|
|
325
|
+
status: 'pass',
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// 2c. Rhythmic contrast — every 4-sentence stretch should have a (max-min)
|
|
330
|
+
// of at least 10 words.
|
|
331
|
+
let lowContrastWindows = 0;
|
|
332
|
+
for (let i = 0; i + 4 <= sentenceLengths.length; i++) {
|
|
333
|
+
const window = sentenceLengths.slice(i, i + 4);
|
|
334
|
+
if (Math.max(...window) - Math.min(...window) < 10) {
|
|
335
|
+
lowContrastWindows += 1;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (lowContrastWindows > Math.max(1, Math.floor(sentenceLengths.length / 8))) {
|
|
339
|
+
gates.push({
|
|
340
|
+
name: 'rhythmic_contrast',
|
|
341
|
+
layer: 'cadence',
|
|
342
|
+
status: 'warn',
|
|
343
|
+
detail: 'Stretches of sentences are landing at similar lengths — vary the rhythm with a short punch or long build.',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
gates.push({ name: 'rhythmic_contrast', layer: 'cadence', status: 'pass' });
|
|
348
|
+
}
|
|
349
|
+
// 2d. Sentence length band distribution — practitioner-derived targets:
|
|
350
|
+
// Short (1-5): ~10%, Medium (6-15): ~55-60%, Long (16-25): ~20-25%, Very Long (26+): ~5-10%
|
|
351
|
+
// The specific percentages are heuristics, not peer-reviewed. The principle
|
|
352
|
+
// of variation IS validated (stylometry, readability research). We flag
|
|
353
|
+
// extreme imbalances, not minor deviations from the target.
|
|
354
|
+
if (sentenceLengths.length >= 8) {
|
|
355
|
+
const total = sentenceLengths.length;
|
|
356
|
+
const shortCount = sentenceLengths.filter((l) => l <= 5).length;
|
|
357
|
+
const mediumCount = sentenceLengths.filter((l) => l >= 6 && l <= 15).length;
|
|
358
|
+
const longCount = sentenceLengths.filter((l) => l >= 16 && l <= 25).length;
|
|
359
|
+
const veryLongCount = sentenceLengths.filter((l) => l >= 26).length;
|
|
360
|
+
const shortPct = shortCount / total;
|
|
361
|
+
const longPlusPct = (longCount + veryLongCount) / total;
|
|
362
|
+
// Flag if zero short punches (no impact sentences) or zero long builds (no crescendo)
|
|
363
|
+
const bandIssues = [];
|
|
364
|
+
if (shortPct === 0)
|
|
365
|
+
bandIssues.push('no short punches (1-5 words) — add impact sentences');
|
|
366
|
+
if (longPlusPct < 0.1)
|
|
367
|
+
bandIssues.push('< 10% long sentences (16+ words) — copy lacks crescendo and momentum');
|
|
368
|
+
if (mediumCount / total > 0.85)
|
|
369
|
+
bandIssues.push('> 85% medium length — monotonous middle-of-the-road rhythm');
|
|
370
|
+
if (bandIssues.length > 0) {
|
|
371
|
+
gates.push({
|
|
372
|
+
name: 'band_distribution',
|
|
373
|
+
layer: 'cadence',
|
|
374
|
+
status: 'warn',
|
|
375
|
+
detail: bandIssues.join('. ') + '.',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
gates.push({
|
|
380
|
+
name: 'band_distribution',
|
|
381
|
+
layer: 'cadence',
|
|
382
|
+
status: 'pass',
|
|
383
|
+
detail: 'ok',
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// 2e. Consecutive same-band — 3+ consecutive sentences in the same length
|
|
388
|
+
// band produces a flat, robotic rhythm. The "never 2+ consecutive" rule
|
|
389
|
+
// is strict; we flag at 3+ as a warn (practitioners vary on threshold).
|
|
390
|
+
if (sentenceLengths.length >= 6) {
|
|
391
|
+
const getBand = (len) => len <= 5 ? 'short' : len <= 15 ? 'medium' : len <= 25 ? 'long' : 'vlong';
|
|
392
|
+
let maxConsecutive = 1;
|
|
393
|
+
let currentRun = 1;
|
|
394
|
+
for (let i = 1; i < sentenceLengths.length; i++) {
|
|
395
|
+
if (getBand(sentenceLengths[i]) === getBand(sentenceLengths[i - 1])) {
|
|
396
|
+
currentRun++;
|
|
397
|
+
if (currentRun > maxConsecutive)
|
|
398
|
+
maxConsecutive = currentRun;
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
currentRun = 1;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (maxConsecutive >= 4) {
|
|
405
|
+
gates.push({
|
|
406
|
+
name: 'consecutive_band',
|
|
407
|
+
layer: 'cadence',
|
|
408
|
+
status: 'warn',
|
|
409
|
+
detail: 'Multiple sentences in a row are the same approximate length — flatline rhythm. Break it up with a short punch or a long build.',
|
|
410
|
+
});
|
|
411
|
+
violations.push({
|
|
412
|
+
pattern: 'consecutive_same_band',
|
|
413
|
+
span: '',
|
|
414
|
+
reason: 'Never let 3+ consecutive sentences land in the same length band. This produces flat, robotic copy.',
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
gates.push({ name: 'consecutive_band', layer: 'cadence', status: 'pass' });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
// 2f. Paragraph opening uniformity — first 3 tokens of each paragraph.
|
|
422
|
+
if (paragraphs.length >= 4) {
|
|
423
|
+
const openings = paragraphs.map((p) => p.toLowerCase().split(/\s+/).slice(0, 3).join(' '));
|
|
424
|
+
const counts = new Map();
|
|
425
|
+
for (const o of openings)
|
|
426
|
+
counts.set(o, (counts.get(o) || 0) + 1);
|
|
427
|
+
const maxRepeat = Math.max(...counts.values());
|
|
428
|
+
const repeatRatio = maxRepeat / openings.length;
|
|
429
|
+
if (repeatRatio > 0.6) {
|
|
430
|
+
gates.push({
|
|
431
|
+
name: 'opening_uniformity',
|
|
432
|
+
layer: 'cadence',
|
|
433
|
+
status: 'warn',
|
|
434
|
+
detail: 'Most paragraphs open with the same shape — vary the openings.',
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
gates.push({ name: 'opening_uniformity', layer: 'cadence', status: 'pass' });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return { gates, violations };
|
|
442
|
+
}
|
|
443
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
444
|
+
// Layer 3 — FLOW (discourse-aware, no POS tagger)
|
|
445
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
446
|
+
const PRONOUN_OPENERS = new Set([
|
|
447
|
+
'it', "it's", 'this', 'that', 'these', 'those', 'they', "they're", 'he', 'she',
|
|
448
|
+
'his', 'her', 'their', 'theirs', 'them',
|
|
449
|
+
]);
|
|
450
|
+
const DISCOURSE_OPENERS = new Set([
|
|
451
|
+
'so', 'but', 'and', 'then', 'because', 'which', "that's", "here's", 'still',
|
|
452
|
+
'yet', 'now', 'plus', 'except', 'meanwhile', 'because', 'since',
|
|
453
|
+
]);
|
|
454
|
+
const BUCKET_BRIGADES = [
|
|
455
|
+
"here's the thing", 'but', 'look:', 'now,', 'the kicker', 'bottom line',
|
|
456
|
+
'and yet', 'still,', 'even so', 'watch this', 'imagine', 'the catch?',
|
|
457
|
+
"here's what", "the truth is", 'one more thing', "here's why",
|
|
458
|
+
];
|
|
459
|
+
function firstWord(sentence) {
|
|
460
|
+
return (sentence.toLowerCase().match(/^[a-z']+/) || [''])[0];
|
|
461
|
+
}
|
|
462
|
+
function layerFlow(text, context) {
|
|
463
|
+
const gates = [];
|
|
464
|
+
const violations = [];
|
|
465
|
+
const sentences = splitSentences(text);
|
|
466
|
+
const paragraphs = splitParagraphs(text);
|
|
467
|
+
const wordCount = countWords(text);
|
|
468
|
+
if (sentences.length < 4)
|
|
469
|
+
return { gates, violations };
|
|
470
|
+
// 3a. Bucket-brigade density (long-form / body only).
|
|
471
|
+
const isLongForm = context === 'long_form' || (context === 'body' && wordCount >= 400);
|
|
472
|
+
if (isLongForm) {
|
|
473
|
+
const lower = text.toLowerCase();
|
|
474
|
+
let bucketCount = 0;
|
|
475
|
+
for (const phrase of BUCKET_BRIGADES) {
|
|
476
|
+
// Trailing (?!\w) (negative lookahead for word char) acts as a "right
|
|
477
|
+
// boundary" that works whether the phrase ends in a word char ("but")
|
|
478
|
+
// OR punctuation ("look:" / "the catch?"). A plain \b would only work
|
|
479
|
+
// for word-char endings and would cause "but" to match inside
|
|
480
|
+
// "butterfly".
|
|
481
|
+
const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
482
|
+
const matches = lower.match(new RegExp(`\\b${escaped}(?!\\w)`, 'g'));
|
|
483
|
+
if (matches)
|
|
484
|
+
bucketCount += matches.length;
|
|
485
|
+
}
|
|
486
|
+
const target = Math.max(1, Math.floor(wordCount / BUCKET_BRIGADE_PER_WORDS_LONG_FORM));
|
|
487
|
+
if (bucketCount < target) {
|
|
488
|
+
gates.push({
|
|
489
|
+
name: 'bucket_brigade_density',
|
|
490
|
+
layer: 'flow',
|
|
491
|
+
status: 'warn',
|
|
492
|
+
detail: 'The prose needs more pull-through phrases — short connectives that drag the reader from one line to the next.',
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
gates.push({
|
|
497
|
+
name: 'bucket_brigade_density',
|
|
498
|
+
layer: 'flow',
|
|
499
|
+
status: 'pass',
|
|
500
|
+
detail: 'ok',
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// 3b. Threading ratio approximation. A sentence "threads" to the prior if:
|
|
505
|
+
// (a) it starts with a pronoun referring to the prior, OR
|
|
506
|
+
// (b) it starts with a discourse connector, OR
|
|
507
|
+
// (c) it shares at least one 4+ char content noun with the prior.
|
|
508
|
+
let threaded = 0;
|
|
509
|
+
for (let i = 1; i < sentences.length; i++) {
|
|
510
|
+
const opener = firstWord(sentences[i]);
|
|
511
|
+
if (PRONOUN_OPENERS.has(opener) || DISCOURSE_OPENERS.has(opener)) {
|
|
512
|
+
threaded += 1;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const prevTokens = contentTokens(sentences[i - 1]);
|
|
516
|
+
const currTokens = contentTokens(sentences[i]);
|
|
517
|
+
let overlap = false;
|
|
518
|
+
for (const t of currTokens) {
|
|
519
|
+
if (prevTokens.has(t)) {
|
|
520
|
+
overlap = true;
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (overlap)
|
|
525
|
+
threaded += 1;
|
|
526
|
+
}
|
|
527
|
+
const threadingRatio = threaded / Math.max(1, sentences.length - 1);
|
|
528
|
+
if (threadingRatio < THREADING_FAIL_BELOW) {
|
|
529
|
+
gates.push({
|
|
530
|
+
name: 'threading_ratio',
|
|
531
|
+
layer: 'flow',
|
|
532
|
+
status: 'fail',
|
|
533
|
+
detail: 'Sentences read as a list, not pulling each other forward. Each one should pick up where the last left off — pronoun reference, discourse connector, or shared key noun.',
|
|
534
|
+
});
|
|
535
|
+
violations.push({
|
|
536
|
+
pattern: 'no_threading',
|
|
537
|
+
span: '',
|
|
538
|
+
reason: 'Each sentence should pick up where the last left off — pronoun reference, discourse connector, or shared key noun.',
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
else if (threadingRatio < THREADING_TARGET) {
|
|
542
|
+
gates.push({
|
|
543
|
+
name: 'threading_ratio',
|
|
544
|
+
layer: 'flow',
|
|
545
|
+
status: 'warn',
|
|
546
|
+
detail: 'Some sentences read as standalone instead of pulling forward.',
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
gates.push({
|
|
551
|
+
name: 'threading_ratio',
|
|
552
|
+
layer: 'flow',
|
|
553
|
+
status: 'pass',
|
|
554
|
+
detail: 'ok',
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
// 3c. Closing recap detection — final paragraph is a recap if it overlaps
|
|
558
|
+
// heavily with prior content AND adds few new content nouns.
|
|
559
|
+
if (paragraphs.length >= 3 && context !== 'tldr') {
|
|
560
|
+
const finalParagraph = paragraphs[paragraphs.length - 1];
|
|
561
|
+
const priorText = paragraphs.slice(0, -1).join(' ');
|
|
562
|
+
const priorTokens = contentTokens(priorText);
|
|
563
|
+
const finalTokens = contentTokens(finalParagraph);
|
|
564
|
+
if (finalTokens.size > 0) {
|
|
565
|
+
let shared = 0;
|
|
566
|
+
let novel = 0;
|
|
567
|
+
for (const t of finalTokens) {
|
|
568
|
+
if (priorTokens.has(t))
|
|
569
|
+
shared += 1;
|
|
570
|
+
else
|
|
571
|
+
novel += 1;
|
|
572
|
+
}
|
|
573
|
+
const overlapRatio = shared / finalTokens.size;
|
|
574
|
+
if (overlapRatio > RECAP_OVERLAP_THRESHOLD && novel < RECAP_NEW_NOUN_THRESHOLD) {
|
|
575
|
+
gates.push({
|
|
576
|
+
name: 'closing_recap',
|
|
577
|
+
layer: 'flow',
|
|
578
|
+
status: 'fail',
|
|
579
|
+
detail: 'Final paragraph mostly restates earlier content instead of moving forward. Cut the recap — end on the strongest unique line.',
|
|
580
|
+
});
|
|
581
|
+
violations.push({
|
|
582
|
+
pattern: 'closing_recap',
|
|
583
|
+
span: safeSlice(finalParagraph, 80) + (finalParagraph.length > 80 ? '…' : ''),
|
|
584
|
+
reason: 'Recap paragraphs are AI tell. End on the strongest unique line.',
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
gates.push({ name: 'closing_recap', layer: 'flow', status: 'pass' });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return { gates, violations };
|
|
593
|
+
}
|
|
594
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
595
|
+
// Composition
|
|
596
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
597
|
+
function computeScore(gates) {
|
|
598
|
+
if (gates.length === 0)
|
|
599
|
+
return { score: 7.0, label: 'passable' };
|
|
600
|
+
// Each fail costs 2.0, each warn 0.7, baseline 10.
|
|
601
|
+
let score = 10.0;
|
|
602
|
+
for (const g of gates) {
|
|
603
|
+
if (g.status === 'fail')
|
|
604
|
+
score -= 2.0;
|
|
605
|
+
else if (g.status === 'warn')
|
|
606
|
+
score -= 0.7;
|
|
607
|
+
}
|
|
608
|
+
if (score < 0)
|
|
609
|
+
score = 0;
|
|
610
|
+
let label;
|
|
611
|
+
if (score < 3)
|
|
612
|
+
label = 'fail';
|
|
613
|
+
else if (score < 5.5)
|
|
614
|
+
label = 'weak';
|
|
615
|
+
else if (score < 7.5)
|
|
616
|
+
label = 'passable';
|
|
617
|
+
else if (score < 9)
|
|
618
|
+
label = 'strong';
|
|
619
|
+
else
|
|
620
|
+
label = 'excellent';
|
|
621
|
+
return { score: Math.round(score * 10) / 10, label };
|
|
622
|
+
}
|
|
623
|
+
function buildSuggestions(gates) {
|
|
624
|
+
const out = [];
|
|
625
|
+
for (const g of gates) {
|
|
626
|
+
if (g.status !== 'fail')
|
|
627
|
+
continue;
|
|
628
|
+
switch (g.name) {
|
|
629
|
+
case 'ai_smell_lexicon':
|
|
630
|
+
out.push('Strip the banned vocabulary. Use plain Anglo-Saxon verbs.');
|
|
631
|
+
break;
|
|
632
|
+
case 'em_dash_density':
|
|
633
|
+
out.push('Cut em-dashes. Replace with periods or restructure as two clauses.');
|
|
634
|
+
break;
|
|
635
|
+
case 'negative_parallelism_cap':
|
|
636
|
+
out.push('Cut "it\'s not X, it\'s Y" constructions. Make one direct assertion instead.');
|
|
637
|
+
break;
|
|
638
|
+
case 'burstiness':
|
|
639
|
+
out.push('Vary sentence length aggressively — drop a 3-word punch between longer builds.');
|
|
640
|
+
break;
|
|
641
|
+
case 'threading_ratio':
|
|
642
|
+
out.push('Make each sentence pick up the previous one — pronoun, connector, or shared key noun.');
|
|
643
|
+
break;
|
|
644
|
+
case 'closing_recap':
|
|
645
|
+
out.push('End on a forward-pointing line. Recaps are an AI tell.');
|
|
646
|
+
break;
|
|
647
|
+
case 'consecutive_band':
|
|
648
|
+
out.push('Break up same-length sentence runs. Drop a 3-word punch between medium builds, or let one sentence roll long.');
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return out.slice(0, 3);
|
|
653
|
+
}
|
|
654
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
655
|
+
// Public entrypoint
|
|
656
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
657
|
+
export function scoreFlow(rawText, options = {}) {
|
|
658
|
+
const context = options.context || 'body';
|
|
659
|
+
// Capture the ORIGINAL counts BEFORE sampling so the metadata reflects what
|
|
660
|
+
// the bot passed in, not what was actually scored. When the input exceeds
|
|
661
|
+
// MAX_FULL_WORDS the scoring numbers come from the sampled subset, but the
|
|
662
|
+
// user/bot needs to see the true input size to interpret the score honestly.
|
|
663
|
+
const originalWordCount = countWords(rawText);
|
|
664
|
+
const originalSentenceCount = splitSentences(rawText).length;
|
|
665
|
+
const originalParagraphCount = splitParagraphs(rawText).length;
|
|
666
|
+
const { sampledText, sampledWindowCount } = sampleWindows(rawText);
|
|
667
|
+
const text = sampledText;
|
|
668
|
+
const sampledWordCount = countWords(text);
|
|
669
|
+
const gates = [];
|
|
670
|
+
const violations = [];
|
|
671
|
+
const contextGated = [];
|
|
672
|
+
// Layer 1 always fires.
|
|
673
|
+
const surface = layerSurface(text, options);
|
|
674
|
+
gates.push(...surface.gates);
|
|
675
|
+
violations.push(...surface.violations);
|
|
676
|
+
// Layers 2 & 3 only fire on prose contexts.
|
|
677
|
+
const isProse = !NON_PROSE_CONTEXTS.has(context);
|
|
678
|
+
if (isProse) {
|
|
679
|
+
const cadence = layerCadence(text);
|
|
680
|
+
gates.push(...cadence.gates);
|
|
681
|
+
violations.push(...cadence.violations);
|
|
682
|
+
const flow = layerFlow(text, context);
|
|
683
|
+
gates.push(...flow.gates);
|
|
684
|
+
violations.push(...flow.violations);
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
contextGated.push('cadence', 'flow');
|
|
688
|
+
}
|
|
689
|
+
const { score, label } = computeScore(gates);
|
|
690
|
+
const suggestions = buildSuggestions(gates);
|
|
691
|
+
const wasSampled = sampledWindowCount > 0;
|
|
692
|
+
return {
|
|
693
|
+
score,
|
|
694
|
+
scoreLabel: label,
|
|
695
|
+
gates,
|
|
696
|
+
violations,
|
|
697
|
+
suggestions,
|
|
698
|
+
metadata: {
|
|
699
|
+
wordCount: originalWordCount,
|
|
700
|
+
sentenceCount: originalSentenceCount,
|
|
701
|
+
paragraphCount: originalParagraphCount,
|
|
702
|
+
sampled: wasSampled,
|
|
703
|
+
sampledWindowCount: wasSampled ? sampledWindowCount : undefined,
|
|
704
|
+
sampledWordCount: wasSampled ? sampledWordCount : undefined,
|
|
705
|
+
contextGated,
|
|
706
|
+
},
|
|
707
|
+
};
|
|
708
|
+
}
|