@mcptoolshop/promo-kit 0.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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/bin/promo-kit.mjs +150 -0
- package/index.mjs +9 -0
- package/kit.config.example.json +19 -0
- package/package.json +45 -0
- package/scripts/apply-control-patch.mjs +205 -0
- package/scripts/apply-submission-status.mjs +225 -0
- package/scripts/gen-baseline.mjs +402 -0
- package/scripts/gen-decision-drift.mjs +253 -0
- package/scripts/gen-experiment-decisions.mjs +282 -0
- package/scripts/gen-feedback-summary.mjs +278 -0
- package/scripts/gen-promo-decisions.mjs +507 -0
- package/scripts/gen-queue-health.mjs +223 -0
- package/scripts/gen-recommendation-patch.mjs +352 -0
- package/scripts/gen-recommendations.mjs +409 -0
- package/scripts/gen-telemetry-aggregate.mjs +266 -0
- package/scripts/gen-trust-receipt.mjs +184 -0
- package/scripts/kit-bootstrap.mjs +246 -0
- package/scripts/kit-migrate.mjs +111 -0
- package/scripts/kit-selftest.mjs +207 -0
- package/scripts/lib/config.mjs +124 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Feedback Summary Generator
|
|
5
|
+
*
|
|
6
|
+
* Reads feedback.jsonl (append-only log), computes per-channel and per-slug
|
|
7
|
+
* statistics, generates recommendations, writes feedback-summary.json.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node scripts/gen-feedback-summary.mjs [--dry-run]
|
|
11
|
+
*
|
|
12
|
+
* Reads:
|
|
13
|
+
* site/src/data/feedback.jsonl
|
|
14
|
+
*
|
|
15
|
+
* Writes:
|
|
16
|
+
* site/src/data/feedback-summary.json
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
20
|
+
import { resolve, join } from "node:path";
|
|
21
|
+
import { getConfig, getRoot } from "./lib/config.mjs";
|
|
22
|
+
|
|
23
|
+
const ROOT = getRoot();
|
|
24
|
+
const config = getConfig();
|
|
25
|
+
|
|
26
|
+
// ── Helpers ─────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function safeParseJson(filePath, fallback = null) {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
31
|
+
} catch {
|
|
32
|
+
return fallback;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const VALID_OUTCOMES = new Set(["sent", "opened", "replied", "ignored", "bounced"]);
|
|
37
|
+
|
|
38
|
+
function makeOutcomeCounter() {
|
|
39
|
+
return { sent: 0, opened: 0, replied: 0, ignored: 0, bounced: 0 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Core ────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse feedback lines from JSONL content.
|
|
46
|
+
* Skips empty lines and malformed JSON. Trims whitespace.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} content - raw JSONL text
|
|
49
|
+
* @returns {Array<{ date: string, slug: string, channel: string, outcome: string, link?: string, notes?: string }>}
|
|
50
|
+
*/
|
|
51
|
+
export function parseFeedbackLines(content) {
|
|
52
|
+
const lines = content.split("\n");
|
|
53
|
+
const entries = [];
|
|
54
|
+
|
|
55
|
+
for (const raw of lines) {
|
|
56
|
+
const line = raw.trim();
|
|
57
|
+
if (!line) continue;
|
|
58
|
+
|
|
59
|
+
let obj;
|
|
60
|
+
try {
|
|
61
|
+
obj = JSON.parse(line);
|
|
62
|
+
} catch {
|
|
63
|
+
console.warn(`Skipping malformed JSONL line: ${line.slice(0, 80)}`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!obj.date || !obj.slug || !obj.channel || !obj.outcome) {
|
|
68
|
+
console.warn(`Skipping entry missing required fields: ${JSON.stringify(obj).slice(0, 80)}`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!VALID_OUTCOMES.has(obj.outcome)) {
|
|
73
|
+
console.warn(`Skipping entry with invalid outcome "${obj.outcome}": ${JSON.stringify(obj).slice(0, 80)}`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
entries.push(obj);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return entries;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Compute summary statistics from parsed feedback entries.
|
|
85
|
+
*
|
|
86
|
+
* @param {Array<object>} entries
|
|
87
|
+
* @returns {{
|
|
88
|
+
* totalEntries: number,
|
|
89
|
+
* perChannel: Record<string, { sent: number, opened: number, replied: number, ignored: number, bounced: number }>,
|
|
90
|
+
* perSlug: Record<string, { sent: number, opened: number, replied: number, ignored: number, bounced: number }>,
|
|
91
|
+
* recommendations: string[],
|
|
92
|
+
* bestPerformingChannel: string|null,
|
|
93
|
+
* replyRate: number
|
|
94
|
+
* }}
|
|
95
|
+
*/
|
|
96
|
+
export function computeFeedbackSummary(entries) {
|
|
97
|
+
const perChannel = {};
|
|
98
|
+
const perSlug = {};
|
|
99
|
+
const perExperiment = {};
|
|
100
|
+
let totalReplied = 0;
|
|
101
|
+
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
const { channel, slug, outcome } = entry;
|
|
104
|
+
|
|
105
|
+
if (!perChannel[channel]) perChannel[channel] = makeOutcomeCounter();
|
|
106
|
+
perChannel[channel][outcome]++;
|
|
107
|
+
|
|
108
|
+
if (!perSlug[slug]) perSlug[slug] = makeOutcomeCounter();
|
|
109
|
+
perSlug[slug][outcome]++;
|
|
110
|
+
|
|
111
|
+
if (outcome === "replied") totalReplied++;
|
|
112
|
+
|
|
113
|
+
// Experiment variant tracking
|
|
114
|
+
if (entry.experimentId && entry.variantKey) {
|
|
115
|
+
if (!perExperiment[entry.experimentId]) perExperiment[entry.experimentId] = {};
|
|
116
|
+
if (!perExperiment[entry.experimentId][entry.variantKey]) {
|
|
117
|
+
perExperiment[entry.experimentId][entry.variantKey] = makeOutcomeCounter();
|
|
118
|
+
}
|
|
119
|
+
perExperiment[entry.experimentId][entry.variantKey][outcome]++;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const totalEntries = entries.length;
|
|
124
|
+
const replyRate = totalEntries > 0 ? totalReplied / totalEntries : 0;
|
|
125
|
+
|
|
126
|
+
// Find best performing channel by reply/sent ratio
|
|
127
|
+
let bestPerformingChannel = null;
|
|
128
|
+
let bestRatio = -1;
|
|
129
|
+
|
|
130
|
+
for (const [channel, counts] of Object.entries(perChannel)) {
|
|
131
|
+
if (counts.sent > 0) {
|
|
132
|
+
const ratio = counts.replied / counts.sent;
|
|
133
|
+
if (ratio > bestRatio) {
|
|
134
|
+
bestRatio = ratio;
|
|
135
|
+
bestPerformingChannel = channel;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Generate recommendations
|
|
141
|
+
const recommendations = [];
|
|
142
|
+
|
|
143
|
+
for (const [channel, counts] of Object.entries(perChannel)) {
|
|
144
|
+
const total = counts.sent + counts.opened + counts.replied + counts.ignored + counts.bounced;
|
|
145
|
+
if (total > 0) {
|
|
146
|
+
if (counts.replied / total > 0.5) {
|
|
147
|
+
recommendations.push(`${channel} performs well -- prioritize for future runs`);
|
|
148
|
+
}
|
|
149
|
+
if (counts.ignored / total > 0.7) {
|
|
150
|
+
recommendations.push(`${channel} underperforming -- review approach or drop`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const [slug, counts] of Object.entries(perSlug)) {
|
|
156
|
+
const total = counts.sent + counts.opened + counts.replied + counts.ignored + counts.bounced;
|
|
157
|
+
if (total > 0 && counts.replied === 0) {
|
|
158
|
+
recommendations.push(`${slug} has no engagement -- review messaging`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Experiment-specific recommendations
|
|
163
|
+
for (const [expId, arms] of Object.entries(perExperiment)) {
|
|
164
|
+
const controlCounts = arms["control"];
|
|
165
|
+
const variantKeys = Object.keys(arms).filter((k) => k !== "control");
|
|
166
|
+
|
|
167
|
+
for (const vk of variantKeys) {
|
|
168
|
+
const variantCounts = arms[vk];
|
|
169
|
+
const controlTotal = controlCounts
|
|
170
|
+
? controlCounts.sent + controlCounts.opened + controlCounts.replied + controlCounts.ignored + controlCounts.bounced
|
|
171
|
+
: 0;
|
|
172
|
+
const variantTotal = variantCounts.sent + variantCounts.opened + variantCounts.replied + variantCounts.ignored + variantCounts.bounced;
|
|
173
|
+
|
|
174
|
+
// Insufficient data check
|
|
175
|
+
if (controlTotal < 5 || variantTotal < 5) {
|
|
176
|
+
recommendations.push(`${expId}: insufficient data (${controlTotal} control, ${variantTotal} variant entries)`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const controlReplyRate = controlCounts.replied / controlTotal;
|
|
181
|
+
const variantReplyRate = variantCounts.replied / variantTotal;
|
|
182
|
+
|
|
183
|
+
if (controlReplyRate > 0 && variantReplyRate / controlReplyRate > 2) {
|
|
184
|
+
const ratio = Math.round(variantReplyRate / controlReplyRate * 10) / 10;
|
|
185
|
+
recommendations.push(`${expId}: ${vk} outperforms control (${ratio}x reply rate)`);
|
|
186
|
+
} else if (variantReplyRate > 0 && controlReplyRate / variantReplyRate > 2) {
|
|
187
|
+
recommendations.push(`${expId}: control outperforms ${vk} -- consider concluding`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
totalEntries,
|
|
194
|
+
perChannel,
|
|
195
|
+
perSlug,
|
|
196
|
+
recommendations,
|
|
197
|
+
bestPerformingChannel,
|
|
198
|
+
replyRate,
|
|
199
|
+
perExperiment,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Full pipeline: read feedback.jsonl, compute summary, write feedback-summary.json.
|
|
205
|
+
*
|
|
206
|
+
* @param {{ dataDir?: string, dryRun?: boolean }} opts
|
|
207
|
+
* @returns {object} Summary object
|
|
208
|
+
*/
|
|
209
|
+
export function generateFeedbackSummary(opts = {}) {
|
|
210
|
+
const dataDir = opts.dataDir || join(ROOT, config.paths.dataDir);
|
|
211
|
+
const dryRun = opts.dryRun || false;
|
|
212
|
+
|
|
213
|
+
const feedbackPath = join(dataDir, "feedback.jsonl");
|
|
214
|
+
const summaryPath = join(dataDir, "feedback-summary.json");
|
|
215
|
+
|
|
216
|
+
// Zero-state if file missing or empty
|
|
217
|
+
if (!existsSync(feedbackPath)) {
|
|
218
|
+
const zeroState = {
|
|
219
|
+
generatedAt: new Date().toISOString(),
|
|
220
|
+
totalEntries: 0,
|
|
221
|
+
perChannel: {},
|
|
222
|
+
perSlug: {},
|
|
223
|
+
recommendations: [],
|
|
224
|
+
bestPerformingChannel: null,
|
|
225
|
+
replyRate: 0,
|
|
226
|
+
perExperiment: {},
|
|
227
|
+
};
|
|
228
|
+
if (!dryRun) {
|
|
229
|
+
writeFileSync(summaryPath, JSON.stringify(zeroState, null, 2) + "\n", "utf8");
|
|
230
|
+
}
|
|
231
|
+
return zeroState;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const content = readFileSync(feedbackPath, "utf8");
|
|
235
|
+
if (!content.trim()) {
|
|
236
|
+
const zeroState = {
|
|
237
|
+
generatedAt: new Date().toISOString(),
|
|
238
|
+
totalEntries: 0,
|
|
239
|
+
perChannel: {},
|
|
240
|
+
perSlug: {},
|
|
241
|
+
recommendations: [],
|
|
242
|
+
bestPerformingChannel: null,
|
|
243
|
+
replyRate: 0,
|
|
244
|
+
perExperiment: {},
|
|
245
|
+
};
|
|
246
|
+
if (!dryRun) {
|
|
247
|
+
writeFileSync(summaryPath, JSON.stringify(zeroState, null, 2) + "\n", "utf8");
|
|
248
|
+
}
|
|
249
|
+
return zeroState;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const entries = parseFeedbackLines(content);
|
|
253
|
+
const summary = computeFeedbackSummary(entries);
|
|
254
|
+
|
|
255
|
+
const output = {
|
|
256
|
+
generatedAt: new Date().toISOString(),
|
|
257
|
+
...summary,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
if (!dryRun) {
|
|
261
|
+
writeFileSync(summaryPath, JSON.stringify(output, null, 2) + "\n", "utf8");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return output;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Main ────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
const isMain = process.argv[1] && resolve(process.argv[1]).endsWith("gen-feedback-summary.mjs");
|
|
270
|
+
|
|
271
|
+
if (isMain) {
|
|
272
|
+
const dryRun = process.argv.includes("--dry-run");
|
|
273
|
+
console.log("Generating feedback summary...");
|
|
274
|
+
if (dryRun) console.log(" Mode: DRY RUN");
|
|
275
|
+
const summary = generateFeedbackSummary({ dryRun });
|
|
276
|
+
console.log(` Entries: ${summary.totalEntries}`);
|
|
277
|
+
console.log(` Recommendations: ${summary.recommendations.length}`);
|
|
278
|
+
}
|