@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
package/dist/roles.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nine reader questions (Thesis VIII of The 18 Theses) and the framework
|
|
3
|
+
* tables built on them. Role names, questions and "if missing" lines are copied
|
|
4
|
+
* from persuasion-taxonomy (app/the-nine-roles/roles-data.ts). The glosses, gaps,
|
|
5
|
+
* the click goal, the coherence pairs and the symptom map are written here; the
|
|
6
|
+
* last three were reviewed and approved by Elijah on 2026-10-04.
|
|
7
|
+
*
|
|
8
|
+
* Every string in this file reaches a model, and through it a writer. Keep it in
|
|
9
|
+
* the voice of a working copywriter: plain words, full sentences, no em dashes.
|
|
10
|
+
*/
|
|
11
|
+
export const ROLE_NAMES = [
|
|
12
|
+
'DISRUPT', 'IDENTIFY', 'AGITATE', 'REFRAME', 'PROVE', 'ELEVATE', 'RESOLVE', 'COMPEL', 'BOND',
|
|
13
|
+
];
|
|
14
|
+
export const ROLES = {
|
|
15
|
+
DISRUPT: { num: 1, name: 'DISRUPT', question: 'Why am I even reading this?', ifMissing: 'Nothing below matters.', gap: "nothing in the opening breaks the reader's autopilot, so they never really arrive", gloss: 'earning the first read' },
|
|
16
|
+
IDENTIFY: { num: 2, name: 'IDENTIFY', question: 'Is this written for me?', ifMissing: "I assume it's for someone else.", gap: 'the copy never shows readers themselves, so it reads as if it were written for anybody', gloss: 'making the reader feel seen' },
|
|
17
|
+
AGITATE: { num: 3, name: 'AGITATE', question: 'How bad is my problem, really?', ifMissing: 'I disengage; the problem feels unrelated.', gap: 'the pain stays abstract, so nothing builds the pressure to fix it', gloss: 'making the problem feel real' },
|
|
18
|
+
REFRAME: { num: 4, name: 'REFRAME', question: 'Am I thinking about this correctly?', ifMissing: 'The default frame keeps the default decision.', gap: 'the copy asserts where it should reframe, so the reader keeps the old way of seeing the problem', gloss: 'changing how they see the problem' },
|
|
19
|
+
PROVE: { num: 5, name: 'PROVE', question: 'Can I trust the claim?', ifMissing: 'All gains are discounted.', gap: 'the claims float with nothing under them, so nothing earns belief', gloss: 'making the claim believable' },
|
|
20
|
+
ELEVATE: { num: 6, name: 'ELEVATE', question: 'What does my life look like after?', ifMissing: "I see information; I don't see myself in it.", gap: "the copy lists features and never shows the reader's life after the change", gloss: 'making the payoff vivid' },
|
|
21
|
+
RESOLVE: { num: 7, name: 'RESOLVE', question: "What's stopping me from saying yes?", ifMissing: 'Doubt accumulates faster than belief.', gap: 'the objections and the risk are still standing between the reader and yes', gloss: 'removing what stops the yes' },
|
|
22
|
+
COMPEL: { num: 8, name: 'COMPEL', question: 'Why should I act right now?', ifMissing: 'I bookmark and never return.', gap: 'nothing makes today the day, so the decision can wait forever', gloss: 'getting them to act now' },
|
|
23
|
+
BOND: { num: 9, name: 'BOND', question: "Do I like and trust who's speaking?", ifMissing: 'Arguments become harder to accept.', gap: 'no relationship forms, so the copy sells at the reader instead of talking with them', gloss: 'making them like and trust the one speaking' },
|
|
24
|
+
};
|
|
25
|
+
/** The question in quotes, with its role name, for the places a model needs both. */
|
|
26
|
+
export const roleLabel = (r) => `"${ROLES[r].question}" (${r})`;
|
|
27
|
+
// ── Goals ────────────────────────────────────────────────────────────────
|
|
28
|
+
export const GOALS = ['purchase', 'signup', 'click', 'engagement'];
|
|
29
|
+
/** Each requirement is satisfied by ANY of its roles. purchase/signup/engagement
|
|
30
|
+
* mirror the analyzer's sales/lead_gen/nurture EXPECTED_ROLES; click was added here (reviewed 2026-10-04). */
|
|
31
|
+
export const GOAL_REQUIREMENTS = {
|
|
32
|
+
purchase: ROLE_NAMES.map((r) => [r]),
|
|
33
|
+
signup: [['DISRUPT'], ['IDENTIFY'], ['AGITATE'], ['REFRAME'], ['PROVE'], ['COMPEL']],
|
|
34
|
+
engagement: [['DISRUPT'], ['IDENTIFY'], ['REFRAME'], ['PROVE'], ['BOND'], ['COMPEL']],
|
|
35
|
+
click: [['DISRUPT'], ['IDENTIFY'], ['AGITATE', 'ELEVATE'], ['COMPEL']],
|
|
36
|
+
};
|
|
37
|
+
/** "For a signup, ..." */
|
|
38
|
+
export const GOAL_NOUN = {
|
|
39
|
+
purchase: 'a sale',
|
|
40
|
+
signup: 'a signup',
|
|
41
|
+
click: 'a click',
|
|
42
|
+
engagement: 'engagement',
|
|
43
|
+
};
|
|
44
|
+
/** "a landing page that ..." */
|
|
45
|
+
export const GOAL_CLAUSE = {
|
|
46
|
+
purchase: 'has to make the sale',
|
|
47
|
+
signup: 'has to get the signup',
|
|
48
|
+
click: 'has to earn the click',
|
|
49
|
+
engagement: 'has to get read and answered',
|
|
50
|
+
};
|
|
51
|
+
export function expectedRoles(goal) {
|
|
52
|
+
return new Set(GOAL_REQUIREMENTS[goal].flat());
|
|
53
|
+
}
|
|
54
|
+
// ── Formats ──────────────────────────────────────────────────────────────
|
|
55
|
+
export const FORMATS = ['ad', 'landing_page', 'sales_letter', 'email', 'email_subject', 'social_post', 'video_script', 'headline'];
|
|
56
|
+
export const FORMAT_LABEL = {
|
|
57
|
+
ad: 'an ad',
|
|
58
|
+
landing_page: 'a landing page',
|
|
59
|
+
sales_letter: 'a sales letter',
|
|
60
|
+
email: 'an email',
|
|
61
|
+
email_subject: 'an email subject line',
|
|
62
|
+
social_post: 'a social post',
|
|
63
|
+
video_script: 'a video script',
|
|
64
|
+
headline: 'a headline',
|
|
65
|
+
};
|
|
66
|
+
/** Corpus channel groups where evidence for a technique counts toward "fits this format". */
|
|
67
|
+
export const FORMAT_CHANNELS = {
|
|
68
|
+
ad: ['ads', 'video'],
|
|
69
|
+
landing_page: ['web', 'direct_mail'],
|
|
70
|
+
sales_letter: ['direct_mail', 'web'],
|
|
71
|
+
email: ['email', 'direct_mail'],
|
|
72
|
+
email_subject: ['email'],
|
|
73
|
+
social_post: ['ads', 'web'],
|
|
74
|
+
video_script: ['video', 'ads'],
|
|
75
|
+
headline: ['ads', 'web'],
|
|
76
|
+
};
|
|
77
|
+
// ── Awareness (Schwartz) ─────────────────────────────────────────────────
|
|
78
|
+
export const AWARENESS = ['unaware', 'problem_aware', 'solution_aware', 'product_aware', 'most_aware'];
|
|
79
|
+
export const AWARENESS_WEIGHT = {
|
|
80
|
+
unaware: { heavy: ['DISRUPT', 'IDENTIFY', 'AGITATE'], note: "They don't know they have the problem yet, so don't open with the product or the price. Open with their world, or with a surprising truth, and earn the problem before you offer the solution." },
|
|
81
|
+
problem_aware: { heavy: ['AGITATE', 'REFRAME', 'IDENTIFY'], note: "They feel the problem but can't see the way out. Show them you understand it better than they do, and then change the way they see it." },
|
|
82
|
+
solution_aware: { heavy: ['REFRAME', 'PROVE', 'ELEVATE'], note: "They know solutions exist. Show them what's different about yours and prove that it works, or you're just one more option." },
|
|
83
|
+
product_aware: { heavy: ['PROVE', 'RESOLVE', 'COMPEL'], note: "They know you, but they haven't said yes. Take away the doubt and the risk, and give them a reason to act now." },
|
|
84
|
+
most_aware: { heavy: ['COMPEL', 'RESOLVE'], note: "They're ready to buy. Lead with the offer and the reason to act today, because a long argument only slows them down." },
|
|
85
|
+
};
|
|
86
|
+
// ── Coherence (Thesis X) ─────────────────────────────────────────────────
|
|
87
|
+
/** Coherence pairs (reviewed 2026-10-04). Readers check the nine answers against each other; a mismatch is what
|
|
88
|
+
* they feel as "something's off". These are questions for the calling model to ask. */
|
|
89
|
+
export const COHERENCE_CHECKS = [
|
|
90
|
+
{ roles: ['DISRUPT', 'ELEVATE'], check: 'Does the rest of the copy pay off what the opening hints at? A bigger promise up top than the body can deliver reads as bait.' },
|
|
91
|
+
{ roles: ['ELEVATE', 'PROVE'], check: 'Is the life after you paint any bigger than your proof can support? It should never be.' },
|
|
92
|
+
{ roles: ['IDENTIFY', 'BOND'], check: 'Does the voice sound like someone this reader would trust, using their words, at their level?' },
|
|
93
|
+
{ roles: ['AGITATE', 'COMPEL'], check: 'Is the urgency in proportion to the pain you set up? A deadline on a mild problem feels manufactured.' },
|
|
94
|
+
{ roles: ['REFRAME', 'PROVE'], check: 'Does your proof support the new way of seeing the problem, or the old one you argued against?' },
|
|
95
|
+
{ roles: ['RESOLVE', 'COMPEL'], check: 'Is the risk reversal big enough for the size of the ask?' },
|
|
96
|
+
{ roles: ['PROVE', 'BOND'], check: 'Does the proof come from people and sources this reader already trusts?' },
|
|
97
|
+
];
|
|
98
|
+
// ── Symptoms → unanswered questions ──────────────────────────────────────
|
|
99
|
+
export const SYMPTOMS = [
|
|
100
|
+
'low_click_through', 'high_bounce', 'reads_but_no_action', 'starts_but_abandons',
|
|
101
|
+
'leads_dont_buy', 'buys_once_no_return', 'low_open_rate', 'opens_no_clicks',
|
|
102
|
+
];
|
|
103
|
+
/** Symptom map (reviewed 2026-10-04), derived from each role's "if missing" consequence. */
|
|
104
|
+
export const SYMPTOM_MAP = {
|
|
105
|
+
low_click_through: {
|
|
106
|
+
label: "people see it but don't click",
|
|
107
|
+
suspects: [
|
|
108
|
+
{ role: 'DISRUPT', why: 'The scroll wins. Nothing in the first line or the first frame interrupts their autopilot, so they never read the rest.', check: 'To check, read only the first line, or only the words on the thumbnail. If it could run for any brand in your category, it fails this question.' },
|
|
109
|
+
{ role: 'IDENTIFY', why: 'Nothing tells them this is for them, so they keep moving.', check: 'To check, look at the opening and ask whether the right person would recognize their own situation in it at a glance.' },
|
|
110
|
+
{ role: 'COMPEL', why: 'Even interested readers need a reason to click now and not later.', check: 'To check, find the reason to click today. If all you have is "Learn more", there isn\'t one.' },
|
|
111
|
+
],
|
|
112
|
+
ruleOutFirst: 'Check the targeting, the placement and the image first. If the wrong people are seeing it, or the visual is wrong, no copy will fix it.',
|
|
113
|
+
},
|
|
114
|
+
high_bounce: {
|
|
115
|
+
label: 'people click, then leave fast',
|
|
116
|
+
suspects: [
|
|
117
|
+
{ role: 'COHERENCE', why: "The page doesn't pay off what the ad or the link promised. Readers feel the mismatch before they can name it, and they leave.", check: "To check, put the ad next to the page's first screen. They should make the same promise, in the same words, to the same reader." },
|
|
118
|
+
{ role: 'IDENTIFY', why: 'The page talks to a different reader than the ad did.', check: 'To check, ask who the first screen is talking to, and whether that is the same person the ad was talking to.' },
|
|
119
|
+
{ role: 'DISRUPT', why: 'The page starts over cold when it should carry on the thought that made them click.', check: 'To check, read the headline right after the ad. It should pick up where the ad left off, not start again with a brand slogan.' },
|
|
120
|
+
],
|
|
121
|
+
ruleOutFirst: 'Check the page speed, the mobile layout and the links first. A slow or broken page looks like a bounce no matter what the copy says.',
|
|
122
|
+
},
|
|
123
|
+
reads_but_no_action: {
|
|
124
|
+
label: "people read it but don't act",
|
|
125
|
+
suspects: [
|
|
126
|
+
{ role: 'PROVE', why: 'They hear the promise, but they don\'t believe it.', check: 'To check, go through every big claim and look for the evidence sitting right next to it: a number, a name, a demonstration.' },
|
|
127
|
+
{ role: 'RESOLVE', why: 'An objection nobody answered, like the price, the time or "will it work for me?", is standing in the way of yes.', check: 'To check, write down the three reasons a reader would say no, and see whether the copy answers each one before it asks.' },
|
|
128
|
+
{ role: 'COMPEL', why: 'Nothing makes today the day.', check: 'To check, look for a real, honest reason to act now. If you can\'t point to one, the reader can\'t either.' },
|
|
129
|
+
{ role: 'ELEVATE', why: 'The copy describes the product and never shows the reader after the change.', check: 'To check, look for a specific, concrete picture of their life afterward. A list of features doesn\'t count.' },
|
|
130
|
+
],
|
|
131
|
+
ruleOutFirst: 'Check the offer itself first, meaning the price, the terms and the fit, and make sure the call to action is easy to find and easy to use.',
|
|
132
|
+
},
|
|
133
|
+
starts_but_abandons: {
|
|
134
|
+
label: 'people start the checkout or the form, then abandon it',
|
|
135
|
+
suspects: [
|
|
136
|
+
{ role: 'RESOLVE', why: 'A last-minute objection about cost, commitment or risk comes up at the moment of yes, and nothing answers it.', check: 'To check, look at the form or the checkout itself. The guarantee, the "cancel anytime" and what happens next should all be visible right there.' },
|
|
137
|
+
{ role: 'COMPEL', why: 'The urgency that started them fades before the finish line.', check: 'To check, see whether the checkout reminds them why now.' },
|
|
138
|
+
{ role: 'PROVE', why: 'Trust drops the moment you ask for money or an email address.', check: 'To check, look for trust signals right where you ask for the card or the email.' },
|
|
139
|
+
],
|
|
140
|
+
ruleOutFirst: 'Rule out surprise costs like shipping and fees, forced account creation, too many fields and payment errors first.',
|
|
141
|
+
},
|
|
142
|
+
leads_dont_buy: {
|
|
143
|
+
label: "signups and leads don't turn into sales",
|
|
144
|
+
suspects: [
|
|
145
|
+
{ role: 'REFRAME', why: 'The lead still sees the problem the old way, so the default decision wins, whether that is doing nothing or sticking with what they know.', check: 'To check, read the follow-up and ask whether it changes how they see the problem or just repeats the pitch.' },
|
|
146
|
+
{ role: 'PROVE', why: 'They wanted the free thing. Nothing has convinced them the paid one works.', check: 'To check, list the evidence a lead actually sees between signing up and being asked to buy.' },
|
|
147
|
+
{ role: 'RESOLVE', why: "Objections that didn't matter for a free signup matter a great deal for a purchase.", check: 'To check, see whether the nurture sequence answers the objections about price, time and fit.' },
|
|
148
|
+
{ role: 'BOND', why: "They don't trust the sender enough yet for a paid ask.", check: 'To check, see whether the sequence builds a relationship, through voice, story and generosity, before it asks.' },
|
|
149
|
+
],
|
|
150
|
+
ruleOutFirst: 'Check the lead quality first, meaning whether these are the right people at all, and how fast sales follows up.',
|
|
151
|
+
},
|
|
152
|
+
buys_once_no_return: {
|
|
153
|
+
label: 'people buy once, then ask for a refund, cancel or never come back',
|
|
154
|
+
suspects: [
|
|
155
|
+
{ role: 'COHERENCE', why: "The promise and the delivery don't match. The sale was made on a life after that the product didn't produce.", check: 'To check, compare the after-picture and the proof in your sales copy with what customers actually experience.' },
|
|
156
|
+
{ role: 'BOND', why: 'No relationship formed, so nothing brings them back.', check: 'To check, read what they get after they buy. It should carry the relationship on, not go silent until the next pitch.' },
|
|
157
|
+
],
|
|
158
|
+
ruleOutFirst: "Rule out problems with the product, the onboarding and the delivery first. Copy can make the promise honest, but it can't make the product deliver.",
|
|
159
|
+
},
|
|
160
|
+
low_open_rate: {
|
|
161
|
+
label: "emails don't get opened",
|
|
162
|
+
suspects: [
|
|
163
|
+
{ role: 'DISRUPT', why: 'The subject line reads like every other email in the inbox.', check: 'To check, cover the sender name and ask whether the subject line could have come from anyone.' },
|
|
164
|
+
{ role: 'IDENTIFY', why: 'Nothing in the subject or the preview says this one is for them.', check: 'To check, see whether the subject or the preview text names their situation.' },
|
|
165
|
+
{ role: 'BOND', why: "They don't know or care who is sending it.", check: 'To check, look at the sender name. It should be a person or a brand they recognize and like.' },
|
|
166
|
+
],
|
|
167
|
+
ruleOutFirst: "Check deliverability first, meaning the spam folder and your authentication, and the health of the list. Nobody opens an email they never see.",
|
|
168
|
+
},
|
|
169
|
+
opens_no_clicks: {
|
|
170
|
+
label: 'emails get opened but nobody clicks',
|
|
171
|
+
suspects: [
|
|
172
|
+
{ role: 'COHERENCE', why: "The body doesn't pay off the subject line, so the open feels like bait.", check: 'To check, read the first line right after the subject. It should deliver what the subject promised.' },
|
|
173
|
+
{ role: 'IDENTIFY', why: 'The body drifts away from the reader and toward the brand.', check: 'To check, count the "you"s against the "we"s. The email should be about them.' },
|
|
174
|
+
{ role: 'COMPEL', why: 'There is no single, clear reason to click now.', check: 'To check, look for one obvious click with a reason attached. Several competing links split the reader\'s attention.' },
|
|
175
|
+
],
|
|
176
|
+
ruleOutFirst: 'Rule out broken links, image-only emails and too many competing calls to action first.',
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
export function normalizeRole(v) {
|
|
180
|
+
if (typeof v !== 'string')
|
|
181
|
+
return v;
|
|
182
|
+
const up = v.trim().toUpperCase();
|
|
183
|
+
if (ROLE_NAMES.includes(up))
|
|
184
|
+
return up;
|
|
185
|
+
// accept the question text itself
|
|
186
|
+
const byQ = ROLE_NAMES.find((r) => ROLES[r].question.toLowerCase().replace(/[^a-z ]/g, '') === v.toLowerCase().replace(/[^a-z ]/g, '').trim());
|
|
187
|
+
return byQ ?? up;
|
|
188
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const goalSchema: z.ZodEnum<["purchase", "signup", "click", "engagement"]>;
|
|
3
|
+
export declare const formatSchema: z.ZodEnum<["ad", "landing_page", "sales_letter", "email", "email_subject", "social_post", "video_script", "headline"]>;
|
|
4
|
+
export declare const awarenessSchema: z.ZodEnum<["unaware", "problem_aware", "solution_aware", "product_aware", "most_aware"]>;
|
|
5
|
+
export declare const roleSchema: z.ZodEffects<z.ZodEnum<["DISRUPT", "IDENTIFY", "AGITATE", "REFRAME", "PROVE", "ELEVATE", "RESOLVE", "COMPEL", "BOND"]>, "DISRUPT" | "IDENTIFY" | "AGITATE" | "REFRAME" | "PROVE" | "ELEVATE" | "RESOLVE" | "COMPEL" | "BOND", unknown>;
|
|
6
|
+
export declare const categorySchema: z.ZodString;
|
|
7
|
+
export declare const modeSchema: z.ZodDefault<z.ZodEnum<["aggressive", "balanced", "equity"]>>;
|
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { GOALS, FORMATS, AWARENESS, ROLE_NAMES, ROLES, normalizeRole } from './roles.js';
|
|
3
|
+
import { CATEGORY_NAMES } from './data.js';
|
|
4
|
+
export const goalSchema = z.enum(GOALS).describe('What the copy has to get the reader to do. Use purchase when they should buy now, signup for an opt-in, a trial, a demo or a lead form, ' +
|
|
5
|
+
'click when the whole job is the click (most ads and links), and engagement for nurture emails, newsletters and social posts that should be read, answered or followed.');
|
|
6
|
+
export const formatSchema = z.enum(FORMATS).describe('The kind of copy it is: ad, landing_page, sales_letter for long-form sales copy, email, email_subject, social_post, video_script or headline.');
|
|
7
|
+
export const awarenessSchema = z.enum(AWARENESS).describe("How much the reader already knows, on Eugene Schwartz's scale. Use unaware if they don't know they have the problem, problem_aware if they feel it, " +
|
|
8
|
+
"solution_aware if they know solutions exist, product_aware if they know you, and most_aware if they're ready and only need the offer.");
|
|
9
|
+
export const roleSchema = z.preprocess(normalizeRole, z.enum(ROLE_NAMES)).describe('The reader question the text answers, by its role name: ' + ROLE_NAMES.map((r) => `${r} for "${ROLES[r].question}"`).join(', ') + '.');
|
|
10
|
+
export const categorySchema = z.string().max(60).describe(`The industry, if you want to compare against what brands in that category usually do. There is enough data for ${CATEGORY_NAMES.join(', ')}. ` +
|
|
11
|
+
'Any other category still works, just without the comparison.');
|
|
12
|
+
export const modeSchema = z.enum(['aggressive', 'balanced', 'equity']).default('balanced').describe('How hard the copy is allowed to push. Balanced is the default, persuasive and sustainable over time. Equity protects long-term trust, so it rules out fear, hype and hard-sell moves. ' +
|
|
13
|
+
'Aggressive is for short-term direct response and allows fear, threat and hard scarcity. In every mode, the claims stay true.');
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type HookFormat, type AwarenessLevel } from './hook-framework.js';
|
|
2
|
+
/**
|
|
3
|
+
* CLAIM FRAMEWORK — Phase 2 of the writing-tools strategy.
|
|
4
|
+
*
|
|
5
|
+
* The hook framework scores openers (single sentences). The flow framework
|
|
6
|
+
* scores blocks (paragraphs). This sits between them: it scores SENTENCES
|
|
7
|
+
* with role-specific gates. v1 ships three claim types:
|
|
8
|
+
*
|
|
9
|
+
* - headline → delegates to scoreHook (single source of truth for
|
|
10
|
+
* opener scoring; reconciles with the universal headline
|
|
11
|
+
* rule from Layer 1)
|
|
12
|
+
* - cta → button / CTA-specific gates (generic-text fail,
|
|
13
|
+
* first-person or outcome, single-verb strength,
|
|
14
|
+
* low-friction word cap, anti-smell)
|
|
15
|
+
* - body_claim → in-body assertion gates (specificity, mechanism named,
|
|
16
|
+
* proof welded, anti-smell)
|
|
17
|
+
*
|
|
18
|
+
* Phase 2.5 will add: subhead, bullet, proof_statement, objection_rebuttal,
|
|
19
|
+
* guarantee. Per the v3 plan: ship 3 first, expand based on observed gaps.
|
|
20
|
+
*
|
|
21
|
+
* Pure module — no I/O, no DB. The evaluate_claims tool wraps this and
|
|
22
|
+
* persists results to claim_evaluations. Reuses detectAISmell from
|
|
23
|
+
* hook-framework so the banned-word list has one source of truth.
|
|
24
|
+
*
|
|
25
|
+
* Batched scoring is the default: scoreClaims(claims[]) keeps token cost low
|
|
26
|
+
* by scoring an array of mixed-type claims in one call.
|
|
27
|
+
*/
|
|
28
|
+
export type ClaimType = 'headline' | 'cta' | 'body_claim';
|
|
29
|
+
interface ClaimGateResult {
|
|
30
|
+
name: string;
|
|
31
|
+
status: 'pass' | 'warn' | 'fail';
|
|
32
|
+
detail?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ClaimScore {
|
|
35
|
+
/** Echoed back so the bot can correlate batched results to its inputs. */
|
|
36
|
+
text: string;
|
|
37
|
+
type: ClaimType;
|
|
38
|
+
score: number;
|
|
39
|
+
scoreLabel: 'fail' | 'weak' | 'passable' | 'strong' | 'excellent';
|
|
40
|
+
gates: ClaimGateResult[];
|
|
41
|
+
violations: string[];
|
|
42
|
+
suggestions: string[];
|
|
43
|
+
}
|
|
44
|
+
export interface BatchClaimInput {
|
|
45
|
+
text: string;
|
|
46
|
+
type: ClaimType;
|
|
47
|
+
context?: string;
|
|
48
|
+
headline_format?: HookFormat;
|
|
49
|
+
awareness_level?: AwarenessLevel;
|
|
50
|
+
}
|
|
51
|
+
export declare function scoreClaims(claims: BatchClaimInput[]): ClaimScore[];
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// Copied from persuasion-ecosystem lib/claim-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, scoreHook } from './hook-framework.js';
|
|
4
|
+
import { safeSlice } from './string-utils.js';
|
|
5
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
6
|
+
// Tunables
|
|
7
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
8
|
+
const MAX_CLAIM_LENGTH = 1000;
|
|
9
|
+
/** CTA hard-fails on these literal words (case insensitive). */
|
|
10
|
+
// Note: trailing punctuation is stripped by `replace(/[!.?]+$/, '')` in scoreCta
|
|
11
|
+
// before this Set is consulted, so dotted variants ('click here.', 'learn more.')
|
|
12
|
+
// would never match — kept the canonical un-dotted forms only.
|
|
13
|
+
const CTA_GENERIC_BUTTONS = new Set([
|
|
14
|
+
'submit',
|
|
15
|
+
'click here',
|
|
16
|
+
'click',
|
|
17
|
+
'learn more',
|
|
18
|
+
'read more',
|
|
19
|
+
'continue',
|
|
20
|
+
'next',
|
|
21
|
+
'go',
|
|
22
|
+
'enter',
|
|
23
|
+
'send',
|
|
24
|
+
'okay',
|
|
25
|
+
'ok',
|
|
26
|
+
// Stock buttons that name no outcome. "Buy now" / "Shop now" stay off the list:
|
|
27
|
+
// for a ready buyer they are the clear, honest button.
|
|
28
|
+
'get started',
|
|
29
|
+
'sign up',
|
|
30
|
+
'sign up now',
|
|
31
|
+
'start now',
|
|
32
|
+
'start here',
|
|
33
|
+
'try it',
|
|
34
|
+
'try it now',
|
|
35
|
+
'find out more',
|
|
36
|
+
'discover more',
|
|
37
|
+
'see more',
|
|
38
|
+
]);
|
|
39
|
+
/** Strong action verbs that indicate the CTA names a real action. */
|
|
40
|
+
const CTA_STRONG_VERBS = [
|
|
41
|
+
'get', 'grab', 'claim', 'reserve', 'save', 'book', 'unlock', 'start',
|
|
42
|
+
'try', 'join', 'download', 'install', 'send', 'request', 'apply',
|
|
43
|
+
'see', 'show', 'reveal', 'discover', 'open', 'activate', 'enroll',
|
|
44
|
+
'enter', 'subscribe', 'access', 'create', 'build', 'launch', 'fix',
|
|
45
|
+
'cancel', 'stop', 'remove', 'delete', 'add',
|
|
46
|
+
];
|
|
47
|
+
/** Outcome words that indicate the CTA promises something specific. */
|
|
48
|
+
const CTA_OUTCOME_NOUNS = [
|
|
49
|
+
'spot', 'seat', 'session', 'demo', 'access', 'guide', 'report',
|
|
50
|
+
'audit', 'review', 'plan', 'kit', 'template', 'discount', 'sample',
|
|
51
|
+
'consult', 'consultation', 'invitation', 'invite', 'preview', 'trial',
|
|
52
|
+
'subscription', 'license', 'key', 'pass', 'ticket', 'copy', 'edition',
|
|
53
|
+
'meeting', 'call',
|
|
54
|
+
];
|
|
55
|
+
// Pre-compiled regex for CTA outcome detection. Compiling once at module load
|
|
56
|
+
// is N× faster than building a new RegExp inside scoreCta on every call,
|
|
57
|
+
// which matters in batched mode (up to 50 claims per evaluate_claims call).
|
|
58
|
+
const CTA_OUTCOME_REGEX = new RegExp(`\\b(${CTA_OUTCOME_NOUNS.join('|')})\\b`, 'i');
|
|
59
|
+
/** CTAs longer than this many words feel like sentences, not buttons. */
|
|
60
|
+
const CTA_LOW_FRICTION_WORDS = 6;
|
|
61
|
+
/** Mechanism markers — words that indicate the claim names HOW. */
|
|
62
|
+
const MECHANISM_MARKERS = [
|
|
63
|
+
'because', 'by ', 'through ', 'via ', 'using ', 'thanks to ',
|
|
64
|
+
'powered by ', 'based on ', 'driven by ', 'built on ',
|
|
65
|
+
];
|
|
66
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
67
|
+
// Headline — delegates to scoreHook
|
|
68
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
69
|
+
function normalizeHookResult(text, options) {
|
|
70
|
+
// Hook framework owns the 7-gate logic for openers. Translate its return
|
|
71
|
+
// shape into the unified ClaimScore shape so the consumer (the bot) sees
|
|
72
|
+
// a consistent structure across claim types.
|
|
73
|
+
const result = scoreHook(text, {
|
|
74
|
+
format: options.headlineFormat,
|
|
75
|
+
awarenessLevel: options.awarenessLevel,
|
|
76
|
+
});
|
|
77
|
+
const gates = result.gates.map((g) => ({
|
|
78
|
+
name: g.name,
|
|
79
|
+
status: g.result,
|
|
80
|
+
detail: g.detail,
|
|
81
|
+
}));
|
|
82
|
+
return {
|
|
83
|
+
text,
|
|
84
|
+
type: 'headline',
|
|
85
|
+
score: result.score,
|
|
86
|
+
scoreLabel: result.scoreLabel,
|
|
87
|
+
gates,
|
|
88
|
+
violations: result.violations,
|
|
89
|
+
suggestions: result.suggestions.slice(0, 3),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
93
|
+
// CTA gates
|
|
94
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
95
|
+
function scoreCta(text) {
|
|
96
|
+
const trimmed = text.trim();
|
|
97
|
+
const lower = trimmed.toLowerCase().replace(/[!.?]+$/, '');
|
|
98
|
+
const words = trimmed.split(/\s+/).filter(Boolean);
|
|
99
|
+
const wordCount = words.length;
|
|
100
|
+
const gates = [];
|
|
101
|
+
const violations = [];
|
|
102
|
+
const suggestions = [];
|
|
103
|
+
// CTA 1: hard-fail on generic button text
|
|
104
|
+
if (CTA_GENERIC_BUTTONS.has(lower)) {
|
|
105
|
+
gates.push({
|
|
106
|
+
name: 'generic_button_text',
|
|
107
|
+
status: 'fail',
|
|
108
|
+
detail: `"${trimmed}" is a generic button label. Replace with a specific outcome ("Save my seat", "Book my audit", "Send me the guide").`,
|
|
109
|
+
});
|
|
110
|
+
violations.push('generic_button_text');
|
|
111
|
+
suggestions.push('Replace with a benefit-oriented action ("Save my seat" not "Submit").');
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
gates.push({ name: 'generic_button_text', status: 'pass' });
|
|
115
|
+
}
|
|
116
|
+
// CTA 2: first-person possessive OR specific outcome noun
|
|
117
|
+
const hasFirstPerson = /\b(my|me|i)\b/i.test(trimmed);
|
|
118
|
+
const lowerForOutcome = lower.replace(/[^a-z\s]/g, ' ');
|
|
119
|
+
const hasOutcome = CTA_OUTCOME_REGEX.test(lowerForOutcome);
|
|
120
|
+
if (hasFirstPerson || hasOutcome) {
|
|
121
|
+
gates.push({
|
|
122
|
+
name: 'first_person_or_outcome',
|
|
123
|
+
status: 'pass',
|
|
124
|
+
detail: hasFirstPerson ? 'first-person possessive' : 'specific outcome noun',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
gates.push({
|
|
129
|
+
name: 'first_person_or_outcome',
|
|
130
|
+
status: 'fail',
|
|
131
|
+
detail: 'CTA needs first-person ("Get my X") or a specific outcome noun ("seat", "session", "audit").',
|
|
132
|
+
});
|
|
133
|
+
violations.push('first_person_or_outcome');
|
|
134
|
+
suggestions.push('Use first-person ("Get my plan") — Unbounce documents +90% lift over second-person.');
|
|
135
|
+
}
|
|
136
|
+
// CTA 3: starts with a strong action verb
|
|
137
|
+
const firstWord = words[0]?.toLowerCase().replace(/[^a-z]/g, '') || '';
|
|
138
|
+
if (CTA_STRONG_VERBS.includes(firstWord)) {
|
|
139
|
+
gates.push({
|
|
140
|
+
name: 'strong_action_verb',
|
|
141
|
+
status: 'pass',
|
|
142
|
+
detail: firstWord,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
gates.push({
|
|
147
|
+
name: 'strong_action_verb',
|
|
148
|
+
status: 'warn',
|
|
149
|
+
detail: `First word "${firstWord || '(none)'}" is not a strong action verb. CTAs should open with the verb of the action.`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// CTA 4: low-friction word cap
|
|
153
|
+
if (wordCount === 0) {
|
|
154
|
+
gates.push({ name: 'low_friction', status: 'fail', detail: 'empty CTA' });
|
|
155
|
+
}
|
|
156
|
+
else if (wordCount > CTA_LOW_FRICTION_WORDS) {
|
|
157
|
+
gates.push({
|
|
158
|
+
name: 'low_friction',
|
|
159
|
+
status: 'warn',
|
|
160
|
+
detail: `${wordCount} words. Buttons over ${CTA_LOW_FRICTION_WORDS} words read as sentences, not actions.`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
gates.push({
|
|
165
|
+
name: 'low_friction',
|
|
166
|
+
status: 'pass',
|
|
167
|
+
detail: `${wordCount} words`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
// CTA 5: anti-smell
|
|
171
|
+
const smell = detectAISmell(trimmed);
|
|
172
|
+
if (smell.hasSmells) {
|
|
173
|
+
gates.push({
|
|
174
|
+
name: 'anti_smell',
|
|
175
|
+
status: 'fail',
|
|
176
|
+
detail: smell.violations.slice(0, 3).join('; '),
|
|
177
|
+
});
|
|
178
|
+
violations.push(...smell.violations.map((v) => `anti_smell: ${v}`));
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
gates.push({ name: 'anti_smell', status: 'pass' });
|
|
182
|
+
}
|
|
183
|
+
return finalizeScore(trimmed, 'cta', gates, violations, suggestions);
|
|
184
|
+
}
|
|
185
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
186
|
+
// Body-claim gates
|
|
187
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
188
|
+
function scoreBodyClaim(text, options) {
|
|
189
|
+
const trimmed = text.trim();
|
|
190
|
+
const lower = trimmed.toLowerCase();
|
|
191
|
+
const gates = [];
|
|
192
|
+
const violations = [];
|
|
193
|
+
const suggestions = [];
|
|
194
|
+
// Body 1: specificity — number, $, %, year, named entity, or timeframe
|
|
195
|
+
const hasNumber = /\b\d+([,.]\d+)?[%kKmM]?\b/.test(trimmed) || /\$[\d,]+/.test(trimmed);
|
|
196
|
+
// Named-entity check: skip the first capitalized word of the sentence so
|
|
197
|
+
// sentence-initial "The" / "This" / "They" / "We" / "Our" don't false-
|
|
198
|
+
// positive every sentence into a "specificity pass". Strip leading
|
|
199
|
+
// punctuation, then strip the first capitalized word, then look for
|
|
200
|
+
// proper-noun patterns in what remains.
|
|
201
|
+
const stripped = trimmed.replace(/^[^a-zA-Z]*/, '').replace(/^[A-Z][a-z]*(?:'s)?\s*/, '');
|
|
202
|
+
const hasNamedEntity = /\b[A-Z][a-z]{2,}(?:\s+[A-Z][a-z]+)*\b/.test(stripped);
|
|
203
|
+
const hasTimeframe = /\b(day|days|week|weeks|month|months|year|years|hour|hours|minute|minutes|2024|2025|2026)\b/i.test(trimmed);
|
|
204
|
+
if (hasNumber || hasNamedEntity || hasTimeframe) {
|
|
205
|
+
const sources = [];
|
|
206
|
+
if (hasNumber)
|
|
207
|
+
sources.push('number');
|
|
208
|
+
if (hasNamedEntity)
|
|
209
|
+
sources.push('named entity');
|
|
210
|
+
if (hasTimeframe)
|
|
211
|
+
sources.push('timeframe');
|
|
212
|
+
gates.push({
|
|
213
|
+
name: 'specificity',
|
|
214
|
+
status: 'pass',
|
|
215
|
+
detail: sources.join(', '),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
gates.push({
|
|
220
|
+
name: 'specificity',
|
|
221
|
+
status: 'fail',
|
|
222
|
+
detail: 'No number, named entity, or timeframe. Generic claims are forgettable.',
|
|
223
|
+
});
|
|
224
|
+
violations.push('specificity');
|
|
225
|
+
suggestions.push('Anchor the claim with a number, dollar amount, percentage, year, or named entity.');
|
|
226
|
+
}
|
|
227
|
+
// Body 2: mechanism named — claim or its surrounding context names HOW.
|
|
228
|
+
// This mirrors the Schwartz "stage 4-5 sophistication" requirement: at
|
|
229
|
+
// higher market sophistication, the audience needs to know HOW the result
|
|
230
|
+
// is produced, not just WHAT the result is.
|
|
231
|
+
const haystack = `${lower} ${(options.context || '').toLowerCase()}`;
|
|
232
|
+
const hasMechanism = MECHANISM_MARKERS.some((m) => haystack.includes(m));
|
|
233
|
+
if (hasMechanism) {
|
|
234
|
+
gates.push({ name: 'mechanism_named', status: 'pass' });
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
gates.push({
|
|
238
|
+
name: 'mechanism_named',
|
|
239
|
+
status: 'warn',
|
|
240
|
+
detail: 'Claim does not name HOW the result happens (no "because", "by", "through", "via"). At sophistication stage 4+, mechanism > pure benefit.',
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
// Body 3: proof welded — claim has at least one verifiable hook (number,
|
|
244
|
+
// source attribution, or named source). Reuses the specificity check but
|
|
245
|
+
// weights it differently: a number alone is enough here.
|
|
246
|
+
const hasSourceAttribution = /\b(according to|study|research|survey|reported|documented|measured|tested)\b/i.test(trimmed);
|
|
247
|
+
if (hasNumber || hasSourceAttribution) {
|
|
248
|
+
gates.push({
|
|
249
|
+
name: 'proof_welded',
|
|
250
|
+
status: 'pass',
|
|
251
|
+
detail: hasSourceAttribution ? 'source attribution' : 'verifiable number',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
gates.push({
|
|
256
|
+
name: 'proof_welded',
|
|
257
|
+
status: 'warn',
|
|
258
|
+
detail: 'No verifiable number or source attribution. Body claims need a proof anchor a skeptic can check.',
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// Body 4: anti-smell
|
|
262
|
+
const smell = detectAISmell(trimmed);
|
|
263
|
+
if (smell.hasSmells) {
|
|
264
|
+
gates.push({
|
|
265
|
+
name: 'anti_smell',
|
|
266
|
+
status: 'fail',
|
|
267
|
+
detail: smell.violations.slice(0, 3).join('; '),
|
|
268
|
+
});
|
|
269
|
+
violations.push(...smell.violations.map((v) => `anti_smell: ${v}`));
|
|
270
|
+
suggestions.push('Strip the AI-smell vocabulary and rewrite in plain Anglo-Saxon verbs.');
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
gates.push({ name: 'anti_smell', status: 'pass' });
|
|
274
|
+
}
|
|
275
|
+
return finalizeScore(trimmed, 'body_claim', gates, violations, suggestions);
|
|
276
|
+
}
|
|
277
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
278
|
+
// Composition
|
|
279
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
280
|
+
function finalizeScore(text, type, gates, violations, suggestions) {
|
|
281
|
+
// Each fail costs 2.5, each warn 0.7, baseline 10.
|
|
282
|
+
let score = 10.0;
|
|
283
|
+
for (const g of gates) {
|
|
284
|
+
if (g.status === 'fail')
|
|
285
|
+
score -= 2.5;
|
|
286
|
+
else if (g.status === 'warn')
|
|
287
|
+
score -= 0.7;
|
|
288
|
+
}
|
|
289
|
+
if (score < 0)
|
|
290
|
+
score = 0;
|
|
291
|
+
// Auto-suggestions from any failed gate that didn't already contribute one.
|
|
292
|
+
for (const g of gates) {
|
|
293
|
+
if (g.status === 'fail' && g.detail && suggestions.length < 3) {
|
|
294
|
+
// Skip if a more specific suggestion already exists.
|
|
295
|
+
const alreadyCovered = suggestions.some((s) => s.toLowerCase().includes(g.name.replace(/_/g, ' ')));
|
|
296
|
+
if (!alreadyCovered)
|
|
297
|
+
suggestions.push(g.detail);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
let scoreLabel;
|
|
301
|
+
if (score < 4)
|
|
302
|
+
scoreLabel = 'fail';
|
|
303
|
+
else if (score < 6)
|
|
304
|
+
scoreLabel = 'weak';
|
|
305
|
+
else if (score < 7.5)
|
|
306
|
+
scoreLabel = 'passable';
|
|
307
|
+
else if (score < 9)
|
|
308
|
+
scoreLabel = 'strong';
|
|
309
|
+
else
|
|
310
|
+
scoreLabel = 'excellent';
|
|
311
|
+
return {
|
|
312
|
+
text,
|
|
313
|
+
type,
|
|
314
|
+
score: Math.round(score * 10) / 10,
|
|
315
|
+
scoreLabel,
|
|
316
|
+
gates,
|
|
317
|
+
violations,
|
|
318
|
+
suggestions: suggestions.slice(0, 3),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
322
|
+
// Public entrypoints
|
|
323
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
324
|
+
function scoreClaim(text, type, options = {}) {
|
|
325
|
+
const trimmed = text.trim();
|
|
326
|
+
if (!trimmed) {
|
|
327
|
+
return {
|
|
328
|
+
text: '',
|
|
329
|
+
type,
|
|
330
|
+
score: 0,
|
|
331
|
+
scoreLabel: 'fail',
|
|
332
|
+
gates: [{ name: 'empty', status: 'fail', detail: 'Empty claim.' }],
|
|
333
|
+
violations: ['empty'],
|
|
334
|
+
suggestions: ['Pass non-empty text.'],
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
if (trimmed.length > MAX_CLAIM_LENGTH) {
|
|
338
|
+
return {
|
|
339
|
+
text: safeSlice(trimmed, 80) + '…',
|
|
340
|
+
type,
|
|
341
|
+
score: 0,
|
|
342
|
+
scoreLabel: 'fail',
|
|
343
|
+
gates: [{ name: 'too_long', status: 'fail', detail: `Claims must be ≤${MAX_CLAIM_LENGTH} chars (received ${trimmed.length}). Use evaluate_flow for longer blocks.` }],
|
|
344
|
+
violations: ['too_long'],
|
|
345
|
+
suggestions: [`Pass a single sentence, not a paragraph (max ${MAX_CLAIM_LENGTH} chars).`],
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
switch (type) {
|
|
349
|
+
case 'headline':
|
|
350
|
+
return normalizeHookResult(trimmed, options);
|
|
351
|
+
case 'cta':
|
|
352
|
+
return scoreCta(trimmed);
|
|
353
|
+
case 'body_claim':
|
|
354
|
+
return scoreBodyClaim(trimmed, options);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
export function scoreClaims(claims) {
|
|
358
|
+
return claims.map((c) => scoreClaim(c.text, c.type, {
|
|
359
|
+
context: c.context,
|
|
360
|
+
headlineFormat: c.headline_format,
|
|
361
|
+
awarenessLevel: c.awareness_level,
|
|
362
|
+
}));
|
|
363
|
+
}
|