@heyramzi/social 0.0.0-stage → 1.1.0
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 +58 -2
- package/grade.d.ts +75 -0
- package/grade.js +166 -0
- package/hooks.d.ts +10 -0
- package/hooks.js +114 -0
- package/jev.d.ts +29 -0
- package/jev.js +71 -0
- package/package.json +60 -4
- package/questions.d.ts +22 -0
- package/questions.js +70 -0
- package/shorts.d.ts +9 -0
- package/shorts.js +111 -0
- package/story.d.ts +16 -0
- package/story.js +127 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ramzi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @heyramzi/social
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Content evaluation, written once. CutKit's script check and UpSys's Jev loop are different apps
|
|
4
|
+
with their own weights and contracts, and both import what's the same from here, so a rule
|
|
5
|
+
changes in one place.
|
|
6
|
+
|
|
7
|
+
| Entry | What it holds | Read by |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `./story` | Checks with no model: `openerFault` (greeting, hidden point, scene first), `hedgeScan`, `flatRun`, `HOOK_MAX_WORDS` | CutKit's offline lint, `./shorts` |
|
|
10
|
+
| `./questions` | `storyQuestions({ field, piece, reader })`: the story questions a judge model answers, in the surface's own words | CutKit's check, Jev's `opening`, `linkedin`, `x` and `reddit` surfaces |
|
|
11
|
+
| `./hooks` | One regex per hook-library shape, and the banned shape | `./shorts` |
|
|
12
|
+
| `./shorts` | `matchHook` and `auditShorts`, the skeleton of a Short | `heyramzi-slop --register=shorts` in `@heyramzi/lint` |
|
|
13
|
+
| `./jev` | The wire shape of a question (`questionBody`), the `Answer` type, and `askJev`: one call that takes the key, model, state and questions, and returns the answers | CutKit's check, UpSys's `tools/social` client |
|
|
14
|
+
| `./grade` | A trained model plus Jev's answers to a grade: `gradeAnswers` (grade 0 to 10, confidence, the three weakest answers with their fixes, the nearest winners, the pass gate), `encode`, `predict`, `rankOn`, `letterGrade`, `PASS_GRADE` | UpSys's loop, training and draft grading, CutKit's letter |
|
|
15
|
+
|
|
16
|
+
`./jev` reads no env and keeps no cache. The caller passes the key, the model and, if it wants
|
|
17
|
+
one, `fetch` and `retries` (a dropped connection, a 429, a 529 or a 5xx waits 0.5s, 1s, 2s and
|
|
18
|
+
on). Every call gives up after 60 seconds. It throws when a question comes back unanswered, so
|
|
19
|
+
nobody grades a partial set. A disk or database cache wraps it in the app, keyed on
|
|
20
|
+
`questionBody`.
|
|
21
|
+
|
|
22
|
+
`./grade` is pure. Training reads the same `encode` and `predict` that grade a draft, so a model
|
|
23
|
+
grades the way it was fitted.
|
|
24
|
+
|
|
25
|
+
Every evaluator grades on one scale. `rankOn(ruler, value)` is the share of a sorted corpus
|
|
26
|
+
ruler a value beats, in tenths, and `letterGrade` reads that as A+ to F with 8 as the B- bar.
|
|
27
|
+
So a B- beats 80% of its corpus, whether it's a Short, a LinkedIn post or a Reddit reply. A
|
|
28
|
+
trained model's ruler holds its predicted probabilities. CutKit's check maps answers to
|
|
29
|
+
hand-written items with fixed weights, so its ruler holds the craft scores of its corpus
|
|
30
|
+
scripts (`pnpm eval:ruler` in cutkit). Training (logistic regression, the corpus rows) stays in
|
|
31
|
+
UpSys, because it reads local corpora.
|
|
32
|
+
|
|
33
|
+
Fix copy never lives here. A question in the bank says what to ask; the app says what to tell
|
|
34
|
+
the person when the answer drags the grade down.
|
|
35
|
+
|
|
36
|
+
## Adding a question
|
|
37
|
+
|
|
38
|
+
Add it to `storyQuestions` with its `source`. It earns a place in a surface once
|
|
39
|
+
`pnpm jev train <surface>` in UpSys keeps it, and in CutKit's check once its rating bench
|
|
40
|
+
(`evals/rating`) keeps it. The `script` wording CutKit already ships is pinned in `questions.test.mjs`, because the
|
|
41
|
+
judge's answers move when a word does.
|
|
42
|
+
|
|
43
|
+
## Calibration
|
|
44
|
+
|
|
45
|
+
A rule here is measured against the CutKit corpus before an app gates on it. On 14 Oct 2026,
|
|
46
|
+
over 5,089 punctuated Short transcripts from 23 creators, a sentence-rhythm rule (4 sentences in
|
|
47
|
+
a row at one length) fired on 30.5% of winners and 27.9% of the rest, so it isn't here. `flatRun`
|
|
48
|
+
flags 57% of winners and 60% of the rest: CutKit shows it as a nudge, never a fail.
|
|
49
|
+
|
|
50
|
+
The same day, a Jev discovery round asked every question in the bank on each surface. Only
|
|
51
|
+
`villain` earned a place: on `post` (+0.0020 AUC, coefficient +0.16) and `x` (+0.0025, +0.12).
|
|
52
|
+
`intro_order` helped `opening`'s ranking with a negative coefficient (W-order openings did worse
|
|
53
|
+
there), and `stakes`, `visual_first`, `head_fake` and `atomic_share` added nothing or pointed
|
|
54
|
+
backwards. They stay in the bank to test again. UpSys's `short` surface, whose song-lyric
|
|
55
|
+
transcripts pulled its AUC to 0.52, is gone: CutKit's check grades Shorts.
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
pnpm test
|
|
59
|
+
```
|
package/grade.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Answer, JevQuestion } from "#social/jev.js";
|
|
2
|
+
|
|
3
|
+
export type Question = JevQuestion & {
|
|
4
|
+
/** Code-only name. Jev never sees it, so the instructions carry the whole meaning. */
|
|
5
|
+
id: string;
|
|
6
|
+
/** Where the question came from: a control-tested lever, Kallaway, a hook-library shape, a discovery round. */
|
|
7
|
+
source?: string;
|
|
8
|
+
/** Said to the writer when this answer is what's pulling the grade down. */
|
|
9
|
+
fix?: string;
|
|
10
|
+
/** Training checks fix direction against the learned coefficient because advice must counter the trait dragging the grade. */
|
|
11
|
+
fixPushes?: "more" | "less";
|
|
12
|
+
/** Answered and weighed so the other coefficients aren't skewed by a tactic we ban, but never offered as advice. */
|
|
13
|
+
control?: true;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type Fit = {
|
|
17
|
+
mean: number[];
|
|
18
|
+
sd: number[];
|
|
19
|
+
coef: number[];
|
|
20
|
+
intercept: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type Model<Surface extends string = string> = Fit & {
|
|
24
|
+
surface: Surface;
|
|
25
|
+
trainedAt: string;
|
|
26
|
+
questions: Question[];
|
|
27
|
+
features: string[];
|
|
28
|
+
/** Sorted predicted probabilities over the training corpus, the ruler a grade is read off. */
|
|
29
|
+
ruler: number[];
|
|
30
|
+
metrics: {
|
|
31
|
+
cvAuc: number;
|
|
32
|
+
testAuc: number | null;
|
|
33
|
+
ownAuc: number | null;
|
|
34
|
+
rows: number;
|
|
35
|
+
positives: number;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type TrainedModel<Surface extends string = string> = Model<Surface> & {
|
|
40
|
+
winners: { creator: string; text: string; breakout: number; x: number[] }[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type Weak = {
|
|
44
|
+
question: string;
|
|
45
|
+
answer: string;
|
|
46
|
+
pull: number;
|
|
47
|
+
fix?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type Grade<Surface extends string = string> = {
|
|
51
|
+
surface: Surface;
|
|
52
|
+
grade: number;
|
|
53
|
+
confidence: number;
|
|
54
|
+
pass: boolean;
|
|
55
|
+
/** False when the model failed its held-out test: the grade is advice and can't block a ship. */
|
|
56
|
+
gates: boolean;
|
|
57
|
+
testAuc: number | null;
|
|
58
|
+
weakest: Weak[];
|
|
59
|
+
winners: { creator: string; text: string; breakout: number }[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export declare const PASS_GRADE: number;
|
|
63
|
+
export declare const MIN_CONFIDENCE: number;
|
|
64
|
+
export declare const MIN_TEST_AUC: number;
|
|
65
|
+
/** The 0 to 10 grade as A+ to F. 8 is the B- bar. */
|
|
66
|
+
export declare function letterGrade(grade: number): string;
|
|
67
|
+
/** A value's rank on a sorted ruler of corpus scores, as a 0 to 10 grade: the share it beats. */
|
|
68
|
+
export declare function rankOn(ruler: number[], value: number): number;
|
|
69
|
+
export declare function encode(questions: Question[], answers: Record<string, Answer>): number[];
|
|
70
|
+
export declare function predict(fit: Fit, x: number[]): number;
|
|
71
|
+
export declare function contributions(fit: Fit, x: number[]): number[];
|
|
72
|
+
export declare function gradeAnswers<Surface extends string>(
|
|
73
|
+
model: TrainedModel<Surface>,
|
|
74
|
+
answers: Record<string, Answer>,
|
|
75
|
+
): Grade<Surface>;
|
package/grade.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jev's answers to a grade on a trained model's corpus ruler. Pure. Training reads the same
|
|
3
|
+
* encode and predict, so a model grades the way it was fitted.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The grade a piece needs before it ships. */
|
|
7
|
+
export const PASS_GRADE = 8;
|
|
8
|
+
/** Under this confidence Jev is closer to guessing, so a gating model won't pass the piece. */
|
|
9
|
+
export const MIN_CONFIDENCE = 0.6;
|
|
10
|
+
/** Only a model that ranks unseen winners above controls can block shipping. The rest advise. */
|
|
11
|
+
export const MIN_TEST_AUC = 0.6;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The grade times ten, so the bar of 8 reads B-. Every grade comes off `rankOn`, so a B-
|
|
15
|
+
* beats 80% of its corpus on any surface.
|
|
16
|
+
*/
|
|
17
|
+
const LETTERS = [
|
|
18
|
+
[97, "A+"],
|
|
19
|
+
[93, "A"],
|
|
20
|
+
[90, "A-"],
|
|
21
|
+
[87, "B+"],
|
|
22
|
+
[83, "B"],
|
|
23
|
+
[80, "B-"],
|
|
24
|
+
[77, "C+"],
|
|
25
|
+
[73, "C"],
|
|
26
|
+
[70, "C-"],
|
|
27
|
+
[67, "D+"],
|
|
28
|
+
[63, "D"],
|
|
29
|
+
[60, "D-"],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** @param {number} grade 0 to 10 */
|
|
33
|
+
export function letterGrade(grade) {
|
|
34
|
+
const score = Math.round(grade * 10);
|
|
35
|
+
return LETTERS.find(([floor]) => score >= floor)?.[1] ?? "F";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The share of a sorted corpus ruler a value beats, as a 0 to 10 grade.
|
|
40
|
+
* @param {number[]} ruler sorted ascending
|
|
41
|
+
* @param {number} value
|
|
42
|
+
*/
|
|
43
|
+
export function rankOn(ruler, value) {
|
|
44
|
+
if (!ruler.length) return 0;
|
|
45
|
+
const below = ruler.filter((r) => r < value).length;
|
|
46
|
+
return Number(((below / ruler.length) * 10).toFixed(1));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A score answer as a fraction of the scale; no answer sits mid scale. */
|
|
50
|
+
function encodeScore(levels, a) {
|
|
51
|
+
return (a?.score ?? (levels - 1) / 2) / (levels - 1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One column per choice past the first (the baseline): its probability, else 1 for the pick. */
|
|
55
|
+
function encodeChoice(criteria, a) {
|
|
56
|
+
return Object.keys(criteria)
|
|
57
|
+
.slice(1)
|
|
58
|
+
.map((k) => a?.probabilities?.[k] ?? (a?.choice === k ? 1 : 0));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function encodeOne(q, a) {
|
|
62
|
+
if (q.type === "noul") return [a?.noul ?? 0.5];
|
|
63
|
+
if (q.type === "score") return [encodeScore(q.criteria.length, a)];
|
|
64
|
+
return encodeChoice(q.criteria, a);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One number per feature, in the order a model's `features` lists them. */
|
|
68
|
+
export function encode(questions, answers) {
|
|
69
|
+
return questions.flatMap((q) => encodeOne(q, answers[q.id]));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sigmoid = (z) => 1 / (1 + Math.exp(-z));
|
|
73
|
+
const standardized = (f, x, j) => ((x[j] - (f.mean[j] ?? 0)) / (f.sd[j] ?? 1)) * (f.coef[j] ?? 0);
|
|
74
|
+
|
|
75
|
+
export function predict(f, x) {
|
|
76
|
+
return sigmoid(f.intercept + x.reduce((sum, _, j) => sum + standardized(f, x, j), 0));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Each feature's pull on the logit, against an average corpus row. */
|
|
80
|
+
export function contributions(f, x) {
|
|
81
|
+
return x.map((_, j) => standardized(f, x, j));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function describe(a, q) {
|
|
85
|
+
if (!a) return "no answer";
|
|
86
|
+
if (q.type === "noul") return `yes ${Math.round((a.noul ?? 0) * 100)}%`;
|
|
87
|
+
if (q.type === "score") {
|
|
88
|
+
const levels = q.criteria;
|
|
89
|
+
const i = Math.max(0, Math.min(levels.length - 1, Math.round(a.score ?? 0)));
|
|
90
|
+
return `level ${i + 1} of ${levels.length}: "${levels[i]}"`;
|
|
91
|
+
}
|
|
92
|
+
return `picked "${a.choice}"`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Each question's pull: the sum over its features, against an average corpus row. */
|
|
96
|
+
function pullsOf(model, contrib) {
|
|
97
|
+
return model.questions.map((q) => {
|
|
98
|
+
const cols = model.features
|
|
99
|
+
.map((f, j) => [f, j])
|
|
100
|
+
.filter(([f]) => f === q.id || f.startsWith(`${q.id}=`));
|
|
101
|
+
return { q, pull: cols.reduce((sum, [, j]) => sum + (contrib[j] ?? 0), 0) };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Confidence that counts is the confidence on the answers doing the work. */
|
|
106
|
+
function confidenceOf(pulls, answers) {
|
|
107
|
+
let weight = 0;
|
|
108
|
+
let sum = 0;
|
|
109
|
+
for (const { q, pull } of pulls) {
|
|
110
|
+
const c = answers[q.id]?.confidence;
|
|
111
|
+
if (c === undefined) continue;
|
|
112
|
+
weight += Math.abs(pull);
|
|
113
|
+
sum += Math.abs(pull) * c;
|
|
114
|
+
}
|
|
115
|
+
return Number((weight ? sum / weight : 1).toFixed(2));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The winners nearest the answers, with distance weighted by each feature's coefficient. */
|
|
119
|
+
function nearestWinners(model, x) {
|
|
120
|
+
const z = (v, j) =>
|
|
121
|
+
((v - (model.mean[j] ?? 0)) / (model.sd[j] ?? 1)) * Math.abs(model.coef[j] ?? 0);
|
|
122
|
+
const zx = x.map(z);
|
|
123
|
+
return model.winners
|
|
124
|
+
.map((w) => ({ w, d: w.x.reduce((sum, v, j) => sum + (z(v, j) - (zx[j] ?? 0)) ** 2, 0) }))
|
|
125
|
+
.toSorted((a, b) => a.d - b.d)
|
|
126
|
+
.slice(0, 3)
|
|
127
|
+
.map(({ w }) => ({ creator: w.creator, text: w.text, breakout: w.breakout }));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Where the answers rank, what drags them down, and the nearest winners. Under `MIN_TEST_AUC`
|
|
132
|
+
* a model only advises: `gates` false, `pass` true.
|
|
133
|
+
*/
|
|
134
|
+
export function gradeAnswers(model, answers) {
|
|
135
|
+
const x = encode(model.questions, answers);
|
|
136
|
+
const fitted = { mean: model.mean, sd: model.sd, coef: model.coef, intercept: model.intercept };
|
|
137
|
+
const p = predict(fitted, x);
|
|
138
|
+
const grade = rankOn(model.ruler, p);
|
|
139
|
+
|
|
140
|
+
const pulls = pullsOf(model, contributions(fitted, x));
|
|
141
|
+
const confidence = confidenceOf(pulls, answers);
|
|
142
|
+
const weakest = pulls
|
|
143
|
+
.filter((e) => e.pull < 0 && !e.q.control)
|
|
144
|
+
.toSorted((a, b) => a.pull - b.pull)
|
|
145
|
+
.slice(0, 3)
|
|
146
|
+
.map(({ q, pull }) => ({
|
|
147
|
+
question: q.instructions,
|
|
148
|
+
answer: describe(answers[q.id], q),
|
|
149
|
+
pull: Number(pull.toFixed(2)),
|
|
150
|
+
fix: q.fix,
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
const testAuc = model.metrics.testAuc;
|
|
154
|
+
const gates = testAuc !== null && testAuc >= MIN_TEST_AUC;
|
|
155
|
+
const pass = !gates || (grade >= PASS_GRADE && confidence >= MIN_CONFIDENCE);
|
|
156
|
+
return {
|
|
157
|
+
surface: model.surface,
|
|
158
|
+
grade,
|
|
159
|
+
confidence,
|
|
160
|
+
pass,
|
|
161
|
+
gates,
|
|
162
|
+
testAuc,
|
|
163
|
+
weakest,
|
|
164
|
+
winners: nearestWinners(model, x),
|
|
165
|
+
};
|
|
166
|
+
}
|
package/hooks.d.ts
ADDED
package/hooks.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** One regex per hook-library shape, keyed by its number. The prose lives in shorts-script. */
|
|
2
|
+
const N = "(?:\\d+|two|three|four|five|six|seven|eight|nine|ten|twelve)";
|
|
3
|
+
|
|
4
|
+
export const hookShapes = [
|
|
5
|
+
{ n: 1, family: "Promise", test: /^here(?:'s| is) the only\b/i },
|
|
6
|
+
{ n: 2, family: "Promise", test: /^(?:here(?:'s| is)|this is) how (?:to|you|i)\b/i },
|
|
7
|
+
{ n: 3, family: "Promise", test: new RegExp(`^here are ${N}\\b`, "i") },
|
|
8
|
+
{
|
|
9
|
+
n: 4,
|
|
10
|
+
family: "Promise",
|
|
11
|
+
second: true,
|
|
12
|
+
test: /\bi (?:use|built|made) (?:a|an|the) [\w ]{1,24}(?:i call|called)\b/i,
|
|
13
|
+
},
|
|
14
|
+
{ n: 5, family: "Diagnosis", test: /^the (?:real )?reason (?:your|you)\b/i },
|
|
15
|
+
{ n: 6, family: "Diagnosis", test: /^this is the most\b/i },
|
|
16
|
+
{ n: 7, family: "Diagnosis", test: /\bthe fastest way to\b.*\bthe fastest way to\b/i },
|
|
17
|
+
{
|
|
18
|
+
n: 8,
|
|
19
|
+
family: "Received-wisdom break",
|
|
20
|
+
test: /^(?:everyone|everybody|people)(?: will| keep)? (?:tells?|telling|says?|saying) you\b|, bro[.!?]?$/i,
|
|
21
|
+
},
|
|
22
|
+
{ n: 9, family: "Received-wisdom break", test: /^you (?:do not|don't) need\b/i },
|
|
23
|
+
{ n: 10, family: "Received-wisdom break", test: /^every \w+ (?:is|are)\b/i },
|
|
24
|
+
{
|
|
25
|
+
n: 11,
|
|
26
|
+
family: "Received-wisdom break",
|
|
27
|
+
test: /^[^.?!,]{2,50} or [^.?!,]{2,50}[.?]$/i,
|
|
28
|
+
maxWords: 10,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
n: 12,
|
|
32
|
+
family: "In medias res",
|
|
33
|
+
test: /^(?:can't|couldn't|just|still|got|lost|spent|found|watched)\b/i,
|
|
34
|
+
},
|
|
35
|
+
{ n: 13, family: "In medias res", test: /^i(?:'m| am) (?:processing|realizing|still)\b/i },
|
|
36
|
+
{ n: 14, family: "In medias res", test: /^i regret\b/i },
|
|
37
|
+
{ n: 15, family: "Tribe", test: /^if you want to\b.*\bbut\b/i },
|
|
38
|
+
{
|
|
39
|
+
n: 17,
|
|
40
|
+
family: "Tribe",
|
|
41
|
+
test: /^if you(?:'re| are) [^,]{2,40}, you (?:gotta|have to|need to|should|must)\b/i,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
n: 18,
|
|
45
|
+
family: "Tribe",
|
|
46
|
+
test: /^there are (?:fully grown |grown )?\w+(?: \w+)? (?:out there|who)\b/i,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
n: 19,
|
|
50
|
+
family: "Artefact",
|
|
51
|
+
test: /^(?:so )?i (?:saw|got|found) this (?:comment|post|email|message|dm|video|review)\b/i,
|
|
52
|
+
},
|
|
53
|
+
{ n: 20, family: "Artefact", test: /^(?:open|pull up|go to) (?:your|the)\b/i },
|
|
54
|
+
{ n: 21, family: "Artefact", test: /^pro [\w ]{1,24} tip\b/i },
|
|
55
|
+
{
|
|
56
|
+
n: 22,
|
|
57
|
+
family: "Number",
|
|
58
|
+
test: new RegExp(`^i (?:spent|tested|tried|ran|used|asked) (?:the last |over )?${N}\\b`, "i"),
|
|
59
|
+
},
|
|
60
|
+
{ n: 23, family: "Number", test: /^\d[\d,.]*%?\s.*\bso\b/i },
|
|
61
|
+
{ n: 24, family: "Number", test: new RegExp(`^${N} [a-z]+`, "i") },
|
|
62
|
+
{
|
|
63
|
+
n: 25,
|
|
64
|
+
family: "Number",
|
|
65
|
+
test: /^[\w'-]+(?: [\w'-]+)?, [\w'-]+(?: [\w'-]+)?,? (?:and )?[\w'-]+(?: [\w'-]+)?\.$/i,
|
|
66
|
+
maxWords: 8,
|
|
67
|
+
},
|
|
68
|
+
{ n: 26, family: "Number", test: /^every \w+(?: \w+)? (?:does|do|has) this\b/i },
|
|
69
|
+
{
|
|
70
|
+
n: 27,
|
|
71
|
+
family: "Question",
|
|
72
|
+
test: /^(?:(?:do|does|is|are|can|will|should|did) [^?]{3,80}|what happens when [^?]{3,80})\?$/i,
|
|
73
|
+
},
|
|
74
|
+
{ n: 28, family: "Question", test: /^(?:you know|do you know) (?:who|what|why|how)\b/i },
|
|
75
|
+
{ n: 29, family: "Confession", test: /^i have a confession\b/i },
|
|
76
|
+
{
|
|
77
|
+
n: 31,
|
|
78
|
+
family: "News",
|
|
79
|
+
test: /^(?:breaking news\b|[\w.\- ]{1,30} just (?:killed|replaced|ended|dropped|changed|released|launched|did it again)\b)/i,
|
|
80
|
+
},
|
|
81
|
+
{ n: 32, family: "News", test: /^this is the (?:one|only|new) \w+(?: \w+)? you need\b/i },
|
|
82
|
+
{
|
|
83
|
+
n: 33,
|
|
84
|
+
family: "News",
|
|
85
|
+
test: /^this (?:has (?:got )?to be|might be|is) the (?:craziest|best|wildest|biggest)\b/i,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
n: 34,
|
|
89
|
+
family: "Contradiction",
|
|
90
|
+
test: /^(?:i don't think there's a (?:more|better)\b|[\w.\- ]{2,30} (?:lies|fails|breaks) more than you think\b)/i,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
n: 35,
|
|
94
|
+
family: "Contradiction",
|
|
95
|
+
test: /^most people (?:don't|do not) (?:know|realize|realise)\b/i,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
n: 36,
|
|
99
|
+
family: "Promise",
|
|
100
|
+
test: new RegExp(`^(?:turn on|add|type|use) (?:these|this) (?:${N} )?\\w+`, "i"),
|
|
101
|
+
},
|
|
102
|
+
{ n: 37, family: "Promise", test: /^how (?:an?|the) [\w'’ -]{2,40} would\b/i },
|
|
103
|
+
{
|
|
104
|
+
n: 38,
|
|
105
|
+
family: "News",
|
|
106
|
+
test: /^[\w.$'’ -]{2,40} just (?:turned down|said no to|walked away from|rejected)\b/i,
|
|
107
|
+
},
|
|
108
|
+
{ n: 39, family: "Diagnosis", test: /^the difference between an? [\w -]{1,30} and an? \w+/i },
|
|
109
|
+
// Shape 16 qualifies the audience and opens no gap, so it only counts beside another shape.
|
|
110
|
+
{ n: 16, family: "Tribe", alone: false, test: /^if you\b/i },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
// Shape 30: our worst post ran 2 views on it.
|
|
114
|
+
export const bannedHook = /^(?:when i (?:first )?started|i used to think|one year ago,? i)\b/i;
|
package/jev.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** One answer from Jev. Which fields are set depends on the question's type. */
|
|
2
|
+
export type Answer = {
|
|
3
|
+
type?: string;
|
|
4
|
+
noul?: number;
|
|
5
|
+
score?: number;
|
|
6
|
+
choice?: string;
|
|
7
|
+
probabilities?: Record<string, number>;
|
|
8
|
+
confidence?: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type JevQuestion =
|
|
12
|
+
| { type: "noul"; instructions: string; criteria?: undefined }
|
|
13
|
+
/** Ordered low to high, 2 to 10 levels. */
|
|
14
|
+
| { type: "score"; instructions: string; criteria: string[] }
|
|
15
|
+
/** Option key to its meaning. */
|
|
16
|
+
| { type: "choice"; instructions: string; criteria: Record<string, string> };
|
|
17
|
+
|
|
18
|
+
export declare const JEV_URL: string;
|
|
19
|
+
export declare function questionBody(q: JevQuestion): JevQuestion;
|
|
20
|
+
|
|
21
|
+
export declare function askJev(request: {
|
|
22
|
+
key: string;
|
|
23
|
+
model: string;
|
|
24
|
+
state: unknown;
|
|
25
|
+
questions: Record<string, JevQuestion>;
|
|
26
|
+
url?: string;
|
|
27
|
+
fetch?: typeof fetch;
|
|
28
|
+
retries?: number;
|
|
29
|
+
}): Promise<{ answers: Record<string, Answer>; model: string }>;
|
package/jev.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire shape of a Jev question and the one call that answers it. No env reads and no
|
|
3
|
+
* cache: the app passes its key and model and caches.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** TypeSafe's /v1/systemone behind Kilo, which bills our Kilo credits. */
|
|
7
|
+
export const JEV_URL = "https://api.kilo.ai/api/gateway/typesafe/v1/systemone";
|
|
8
|
+
|
|
9
|
+
/** A dead socket hung a run for 15 hours on 14 Oct 2026, so every call gives up at this. */
|
|
10
|
+
const TIMEOUT_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What Jev sees of a question. The app's id, fix and source stay out, so a cache key hashed
|
|
14
|
+
* from this survives a change to them.
|
|
15
|
+
*/
|
|
16
|
+
export function questionBody(q) {
|
|
17
|
+
return q.type === "noul"
|
|
18
|
+
? { type: "noul", instructions: q.instructions }
|
|
19
|
+
: { type: q.type, instructions: q.instructions, criteria: q.criteria };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const retryable = (status) => status === 429 || status === 529 || status >= 500;
|
|
23
|
+
const backoff = (attempt) => new Promise((done) => setTimeout(done, 500 * 2 ** attempt));
|
|
24
|
+
|
|
25
|
+
async function post(request, attempt) {
|
|
26
|
+
const { key, model, state, questions, url = JEV_URL, fetch: send = fetch, retries = 0 } = request;
|
|
27
|
+
let res;
|
|
28
|
+
try {
|
|
29
|
+
res = await send(url, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
32
|
+
body: JSON.stringify({ state, model, questions }),
|
|
33
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
34
|
+
});
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (attempt >= retries) throw error;
|
|
37
|
+
return backoff(attempt).then(() => post(request, attempt + 1));
|
|
38
|
+
}
|
|
39
|
+
if (retryable(res.status) && attempt < retries)
|
|
40
|
+
return backoff(attempt).then(() => post(request, attempt + 1));
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
const detail = await res.text().catch(() => "");
|
|
43
|
+
throw new Error(`Jev answered ${res.status}: ${detail.slice(0, 200)}`);
|
|
44
|
+
}
|
|
45
|
+
return res.json();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {{
|
|
50
|
+
* key: string,
|
|
51
|
+
* model: string,
|
|
52
|
+
* state: unknown,
|
|
53
|
+
* questions: Record<string, ReturnType<typeof questionBody>>,
|
|
54
|
+
* url?: string,
|
|
55
|
+
* fetch?: typeof fetch,
|
|
56
|
+
* retries?: number,
|
|
57
|
+
* }} JevCall
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Asks every question about one state. A drop, 429, 529 or 5xx retries after 0.5s, 1s, 2s and
|
|
62
|
+
* on. A missing answer throws.
|
|
63
|
+
* @param {JevCall} request
|
|
64
|
+
*/
|
|
65
|
+
export async function askJev(request) {
|
|
66
|
+
const reply = await post(request, 0);
|
|
67
|
+
for (const id of Object.keys(request.questions)) {
|
|
68
|
+
if (!reply.answers?.[id]) throw new Error(`Jev returned no answer for ${id}`);
|
|
69
|
+
}
|
|
70
|
+
return { answers: reply.answers, model: reply.model ?? request.model };
|
|
71
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/social",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Content evaluation CutKit and UpSys share: hook shapes, the Shorts audit, story checks with no model, the story question bank, and the Jev call and trained-model grade",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/heyramzi/vibe-kit.git",
|
|
9
|
+
"directory": "packages/social"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"grade.js",
|
|
13
|
+
"grade.d.ts",
|
|
14
|
+
"hooks.js",
|
|
15
|
+
"hooks.d.ts",
|
|
16
|
+
"jev.js",
|
|
17
|
+
"jev.d.ts",
|
|
18
|
+
"questions.js",
|
|
19
|
+
"questions.d.ts",
|
|
20
|
+
"shorts.js",
|
|
21
|
+
"shorts.d.ts",
|
|
22
|
+
"story.js",
|
|
23
|
+
"story.d.ts",
|
|
24
|
+
"package.json"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"imports": {
|
|
28
|
+
"#social/*": "./*"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
"./grade": {
|
|
32
|
+
"types": "./grade.d.ts",
|
|
33
|
+
"default": "./grade.js"
|
|
34
|
+
},
|
|
35
|
+
"./hooks": {
|
|
36
|
+
"types": "./hooks.d.ts",
|
|
37
|
+
"default": "./hooks.js"
|
|
38
|
+
},
|
|
39
|
+
"./jev": {
|
|
40
|
+
"types": "./jev.d.ts",
|
|
41
|
+
"default": "./jev.js"
|
|
42
|
+
},
|
|
43
|
+
"./questions": {
|
|
44
|
+
"types": "./questions.d.ts",
|
|
45
|
+
"default": "./questions.js"
|
|
46
|
+
},
|
|
47
|
+
"./shorts": {
|
|
48
|
+
"types": "./shorts.d.ts",
|
|
49
|
+
"default": "./shorts.js"
|
|
50
|
+
},
|
|
51
|
+
"./story": {
|
|
52
|
+
"types": "./story.d.ts",
|
|
53
|
+
"default": "./story.js"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"test": "node --test \"*.test.mjs\""
|
|
61
|
+
}
|
|
62
|
+
}
|
package/questions.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type StoryQuestion = { instructions: string; source: string } & (
|
|
2
|
+
| { type: "noul"; criteria?: undefined }
|
|
3
|
+
/** Ordered low to high. */
|
|
4
|
+
| { type: "score"; criteria: string[] }
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
export type StoryQuestionId =
|
|
8
|
+
| "opening_speed"
|
|
9
|
+
| "contrast"
|
|
10
|
+
| "rehook"
|
|
11
|
+
| "stakes"
|
|
12
|
+
| "intro_order"
|
|
13
|
+
| "visual_first"
|
|
14
|
+
| "head_fake"
|
|
15
|
+
| "villain"
|
|
16
|
+
| "atomic_share";
|
|
17
|
+
|
|
18
|
+
export declare function storyQuestions(voice: {
|
|
19
|
+
field: string;
|
|
20
|
+
piece: string;
|
|
21
|
+
reader: string;
|
|
22
|
+
}): Record<StoryQuestionId, StoryQuestion>;
|
package/questions.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The story questions a judge model answers, written once and asked in each surface's words.
|
|
3
|
+
* Which ones a surface asks, its weights and its fix copy stay in the app.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const KALLAWAY = "Kallaway, How to Master Storytelling (youtube.com/watch?v=WluuZB_ck9Q)";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {{ field: string, piece: string, reader: string }} voice
|
|
10
|
+
* `field` is the state key the judge reads ("script"), `piece` what it is ("video"),
|
|
11
|
+
* `reader` who meets it ("viewer").
|
|
12
|
+
*/
|
|
13
|
+
export function storyQuestions({ field, piece, reader }) {
|
|
14
|
+
const f = `\`${field}\``;
|
|
15
|
+
const keep = reader === "viewer" ? "keep watching" : "keep reading";
|
|
16
|
+
const once = reader === "viewer" ? "one watch" : "one read";
|
|
17
|
+
return {
|
|
18
|
+
opening_speed: {
|
|
19
|
+
type: "score",
|
|
20
|
+
instructions: `How fast does the ${f} say what the ${piece} is about and what the ${reader} gets? Judge the first two sentences.`,
|
|
21
|
+
criteria: [
|
|
22
|
+
`After more than two sentences the ${reader} still doesn't know the subject or the payoff.`,
|
|
23
|
+
"The subject shows up in the first two sentences but the payoff stays unclear.",
|
|
24
|
+
"The subject is clear in the first sentence, the payoff by the second.",
|
|
25
|
+
"One short sentence of about ten words names both the subject and the outcome.",
|
|
26
|
+
],
|
|
27
|
+
source: "Kallaway interrupt theory: subject, stakes, speed, clarity",
|
|
28
|
+
},
|
|
29
|
+
contrast: {
|
|
30
|
+
type: "noul",
|
|
31
|
+
instructions: `In its first three sentences, does the ${f} set what the ${reader} already believes or does against something different, stated ('most people do A, this does B') or implied ('this is 8 times faster')?`,
|
|
32
|
+
source: "Kallaway headfake; vidIQ: statement adds contrast",
|
|
33
|
+
},
|
|
34
|
+
rehook: {
|
|
35
|
+
type: "noul",
|
|
36
|
+
instructions: `Halfway through, does the ${f} add a new question, a higher stake, a revealed mechanism, or bring back the problem from the opening and show that a better answer is still ahead, giving the ${reader} a new reason to ${keep}? A sentence that only announces what's next ('this is where it gets crazy') doesn't count.`,
|
|
37
|
+
source: `Kallaway rehook and rebuy; ${KALLAWAY}`,
|
|
38
|
+
},
|
|
39
|
+
stakes: {
|
|
40
|
+
type: "noul",
|
|
41
|
+
instructions: `Does the ${f} name who it's about, what they could lose, and why now?`,
|
|
42
|
+
source: "Kallaway addiction loop: a character, something at risk, urgency",
|
|
43
|
+
},
|
|
44
|
+
intro_order: {
|
|
45
|
+
type: "noul",
|
|
46
|
+
instructions: `In its first two sentences, does the ${f} say what is happening and why it matters to the ${reader} before it says who, when or where? Opening on a time and place ('Last month I was at a conference in Miami') or on the author's credentials counts as no.`,
|
|
47
|
+
source: `Kallaway W order: what and why, then who and how, then when and where; ${KALLAWAY}`,
|
|
48
|
+
},
|
|
49
|
+
visual_first: {
|
|
50
|
+
type: "noul",
|
|
51
|
+
instructions: `Does the first sentence of the ${f} name something the ${reader} can picture at once, such as a physical object, a screen, a result or a number, rather than only an idea?`,
|
|
52
|
+
source: `Kallaway: visual hooks beat audio-only hooks, show while you tell; ${KALLAWAY}`,
|
|
53
|
+
},
|
|
54
|
+
head_fake: {
|
|
55
|
+
type: "noul",
|
|
56
|
+
instructions: `Does the ${f} lead the ${reader} toward one answer and then give a different one that its earlier details make sense of? A twist with no clues before it counts as no.`,
|
|
57
|
+
source: `Kallaway addiction loop: prediction error, the head fake; ${KALLAWAY}`,
|
|
58
|
+
},
|
|
59
|
+
villain: {
|
|
60
|
+
type: "noul",
|
|
61
|
+
instructions: `Does the ${f} set the ${reader} against a named opponent: a person, company, group, tool, habit or belief it paints as the problem?`,
|
|
62
|
+
source: `Kallaway: a missing villain; ${KALLAWAY}`,
|
|
63
|
+
},
|
|
64
|
+
atomic_share: {
|
|
65
|
+
type: "noul",
|
|
66
|
+
instructions: `After ${once}, could the ${reader} retell the main point of the ${f} to a friend in one sentence of under 10 words?`,
|
|
67
|
+
source: `Kallaway atomic shareability, the Paul Revere effect; ${KALLAWAY}`,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/shorts.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type HookMatch =
|
|
2
|
+
| { shape: number; banned: true }
|
|
3
|
+
| { vague: true }
|
|
4
|
+
| { shape: number; family: string; alone: boolean };
|
|
5
|
+
export declare function matchHook(first: string, second?: string): HookMatch | null;
|
|
6
|
+
export declare function auditShorts(text: string): {
|
|
7
|
+
hook: HookMatch | null;
|
|
8
|
+
findings: { kind: "shorts"; id: string; detail: string }[];
|
|
9
|
+
};
|
package/shorts.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { bannedHook, hookShapes } from "#social/hooks.js";
|
|
2
|
+
import { HOOK_MAX_WORDS, openerFault } from "#social/story.js";
|
|
3
|
+
|
|
4
|
+
const SENTENCE = /[^.!?]+(?:[.!?]+["'”’)]*|$)/g;
|
|
5
|
+
const COUNT =
|
|
6
|
+
/\b(?:no\.? ?\d|number (?:one|two|three|four|five|\d)|step (?:one|two|three|four|\d)|(?:the )?first|second|third|fourth)\b/gi;
|
|
7
|
+
const REPLY_CTA = /\b(?:comment|dm me|type|reply)\b/i;
|
|
8
|
+
|
|
9
|
+
/** The spoken part of a note: header lines and everything under a `---` divider are notes to self. */
|
|
10
|
+
function spoken(text) {
|
|
11
|
+
const body = text.split(/^-{3,}\s*$/m)[0];
|
|
12
|
+
return body
|
|
13
|
+
.split("\n")
|
|
14
|
+
.filter((line) => /[.!?]["'”’)]*\s*$/.test(line.trim()))
|
|
15
|
+
.join(" ")
|
|
16
|
+
.trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const words = (s) => s.split(/\s+/).filter(Boolean).length;
|
|
20
|
+
|
|
21
|
+
const STOP = new Set(
|
|
22
|
+
"that this with they them their there then than what when where which your yours from have been were will would could should about into just like more most some such very also only even ever never always because while these those other every thing things people".split(
|
|
23
|
+
" ",
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
const content = (s) =>
|
|
27
|
+
(s.toLowerCase().match(/[a-z']{4,}/g) ?? [])
|
|
28
|
+
.map((w) => w.replace(/s$/, ""))
|
|
29
|
+
.filter((w) => !STOP.has(w));
|
|
30
|
+
/** Two short sentences that swap 2 nearby words around a shared frame: "good team, bad plan. good plans, bad teams." */
|
|
31
|
+
function chiasmus(sentences) {
|
|
32
|
+
for (let i = 0; i + 1 < sentences.length; i++) {
|
|
33
|
+
const short = (s) => s.split(/\s+/).length <= 18;
|
|
34
|
+
if (!short(sentences[i]) || !short(sentences[i + 1])) continue;
|
|
35
|
+
const a = content(sentences[i]),
|
|
36
|
+
b = content(sentences[i + 1]);
|
|
37
|
+
const once = (arr, w) => arr.indexOf(w) === arr.lastIndexOf(w);
|
|
38
|
+
for (let x = 0; x < a.length; x++)
|
|
39
|
+
for (let y = x + 1; y < a.length; y++) {
|
|
40
|
+
const p = a[x],
|
|
41
|
+
q = a[y];
|
|
42
|
+
if (p === q || y - x > 2) continue;
|
|
43
|
+
const bp = b.indexOf(p),
|
|
44
|
+
bq = b.indexOf(q);
|
|
45
|
+
if (
|
|
46
|
+
bp !== -1 &&
|
|
47
|
+
bq !== -1 &&
|
|
48
|
+
bq < bp &&
|
|
49
|
+
bp - bq <= 2 &&
|
|
50
|
+
once(a, p) &&
|
|
51
|
+
once(a, q) &&
|
|
52
|
+
once(b, p) &&
|
|
53
|
+
once(b, q)
|
|
54
|
+
) {
|
|
55
|
+
const kept = a.filter((w) => w !== p && w !== q && b.includes(w));
|
|
56
|
+
const ordered = kept.every((w, k) => k === 0 || b.indexOf(kept[k - 1]) < b.indexOf(w));
|
|
57
|
+
if (kept.length >= 1 && ordered) return `${sentences[i]} ${sentences[i + 1]}`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Which library shape the opening runs on, or why it runs on none. */
|
|
65
|
+
export function matchHook(first, second = "") {
|
|
66
|
+
const hook = first.trim();
|
|
67
|
+
if (bannedHook.test(hook)) return { shape: 30, banned: true };
|
|
68
|
+
const fault = openerFault(hook);
|
|
69
|
+
if (fault === "opaque" || fault === "scene") return { vague: true };
|
|
70
|
+
for (const s of hookShapes) {
|
|
71
|
+
const target = s.second ? second.trim() : hook;
|
|
72
|
+
if (s.maxWords && words(target) > s.maxWords) continue;
|
|
73
|
+
if (s.test.test(target)) return { shape: s.n, family: s.family, alone: s.alone === false };
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The hook and the skeleton of a Short, per the shorts-script skill. */
|
|
79
|
+
export function auditShorts(text) {
|
|
80
|
+
const findings = [];
|
|
81
|
+
const sentences = (spoken(text).match(SENTENCE) ?? []).map((s) => s.trim()).filter(Boolean);
|
|
82
|
+
if (sentences.length === 0) return { hook: null, findings };
|
|
83
|
+
const [first, second] = sentences;
|
|
84
|
+
const push = (id, detail) => findings.push({ kind: "shorts", id, detail });
|
|
85
|
+
|
|
86
|
+
const hook = matchHook(first, second);
|
|
87
|
+
if (!hook) push("hook-shape", `"${first}" matches no shape in the hook library`);
|
|
88
|
+
else if (hook.vague) push("hook-vague", `"${first}" hides the plot or opens on a time and place`);
|
|
89
|
+
else if (hook.banned) push("hook-banned", `"${first}" is shape 30, banned`);
|
|
90
|
+
else if (hook.alone)
|
|
91
|
+
push("hook-alone", `"${first}" is shape 16, which qualifies the audience and opens no gap`);
|
|
92
|
+
if (words(first) > HOOK_MAX_WORDS)
|
|
93
|
+
push("hook-length", `the hook is ${words(first)} words, the window takes ${HOOK_MAX_WORDS}`);
|
|
94
|
+
|
|
95
|
+
const clefts = sentences.filter(
|
|
96
|
+
(s) => /^What [^?]{3,60}?\b(?:is|are|was|were)\s/.test(s) && !s.endsWith("?"),
|
|
97
|
+
);
|
|
98
|
+
if (clefts.length > 1)
|
|
99
|
+
push("what-cleft", `${clefts.length} "What X is Y" sentences; 1 per script`);
|
|
100
|
+
const mirror = chiasmus(sentences);
|
|
101
|
+
if (mirror) push("chiasmus", `mirrored close: "${mirror}"`);
|
|
102
|
+
|
|
103
|
+
const counted = (sentences.slice(1).join(" ").match(COUNT) ?? []).length;
|
|
104
|
+
if (counted < 2)
|
|
105
|
+
push("uncounted-steps", `${counted} count markers in the body; name the steps out loud`);
|
|
106
|
+
|
|
107
|
+
const close = sentences.slice(-2).join(" ");
|
|
108
|
+
if (!REPLY_CTA.test(close)) push("cta-reply", "the close carries no reply-trigger CTA");
|
|
109
|
+
|
|
110
|
+
return { hook, findings };
|
|
111
|
+
}
|
package/story.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const HOOK_MAX_WORDS: number;
|
|
2
|
+
export type OpenerFault = "greeting" | "opaque" | "scene";
|
|
3
|
+
export declare function openerFault(text: string): OpenerFault | null;
|
|
4
|
+
export declare function wordCount(text: string): number;
|
|
5
|
+
export declare function firstSentence(text: string): string;
|
|
6
|
+
export declare const HEDGES: string[];
|
|
7
|
+
export declare function hedgeScan(text: string): {
|
|
8
|
+
pass: boolean;
|
|
9
|
+
dense: boolean;
|
|
10
|
+
total: number;
|
|
11
|
+
found: string[];
|
|
12
|
+
};
|
|
13
|
+
export declare const FLAT_RUN_WORDS: number;
|
|
14
|
+
export declare const FLAT_RUN_MIN_WORDS: number;
|
|
15
|
+
export declare const WORDS_PER_SECOND: number;
|
|
16
|
+
export declare function flatRun(text: string): { words: number; seconds: number; flagged: boolean };
|
package/story.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The story checks that need no model: how a script or post opens, hedges and turns.
|
|
3
|
+
* CutKit and the Shorts gate both read these. Fix copy stays in each app.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The window a hook gets: past this the first sentence is still talking when the swipe comes. */
|
|
7
|
+
export const HOOK_MAX_WORDS = 15;
|
|
8
|
+
|
|
9
|
+
// WHY: every short-form source we read names a greeting opener as the top failure.
|
|
10
|
+
const GREETING =
|
|
11
|
+
/^(hey|hi|hello|welcome|so basically|in this video|today we're going to|today i'm going to|let me tell you)\b/iu;
|
|
12
|
+
// Kallaway's classroom: hiding the plot loses the swipe.
|
|
13
|
+
const OPAQUE =
|
|
14
|
+
/^(?:wait (?:till|until) you see|you(?:['’]re| are) (?:never |not )?(?:going to|gonna) believe)/iu;
|
|
15
|
+
// Kallaway's W order: what and why first, who and how next, when and where last. A time and
|
|
16
|
+
// place in the first words is the icing served before the cake.
|
|
17
|
+
const SCENE =
|
|
18
|
+
/^(?:(?:last|this) (?:week|month|year|summer|winter),? i was\b|it was an? (?:[\w,]+ ){0,3}(?:night|day|morning|evening)\b)/iu;
|
|
19
|
+
|
|
20
|
+
/** The rule an opening breaks: a greeting, a hidden point or a scene set first. Null when clean. */
|
|
21
|
+
export function openerFault(text) {
|
|
22
|
+
const opening = text.trim();
|
|
23
|
+
if (GREETING.test(opening)) return "greeting";
|
|
24
|
+
if (OPAQUE.test(opening)) return "opaque";
|
|
25
|
+
if (SCENE.test(opening)) return "scene";
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const wordCount = (text) => text.split(/\s+/u).filter(Boolean).length;
|
|
30
|
+
|
|
31
|
+
export function firstSentence(text) {
|
|
32
|
+
return text.trim().split(/(?<=[.!?])(?:\s|$)|\n+/u, 1)[0] ?? "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// WHY no "if": 6 of the 10 viral scripts in CutKit's REAL_SHORTS say "if you", 2 in their first line.
|
|
36
|
+
export const HEDGES = [
|
|
37
|
+
"maybe",
|
|
38
|
+
"might",
|
|
39
|
+
"could",
|
|
40
|
+
"probably",
|
|
41
|
+
"perhaps",
|
|
42
|
+
"possibly",
|
|
43
|
+
"hopefully",
|
|
44
|
+
"kind of",
|
|
45
|
+
"sort of",
|
|
46
|
+
"i think",
|
|
47
|
+
"i guess",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const hedgePattern = (hedge, flags) => new RegExp(`\\b${hedge.replaceAll(" ", "\\s+")}\\b`, flags);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Honest uncertainty is fine, so only a hedge in the first 2 sentences, or more than 1 per
|
|
54
|
+
* 100 words, fails. `found` names the hedges to cut.
|
|
55
|
+
*/
|
|
56
|
+
export function hedgeScan(text) {
|
|
57
|
+
const words = wordCount(text);
|
|
58
|
+
const early = text
|
|
59
|
+
.trim()
|
|
60
|
+
.split(/(?<=[.!?])(?:\s|$)/u)
|
|
61
|
+
.slice(0, 2)
|
|
62
|
+
.join(" ");
|
|
63
|
+
const earlyFound = HEDGES.filter((hedge) => hedgePattern(hedge, "iu").test(early));
|
|
64
|
+
const total = HEDGES.reduce(
|
|
65
|
+
(sum, hedge) => sum + (text.match(hedgePattern(hedge, "igu")) ?? []).length,
|
|
66
|
+
0,
|
|
67
|
+
);
|
|
68
|
+
const dense = total > Math.max(1, words / 100);
|
|
69
|
+
const found = earlyFound.length
|
|
70
|
+
? earlyFound
|
|
71
|
+
: dense
|
|
72
|
+
? HEDGES.filter((hedge) => hedgePattern(hedge, "iu").test(text))
|
|
73
|
+
: [];
|
|
74
|
+
return { pass: earlyFound.length === 0 && !dense, dense, total, found };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The words that turn a story: a contrast, or a list stepping on.
|
|
78
|
+
const TURNS = new Set([
|
|
79
|
+
"but",
|
|
80
|
+
"actually",
|
|
81
|
+
"except",
|
|
82
|
+
"however",
|
|
83
|
+
"yet",
|
|
84
|
+
"though",
|
|
85
|
+
"instead",
|
|
86
|
+
"until",
|
|
87
|
+
"whereas",
|
|
88
|
+
// WHY: a list moves on at each step, and 5 of 10 viral scripts in REAL_SHORTS turn only that way.
|
|
89
|
+
"next",
|
|
90
|
+
"first",
|
|
91
|
+
"second",
|
|
92
|
+
"third",
|
|
93
|
+
"finally",
|
|
94
|
+
]);
|
|
95
|
+
/** A stretch longer than this with no turn in it drifts. South Park's rule: but or therefore, never and then. */
|
|
96
|
+
export const FLAT_RUN_WORDS = 70;
|
|
97
|
+
/** Below this length a whole script is one breath, so a flat run says nothing. */
|
|
98
|
+
export const FLAT_RUN_MIN_WORDS = 120;
|
|
99
|
+
export const WORDS_PER_SECOND = 2.5;
|
|
100
|
+
|
|
101
|
+
/** The longest run of words with no turn, a question or a numbered step in it. */
|
|
102
|
+
export function flatRun(text) {
|
|
103
|
+
const raw = text.split(/\s+/u).filter(Boolean);
|
|
104
|
+
const words = raw.map((word) => word.toLowerCase().replace(/[^a-z]/gu, ""));
|
|
105
|
+
let run = 0;
|
|
106
|
+
let worst = 0;
|
|
107
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
108
|
+
// WHY the pair: "turns out" turns on two words, so one token never matches it.
|
|
109
|
+
if (words[index] === "turns" && words[index + 1] === "out") {
|
|
110
|
+
run = 0;
|
|
111
|
+
index += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (
|
|
115
|
+
TURNS.has(words[index] ?? "") ||
|
|
116
|
+
raw[index]?.includes("?") ||
|
|
117
|
+
/^\d+[.)]$/u.test(raw[index] ?? "")
|
|
118
|
+
)
|
|
119
|
+
run = 0;
|
|
120
|
+
else {
|
|
121
|
+
run += 1;
|
|
122
|
+
worst = Math.max(worst, run);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const flagged = raw.length > FLAT_RUN_MIN_WORDS && worst > FLAT_RUN_WORDS;
|
|
126
|
+
return { words: worst, seconds: Math.round(worst / WORDS_PER_SECOND), flagged };
|
|
127
|
+
}
|