@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,187 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "Upworthy Research Archive (Matias, Munger, Le Quere, Ebersole 2021, Nature Scientific Data), CC BY 4.0",
|
|
3
|
+
"method": "Within-test trait analysis on the Upworthy Research Archive (CC BY 4.0, Matias et al. 2021).\nUnit = one test x one image (eyecatcher), so only headlines shown with the same image are compared.\nPackages need >= MIN_IMP impressions. For a binary trait, a unit votes 1 if its packages WITH the\ntrait have a higher pooled CTR than those without. For a continuous trait, the unit compares its\nhighest- and lowest-valued packages. Win rate > 50% = the trait tends to win. Tests in the\nbroken-randomization window (2013-06-25 .. 2014-01-10) are excluded.",
|
|
4
|
+
"min_impressions": 1000,
|
|
5
|
+
"traits": {
|
|
6
|
+
"number": {
|
|
7
|
+
"confirmatory_win_rate": 0.5443,
|
|
8
|
+
"confirmatory_n": 3316,
|
|
9
|
+
"ci95": [
|
|
10
|
+
0.5273,
|
|
11
|
+
0.5612
|
|
12
|
+
],
|
|
13
|
+
"holdout_win_rate": 0.5811,
|
|
14
|
+
"holdout_n": 721,
|
|
15
|
+
"exploratory_win_rate": 0.5405,
|
|
16
|
+
"verdict": "REPLICATES wins"
|
|
17
|
+
},
|
|
18
|
+
"question": {
|
|
19
|
+
"confirmatory_win_rate": 0.3856,
|
|
20
|
+
"confirmatory_n": 4217,
|
|
21
|
+
"ci95": [
|
|
22
|
+
0.371,
|
|
23
|
+
0.4004
|
|
24
|
+
],
|
|
25
|
+
"holdout_win_rate": 0.3619,
|
|
26
|
+
"holdout_n": 923,
|
|
27
|
+
"exploratory_win_rate": 0.3838,
|
|
28
|
+
"verdict": "REPLICATES loses"
|
|
29
|
+
},
|
|
30
|
+
"you": {
|
|
31
|
+
"confirmatory_win_rate": 0.487,
|
|
32
|
+
"confirmatory_n": 5010,
|
|
33
|
+
"ci95": [
|
|
34
|
+
0.4732,
|
|
35
|
+
0.5009
|
|
36
|
+
],
|
|
37
|
+
"holdout_win_rate": 0.5032,
|
|
38
|
+
"holdout_n": 1099,
|
|
39
|
+
"exploratory_win_rate": 0.4876,
|
|
40
|
+
"verdict": "no reliable effect"
|
|
41
|
+
},
|
|
42
|
+
"first_person": {
|
|
43
|
+
"confirmatory_win_rate": 0.5001,
|
|
44
|
+
"confirmatory_n": 4475,
|
|
45
|
+
"ci95": [
|
|
46
|
+
0.4855,
|
|
47
|
+
0.5148
|
|
48
|
+
],
|
|
49
|
+
"holdout_win_rate": 0.4964,
|
|
50
|
+
"holdout_n": 985,
|
|
51
|
+
"exploratory_win_rate": 0.4743,
|
|
52
|
+
"verdict": "no reliable effect"
|
|
53
|
+
},
|
|
54
|
+
"quotation": {
|
|
55
|
+
"confirmatory_win_rate": 0.4377,
|
|
56
|
+
"confirmatory_n": 361,
|
|
57
|
+
"ci95": [
|
|
58
|
+
0.3874,
|
|
59
|
+
0.4892
|
|
60
|
+
],
|
|
61
|
+
"holdout_win_rate": 0.4177,
|
|
62
|
+
"holdout_n": 79,
|
|
63
|
+
"exploratory_win_rate": 0.4754,
|
|
64
|
+
"verdict": "no reliable effect"
|
|
65
|
+
},
|
|
66
|
+
"negation": {
|
|
67
|
+
"confirmatory_win_rate": 0.4996,
|
|
68
|
+
"confirmatory_n": 4548,
|
|
69
|
+
"ci95": [
|
|
70
|
+
0.485,
|
|
71
|
+
0.5141
|
|
72
|
+
],
|
|
73
|
+
"holdout_win_rate": 0.5165,
|
|
74
|
+
"holdout_n": 999,
|
|
75
|
+
"exploratory_win_rate": 0.4839,
|
|
76
|
+
"verdict": "no reliable effect"
|
|
77
|
+
},
|
|
78
|
+
"superlative": {
|
|
79
|
+
"confirmatory_win_rate": 0.5334,
|
|
80
|
+
"confirmatory_n": 2261,
|
|
81
|
+
"ci95": [
|
|
82
|
+
0.5128,
|
|
83
|
+
0.5539
|
|
84
|
+
],
|
|
85
|
+
"holdout_win_rate": 0.5382,
|
|
86
|
+
"holdout_n": 524,
|
|
87
|
+
"exploratory_win_rate": 0.5558,
|
|
88
|
+
"verdict": "no reliable effect"
|
|
89
|
+
},
|
|
90
|
+
"this_these": {
|
|
91
|
+
"confirmatory_win_rate": 0.5578,
|
|
92
|
+
"confirmatory_n": 5522,
|
|
93
|
+
"ci95": [
|
|
94
|
+
0.5446,
|
|
95
|
+
0.5708
|
|
96
|
+
],
|
|
97
|
+
"holdout_win_rate": 0.5725,
|
|
98
|
+
"holdout_n": 1214,
|
|
99
|
+
"exploratory_win_rate": 0.5526,
|
|
100
|
+
"verdict": "REPLICATES wins"
|
|
101
|
+
},
|
|
102
|
+
"exclamation": {
|
|
103
|
+
"confirmatory_win_rate": 0.4034,
|
|
104
|
+
"confirmatory_n": 761,
|
|
105
|
+
"ci95": [
|
|
106
|
+
0.3691,
|
|
107
|
+
0.4387
|
|
108
|
+
],
|
|
109
|
+
"holdout_win_rate": 0.3701,
|
|
110
|
+
"holdout_n": 154,
|
|
111
|
+
"exploratory_win_rate": 0.3576,
|
|
112
|
+
"verdict": "REPLICATES loses"
|
|
113
|
+
},
|
|
114
|
+
"emphasis_caps": {
|
|
115
|
+
"confirmatory_win_rate": 0.4178,
|
|
116
|
+
"confirmatory_n": 1776,
|
|
117
|
+
"ci95": [
|
|
118
|
+
0.3951,
|
|
119
|
+
0.4409
|
|
120
|
+
],
|
|
121
|
+
"holdout_win_rate": 0.4474,
|
|
122
|
+
"holdout_n": 380,
|
|
123
|
+
"exploratory_win_rate": 0.4103,
|
|
124
|
+
"verdict": "REPLICATES loses"
|
|
125
|
+
},
|
|
126
|
+
"how_why_what": {
|
|
127
|
+
"confirmatory_win_rate": 0.4963,
|
|
128
|
+
"confirmatory_n": 5561,
|
|
129
|
+
"ci95": [
|
|
130
|
+
0.4832,
|
|
131
|
+
0.5095
|
|
132
|
+
],
|
|
133
|
+
"holdout_win_rate": 0.5033,
|
|
134
|
+
"holdout_n": 1220,
|
|
135
|
+
"exploratory_win_rate": 0.5042,
|
|
136
|
+
"verdict": "no reliable effect"
|
|
137
|
+
},
|
|
138
|
+
"time_word": {
|
|
139
|
+
"confirmatory_win_rate": 0.5366,
|
|
140
|
+
"confirmatory_n": 2158,
|
|
141
|
+
"ci95": [
|
|
142
|
+
0.5155,
|
|
143
|
+
0.5576
|
|
144
|
+
],
|
|
145
|
+
"holdout_win_rate": 0.5593,
|
|
146
|
+
"holdout_n": 447,
|
|
147
|
+
"exploratory_win_rate": 0.5441,
|
|
148
|
+
"verdict": "REPLICATES wins"
|
|
149
|
+
},
|
|
150
|
+
"old_scoreHook": {
|
|
151
|
+
"confirmatory_win_rate": 0.4632,
|
|
152
|
+
"confirmatory_n": 7726,
|
|
153
|
+
"ci95": [
|
|
154
|
+
0.4521,
|
|
155
|
+
0.4744
|
|
156
|
+
],
|
|
157
|
+
"holdout_win_rate": 0.4949,
|
|
158
|
+
"holdout_n": 1673,
|
|
159
|
+
"exploratory_win_rate": 0.482,
|
|
160
|
+
"verdict": "no reliable effect"
|
|
161
|
+
},
|
|
162
|
+
"word_count": {
|
|
163
|
+
"confirmatory_win_rate": 0.5385,
|
|
164
|
+
"confirmatory_n": 9564,
|
|
165
|
+
"ci95": [
|
|
166
|
+
0.5285,
|
|
167
|
+
0.5485
|
|
168
|
+
],
|
|
169
|
+
"holdout_win_rate": 0.5435,
|
|
170
|
+
"holdout_n": 2079,
|
|
171
|
+
"exploratory_win_rate": 0.5493,
|
|
172
|
+
"verdict": "REPLICATES wins"
|
|
173
|
+
},
|
|
174
|
+
"article_overlap": {
|
|
175
|
+
"confirmatory_win_rate": 0.5268,
|
|
176
|
+
"confirmatory_n": 9406,
|
|
177
|
+
"ci95": [
|
|
178
|
+
0.5167,
|
|
179
|
+
0.5369
|
|
180
|
+
],
|
|
181
|
+
"holdout_win_rate": 0.5164,
|
|
182
|
+
"holdout_n": 2043,
|
|
183
|
+
"exploratory_win_rate": 0.5188,
|
|
184
|
+
"verdict": "no reliable effect"
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
package/dist/data.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type Role, type Format } from './roles.js';
|
|
2
|
+
export interface Example {
|
|
3
|
+
brand: string;
|
|
4
|
+
year: string | null;
|
|
5
|
+
headline: string;
|
|
6
|
+
excerpt: string;
|
|
7
|
+
why: string;
|
|
8
|
+
}
|
|
9
|
+
export interface Technique {
|
|
10
|
+
slug: string;
|
|
11
|
+
pt_id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
role: Role;
|
|
14
|
+
family: string | null;
|
|
15
|
+
parent: string | null;
|
|
16
|
+
lede: string;
|
|
17
|
+
what_it_does: string;
|
|
18
|
+
why_it_works: string;
|
|
19
|
+
lands_when: string;
|
|
20
|
+
dilutes_when: string;
|
|
21
|
+
examples: Example[];
|
|
22
|
+
sisters: {
|
|
23
|
+
pt_id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
}[];
|
|
26
|
+
counters: {
|
|
27
|
+
pt_id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
}[];
|
|
30
|
+
url: string;
|
|
31
|
+
brands: number;
|
|
32
|
+
channels: Record<string, number>;
|
|
33
|
+
/** distinct brands per persuasion mode, from the aggressive / balanced / equity opener tables */
|
|
34
|
+
modes: Partial<Record<Mode, number>>;
|
|
35
|
+
}
|
|
36
|
+
export type Mode = 'aggressive' | 'balanced' | 'equity';
|
|
37
|
+
export type Tier = 'table_stakes' | 'common' | 'occasional' | 'less_common';
|
|
38
|
+
export declare const CATALOG_META: Record<string, unknown>;
|
|
39
|
+
export declare const TECHNIQUES: Technique[];
|
|
40
|
+
export declare const CATEGORIES: Record<string, {
|
|
41
|
+
brands: number;
|
|
42
|
+
tiers: Record<string, Tier>;
|
|
43
|
+
}>;
|
|
44
|
+
export declare const CATEGORY_NAMES: string[];
|
|
45
|
+
export declare const byRole: Map<"DISRUPT" | "IDENTIFY" | "AGITATE" | "REFRAME" | "PROVE" | "ELEVATE" | "RESOLVE" | "COMPEL" | "BOND", Technique[]>;
|
|
46
|
+
export declare function lookupTechnique(id: string): Technique | null;
|
|
47
|
+
/** Accept a category written loosely ("SaaS", "b2b saas", "skincare"). */
|
|
48
|
+
export declare function resolveCategory(input?: string | null): string | null;
|
|
49
|
+
export declare function tierIn(category: string | null, slug: string): Tier | null;
|
|
50
|
+
export declare function prevalenceLabel(t: Technique, category: string | null): string;
|
|
51
|
+
export declare function channelFit(t: Technique, format: Format | null): number;
|
|
52
|
+
/** Share of a technique's opener appearances that come from aggressive-mode copy (null = too little data). */
|
|
53
|
+
export declare function aggressiveShare(t: Technique): number | null;
|
|
54
|
+
/** Multiplier that keeps hard-sell moves out of balanced and equity copy (Coppica's persuasion modes). */
|
|
55
|
+
export declare function modeWeight(t: Technique, mode: Mode): number;
|
|
56
|
+
export declare function searchTechniques(query: string, opts?: {
|
|
57
|
+
role?: Role | null;
|
|
58
|
+
format?: Format | null;
|
|
59
|
+
limit?: number;
|
|
60
|
+
}): Technique[];
|
|
61
|
+
export interface PickedMove {
|
|
62
|
+
t: Technique;
|
|
63
|
+
kind: 'established' | 'less_expected';
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Options for answering one reader question: one established move plus less-expected ones.
|
|
67
|
+
* Ranked by relevance to the brief (BM25 over the brief or copy), fit to the format's
|
|
68
|
+
* channels in the corpus, and the persuasion mode; then diversified by family and a seed,
|
|
69
|
+
* so the same brief gets the same options and different briefs get different ones.
|
|
70
|
+
* "Less expected" = not the category default: the long tail a model would not reach for
|
|
71
|
+
* on its own (Theses IV, XIV). The calling model makes the final choice.
|
|
72
|
+
*/
|
|
73
|
+
export declare function pickMoves(role: Role, opts: {
|
|
74
|
+
n: number;
|
|
75
|
+
format: Format | null;
|
|
76
|
+
category: string | null;
|
|
77
|
+
seed: string;
|
|
78
|
+
context: string;
|
|
79
|
+
mode: Mode;
|
|
80
|
+
exclude?: Set<string>;
|
|
81
|
+
}): PickedMove[];
|
package/dist/data.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bundled catalog snapshot (data/*.json, written by scripts/export-catalog.mjs)
|
|
3
|
+
* plus in-memory search and move selection. No database, no network, no AI calls.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { ROLE_NAMES, FORMAT_CHANNELS } from './roles.js';
|
|
9
|
+
const DATA_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'data');
|
|
10
|
+
const catalogFile = JSON.parse(readFileSync(join(DATA_DIR, 'catalog.json'), 'utf8'));
|
|
11
|
+
const categoriesFile = JSON.parse(readFileSync(join(DATA_DIR, 'categories.json'), 'utf8'));
|
|
12
|
+
export const CATALOG_META = catalogFile.meta;
|
|
13
|
+
export const TECHNIQUES = catalogFile.techniques;
|
|
14
|
+
export const CATEGORIES = categoriesFile.categories;
|
|
15
|
+
export const CATEGORY_NAMES = Object.keys(CATEGORIES).sort();
|
|
16
|
+
const byPtId = new Map(TECHNIQUES.map((t) => [t.pt_id.toUpperCase(), t]));
|
|
17
|
+
const bySlug = new Map(TECHNIQUES.map((t) => [t.slug.toLowerCase(), t]));
|
|
18
|
+
const byName = new Map(TECHNIQUES.map((t) => [norm(t.name), t]));
|
|
19
|
+
export const byRole = new Map(ROLE_NAMES.map((r) => [r, TECHNIQUES.filter((t) => t.role === r)]));
|
|
20
|
+
function norm(s) {
|
|
21
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
22
|
+
}
|
|
23
|
+
export function lookupTechnique(id) {
|
|
24
|
+
const raw = id.trim();
|
|
25
|
+
return byPtId.get(raw.toUpperCase()) ?? bySlug.get(raw.toLowerCase().replace(/[\s-]+/g, '_')) ?? byName.get(norm(raw)) ?? null;
|
|
26
|
+
}
|
|
27
|
+
/** Accept a category written loosely ("SaaS", "b2b saas", "skincare"). */
|
|
28
|
+
export function resolveCategory(input) {
|
|
29
|
+
if (!input)
|
|
30
|
+
return null;
|
|
31
|
+
const n = norm(input).replace(/ /g, '_');
|
|
32
|
+
if (CATEGORIES[n])
|
|
33
|
+
return n;
|
|
34
|
+
const hit = CATEGORY_NAMES.find((c) => c.includes(n) || n.includes(c) || c.replace(/_/g, '').includes(n.replace(/_/g, '')));
|
|
35
|
+
return hit ?? null;
|
|
36
|
+
}
|
|
37
|
+
export function tierIn(category, slug) {
|
|
38
|
+
if (!category)
|
|
39
|
+
return null;
|
|
40
|
+
return CATEGORIES[category]?.tiers[slug] ?? null;
|
|
41
|
+
}
|
|
42
|
+
export function prevalenceLabel(t, category) {
|
|
43
|
+
if (category) {
|
|
44
|
+
const tier = tierIn(category, t.slug);
|
|
45
|
+
const cat = category.replace(/_/g, ' ');
|
|
46
|
+
if (tier === 'table_stakes')
|
|
47
|
+
return `table stakes in ${cat}`;
|
|
48
|
+
if (tier === 'common')
|
|
49
|
+
return `common in ${cat}`;
|
|
50
|
+
if (tier === 'occasional')
|
|
51
|
+
return `occasional in ${cat}`;
|
|
52
|
+
if (tier === 'less_common')
|
|
53
|
+
return `less common in ${cat}`;
|
|
54
|
+
return `not seen in our ${cat} sample`;
|
|
55
|
+
}
|
|
56
|
+
if (t.brands >= 150)
|
|
57
|
+
return 'widely used';
|
|
58
|
+
if (t.brands >= 40)
|
|
59
|
+
return 'established';
|
|
60
|
+
return 'less common';
|
|
61
|
+
}
|
|
62
|
+
export function channelFit(t, format) {
|
|
63
|
+
if (!format)
|
|
64
|
+
return 0;
|
|
65
|
+
return FORMAT_CHANNELS[format].reduce((s, g) => s + (t.channels[g] ?? 0), 0);
|
|
66
|
+
}
|
|
67
|
+
// ── search (BM25 over name / lede / definitions / family) ─────────────────
|
|
68
|
+
const STOP = new Set('a an the and or of to in on for with by is are be as at it its this that from your you their they them how what when why who make makes making use using get way ways do does into than more most less'.split(' '));
|
|
69
|
+
function tokens(s) {
|
|
70
|
+
return norm(s).split(' ').filter((w) => w.length > 1 && !STOP.has(w)).map((w) => (w.length > 4 && w.endsWith('ies') ? w.slice(0, -3) + 'y' : w.length > 4 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w));
|
|
71
|
+
}
|
|
72
|
+
const FIELDS = [['name', 3], ['lede', 2], ['family', 1.5], ['what_it_does', 1], ['why_it_works', 0.5], ['lands_when', 0.5]];
|
|
73
|
+
const docs = TECHNIQUES.map((t) => {
|
|
74
|
+
const tf = new Map();
|
|
75
|
+
let len = 0;
|
|
76
|
+
for (const [f, w] of FIELDS) {
|
|
77
|
+
const v = t[f];
|
|
78
|
+
if (typeof v !== 'string')
|
|
79
|
+
continue;
|
|
80
|
+
for (const tok of tokens(f === 'family' ? v.replace(/_/g, ' ') : v)) {
|
|
81
|
+
tf.set(tok, (tf.get(tok) ?? 0) + w);
|
|
82
|
+
len += w;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const e of t.examples) {
|
|
86
|
+
for (const tok of tokens(`${e.headline} ${e.why}`)) {
|
|
87
|
+
tf.set(tok, (tf.get(tok) ?? 0) + 0.4);
|
|
88
|
+
len += 0.4;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { t, tf, len };
|
|
92
|
+
});
|
|
93
|
+
const avgLen = docs.reduce((s, d) => s + d.len, 0) / docs.length;
|
|
94
|
+
const df = new Map();
|
|
95
|
+
for (const d of docs)
|
|
96
|
+
for (const tok of d.tf.keys())
|
|
97
|
+
df.set(tok, (df.get(tok) ?? 0) + 1);
|
|
98
|
+
function bm25(d, q) {
|
|
99
|
+
const N = docs.length;
|
|
100
|
+
let s = 0;
|
|
101
|
+
for (const tok of q) {
|
|
102
|
+
const f = d.tf.get(tok);
|
|
103
|
+
if (!f)
|
|
104
|
+
continue;
|
|
105
|
+
const idf = Math.log(1 + (N - (df.get(tok) ?? 0) + 0.5) / ((df.get(tok) ?? 0) + 0.5));
|
|
106
|
+
s += idf * ((f * 2.2) / (f + 1.2 * (0.25 + 0.75 * (d.len / avgLen))));
|
|
107
|
+
}
|
|
108
|
+
return s;
|
|
109
|
+
}
|
|
110
|
+
const docBySlug = new Map(docs.map((d) => [d.t.slug, d]));
|
|
111
|
+
/** Share of a technique's opener appearances that come from aggressive-mode copy (null = too little data). */
|
|
112
|
+
export function aggressiveShare(t) {
|
|
113
|
+
const a = t.modes.aggressive ?? 0, b = t.modes.balanced ?? 0, e = t.modes.equity ?? 0;
|
|
114
|
+
const n = a + b + e;
|
|
115
|
+
return n >= 5 ? a / n : null;
|
|
116
|
+
}
|
|
117
|
+
/** Multiplier that keeps hard-sell moves out of balanced and equity copy (Coppica's persuasion modes). */
|
|
118
|
+
export function modeWeight(t, mode) {
|
|
119
|
+
if (mode === 'aggressive')
|
|
120
|
+
return 1;
|
|
121
|
+
const share = aggressiveShare(t);
|
|
122
|
+
if (share == null)
|
|
123
|
+
return 1;
|
|
124
|
+
if (mode === 'equity')
|
|
125
|
+
return share >= 0.5 ? 0 : 1 + Math.min(0.3, (t.modes.equity ?? 0) / 20);
|
|
126
|
+
return share >= 0.6 ? 0.35 : 1;
|
|
127
|
+
}
|
|
128
|
+
export function searchTechniques(query, opts = {}) {
|
|
129
|
+
const q = [...new Set(tokens(query))];
|
|
130
|
+
const scored = [];
|
|
131
|
+
for (const d of docs) {
|
|
132
|
+
if (opts.role && d.t.role !== opts.role)
|
|
133
|
+
continue;
|
|
134
|
+
let s = bm25(d, q);
|
|
135
|
+
if (s <= 0)
|
|
136
|
+
continue;
|
|
137
|
+
if (opts.format)
|
|
138
|
+
s *= 1 + Math.min(0.3, Math.log10(1 + channelFit(d.t, opts.format)) / 10);
|
|
139
|
+
scored.push({ t: d.t, s });
|
|
140
|
+
}
|
|
141
|
+
return scored.sort((a, b) => b.s - a.s).slice(0, opts.limit ?? 8).map((x) => x.t);
|
|
142
|
+
}
|
|
143
|
+
// ── deterministic, seeded move selection ─────────────────────────────────
|
|
144
|
+
function hash(s) {
|
|
145
|
+
let h = 2166136261;
|
|
146
|
+
for (let i = 0; i < s.length; i++) {
|
|
147
|
+
h ^= s.charCodeAt(i);
|
|
148
|
+
h = Math.imul(h, 16777619);
|
|
149
|
+
}
|
|
150
|
+
return h >>> 0;
|
|
151
|
+
}
|
|
152
|
+
function rng(seed) {
|
|
153
|
+
let a = seed;
|
|
154
|
+
return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Options for answering one reader question: one established move plus less-expected ones.
|
|
158
|
+
* Ranked by relevance to the brief (BM25 over the brief or copy), fit to the format's
|
|
159
|
+
* channels in the corpus, and the persuasion mode; then diversified by family and a seed,
|
|
160
|
+
* so the same brief gets the same options and different briefs get different ones.
|
|
161
|
+
* "Less expected" = not the category default: the long tail a model would not reach for
|
|
162
|
+
* on its own (Theses IV, XIV). The calling model makes the final choice.
|
|
163
|
+
*/
|
|
164
|
+
export function pickMoves(role, opts) {
|
|
165
|
+
const rand = rng(hash(`${opts.seed}|${role}`));
|
|
166
|
+
const q = [...new Set(tokens(opts.context))];
|
|
167
|
+
const all = (byRole.get(role) ?? []).filter((t) => !t.parent && t.brands >= 5 && !opts.exclude?.has(t.slug) && modeWeight(t, opts.mode) > 0);
|
|
168
|
+
const rel = new Map(all.map((t) => [t.slug, bm25(docBySlug.get(t.slug), q)]));
|
|
169
|
+
const maxRel = Math.max(1e-9, ...rel.values());
|
|
170
|
+
const fit = (t) => channelFit(t, opts.format);
|
|
171
|
+
const maxFit = Math.max(1, ...all.map(fit));
|
|
172
|
+
const score = (t) => (0.65 * (rel.get(t.slug) / maxRel) + 0.35 * (Math.log1p(fit(t)) / Math.log1p(maxFit))) * modeWeight(t, opts.mode);
|
|
173
|
+
const pool = [...all].sort((a, b) => score(b) - score(a)).slice(0, Math.max(12, opts.n * 3));
|
|
174
|
+
const tierRank = (t) => ({ table_stakes: 3, common: 2, occasional: 1, less_common: 0 }[tierIn(opts.category, t.slug) ?? ''] ?? -1);
|
|
175
|
+
const out = [];
|
|
176
|
+
const established = [...pool.slice(0, 5)].sort((a, b) => (opts.category ? tierRank(b) - tierRank(a) : 0) || b.brands - a.brands)[0];
|
|
177
|
+
if (established)
|
|
178
|
+
out.push({ t: established, kind: 'established' });
|
|
179
|
+
const medianBrands = [...all].map((t) => t.brands).sort((a, b) => a - b)[Math.floor(all.length / 2)] ?? 0;
|
|
180
|
+
const isLessExpected = (t) => {
|
|
181
|
+
if (opts.category) {
|
|
182
|
+
const tr = tierIn(opts.category, t.slug);
|
|
183
|
+
return tr === null || tr === 'occasional' || tr === 'less_common';
|
|
184
|
+
}
|
|
185
|
+
return t.brands <= medianBrands;
|
|
186
|
+
};
|
|
187
|
+
// seen in the category but uncommon beats never seen there: evidence it can work for this kind of buyer
|
|
188
|
+
const lessExpectedBonus = (t) => {
|
|
189
|
+
if (!isLessExpected(t))
|
|
190
|
+
return 0;
|
|
191
|
+
if (opts.category && tierIn(opts.category, t.slug) === null)
|
|
192
|
+
return 0.05;
|
|
193
|
+
return 0.25;
|
|
194
|
+
};
|
|
195
|
+
const jittered = pool
|
|
196
|
+
.filter((t) => !out.some((o) => o.t.slug === t.slug))
|
|
197
|
+
.map((t) => ({ t, s: score(t) * (0.85 + 0.3 * rand()) + lessExpectedBonus(t) }))
|
|
198
|
+
.sort((a, b) => b.s - a.s);
|
|
199
|
+
for (const { t } of jittered) {
|
|
200
|
+
if (out.length >= opts.n)
|
|
201
|
+
break;
|
|
202
|
+
if (t.family && out.some((o) => o.t.family === t.family))
|
|
203
|
+
continue; // distinct mechanisms, not near-synonyms
|
|
204
|
+
out.push({ t, kind: isLessExpected(t) ? 'less_expected' : 'established' });
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Technique, Example } from './data.js';
|
|
2
|
+
export declare const FOOTER: string;
|
|
3
|
+
export declare function text(body: string): {
|
|
4
|
+
content: {
|
|
5
|
+
type: "text";
|
|
6
|
+
text: string;
|
|
7
|
+
}[];
|
|
8
|
+
};
|
|
9
|
+
export declare function errorText(body: string): {
|
|
10
|
+
content: {
|
|
11
|
+
type: "text";
|
|
12
|
+
text: string;
|
|
13
|
+
}[];
|
|
14
|
+
isError: boolean;
|
|
15
|
+
};
|
|
16
|
+
export declare const categoryName: (c: string) => string;
|
|
17
|
+
export type MoveKind = 'established' | 'less expected';
|
|
18
|
+
/** One sentence on where a technique stands: the kind of move it is, and how common it is. */
|
|
19
|
+
export declare function usageSentence(t: Technique, category: string | null, kind?: MoveKind): string;
|
|
20
|
+
/** "Ford Motor Company in the 1920s" / "an unnamed wholesaler" */
|
|
21
|
+
export declare function exampleSource(e: Example): string;
|
|
22
|
+
/** One technique: name and ID, what it is, where it stands, one real example, then any closing sentence, then the link. */
|
|
23
|
+
export declare function techniqueLine(t: Technique, opts?: {
|
|
24
|
+
category?: string | null;
|
|
25
|
+
kind?: MoveKind;
|
|
26
|
+
example?: boolean;
|
|
27
|
+
after?: string;
|
|
28
|
+
}): string;
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { tierIn } from './data.js';
|
|
2
|
+
import { clipText, innerQuotes } from './voice.js';
|
|
3
|
+
export const FOOTER = '\n\n---\nFrom the Persuasion Taxonomy by Coppica, free to use under CC BY 4.0 at https://taxonomy.coppica.com. ' +
|
|
4
|
+
'When you use a technique, name it and link to it, so your reader can see the real examples behind it.';
|
|
5
|
+
export function text(body) {
|
|
6
|
+
return { content: [{ type: 'text', text: body + FOOTER }] };
|
|
7
|
+
}
|
|
8
|
+
export function errorText(body) {
|
|
9
|
+
return { content: [{ type: 'text', text: body }], isError: true };
|
|
10
|
+
}
|
|
11
|
+
export const categoryName = (c) => c.replace(/_/g, ' ');
|
|
12
|
+
/** One sentence on where a technique stands: the kind of move it is, and how common it is. */
|
|
13
|
+
export function usageSentence(t, category, kind) {
|
|
14
|
+
const lead = kind === 'established' ? "It's the established move" : kind === 'less expected' ? "It's a less expected move" : null;
|
|
15
|
+
if (category) {
|
|
16
|
+
const cat = categoryName(category);
|
|
17
|
+
const tier = tierIn(category, t.slug);
|
|
18
|
+
const how = tier && {
|
|
19
|
+
table_stakes: `table stakes in ${cat}`,
|
|
20
|
+
common: `common in ${cat}`,
|
|
21
|
+
occasional: `used now and then in ${cat}`,
|
|
22
|
+
less_common: `less common in ${cat}`,
|
|
23
|
+
}[tier];
|
|
24
|
+
if (lead)
|
|
25
|
+
return how ? `${lead}, and ${how}.` : `${lead}, and we haven't seen it in our ${cat} sample.`;
|
|
26
|
+
return how ? `It's ${how}.` : `We haven't seen it in our ${cat} sample.`;
|
|
27
|
+
}
|
|
28
|
+
if (lead)
|
|
29
|
+
return `${lead}.`;
|
|
30
|
+
return t.brands >= 150 ? "It's widely used." : t.brands >= 40 ? "It's well established." : "It's less common.";
|
|
31
|
+
}
|
|
32
|
+
/** "Ford Motor Company in the 1920s" / "an unnamed wholesaler" */
|
|
33
|
+
export function exampleSource(e) {
|
|
34
|
+
const brand = /^unnamed\b/i.test(e.brand) ? `an ${e.brand}` : e.brand;
|
|
35
|
+
const y = e.year ?? '';
|
|
36
|
+
const when = /^\d{4}s\b/.test(y) ? ` in the ${y}` : /^\d{4}$/.test(y) ? ` in ${y}` : y && y !== 'current' && y !== 'null' ? ` (${y})` : '';
|
|
37
|
+
return `${brand}${when}`;
|
|
38
|
+
}
|
|
39
|
+
/** One technique: name and ID, what it is, where it stands, one real example, then any closing sentence, then the link. */
|
|
40
|
+
export function techniqueLine(t, opts = {}) {
|
|
41
|
+
let line = `- **${t.name}** (${t.pt_id}). ${clipText(t.lede, 220)} ${usageSentence(t, opts.category ?? null, opts.kind)}`;
|
|
42
|
+
if (opts.example !== false) {
|
|
43
|
+
const ex = t.examples.find((e) => e.headline) ?? t.examples[0];
|
|
44
|
+
if (ex)
|
|
45
|
+
line += ` For example, from ${exampleSource(ex)}: "${innerQuotes(clipText(ex.headline || ex.excerpt, 140))}"`;
|
|
46
|
+
}
|
|
47
|
+
if (opts.after)
|
|
48
|
+
line += ` ${opts.after}`;
|
|
49
|
+
return `${line} ${t.url}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOOK CHECK: the replacement for scoreHook (see docs/hook-check.md).
|
|
3
|
+
*
|
|
4
|
+
* Asks three things of an opening line: does it do its job in this slot (which of the nine reader
|
|
5
|
+
* questions it answers), could a competitor send it unchanged (swap test against the brief), and
|
|
6
|
+
* what is it a bet on. Reports hard facts and replicated evidence instead of a 0-10 score.
|
|
7
|
+
*
|
|
8
|
+
* Pure module: no I/O, no AI calls. The calling model supplies the labels (which question the
|
|
9
|
+
* line answers, quoting the words); this code verifies them and does the rest.
|
|
10
|
+
*
|
|
11
|
+
* Every message here is read by a model and echoed to a writer, so each one is a full, plain
|
|
12
|
+
* sentence. The fix logic keys off flags, never off the wording, so the wording can change freely.
|
|
13
|
+
*/
|
|
14
|
+
import { type Role, type Awareness } from '../roles.js';
|
|
15
|
+
export declare const SLOTS: readonly ["email_subject", "ad_opening", "landing_page_headline", "first_line", "video_hook", "social_post_open", "sales_letter_headline"];
|
|
16
|
+
export type Slot = (typeof SLOTS)[number];
|
|
17
|
+
interface SlotInfo {
|
|
18
|
+
label: string;
|
|
19
|
+
job: string;
|
|
20
|
+
requires: Role[][];
|
|
21
|
+
visibleChars?: number;
|
|
22
|
+
visibleNote?: string;
|
|
23
|
+
maxWords?: number;
|
|
24
|
+
spoken?: boolean;
|
|
25
|
+
headlineLike: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** label takes an article ("an email subject line"); job completes "Its job is to ..."; visibleNote completes "Keep in mind that ...". */
|
|
28
|
+
export declare const SLOT_INFO: Record<Slot, SlotInfo>;
|
|
29
|
+
export declare const article: (s: string) => string;
|
|
30
|
+
/** What a set of requirement groups asks for, in words: '"A?" and "B?", plus one of "C?", "D?" or "E?"' */
|
|
31
|
+
export declare function questionsNeeded(groups: Role[][]): string;
|
|
32
|
+
declare const EVIDENCE_SOURCE: string;
|
|
33
|
+
export interface HookAnswer {
|
|
34
|
+
question: Role;
|
|
35
|
+
words: string;
|
|
36
|
+
}
|
|
37
|
+
export interface HookBrief {
|
|
38
|
+
brand?: string;
|
|
39
|
+
product?: string;
|
|
40
|
+
offer?: string;
|
|
41
|
+
audience?: string;
|
|
42
|
+
proof?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface HookInput {
|
|
45
|
+
text: string;
|
|
46
|
+
slot: Slot;
|
|
47
|
+
answers: HookAnswer[];
|
|
48
|
+
brief?: HookBrief;
|
|
49
|
+
context?: string;
|
|
50
|
+
follows?: string;
|
|
51
|
+
awareness?: Awareness;
|
|
52
|
+
}
|
|
53
|
+
export type Verdict = 'does_its_job' | 'partly' | 'doesnt';
|
|
54
|
+
export type SwapLevel = 'only_you' | 'on_target' | 'concrete_but_swappable' | 'generic' | 'unknown';
|
|
55
|
+
export interface HookCheck {
|
|
56
|
+
text: string;
|
|
57
|
+
slot: Slot;
|
|
58
|
+
verdict: Verdict;
|
|
59
|
+
needs: Role[][];
|
|
60
|
+
answered: Role[];
|
|
61
|
+
missing: Role[][];
|
|
62
|
+
unverified: HookAnswer[];
|
|
63
|
+
swap: {
|
|
64
|
+
level: SwapLevel;
|
|
65
|
+
specifics: string[];
|
|
66
|
+
note: string;
|
|
67
|
+
};
|
|
68
|
+
visible: string | null;
|
|
69
|
+
/** Problems that stop the line doing its job, each a full sentence. */
|
|
70
|
+
hardFails: string[];
|
|
71
|
+
/** Problems worth fixing, each a full sentence. */
|
|
72
|
+
warnings: string[];
|
|
73
|
+
/** The warnings about believability (an unproven superlative, a result with nothing behind it,
|
|
74
|
+
* a line that runs past the cutoff). Callers that treat these as blocking use this list. */
|
|
75
|
+
believability: string[];
|
|
76
|
+
evidence: string[];
|
|
77
|
+
/** The subset of evidence that cuts against the line (traits that lost head-to-heads). */
|
|
78
|
+
evidenceAgainst: string[];
|
|
79
|
+
bet: string;
|
|
80
|
+
fix: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The swap test (Thesis II): could a competitor send this line unchanged?
|
|
84
|
+
* only_you: names the brand, or a fact from your proof. on_target: names this reader's situation or the
|
|
85
|
+
* product's specifics (a direct competitor could match it). concrete_but_swappable: only a number or offer
|
|
86
|
+
* term. generic: nothing specific. unknown: no brief.
|
|
87
|
+
*/
|
|
88
|
+
export declare function swapTest(text: string, brief?: HookBrief, context?: string): HookCheck['swap'];
|
|
89
|
+
export declare function checkHook(input: HookInput): HookCheck;
|
|
90
|
+
/** Completes a heading like '"Your bakery posted 3 times..." ___' */
|
|
91
|
+
export declare const VERDICT_LABEL: Record<Verdict, string>;
|
|
92
|
+
export { EVIDENCE_SOURCE };
|
|
93
|
+
/** The body of a checked line as one paragraph: the bet, what is missing, the swap test, the problems. */
|
|
94
|
+
export declare function describeCheck(c: HookCheck, opts?: {
|
|
95
|
+
evidence?: boolean;
|
|
96
|
+
}): string;
|
|
97
|
+
/** Compare checked lines: drop hard failures, describe each bet, recommend a test. */
|
|
98
|
+
export declare function compareHooks(checks: HookCheck[], names: string[]): string;
|