dopple-ai 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/LICENSE +21 -0
- package/README.md +412 -0
- package/dist/chunk-FA7ZWJOA.js +365 -0
- package/dist/chunk-FA7ZWJOA.js.map +1 -0
- package/dist/chunk-KEWXLWAO.js +247 -0
- package/dist/chunk-KEWXLWAO.js.map +1 -0
- package/dist/chunk-PGZVVIL6.js +249 -0
- package/dist/chunk-PGZVVIL6.js.map +1 -0
- package/dist/chunk-QR5GEK27.js +302 -0
- package/dist/chunk-QR5GEK27.js.map +1 -0
- package/dist/chunk-RXD7VZ7P.js +193 -0
- package/dist/chunk-RXD7VZ7P.js.map +1 -0
- package/dist/chunk-XETRT4X6.js +300 -0
- package/dist/chunk-XETRT4X6.js.map +1 -0
- package/dist/cli.js +4603 -0
- package/dist/cli.js.map +1 -0
- package/dist/figma-N554M5KW.js +107 -0
- package/dist/figma-N554M5KW.js.map +1 -0
- package/dist/graph-P5GYGDF7.js +9 -0
- package/dist/graph-P5GYGDF7.js.map +1 -0
- package/dist/index.d.ts +2056 -0
- package/dist/index.js +4543 -0
- package/dist/index.js.map +1 -0
- package/dist/ocean-BIG4XCMX.js +17 -0
- package/dist/ocean-BIG4XCMX.js.map +1 -0
- package/dist/ocean-SIPS4NY7.js +18 -0
- package/dist/ocean-SIPS4NY7.js.map +1 -0
- package/dist/provider-7PWDG74H.js +16 -0
- package/dist/provider-7PWDG74H.js.map +1 -0
- package/dist/query-GEL76KSF.js +16 -0
- package/dist/query-GEL76KSF.js.map +1 -0
- package/dist/query-ZZJQOTD6.js +15 -0
- package/dist/query-ZZJQOTD6.js.map +1 -0
- package/dist/review-DNYYHU2M.js +77 -0
- package/dist/review-DNYYHU2M.js.map +1 -0
- package/package.json +69 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,4603 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
createProvider,
|
|
4
|
+
listProviders
|
|
5
|
+
} from "./chunk-RXD7VZ7P.js";
|
|
6
|
+
import {
|
|
7
|
+
TRAIT_PROFILES,
|
|
8
|
+
compileBehavioralProfile,
|
|
9
|
+
traitDistance
|
|
10
|
+
} from "./chunk-QR5GEK27.js";
|
|
11
|
+
import {
|
|
12
|
+
DoppleGraph
|
|
13
|
+
} from "./chunk-FA7ZWJOA.js";
|
|
14
|
+
import "./chunk-PGZVVIL6.js";
|
|
15
|
+
|
|
16
|
+
// src/cli.ts
|
|
17
|
+
import { Command } from "commander";
|
|
18
|
+
import { readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
|
|
19
|
+
|
|
20
|
+
// src/traits/compiler.ts
|
|
21
|
+
function describeTraitLevel(score) {
|
|
22
|
+
if (score >= 0.85) return "very high";
|
|
23
|
+
if (score >= 0.65) return "high";
|
|
24
|
+
if (score >= 0.45) return "moderate";
|
|
25
|
+
if (score >= 0.25) return "low";
|
|
26
|
+
return "very low";
|
|
27
|
+
}
|
|
28
|
+
function compilePersonaPrompt(persona) {
|
|
29
|
+
const sections = [];
|
|
30
|
+
sections.push(`You are ${persona.name}.`);
|
|
31
|
+
const demo = persona.demographics;
|
|
32
|
+
const demoLines = [];
|
|
33
|
+
if (demo.age) demoLines.push(`${demo.age} years old`);
|
|
34
|
+
if (demo.gender) demoLines.push(demo.gender);
|
|
35
|
+
if (demo.location) demoLines.push(`lives in ${demo.location}`);
|
|
36
|
+
if (demo.occupation) demoLines.push(`works as ${demo.occupation}`);
|
|
37
|
+
if (demo.income) demoLines.push(`income: ${demo.income}`);
|
|
38
|
+
if (demo.education) demoLines.push(`education: ${demo.education}`);
|
|
39
|
+
if (demoLines.length > 0) {
|
|
40
|
+
sections.push(`Demographics: ${demoLines.join(", ")}.`);
|
|
41
|
+
}
|
|
42
|
+
const traitNames = [
|
|
43
|
+
"openness",
|
|
44
|
+
"conscientiousness",
|
|
45
|
+
"extraversion",
|
|
46
|
+
"agreeableness",
|
|
47
|
+
"neuroticism"
|
|
48
|
+
];
|
|
49
|
+
const traitDesc = [];
|
|
50
|
+
for (const t of traitNames) {
|
|
51
|
+
const score = persona.traits[t];
|
|
52
|
+
const level = describeTraitLevel(score);
|
|
53
|
+
const profile = TRAIT_PROFILES[t];
|
|
54
|
+
const behaviors = score >= 0.5 ? profile.highBehaviors : profile.lowBehaviors;
|
|
55
|
+
const topBehaviors = behaviors.slice(0, 2).join("; ");
|
|
56
|
+
traitDesc.push(
|
|
57
|
+
`- ${t} (${level}, ${score.toFixed(2)}): ${topBehaviors}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
sections.push(`Personality (Big Five / OCEAN):
|
|
61
|
+
${traitDesc.join("\n")}`);
|
|
62
|
+
const bp = persona.behavioralProfile;
|
|
63
|
+
sections.push(
|
|
64
|
+
`Behavioral tendencies:
|
|
65
|
+
${bp.rules.map((r) => `- ${r}`).join("\n")}`
|
|
66
|
+
);
|
|
67
|
+
if (persona.context.product) {
|
|
68
|
+
sections.push(
|
|
69
|
+
`You are being asked about: ${persona.context.product}. Answer from your genuine perspective as a user/potential user of this product.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (persona.context.behaviorData && persona.context.behaviorData.length > 0) {
|
|
73
|
+
sections.push(
|
|
74
|
+
`Real behavioral data about users like you:
|
|
75
|
+
${persona.context.behaviorData.map((d) => `- ${d}`).join("\n")}`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (persona.context.supportData && persona.context.supportData.length > 0) {
|
|
79
|
+
sections.push(
|
|
80
|
+
`Real support conversations from users like you:
|
|
81
|
+
${persona.context.supportData.map((d) => `- ${d}`).join("\n")}`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (persona.context.paymentData && persona.context.paymentData.length > 0) {
|
|
85
|
+
sections.push(
|
|
86
|
+
`Real payment behavior from users like you:
|
|
87
|
+
${persona.context.paymentData.map((d) => `- ${d}`).join("\n")}`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (persona.context.freeformContext && persona.context.freeformContext.length > 0) {
|
|
91
|
+
sections.push(
|
|
92
|
+
`Additional context:
|
|
93
|
+
${persona.context.freeformContext.map((d) => `- ${d}`).join("\n")}`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
sections.push(
|
|
97
|
+
[
|
|
98
|
+
"INSTRUCTIONS:",
|
|
99
|
+
"- Respond authentically as this person. Your personality traits should shape HOW you respond.",
|
|
100
|
+
"- Be specific and concrete \u2014 mention real details, not generic platitudes.",
|
|
101
|
+
"- If you would not care about something, say so directly.",
|
|
102
|
+
"- If you would be excited about something, show genuine enthusiasm.",
|
|
103
|
+
"- If you would be confused or skeptical, express that.",
|
|
104
|
+
"- Do NOT break character. Do NOT mention that you are an AI or a persona.",
|
|
105
|
+
"- Keep responses concise (2-4 sentences) unless asked for detail.",
|
|
106
|
+
`- Your confidence level is ${persona.confidence.toUpperCase()}. ${persona.confidence === "low" ? "Your responses are based on general knowledge, not real user data." : persona.confidence === "medium" ? "Your responses are partially grounded in real behavioral data." : "Your responses are grounded in real user data from multiple sources."}`
|
|
107
|
+
].join("\n")
|
|
108
|
+
);
|
|
109
|
+
return sections.join("\n\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/memory/memory.ts
|
|
113
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
114
|
+
import { join } from "path";
|
|
115
|
+
var PersonaMemory = class {
|
|
116
|
+
personaId;
|
|
117
|
+
baseDir;
|
|
118
|
+
facts = [];
|
|
119
|
+
episodes = [];
|
|
120
|
+
stances = [];
|
|
121
|
+
loaded = false;
|
|
122
|
+
constructor(personaId, storageDir) {
|
|
123
|
+
this.personaId = personaId;
|
|
124
|
+
this.baseDir = join(
|
|
125
|
+
storageDir ?? join(process.cwd(), ".dopple"),
|
|
126
|
+
"memory",
|
|
127
|
+
personaId
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
// -------------------------------------------------------------------------
|
|
131
|
+
// Fact Store
|
|
132
|
+
// -------------------------------------------------------------------------
|
|
133
|
+
/**
|
|
134
|
+
* Add a ground truth fact from a data source.
|
|
135
|
+
* Facts are immutable — they represent real data, not opinions.
|
|
136
|
+
*/
|
|
137
|
+
addFact(source, content, salience = 0.5) {
|
|
138
|
+
this.facts.push({
|
|
139
|
+
source,
|
|
140
|
+
content,
|
|
141
|
+
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
142
|
+
salience: Math.max(0, Math.min(1, salience))
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Add multiple facts from adapter data.
|
|
147
|
+
*/
|
|
148
|
+
addFacts(facts) {
|
|
149
|
+
for (const f of facts) {
|
|
150
|
+
this.addFact(f.source, f.content, f.salience);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Get facts above a salience threshold (default: include all).
|
|
155
|
+
*/
|
|
156
|
+
getFacts(minSalience = 0) {
|
|
157
|
+
return this.facts.filter((f) => f.salience >= minSalience).sort((a, b) => b.salience - a.salience);
|
|
158
|
+
}
|
|
159
|
+
// -------------------------------------------------------------------------
|
|
160
|
+
// Episodic Memory
|
|
161
|
+
// -------------------------------------------------------------------------
|
|
162
|
+
/**
|
|
163
|
+
* Record a conversation exchange.
|
|
164
|
+
*/
|
|
165
|
+
addEpisode(question, answer, topics = []) {
|
|
166
|
+
this.episodes.push({
|
|
167
|
+
question,
|
|
168
|
+
answer,
|
|
169
|
+
topics,
|
|
170
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Retrieve relevant past episodes.
|
|
175
|
+
*
|
|
176
|
+
* Retrieval strategy: topic match > recency.
|
|
177
|
+
* Returns the most relevant episodes up to `limit`.
|
|
178
|
+
*/
|
|
179
|
+
recall(query, limit = 5) {
|
|
180
|
+
if (this.episodes.length === 0) return [];
|
|
181
|
+
const queryWords = new Set(
|
|
182
|
+
query.toLowerCase().split(/\W+/).filter((w) => w.length > 3)
|
|
183
|
+
);
|
|
184
|
+
const scored = this.episodes.map((ep) => {
|
|
185
|
+
const epWords = /* @__PURE__ */ new Set([
|
|
186
|
+
...ep.question.toLowerCase().split(/\W+/).filter((w) => w.length > 3),
|
|
187
|
+
...ep.topics.map((t) => t.toLowerCase())
|
|
188
|
+
]);
|
|
189
|
+
const overlap = [...queryWords].filter((w) => epWords.has(w)).length;
|
|
190
|
+
const topicScore = queryWords.size > 0 ? overlap / queryWords.size : 0;
|
|
191
|
+
const ageMs = Date.now() - new Date(ep.timestamp).getTime();
|
|
192
|
+
const ageDays = ageMs / (1e3 * 60 * 60 * 24);
|
|
193
|
+
const recencyScore = Math.exp(-ageDays / 30);
|
|
194
|
+
return { episode: ep, score: topicScore * 0.7 + recencyScore * 0.3 };
|
|
195
|
+
});
|
|
196
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, limit).map((s) => s.episode);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Get all episodes (chronological).
|
|
200
|
+
*/
|
|
201
|
+
getAllEpisodes() {
|
|
202
|
+
return [...this.episodes];
|
|
203
|
+
}
|
|
204
|
+
// -------------------------------------------------------------------------
|
|
205
|
+
// Stance Tracker
|
|
206
|
+
// -------------------------------------------------------------------------
|
|
207
|
+
/**
|
|
208
|
+
* Update or create a stance on a topic.
|
|
209
|
+
*/
|
|
210
|
+
setStance(topic, position, reason) {
|
|
211
|
+
const existing = this.stances.find(
|
|
212
|
+
(s) => s.topic.toLowerCase() === topic.toLowerCase()
|
|
213
|
+
);
|
|
214
|
+
if (existing) {
|
|
215
|
+
existing.position = position;
|
|
216
|
+
existing.reason = reason;
|
|
217
|
+
existing.evidence++;
|
|
218
|
+
existing.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
|
|
219
|
+
} else {
|
|
220
|
+
this.stances.push({
|
|
221
|
+
topic,
|
|
222
|
+
position,
|
|
223
|
+
reason,
|
|
224
|
+
evidence: 1,
|
|
225
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Get all stances.
|
|
231
|
+
*/
|
|
232
|
+
getStances() {
|
|
233
|
+
return [...this.stances];
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Get stance on a specific topic (or null if no stance).
|
|
237
|
+
*/
|
|
238
|
+
getStance(topic) {
|
|
239
|
+
return this.stances.find(
|
|
240
|
+
(s) => s.topic.toLowerCase() === topic.toLowerCase()
|
|
241
|
+
) ?? null;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Auto-derive stances from episodic memory using an LLM.
|
|
245
|
+
*/
|
|
246
|
+
async deriveStances(llm) {
|
|
247
|
+
if (this.episodes.length < 3) return this.stances;
|
|
248
|
+
const episodeSummary = this.episodes.slice(-20).map((e) => `Q: ${e.question}
|
|
249
|
+
A: ${e.answer}`).join("\n\n");
|
|
250
|
+
const derived = await llm.generateJSON(
|
|
251
|
+
"You analyze conversation history to identify consistent positions/stances a person holds.",
|
|
252
|
+
`Based on these past conversations, identify the key topics this person has taken a clear position on.
|
|
253
|
+
|
|
254
|
+
${episodeSummary}
|
|
255
|
+
|
|
256
|
+
Return a JSON array of stances:
|
|
257
|
+
[{"topic": "<topic>", "position": "strongly_for" | "for" | "neutral" | "against" | "strongly_against", "reason": "<why they hold this position>"}]
|
|
258
|
+
|
|
259
|
+
Only include topics where the person has expressed a clear, consistent position. If they've been ambiguous or contradictory, mark as "neutral".`
|
|
260
|
+
);
|
|
261
|
+
for (const d of derived) {
|
|
262
|
+
this.setStance(d.topic, d.position, d.reason);
|
|
263
|
+
}
|
|
264
|
+
return this.stances;
|
|
265
|
+
}
|
|
266
|
+
// -------------------------------------------------------------------------
|
|
267
|
+
// Prompt compilation
|
|
268
|
+
// -------------------------------------------------------------------------
|
|
269
|
+
/**
|
|
270
|
+
* Compile memory into prompt segments to inject into the persona's system prompt.
|
|
271
|
+
* This is the main integration point — call this before every LLM query.
|
|
272
|
+
*/
|
|
273
|
+
compileForPrompt(query) {
|
|
274
|
+
const sections = [];
|
|
275
|
+
const importantFacts = this.getFacts(0.5);
|
|
276
|
+
if (importantFacts.length > 0) {
|
|
277
|
+
sections.push(
|
|
278
|
+
`GROUND TRUTH FACTS (you must not contradict these):
|
|
279
|
+
${importantFacts.map((f) => `- [${f.source}] ${f.content}`).join("\n")}`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const relevant = this.recall(query, 3);
|
|
283
|
+
if (relevant.length > 0) {
|
|
284
|
+
sections.push(
|
|
285
|
+
`YOUR PAST STATEMENTS (be consistent with these):
|
|
286
|
+
${relevant.map((e) => `- When asked "${e.question}", you said: "${e.answer}"`).join("\n")}`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (this.stances.length > 0) {
|
|
290
|
+
const stanceLines = this.stances.map((s) => {
|
|
291
|
+
const emoji = s.position === "strongly_for" || s.position === "for" ? "FOR" : s.position === "against" || s.position === "strongly_against" ? "AGAINST" : "NEUTRAL";
|
|
292
|
+
return `- ${s.topic}: ${emoji} \u2014 ${s.reason}`;
|
|
293
|
+
});
|
|
294
|
+
sections.push(`YOUR KNOWN POSITIONS:
|
|
295
|
+
${stanceLines.join("\n")}`);
|
|
296
|
+
}
|
|
297
|
+
return sections.length > 0 ? "\n\n" + sections.join("\n\n") : "";
|
|
298
|
+
}
|
|
299
|
+
// -------------------------------------------------------------------------
|
|
300
|
+
// Persistence (JSONL)
|
|
301
|
+
// -------------------------------------------------------------------------
|
|
302
|
+
async save() {
|
|
303
|
+
await mkdir(this.baseDir, { recursive: true });
|
|
304
|
+
await writeFile(
|
|
305
|
+
join(this.baseDir, "facts.jsonl"),
|
|
306
|
+
this.facts.map((f) => JSON.stringify(f)).join("\n") + "\n"
|
|
307
|
+
);
|
|
308
|
+
await writeFile(
|
|
309
|
+
join(this.baseDir, "episodes.jsonl"),
|
|
310
|
+
this.episodes.map((e) => JSON.stringify(e)).join("\n") + "\n"
|
|
311
|
+
);
|
|
312
|
+
await writeFile(
|
|
313
|
+
join(this.baseDir, "stances.jsonl"),
|
|
314
|
+
this.stances.map((s) => JSON.stringify(s)).join("\n") + "\n"
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
async load() {
|
|
318
|
+
if (this.loaded) return;
|
|
319
|
+
this.facts = await loadJsonl(join(this.baseDir, "facts.jsonl"));
|
|
320
|
+
this.episodes = await loadJsonl(
|
|
321
|
+
join(this.baseDir, "episodes.jsonl")
|
|
322
|
+
);
|
|
323
|
+
this.stances = await loadJsonl(
|
|
324
|
+
join(this.baseDir, "stances.jsonl")
|
|
325
|
+
);
|
|
326
|
+
this.loaded = true;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Stats about this persona's memory.
|
|
330
|
+
*/
|
|
331
|
+
stats() {
|
|
332
|
+
return {
|
|
333
|
+
facts: this.facts.length,
|
|
334
|
+
episodes: this.episodes.length,
|
|
335
|
+
stances: this.stances.length
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
async function loadJsonl(path) {
|
|
340
|
+
try {
|
|
341
|
+
const content = await readFile(path, "utf-8");
|
|
342
|
+
return content.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
343
|
+
} catch {
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/persona/persona.ts
|
|
349
|
+
var Persona = class {
|
|
350
|
+
definition;
|
|
351
|
+
memory;
|
|
352
|
+
llm;
|
|
353
|
+
systemPrompt;
|
|
354
|
+
history = [];
|
|
355
|
+
constructor(definition, llm, storageDir) {
|
|
356
|
+
this.definition = definition;
|
|
357
|
+
this.llm = llm;
|
|
358
|
+
this.systemPrompt = compilePersonaPrompt(definition);
|
|
359
|
+
this.memory = new PersonaMemory(definition.id, storageDir);
|
|
360
|
+
}
|
|
361
|
+
get id() {
|
|
362
|
+
return this.definition.id;
|
|
363
|
+
}
|
|
364
|
+
get name() {
|
|
365
|
+
return this.definition.name;
|
|
366
|
+
}
|
|
367
|
+
get confidence() {
|
|
368
|
+
return this.definition.confidence;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Load persisted memory from disk.
|
|
372
|
+
* Call this once after construction if you want cross-session memory.
|
|
373
|
+
*/
|
|
374
|
+
async loadMemory() {
|
|
375
|
+
await this.memory.load();
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Save memory to disk.
|
|
379
|
+
* Call this after a session to persist episodic memory and stances.
|
|
380
|
+
*/
|
|
381
|
+
async saveMemory() {
|
|
382
|
+
await this.memory.save();
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Ask the persona a question and get a sourced response.
|
|
386
|
+
* Memory (facts, past episodes, stances) is automatically injected.
|
|
387
|
+
*/
|
|
388
|
+
async ask(question) {
|
|
389
|
+
const memoryContext = this.memory.compileForPrompt(question);
|
|
390
|
+
const historyContext = this.history.length > 0 ? `
|
|
391
|
+
|
|
392
|
+
Previous conversation:
|
|
393
|
+
${this.history.slice(-6).map(
|
|
394
|
+
(h) => `${h.role === "user" ? "Interviewer" : this.definition.name}: ${h.content}`
|
|
395
|
+
).join("\n")}` : "";
|
|
396
|
+
const structured = await this.llm.generateJSON(
|
|
397
|
+
this.systemPrompt + memoryContext + historyContext + CITATION_INSTRUCTIONS,
|
|
398
|
+
`Question: "${question}"
|
|
399
|
+
|
|
400
|
+
Respond as JSON:
|
|
401
|
+
{
|
|
402
|
+
"response": "<your answer as this persona, 2-4 sentences>",
|
|
403
|
+
"reasoning": "<which of your personality traits and experiences drive this answer>",
|
|
404
|
+
"citations": [
|
|
405
|
+
{"source": "<source type>", "detail": "<specific data point>", "weight": <0.0-1.0>}
|
|
406
|
+
]
|
|
407
|
+
}`
|
|
408
|
+
);
|
|
409
|
+
this.history.push({ role: "user", content: question });
|
|
410
|
+
this.history.push({ role: "persona", content: structured.response });
|
|
411
|
+
this.memory.addEpisode(question, structured.response);
|
|
412
|
+
return {
|
|
413
|
+
personaId: this.definition.id,
|
|
414
|
+
personaName: this.definition.name,
|
|
415
|
+
question,
|
|
416
|
+
response: structured.response,
|
|
417
|
+
confidence: this.definition.confidence,
|
|
418
|
+
groundedIn: this.definition.dataSources,
|
|
419
|
+
citations: structured.citations ?? [],
|
|
420
|
+
reasoning: structured.reasoning ?? "",
|
|
421
|
+
traits: this.definition.traits
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Get the persona's reaction to a stimulus (feature, price change, etc.)
|
|
426
|
+
*/
|
|
427
|
+
async react(stimulus) {
|
|
428
|
+
return this.ask(
|
|
429
|
+
`React to this as yourself \u2014 what's your honest first reaction? "${stimulus}"`
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Compare two options from the persona's perspective.
|
|
434
|
+
*/
|
|
435
|
+
async compare(optionA, optionB) {
|
|
436
|
+
return this.ask(
|
|
437
|
+
`Compare these two options and tell me which you'd choose and why:
|
|
438
|
+
Option A: ${optionA}
|
|
439
|
+
Option B: ${optionB}`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Derive stances from past conversations.
|
|
444
|
+
* Call after multiple ask() calls to distill positions.
|
|
445
|
+
*/
|
|
446
|
+
async deriveStances() {
|
|
447
|
+
await this.memory.deriveStances(this.llm);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Get the compiled system prompt (useful for debugging).
|
|
451
|
+
*/
|
|
452
|
+
toPrompt() {
|
|
453
|
+
return this.systemPrompt;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Get the full prompt including memory (useful for debugging).
|
|
457
|
+
*/
|
|
458
|
+
toFullPrompt(query = "") {
|
|
459
|
+
return this.systemPrompt + this.memory.compileForPrompt(query);
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Serialize to JSON.
|
|
463
|
+
*/
|
|
464
|
+
toJSON() {
|
|
465
|
+
return this.definition;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* One-line summary.
|
|
469
|
+
*/
|
|
470
|
+
summary() {
|
|
471
|
+
const d = this.definition.demographics;
|
|
472
|
+
const parts = [this.definition.name];
|
|
473
|
+
if (d.age) parts.push(`${d.age}`);
|
|
474
|
+
if (d.occupation) parts.push(d.occupation);
|
|
475
|
+
if (d.location) parts.push(d.location);
|
|
476
|
+
return parts.join(", ");
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Reset conversation history (does NOT clear persistent memory).
|
|
480
|
+
*/
|
|
481
|
+
clearHistory() {
|
|
482
|
+
this.history = [];
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
var CITATION_INSTRUCTIONS = `
|
|
486
|
+
|
|
487
|
+
CITATION REQUIREMENTS:
|
|
488
|
+
For every response, you MUST explain your reasoning and cite sources.
|
|
489
|
+
|
|
490
|
+
For "citations", use these source types:
|
|
491
|
+
- "trait-model" \u2014 your personality traits (OCEAN scores) drive this answer. Detail should name the specific trait.
|
|
492
|
+
- "behavioral-data" \u2014 real user behavior data informs this. Detail should quote the specific pattern.
|
|
493
|
+
- "support-data" \u2014 real support conversations inform this. Detail should reference what users said.
|
|
494
|
+
- "payment-data" \u2014 real payment/subscription data informs this. Detail should reference specific patterns.
|
|
495
|
+
- "memory" \u2014 your past statements or known positions inform this. Detail should reference what you said before.
|
|
496
|
+
- "general-knowledge" \u2014 general market knowledge, not grounded in specific data.
|
|
497
|
+
|
|
498
|
+
Assign weight 0.0-1.0 based on how much this citation influenced your answer.
|
|
499
|
+
If you only have trait-model and general-knowledge to cite, be honest about that.
|
|
500
|
+
If GROUND TRUTH FACTS or YOUR PAST STATEMENTS are provided, you MUST cite them when relevant.`;
|
|
501
|
+
|
|
502
|
+
// src/persona/generator.ts
|
|
503
|
+
import { randomUUID } from "crypto";
|
|
504
|
+
async function generatePersonas(llm, options) {
|
|
505
|
+
const count = options.count ?? 5;
|
|
506
|
+
const diversityThreshold = options.diversityThreshold ?? 0.3;
|
|
507
|
+
const documents = [];
|
|
508
|
+
const dataRecords = [];
|
|
509
|
+
for (const r of options.userData ?? []) {
|
|
510
|
+
if (r.properties._type === "document" || r.properties._type === "context") {
|
|
511
|
+
documents.push(r);
|
|
512
|
+
} else {
|
|
513
|
+
dataRecords.push(r);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const hasData = dataRecords.length > 0;
|
|
517
|
+
const hasDocs = documents.length > 0;
|
|
518
|
+
let confidence;
|
|
519
|
+
let mode;
|
|
520
|
+
const dataSources = [];
|
|
521
|
+
if (hasData && hasDocs) {
|
|
522
|
+
mode = "combined";
|
|
523
|
+
confidence = "high";
|
|
524
|
+
dataSources.push("user-data", "documents");
|
|
525
|
+
} else if (hasData) {
|
|
526
|
+
mode = "data";
|
|
527
|
+
confidence = "medium";
|
|
528
|
+
dataSources.push("user-data");
|
|
529
|
+
} else if (hasDocs) {
|
|
530
|
+
mode = "documents";
|
|
531
|
+
confidence = "medium";
|
|
532
|
+
dataSources.push("documents");
|
|
533
|
+
} else {
|
|
534
|
+
mode = "context-only";
|
|
535
|
+
confidence = "low";
|
|
536
|
+
}
|
|
537
|
+
if (options.context?.length) dataSources.push("context");
|
|
538
|
+
if (options.product) dataSources.push("product-description");
|
|
539
|
+
const contextParts = [];
|
|
540
|
+
if (options.product) {
|
|
541
|
+
contextParts.push(`Product/Domain: ${options.product}`);
|
|
542
|
+
}
|
|
543
|
+
if (options.context?.length) {
|
|
544
|
+
contextParts.push(`Additional context:
|
|
545
|
+
${options.context.join("\n")}`);
|
|
546
|
+
}
|
|
547
|
+
if (hasData) {
|
|
548
|
+
contextParts.push(
|
|
549
|
+
`Real user data summary:
|
|
550
|
+
${summarizeUserData(dataRecords)}`
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
if (hasDocs) {
|
|
554
|
+
contextParts.push(
|
|
555
|
+
`Document content:
|
|
556
|
+
${summarizeDocuments(documents)}`
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
const systemPrompt = mode === "documents" ? STAKEHOLDER_SYSTEM_PROMPT : mode === "combined" ? COMBINED_SYSTEM_PROMPT : GENERATION_SYSTEM_PROMPT;
|
|
560
|
+
const prompt = buildGenerationPrompt(
|
|
561
|
+
contextParts.join("\n\n"),
|
|
562
|
+
count,
|
|
563
|
+
mode
|
|
564
|
+
);
|
|
565
|
+
const suggestions = await llm.generateJSON(
|
|
566
|
+
systemPrompt,
|
|
567
|
+
prompt
|
|
568
|
+
);
|
|
569
|
+
const personas = [];
|
|
570
|
+
for (const suggestion of (suggestions ?? []).slice(0, count)) {
|
|
571
|
+
const traits = enforceTraitBounds(suggestion.traits);
|
|
572
|
+
const tooSimilar = personas.some(
|
|
573
|
+
(p) => traitDistance(p.traits, traits) < diversityThreshold
|
|
574
|
+
);
|
|
575
|
+
const finalTraits = tooSimilar ? perturbVector(traits, diversityThreshold) : traits;
|
|
576
|
+
const behavioralProfile = compileBehavioralProfile(finalTraits);
|
|
577
|
+
const personaContext = {
|
|
578
|
+
product: options.product,
|
|
579
|
+
freeformContext: [
|
|
580
|
+
...options.context ?? [],
|
|
581
|
+
// Inject the rich backstory and motivations into context
|
|
582
|
+
suggestion.backstory,
|
|
583
|
+
...(suggestion.painPoints ?? []).map((p) => `Pain point: ${p}`),
|
|
584
|
+
...(suggestion.goals ?? []).map((g) => `Goal: ${g}`),
|
|
585
|
+
suggestion.relationship ? `Relationship to product: ${suggestion.relationship}` : ""
|
|
586
|
+
].filter(Boolean)
|
|
587
|
+
};
|
|
588
|
+
if (hasData) {
|
|
589
|
+
const enrichment = enrichFromUserData(dataRecords);
|
|
590
|
+
personaContext.behaviorData = enrichment.behaviors;
|
|
591
|
+
personaContext.paymentData = enrichment.payments;
|
|
592
|
+
personaContext.supportData = enrichment.support;
|
|
593
|
+
}
|
|
594
|
+
if (hasDocs) {
|
|
595
|
+
personaContext.freeformContext = [
|
|
596
|
+
...personaContext.freeformContext ?? [],
|
|
597
|
+
...documents.slice(0, 3).map(
|
|
598
|
+
(d) => `From document "${d.properties._filename}": ${String(d.properties._content).slice(0, 2e3)}`
|
|
599
|
+
)
|
|
600
|
+
];
|
|
601
|
+
}
|
|
602
|
+
personas.push({
|
|
603
|
+
id: randomUUID(),
|
|
604
|
+
name: suggestion.name,
|
|
605
|
+
traits: finalTraits,
|
|
606
|
+
demographics: {
|
|
607
|
+
age: suggestion.age,
|
|
608
|
+
gender: suggestion.gender,
|
|
609
|
+
location: suggestion.location,
|
|
610
|
+
occupation: suggestion.occupation,
|
|
611
|
+
income: suggestion.income,
|
|
612
|
+
education: suggestion.education
|
|
613
|
+
},
|
|
614
|
+
context: personaContext,
|
|
615
|
+
behavioralProfile,
|
|
616
|
+
dataSources,
|
|
617
|
+
confidence,
|
|
618
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
return personas;
|
|
622
|
+
}
|
|
623
|
+
var GENERATION_SYSTEM_PROMPT = `You are a psychometrician and consumer behavior researcher. You generate realistic synthetic user personas grounded in Big Five (OCEAN) personality psychology.
|
|
624
|
+
|
|
625
|
+
Your personas must feel like REAL PEOPLE, not data points. Each persona needs:
|
|
626
|
+
|
|
627
|
+
1. A RICH BACKSTORY \u2014 not "34, PM, Singapore." Instead: "Mei Lin is 34, a product manager at a mid-size fintech in Singapore. She joined three years ago after burning out at a big bank. She's methodical \u2014 she keeps a spreadsheet of every SaaS tool her team uses, color-coded by renewal date. She discovered this product through a colleague's recommendation and signed up during a free trial, but almost cancelled when she couldn't figure out the export feature. She stayed because the dashboard saved her 2 hours a week on reporting."
|
|
628
|
+
|
|
629
|
+
2. PAIN POINTS \u2014 specific frustrations, not generic ones. What keeps them up at night?
|
|
630
|
+
|
|
631
|
+
3. GOALS \u2014 what are they trying to achieve? Not just with the product, but in their life/work.
|
|
632
|
+
|
|
633
|
+
4. RELATIONSHIP TO THE PRODUCT \u2014 how did they find it? How do they use it? What would make them leave? What would make them stay forever?
|
|
634
|
+
|
|
635
|
+
5. INTERNALLY CONSISTENT OCEAN TRAITS \u2014 a high-neuroticism person worries about price changes. A low-openness person resists new features. Make the backstory MATCH the traits.
|
|
636
|
+
|
|
637
|
+
When assigning OCEAN scores, use the full range. Real humans cluster at extremes, not 0.5.`;
|
|
638
|
+
var STAKEHOLDER_SYSTEM_PROMPT = `You are a psychometrician and policy researcher. Given documents about a domain, you identify the key STAKEHOLDER TYPES and generate a realistic persona for each.
|
|
639
|
+
|
|
640
|
+
Your job is to READ the documents carefully and figure out: who are the different types of people affected by this? What are their different perspectives, concerns, and behaviors?
|
|
641
|
+
|
|
642
|
+
For each stakeholder, generate a RICH, SPECIFIC persona:
|
|
643
|
+
|
|
644
|
+
1. A NARRATIVE BACKSTORY \u2014 not demographics. A story. "Ah Kow is 72, a retired hawker who's lived in his ground-floor Bishan HDB flat for 38 years. He remembers the big flood in 1978 and still keeps sandbags by the door. His children keep telling him to move upstairs but he says the garden is his life. He reads Lianhe Zaobao every morning and trusts what the government says about drainage, but he noticed the water comes faster now than it used to."
|
|
645
|
+
|
|
646
|
+
2. Their SPECIFIC RELATIONSHIP to the domain/policy \u2014 not generic. What do they stand to gain or lose?
|
|
647
|
+
|
|
648
|
+
3. Their INFORMATION SOURCES \u2014 how do they learn about this topic? Who do they trust?
|
|
649
|
+
|
|
650
|
+
4. OCEAN TRAITS that match their described behavior \u2014 the cautious elderly man is high conscientiousness, moderate neuroticism.
|
|
651
|
+
|
|
652
|
+
Extract stakeholder types DIRECTLY from the document content. Don't invent types the documents don't mention.`;
|
|
653
|
+
var COMBINED_SYSTEM_PROMPT = `You are a psychometrician and researcher. You have BOTH real user data AND domain documents. Generate personas that combine behavioral patterns from the data with contextual knowledge from the documents.
|
|
654
|
+
|
|
655
|
+
Each persona should:
|
|
656
|
+
|
|
657
|
+
1. REPRESENT a real behavioral segment from the data (their actions, usage patterns, payment behavior are grounded in real numbers)
|
|
658
|
+
|
|
659
|
+
2. Be ENRICHED with domain knowledge from the documents (they understand the broader context \u2014 policy implications, industry trends, competitive landscape)
|
|
660
|
+
|
|
661
|
+
3. Have a RICH NARRATIVE BACKSTORY that weaves together their data-revealed behavior with document-revealed context. Not: "Power user, exports a lot." Instead: "Raj is the kind of user who builds his workflow around your tool. He's automated his entire weekly report using your CSV exports \u2014 340 exports last month. But he saw the blog post about the new API-first strategy and he's worried that CSV exports are going to be deprecated. He's already evaluating alternatives, not because he wants to leave, but because he needs a backup plan. His boss asks for that report every Monday at 9am and there's no room for 'the export feature changed.'"
|
|
662
|
+
|
|
663
|
+
4. Have OCEAN TRAITS consistent with both their observed behavior AND their described personality.
|
|
664
|
+
|
|
665
|
+
Ground every claim in either the real data or the documents. If you're speculating, say so.`;
|
|
666
|
+
function buildGenerationPrompt(context, count, mode) {
|
|
667
|
+
const modeInstruction = mode === "documents" ? "Identify the key stakeholder types mentioned or implied in the documents. Generate one persona per stakeholder type." : mode === "combined" ? "Combine behavioral segments from the user data with stakeholder perspectives from the documents." : "Generate diverse personas that represent the range of users for this product/domain.";
|
|
668
|
+
return `${modeInstruction}
|
|
669
|
+
|
|
670
|
+
${context}
|
|
671
|
+
|
|
672
|
+
Generate a JSON array of ${count} personas. Each must have:
|
|
673
|
+
- name: A realistic full first name (culturally appropriate for the context)
|
|
674
|
+
- age: number
|
|
675
|
+
- gender: string
|
|
676
|
+
- location: string (specific \u2014 neighborhood/city, not just country)
|
|
677
|
+
- occupation: string (specific role, not just industry)
|
|
678
|
+
- income: string (range)
|
|
679
|
+
- education: string
|
|
680
|
+
- traits: { openness, conscientiousness, extraversion, agreeableness, neuroticism } (each 0.0 to 1.0)
|
|
681
|
+
- backstory: 3-5 sentences. A NARRATIVE about this person \u2014 their history, daily life, how they relate to this product/domain. Make them feel real. Include specific details only this person would have.
|
|
682
|
+
- painPoints: array of 2-3 specific frustrations (not generic)
|
|
683
|
+
- goals: array of 2-3 things they're trying to achieve
|
|
684
|
+
- relationship: 1-2 sentences on how they found/use/feel about this product or domain
|
|
685
|
+
|
|
686
|
+
Make personas GENUINELY DIVERSE:
|
|
687
|
+
- Spread across age ranges, income levels, and personality types
|
|
688
|
+
- Include skeptics and critics, not just fans
|
|
689
|
+
- Include edge cases \u2014 the person who almost left, the person who uses it "wrong"
|
|
690
|
+
- OCEAN traits should use the full 0-1 range, not cluster around 0.5
|
|
691
|
+
|
|
692
|
+
Return ONLY a JSON array.`;
|
|
693
|
+
}
|
|
694
|
+
function enforceTraitBounds(traits) {
|
|
695
|
+
const bounded = { ...traits };
|
|
696
|
+
for (const key of Object.keys(bounded)) {
|
|
697
|
+
bounded[key] = Math.max(0, Math.min(1, bounded[key]));
|
|
698
|
+
}
|
|
699
|
+
return bounded;
|
|
700
|
+
}
|
|
701
|
+
function perturbVector(traits, minDistance) {
|
|
702
|
+
const perturbed = { ...traits };
|
|
703
|
+
const keys = Object.keys(perturbed);
|
|
704
|
+
const shuffled = keys.sort(() => Math.random() - 0.5);
|
|
705
|
+
for (const key of shuffled.slice(0, 3)) {
|
|
706
|
+
const shift = (Math.random() - 0.5) * minDistance * 2;
|
|
707
|
+
perturbed[key] = Math.max(0, Math.min(1, perturbed[key] + shift));
|
|
708
|
+
}
|
|
709
|
+
return perturbed;
|
|
710
|
+
}
|
|
711
|
+
function summarizeUserData(records) {
|
|
712
|
+
const lines = [];
|
|
713
|
+
lines.push(`Total users: ${records.length}`);
|
|
714
|
+
const propCounts = {};
|
|
715
|
+
for (const r of records) {
|
|
716
|
+
for (const [key, value] of Object.entries(r.properties)) {
|
|
717
|
+
if (key.startsWith("_")) continue;
|
|
718
|
+
if (!propCounts[key]) propCounts[key] = {};
|
|
719
|
+
const v = String(value);
|
|
720
|
+
propCounts[key][v] = (propCounts[key][v] ?? 0) + 1;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
for (const [prop, values] of Object.entries(propCounts)) {
|
|
724
|
+
const sorted = Object.entries(values).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
725
|
+
const total = Object.values(values).reduce((a, b) => a + b, 0);
|
|
726
|
+
const dist = sorted.map(([v, c]) => `${v} (${(c / total * 100).toFixed(0)}%)`).join(", ");
|
|
727
|
+
lines.push(`${prop}: ${dist}`);
|
|
728
|
+
}
|
|
729
|
+
const eventCounts = {};
|
|
730
|
+
for (const r of records) {
|
|
731
|
+
for (const e of r.events ?? []) {
|
|
732
|
+
eventCounts[e.name] = (eventCounts[e.name] ?? 0) + 1;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (Object.keys(eventCounts).length > 0) {
|
|
736
|
+
const topEvents = Object.entries(eventCounts).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([name, count]) => `${name} (${count}x)`).join(", ");
|
|
737
|
+
lines.push(`Top events: ${topEvents}`);
|
|
738
|
+
}
|
|
739
|
+
const paymentStatuses = {};
|
|
740
|
+
const cancelReasons = [];
|
|
741
|
+
for (const r of records) {
|
|
742
|
+
for (const p of r.payments ?? []) {
|
|
743
|
+
paymentStatuses[p.status] = (paymentStatuses[p.status] ?? 0) + 1;
|
|
744
|
+
if (p.cancelReason) cancelReasons.push(p.cancelReason);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (Object.keys(paymentStatuses).length > 0) {
|
|
748
|
+
lines.push(
|
|
749
|
+
`Payment statuses: ${Object.entries(paymentStatuses).map(([s, c]) => `${s}: ${c}`).join(", ")}`
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
if (cancelReasons.length > 0) {
|
|
753
|
+
lines.push(`Cancel reasons: ${cancelReasons.slice(0, 10).join("; ")}`);
|
|
754
|
+
}
|
|
755
|
+
return lines.join("\n");
|
|
756
|
+
}
|
|
757
|
+
function summarizeDocuments(docs) {
|
|
758
|
+
return docs.map((d) => {
|
|
759
|
+
const filename = d.properties._filename ?? "unknown";
|
|
760
|
+
const content = String(d.properties._content ?? "").slice(0, 3e3);
|
|
761
|
+
return `--- ${filename} ---
|
|
762
|
+
${content}`;
|
|
763
|
+
}).join("\n\n");
|
|
764
|
+
}
|
|
765
|
+
function enrichFromUserData(records) {
|
|
766
|
+
const behaviors = [];
|
|
767
|
+
const payments = [];
|
|
768
|
+
const support = [];
|
|
769
|
+
const eventCounts = {};
|
|
770
|
+
for (const r of records) {
|
|
771
|
+
for (const e of r.events ?? []) {
|
|
772
|
+
eventCounts[e.name] = (eventCounts[e.name] ?? 0) + 1;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
for (const [name, count] of Object.entries(eventCounts).sort((a, b) => b[1] - a[1]).slice(0, 5)) {
|
|
776
|
+
behaviors.push(
|
|
777
|
+
`${name}: occurs ${count} times across ${records.length} users`
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
for (const r of records) {
|
|
781
|
+
for (const p of r.payments ?? []) {
|
|
782
|
+
if (p.cancelReason) {
|
|
783
|
+
payments.push(`Cancelled: "${p.cancelReason}" (plan: ${p.plan})`);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
for (const r of records) {
|
|
788
|
+
for (const c of r.conversations ?? []) {
|
|
789
|
+
support.push(c);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
behaviors: behaviors.slice(0, 10),
|
|
794
|
+
payments: payments.slice(0, 10),
|
|
795
|
+
support: support.slice(0, 10)
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// src/groups/segment.ts
|
|
800
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
801
|
+
async function discoverSegments(llm, records, options = {}) {
|
|
802
|
+
if (records.length === 0) {
|
|
803
|
+
return {
|
|
804
|
+
segments: [],
|
|
805
|
+
totalUsersAnalyzed: 0,
|
|
806
|
+
confidence: "low",
|
|
807
|
+
suggestions: [
|
|
808
|
+
{
|
|
809
|
+
source: "dopple",
|
|
810
|
+
suggestion: "No user data available. Connect a data source first.",
|
|
811
|
+
impact: "high"
|
|
812
|
+
}
|
|
813
|
+
]
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
const dataSummary = buildDataSummary(records);
|
|
817
|
+
const segmentCount = options.segmentCount ?? "3-6";
|
|
818
|
+
const prompt = `Analyze this user data and identify ${segmentCount} distinct segments.
|
|
819
|
+
|
|
820
|
+
Product: ${options.product ?? "Unknown"}
|
|
821
|
+
|
|
822
|
+
User Data Summary:
|
|
823
|
+
${dataSummary}
|
|
824
|
+
|
|
825
|
+
For each segment, return:
|
|
826
|
+
- name: A memorable nickname (e.g., "The Optimizer", "The Casual")
|
|
827
|
+
- description: 1-2 sentence description
|
|
828
|
+
- percentage: Estimated % of users in this segment
|
|
829
|
+
- patterns: Array of 3-5 key behavioral patterns
|
|
830
|
+
- persona: A representative person with name, age, gender, occupation, location, income, and OCEAN traits (0.0-1.0)
|
|
831
|
+
|
|
832
|
+
IMPORTANT:
|
|
833
|
+
- Segments should be meaningfully different, not just age brackets
|
|
834
|
+
- Ground segments in the ACTUAL data patterns you see, not generic archetypes
|
|
835
|
+
- OCEAN traits must reflect the behavioral patterns (e.g., methodical researchers = high conscientiousness)
|
|
836
|
+
- Percentages must sum to ~100%
|
|
837
|
+
|
|
838
|
+
Return a JSON array of segments.`;
|
|
839
|
+
const suggestions = await llm.generateJSON(
|
|
840
|
+
DISCOVERY_SYSTEM_PROMPT,
|
|
841
|
+
prompt
|
|
842
|
+
);
|
|
843
|
+
const segments = suggestions.map((s) => {
|
|
844
|
+
const traits = enforceTraitBounds2(s.persona.traits);
|
|
845
|
+
const behavioralProfile = compileBehavioralProfile(traits);
|
|
846
|
+
const persona = {
|
|
847
|
+
id: randomUUID2(),
|
|
848
|
+
name: s.persona.name,
|
|
849
|
+
traits,
|
|
850
|
+
demographics: {
|
|
851
|
+
age: s.persona.age,
|
|
852
|
+
gender: s.persona.gender,
|
|
853
|
+
occupation: s.persona.occupation,
|
|
854
|
+
location: s.persona.location,
|
|
855
|
+
income: s.persona.income
|
|
856
|
+
},
|
|
857
|
+
context: {
|
|
858
|
+
product: options.product,
|
|
859
|
+
behaviorData: s.patterns
|
|
860
|
+
},
|
|
861
|
+
behavioralProfile,
|
|
862
|
+
dataSources: ["user-data", "segmentation"],
|
|
863
|
+
confidence: computeConfidence(records),
|
|
864
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
865
|
+
};
|
|
866
|
+
return {
|
|
867
|
+
id: randomUUID2(),
|
|
868
|
+
name: s.name,
|
|
869
|
+
description: s.description,
|
|
870
|
+
userCount: Math.round(s.percentage / 100 * records.length),
|
|
871
|
+
percentage: s.percentage,
|
|
872
|
+
averageTraits: traits,
|
|
873
|
+
patterns: s.patterns,
|
|
874
|
+
persona
|
|
875
|
+
};
|
|
876
|
+
});
|
|
877
|
+
const dataSuggestions = analyzeDataGaps(records);
|
|
878
|
+
return {
|
|
879
|
+
segments,
|
|
880
|
+
totalUsersAnalyzed: records.length,
|
|
881
|
+
confidence: computeConfidence(records),
|
|
882
|
+
suggestions: dataSuggestions
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
function buildDataSummary(records) {
|
|
886
|
+
const lines = [`Total users: ${records.length}`];
|
|
887
|
+
const propCounts = {};
|
|
888
|
+
for (const r of records) {
|
|
889
|
+
for (const [key, value] of Object.entries(r.properties)) {
|
|
890
|
+
if (key.startsWith("_")) continue;
|
|
891
|
+
if (!propCounts[key]) propCounts[key] = {};
|
|
892
|
+
const v = String(value);
|
|
893
|
+
propCounts[key][v] = (propCounts[key][v] ?? 0) + 1;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
for (const [prop, values] of Object.entries(propCounts)) {
|
|
897
|
+
const uniqueValues = Object.keys(values).length;
|
|
898
|
+
if (uniqueValues > 20) {
|
|
899
|
+
const nums = Object.keys(values).map(Number).filter((n) => !isNaN(n));
|
|
900
|
+
if (nums.length > 0) {
|
|
901
|
+
lines.push(
|
|
902
|
+
`${prop}: range ${Math.min(...nums)}-${Math.max(...nums)}, ${uniqueValues} unique values`
|
|
903
|
+
);
|
|
904
|
+
} else {
|
|
905
|
+
lines.push(`${prop}: ${uniqueValues} unique values`);
|
|
906
|
+
}
|
|
907
|
+
} else {
|
|
908
|
+
const sorted = Object.entries(values).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
909
|
+
const total = records.length;
|
|
910
|
+
const dist = sorted.map(([v, c]) => `${v} (${(c / total * 100).toFixed(0)}%)`).join(", ");
|
|
911
|
+
lines.push(`${prop}: ${dist}`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const eventCounts = {};
|
|
915
|
+
let totalEvents = 0;
|
|
916
|
+
for (const r of records) {
|
|
917
|
+
for (const e of r.events ?? []) {
|
|
918
|
+
eventCounts[e.name] = (eventCounts[e.name] ?? 0) + 1;
|
|
919
|
+
totalEvents++;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (totalEvents > 0) {
|
|
923
|
+
lines.push(`
|
|
924
|
+
Total events: ${totalEvents}`);
|
|
925
|
+
const topEvents = Object.entries(eventCounts).sort((a, b) => b[1] - a[1]).slice(0, 15).map(([name, count]) => ` ${name}: ${count}x`).join("\n");
|
|
926
|
+
lines.push(`Top events:
|
|
927
|
+
${topEvents}`);
|
|
928
|
+
}
|
|
929
|
+
let hasPayments = false;
|
|
930
|
+
const statuses = {};
|
|
931
|
+
const plans = {};
|
|
932
|
+
const cancelReasons = [];
|
|
933
|
+
for (const r of records) {
|
|
934
|
+
for (const p of r.payments ?? []) {
|
|
935
|
+
hasPayments = true;
|
|
936
|
+
statuses[p.status] = (statuses[p.status] ?? 0) + 1;
|
|
937
|
+
if (p.plan) plans[p.plan] = (plans[p.plan] ?? 0) + 1;
|
|
938
|
+
if (p.cancelReason) cancelReasons.push(p.cancelReason);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
if (hasPayments) {
|
|
942
|
+
lines.push(
|
|
943
|
+
`
|
|
944
|
+
Payment statuses: ${Object.entries(statuses).map(([s, c]) => `${s}: ${c}`).join(", ")}`
|
|
945
|
+
);
|
|
946
|
+
if (Object.keys(plans).length > 0) {
|
|
947
|
+
lines.push(
|
|
948
|
+
`Plans: ${Object.entries(plans).map(([p, c]) => `${p}: ${c}`).join(", ")}`
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
if (cancelReasons.length > 0) {
|
|
952
|
+
lines.push(`Cancel reasons: ${cancelReasons.slice(0, 10).join("; ")}`);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
let convCount = 0;
|
|
956
|
+
for (const r of records) {
|
|
957
|
+
convCount += (r.conversations ?? []).length;
|
|
958
|
+
}
|
|
959
|
+
if (convCount > 0) {
|
|
960
|
+
lines.push(`
|
|
961
|
+
Support conversations: ${convCount} total`);
|
|
962
|
+
}
|
|
963
|
+
return lines.join("\n");
|
|
964
|
+
}
|
|
965
|
+
function computeConfidence(records) {
|
|
966
|
+
let score = 0;
|
|
967
|
+
if (records.length >= 100) score += 2;
|
|
968
|
+
else if (records.length >= 20) score += 1;
|
|
969
|
+
const hasEvents = records.some((r) => (r.events ?? []).length > 0);
|
|
970
|
+
if (hasEvents) score += 1;
|
|
971
|
+
const hasPayments = records.some((r) => (r.payments ?? []).length > 0);
|
|
972
|
+
if (hasPayments) score += 1;
|
|
973
|
+
const hasConversations = records.some(
|
|
974
|
+
(r) => (r.conversations ?? []).length > 0
|
|
975
|
+
);
|
|
976
|
+
if (hasConversations) score += 1;
|
|
977
|
+
if (score >= 4) return "high";
|
|
978
|
+
if (score >= 2) return "medium";
|
|
979
|
+
return "low";
|
|
980
|
+
}
|
|
981
|
+
function analyzeDataGaps(records) {
|
|
982
|
+
const suggestions = [];
|
|
983
|
+
const hasEvents = records.some((r) => (r.events ?? []).length > 0);
|
|
984
|
+
const hasPayments = records.some((r) => (r.payments ?? []).length > 0);
|
|
985
|
+
const hasConversations = records.some(
|
|
986
|
+
(r) => (r.conversations ?? []).length > 0
|
|
987
|
+
);
|
|
988
|
+
if (!hasEvents) {
|
|
989
|
+
suggestions.push({
|
|
990
|
+
source: "posthog",
|
|
991
|
+
suggestion: "Connect PostHog to get event data. Behavioral events dramatically improve segment accuracy.",
|
|
992
|
+
impact: "high"
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
if (!hasPayments) {
|
|
996
|
+
suggestions.push({
|
|
997
|
+
source: "stripe",
|
|
998
|
+
suggestion: "Connect Stripe to get payment data. Subscription status and cancel reasons ground pricing personas.",
|
|
999
|
+
impact: "high"
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
if (!hasConversations) {
|
|
1003
|
+
suggestions.push({
|
|
1004
|
+
source: "intercom",
|
|
1005
|
+
suggestion: "Connect a support tool to get real conversations. User words improve persona authenticity.",
|
|
1006
|
+
impact: "medium"
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
const propCoverage = {};
|
|
1010
|
+
for (const r of records) {
|
|
1011
|
+
for (const key of Object.keys(r.properties)) {
|
|
1012
|
+
propCoverage[key] = (propCoverage[key] ?? 0) + 1;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
for (const [prop, count] of Object.entries(propCoverage)) {
|
|
1016
|
+
const coverage = count / records.length;
|
|
1017
|
+
if (coverage < 0.3 && !prop.startsWith("_")) {
|
|
1018
|
+
suggestions.push({
|
|
1019
|
+
source: "data-quality",
|
|
1020
|
+
suggestion: `Property "${prop}" is only set for ${(coverage * 100).toFixed(0)}% of users. Improve coverage for better segmentation.`,
|
|
1021
|
+
impact: "low"
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
if (records.length < 20) {
|
|
1026
|
+
suggestions.push({
|
|
1027
|
+
source: "data-volume",
|
|
1028
|
+
suggestion: `Only ${records.length} users. Segments improve significantly with 50+ users.`,
|
|
1029
|
+
impact: "medium"
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
return suggestions;
|
|
1033
|
+
}
|
|
1034
|
+
function enforceTraitBounds2(traits) {
|
|
1035
|
+
const bounded = { ...traits };
|
|
1036
|
+
for (const key of Object.keys(bounded)) {
|
|
1037
|
+
bounded[key] = Math.max(0, Math.min(1, bounded[key]));
|
|
1038
|
+
}
|
|
1039
|
+
return bounded;
|
|
1040
|
+
}
|
|
1041
|
+
var DISCOVERY_SYSTEM_PROMPT = `You are a behavioral data scientist and psychometrician. You analyze product usage data to discover meaningful user segments.
|
|
1042
|
+
|
|
1043
|
+
Your segmentation must:
|
|
1044
|
+
1. Be grounded in ACTUAL patterns in the data \u2014 not generic marketing personas
|
|
1045
|
+
2. Identify segments that differ in behavior, motivation, and personality \u2014 not just demographics
|
|
1046
|
+
3. Assign OCEAN trait vectors that are CONSISTENT with observed behaviors
|
|
1047
|
+
4. Include edge cases and minority segments \u2014 not just the obvious majority
|
|
1048
|
+
5. Be actionable \u2014 each segment should suggest different product decisions
|
|
1049
|
+
|
|
1050
|
+
When assigning OCEAN scores, map behaviors to traits:
|
|
1051
|
+
- Methodical/planning behavior \u2192 high conscientiousness
|
|
1052
|
+
- Social/sharing behavior \u2192 high extraversion
|
|
1053
|
+
- Price complaints/comparison shopping \u2192 high neuroticism, low agreeableness
|
|
1054
|
+
- Early adopter/feature requests \u2192 high openness
|
|
1055
|
+
- Loyalty despite issues \u2192 high agreeableness`;
|
|
1056
|
+
|
|
1057
|
+
// src/validation/validate.ts
|
|
1058
|
+
var PSYCHOMETRIC_ITEMS = [
|
|
1059
|
+
// Openness
|
|
1060
|
+
{
|
|
1061
|
+
question: "Would you enjoy visiting an art museum?",
|
|
1062
|
+
trait: "openness",
|
|
1063
|
+
positiveKeyed: true
|
|
1064
|
+
},
|
|
1065
|
+
{
|
|
1066
|
+
question: "Do you prefer sticking to tried-and-true methods rather than trying new approaches?",
|
|
1067
|
+
trait: "openness",
|
|
1068
|
+
positiveKeyed: false
|
|
1069
|
+
},
|
|
1070
|
+
{
|
|
1071
|
+
question: "Are you curious about things even if they aren't immediately useful to you?",
|
|
1072
|
+
trait: "openness",
|
|
1073
|
+
positiveKeyed: true
|
|
1074
|
+
},
|
|
1075
|
+
// Conscientiousness
|
|
1076
|
+
{
|
|
1077
|
+
question: "Do you make a plan and follow through with it?",
|
|
1078
|
+
trait: "conscientiousness",
|
|
1079
|
+
positiveKeyed: true
|
|
1080
|
+
},
|
|
1081
|
+
{
|
|
1082
|
+
question: "Do you often forget to put things back in their proper place?",
|
|
1083
|
+
trait: "conscientiousness",
|
|
1084
|
+
positiveKeyed: false
|
|
1085
|
+
},
|
|
1086
|
+
{
|
|
1087
|
+
question: "Do you research products thoroughly before buying them?",
|
|
1088
|
+
trait: "conscientiousness",
|
|
1089
|
+
positiveKeyed: true
|
|
1090
|
+
},
|
|
1091
|
+
// Extraversion
|
|
1092
|
+
{
|
|
1093
|
+
question: "Do you enjoy being the center of attention?",
|
|
1094
|
+
trait: "extraversion",
|
|
1095
|
+
positiveKeyed: true
|
|
1096
|
+
},
|
|
1097
|
+
{
|
|
1098
|
+
question: "Would you rather spend a Saturday night at home alone than at a party?",
|
|
1099
|
+
trait: "extraversion",
|
|
1100
|
+
positiveKeyed: false
|
|
1101
|
+
},
|
|
1102
|
+
{
|
|
1103
|
+
question: "Do you share your opinions about products with friends and family?",
|
|
1104
|
+
trait: "extraversion",
|
|
1105
|
+
positiveKeyed: true
|
|
1106
|
+
},
|
|
1107
|
+
// Agreeableness
|
|
1108
|
+
{
|
|
1109
|
+
question: "Do you generally trust that people have good intentions?",
|
|
1110
|
+
trait: "agreeableness",
|
|
1111
|
+
positiveKeyed: true
|
|
1112
|
+
},
|
|
1113
|
+
{
|
|
1114
|
+
question: "Would you publicly complain about a product that disappointed you?",
|
|
1115
|
+
trait: "agreeableness",
|
|
1116
|
+
positiveKeyed: false
|
|
1117
|
+
},
|
|
1118
|
+
{
|
|
1119
|
+
question: "Do you give products a second chance even after a bad experience?",
|
|
1120
|
+
trait: "agreeableness",
|
|
1121
|
+
positiveKeyed: true
|
|
1122
|
+
},
|
|
1123
|
+
// Neuroticism
|
|
1124
|
+
{
|
|
1125
|
+
question: "Do you worry about making the wrong purchase decision?",
|
|
1126
|
+
trait: "neuroticism",
|
|
1127
|
+
positiveKeyed: true
|
|
1128
|
+
},
|
|
1129
|
+
{
|
|
1130
|
+
question: "Do you handle unexpected price increases calmly?",
|
|
1131
|
+
trait: "neuroticism",
|
|
1132
|
+
positiveKeyed: false
|
|
1133
|
+
},
|
|
1134
|
+
{
|
|
1135
|
+
question: "Do you read negative reviews more carefully than positive ones?",
|
|
1136
|
+
trait: "neuroticism",
|
|
1137
|
+
positiveKeyed: true
|
|
1138
|
+
}
|
|
1139
|
+
];
|
|
1140
|
+
async function testPsychometricFidelity(persona, llm) {
|
|
1141
|
+
const traitScores = {
|
|
1142
|
+
openness: [],
|
|
1143
|
+
conscientiousness: [],
|
|
1144
|
+
extraversion: [],
|
|
1145
|
+
agreeableness: [],
|
|
1146
|
+
neuroticism: []
|
|
1147
|
+
};
|
|
1148
|
+
for (const item of PSYCHOMETRIC_ITEMS) {
|
|
1149
|
+
const response = await llm.generateJSON(
|
|
1150
|
+
`You are answering personality questions as ${persona.name}. Based on the persona's personality, answer honestly.
|
|
1151
|
+
|
|
1152
|
+
Persona traits: O=${persona.definition.traits.openness.toFixed(2)} C=${persona.definition.traits.conscientiousness.toFixed(2)} E=${persona.definition.traits.extraversion.toFixed(2)} A=${persona.definition.traits.agreeableness.toFixed(2)} N=${persona.definition.traits.neuroticism.toFixed(2)}`,
|
|
1153
|
+
`Question: "${item.question}"
|
|
1154
|
+
|
|
1155
|
+
Respond with JSON: {"answer": "yes" | "no" | "maybe"}`
|
|
1156
|
+
);
|
|
1157
|
+
let score2;
|
|
1158
|
+
if (response.answer === "yes") score2 = item.positiveKeyed ? 1 : 0;
|
|
1159
|
+
else if (response.answer === "no") score2 = item.positiveKeyed ? 0 : 1;
|
|
1160
|
+
else score2 = 0.5;
|
|
1161
|
+
traitScores[item.trait].push(score2);
|
|
1162
|
+
}
|
|
1163
|
+
let totalError = 0;
|
|
1164
|
+
let traitCount = 0;
|
|
1165
|
+
for (const trait of Object.keys(traitScores)) {
|
|
1166
|
+
const scores = traitScores[trait];
|
|
1167
|
+
if (scores.length === 0) continue;
|
|
1168
|
+
const measured = scores.reduce((a, b) => a + b, 0) / scores.length;
|
|
1169
|
+
const defined = persona.definition.traits[trait];
|
|
1170
|
+
totalError += Math.abs(measured - defined);
|
|
1171
|
+
traitCount++;
|
|
1172
|
+
}
|
|
1173
|
+
const avgError = traitCount > 0 ? totalError / traitCount : 1;
|
|
1174
|
+
const score = Math.max(0, 1 - avgError);
|
|
1175
|
+
return {
|
|
1176
|
+
testName: "Psychometric Fidelity",
|
|
1177
|
+
passed: score >= 0.6,
|
|
1178
|
+
score: Math.round(score * 100),
|
|
1179
|
+
maxScore: 100,
|
|
1180
|
+
details: score >= 0.6 ? `Persona responses align with defined traits (${(avgError * 100).toFixed(0)}% average error)` : `Persona responses diverge from defined traits (${(avgError * 100).toFixed(0)}% average error). Trait compilation may need calibration.`
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
async function testConsistency(persona, _llm) {
|
|
1184
|
+
const question = "What matters most to you when choosing a new product or service?";
|
|
1185
|
+
const runs = 3;
|
|
1186
|
+
const responses = [];
|
|
1187
|
+
for (let i = 0; i < runs; i++) {
|
|
1188
|
+
persona.clearHistory();
|
|
1189
|
+
const r = await persona.ask(question);
|
|
1190
|
+
responses.push(r.response);
|
|
1191
|
+
}
|
|
1192
|
+
const allWords = responses.map(
|
|
1193
|
+
(r) => r.toLowerCase().split(/\W+/).filter((w) => w.length > 4)
|
|
1194
|
+
);
|
|
1195
|
+
const wordSets = allWords.map((words) => new Set(words));
|
|
1196
|
+
let overlapScore = 0;
|
|
1197
|
+
let comparisons = 0;
|
|
1198
|
+
for (let i = 0; i < wordSets.length; i++) {
|
|
1199
|
+
for (let j = i + 1; j < wordSets.length; j++) {
|
|
1200
|
+
const intersection = new Set(
|
|
1201
|
+
[...wordSets[i]].filter((w) => wordSets[j].has(w))
|
|
1202
|
+
);
|
|
1203
|
+
const union = /* @__PURE__ */ new Set([...wordSets[i], ...wordSets[j]]);
|
|
1204
|
+
overlapScore += union.size > 0 ? intersection.size / union.size : 0;
|
|
1205
|
+
comparisons++;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
const avgOverlap = comparisons > 0 ? overlapScore / comparisons : 0;
|
|
1209
|
+
const score = Math.min(1, avgOverlap * 3);
|
|
1210
|
+
return {
|
|
1211
|
+
testName: "Response Consistency",
|
|
1212
|
+
passed: score >= 0.3,
|
|
1213
|
+
score: Math.round(score * 100),
|
|
1214
|
+
maxScore: 100,
|
|
1215
|
+
details: score >= 0.3 ? `Persona gives thematically consistent responses across ${runs} runs (${(avgOverlap * 100).toFixed(0)}% lexical overlap)` : `Persona responses vary too much across runs. Consider stronger trait constraints.`
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
async function testDiscrimination(persona, llm) {
|
|
1219
|
+
const oppositeTraits = {
|
|
1220
|
+
openness: 1 - persona.definition.traits.openness,
|
|
1221
|
+
conscientiousness: 1 - persona.definition.traits.conscientiousness,
|
|
1222
|
+
extraversion: 1 - persona.definition.traits.extraversion,
|
|
1223
|
+
agreeableness: 1 - persona.definition.traits.agreeableness,
|
|
1224
|
+
neuroticism: 1 - persona.definition.traits.neuroticism
|
|
1225
|
+
};
|
|
1226
|
+
const oppositeDef = {
|
|
1227
|
+
...persona.definition,
|
|
1228
|
+
id: "opposite-" + persona.definition.id,
|
|
1229
|
+
name: "Opposite-" + persona.definition.name,
|
|
1230
|
+
traits: oppositeTraits
|
|
1231
|
+
};
|
|
1232
|
+
const { compileBehavioralProfile: compileBehavioralProfile2 } = await import("./ocean-SIPS4NY7.js");
|
|
1233
|
+
oppositeDef.behavioralProfile = compileBehavioralProfile2(oppositeTraits);
|
|
1234
|
+
const oppositePersona = new Persona(oppositeDef, llm);
|
|
1235
|
+
const question = "A new product you've never heard of is being recommended by a friend. It costs 20% more than what you currently use. What do you do?";
|
|
1236
|
+
persona.clearHistory();
|
|
1237
|
+
oppositePersona.clearHistory();
|
|
1238
|
+
const r1 = await persona.ask(question);
|
|
1239
|
+
const r2 = await oppositePersona.ask(question);
|
|
1240
|
+
const words1 = new Set(
|
|
1241
|
+
r1.response.toLowerCase().split(/\W+/).filter((w) => w.length > 4)
|
|
1242
|
+
);
|
|
1243
|
+
const words2 = new Set(
|
|
1244
|
+
r2.response.toLowerCase().split(/\W+/).filter((w) => w.length > 4)
|
|
1245
|
+
);
|
|
1246
|
+
const intersection = new Set([...words1].filter((w) => words2.has(w)));
|
|
1247
|
+
const union = /* @__PURE__ */ new Set([...words1, ...words2]);
|
|
1248
|
+
const similarity = union.size > 0 ? intersection.size / union.size : 0;
|
|
1249
|
+
const difference = 1 - similarity;
|
|
1250
|
+
return {
|
|
1251
|
+
testName: "Trait Discrimination",
|
|
1252
|
+
passed: difference >= 0.4,
|
|
1253
|
+
score: Math.round(difference * 100),
|
|
1254
|
+
maxScore: 100,
|
|
1255
|
+
details: difference >= 0.4 ? `Opposite personas give meaningfully different responses (${(difference * 100).toFixed(0)}% different)` : `Opposite personas give too-similar responses. Trait constraints may not be strong enough.`
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
async function validatePersona(definition, llm) {
|
|
1259
|
+
const persona = new Persona(definition, llm);
|
|
1260
|
+
const tests = [];
|
|
1261
|
+
tests.push(await testPsychometricFidelity(persona, llm));
|
|
1262
|
+
tests.push(await testConsistency(persona, llm));
|
|
1263
|
+
tests.push(await testDiscrimination(persona, llm));
|
|
1264
|
+
const totalScore = tests.reduce((sum, t) => sum + t.score, 0);
|
|
1265
|
+
const maxScore = tests.reduce((sum, t) => sum + t.maxScore, 0);
|
|
1266
|
+
const allPassed = tests.every((t) => t.passed);
|
|
1267
|
+
return {
|
|
1268
|
+
overall: { passed: allPassed, score: totalScore, maxScore },
|
|
1269
|
+
tests,
|
|
1270
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// src/adapters/base.ts
|
|
1275
|
+
var registry = /* @__PURE__ */ new Map();
|
|
1276
|
+
function registerAdapter(type, factory) {
|
|
1277
|
+
registry.set(type, factory);
|
|
1278
|
+
}
|
|
1279
|
+
function createAdapter(config) {
|
|
1280
|
+
const factory = registry.get(config.type);
|
|
1281
|
+
if (!factory) {
|
|
1282
|
+
throw new Error(
|
|
1283
|
+
`Unknown adapter type: "${config.type}". Available: ${[...registry.keys()].join(", ")}`
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
return factory(config);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// src/storage/store.ts
|
|
1290
|
+
import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2, readdir } from "fs/promises";
|
|
1291
|
+
import { join as join2 } from "path";
|
|
1292
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1293
|
+
var DoppleStore = class {
|
|
1294
|
+
baseDir;
|
|
1295
|
+
constructor(baseDir) {
|
|
1296
|
+
this.baseDir = baseDir ?? join2(process.cwd(), ".dopple");
|
|
1297
|
+
}
|
|
1298
|
+
// -------------------------------------------------------------------------
|
|
1299
|
+
// Panels
|
|
1300
|
+
// -------------------------------------------------------------------------
|
|
1301
|
+
async savePanel(name, personas, product) {
|
|
1302
|
+
const dir = join2(this.baseDir, "panels");
|
|
1303
|
+
await mkdir2(dir, { recursive: true });
|
|
1304
|
+
const panel = {
|
|
1305
|
+
id: randomUUID3(),
|
|
1306
|
+
name,
|
|
1307
|
+
product,
|
|
1308
|
+
personas,
|
|
1309
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1310
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1311
|
+
};
|
|
1312
|
+
const filename = `${slugify(name)}-${panel.id.slice(0, 8)}.jsonl`;
|
|
1313
|
+
const lines = personas.map((p) => JSON.stringify(p));
|
|
1314
|
+
const meta = JSON.stringify({
|
|
1315
|
+
_type: "panel",
|
|
1316
|
+
id: panel.id,
|
|
1317
|
+
name: panel.name,
|
|
1318
|
+
product: panel.product,
|
|
1319
|
+
personaCount: personas.length,
|
|
1320
|
+
createdAt: panel.createdAt
|
|
1321
|
+
});
|
|
1322
|
+
await writeFile2(join2(dir, filename), [meta, ...lines].join("\n") + "\n");
|
|
1323
|
+
return panel;
|
|
1324
|
+
}
|
|
1325
|
+
async loadPanel(nameOrId) {
|
|
1326
|
+
const dir = join2(this.baseDir, "panels");
|
|
1327
|
+
try {
|
|
1328
|
+
const files = await readdir(dir);
|
|
1329
|
+
const match = files.find(
|
|
1330
|
+
(f) => f.includes(nameOrId) || f.startsWith(slugify(nameOrId))
|
|
1331
|
+
);
|
|
1332
|
+
if (!match) return null;
|
|
1333
|
+
const content = await readFile2(join2(dir, match), "utf-8");
|
|
1334
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
1335
|
+
if (lines.length === 0) return null;
|
|
1336
|
+
const meta = JSON.parse(lines[0]);
|
|
1337
|
+
const personas = lines.slice(1).map((l) => JSON.parse(l));
|
|
1338
|
+
return {
|
|
1339
|
+
id: meta.id,
|
|
1340
|
+
name: meta.name,
|
|
1341
|
+
product: meta.product,
|
|
1342
|
+
personas,
|
|
1343
|
+
createdAt: meta.createdAt,
|
|
1344
|
+
updatedAt: meta.createdAt
|
|
1345
|
+
};
|
|
1346
|
+
} catch {
|
|
1347
|
+
return null;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
async listPanels() {
|
|
1351
|
+
const dir = join2(this.baseDir, "panels");
|
|
1352
|
+
try {
|
|
1353
|
+
const files = await readdir(dir);
|
|
1354
|
+
const panels = [];
|
|
1355
|
+
for (const file of files) {
|
|
1356
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
1357
|
+
const content = await readFile2(join2(dir, file), "utf-8");
|
|
1358
|
+
const firstLine = content.split("\n")[0];
|
|
1359
|
+
if (!firstLine) continue;
|
|
1360
|
+
const meta = JSON.parse(firstLine);
|
|
1361
|
+
if (meta._type === "panel") {
|
|
1362
|
+
panels.push({
|
|
1363
|
+
id: meta.id,
|
|
1364
|
+
name: meta.name,
|
|
1365
|
+
personaCount: meta.personaCount,
|
|
1366
|
+
createdAt: meta.createdAt
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
return panels.sort(
|
|
1371
|
+
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
1372
|
+
);
|
|
1373
|
+
} catch {
|
|
1374
|
+
return [];
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
// -------------------------------------------------------------------------
|
|
1378
|
+
// Surveys
|
|
1379
|
+
// -------------------------------------------------------------------------
|
|
1380
|
+
async saveSurvey(result) {
|
|
1381
|
+
const dir = join2(this.baseDir, "surveys");
|
|
1382
|
+
await mkdir2(dir, { recursive: true });
|
|
1383
|
+
const filename = `${slugify(result.surveyName)}-${Date.now()}.jsonl`;
|
|
1384
|
+
const meta = JSON.stringify({
|
|
1385
|
+
_type: "survey",
|
|
1386
|
+
surveyName: result.surveyName,
|
|
1387
|
+
personaCount: result.personaCount,
|
|
1388
|
+
questionCount: result.questions.length,
|
|
1389
|
+
timestamp: result.timestamp
|
|
1390
|
+
});
|
|
1391
|
+
const questions = JSON.stringify({
|
|
1392
|
+
_type: "questions",
|
|
1393
|
+
questions: result.questions
|
|
1394
|
+
});
|
|
1395
|
+
const summary = JSON.stringify({
|
|
1396
|
+
_type: "summary",
|
|
1397
|
+
summary: result.summary
|
|
1398
|
+
});
|
|
1399
|
+
const responseLines = result.responses.map((r) => JSON.stringify(r));
|
|
1400
|
+
await writeFile2(
|
|
1401
|
+
join2(dir, filename),
|
|
1402
|
+
[meta, questions, summary, ...responseLines].join("\n") + "\n"
|
|
1403
|
+
);
|
|
1404
|
+
return filename;
|
|
1405
|
+
}
|
|
1406
|
+
// -------------------------------------------------------------------------
|
|
1407
|
+
// Conversation History
|
|
1408
|
+
// -------------------------------------------------------------------------
|
|
1409
|
+
async appendResponse(response) {
|
|
1410
|
+
const dir = join2(this.baseDir, "history");
|
|
1411
|
+
await mkdir2(dir, { recursive: true });
|
|
1412
|
+
const filename = `${response.personaId}.jsonl`;
|
|
1413
|
+
const line = JSON.stringify({
|
|
1414
|
+
...response,
|
|
1415
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1416
|
+
}) + "\n";
|
|
1417
|
+
const filepath = join2(dir, filename);
|
|
1418
|
+
try {
|
|
1419
|
+
const existing = await readFile2(filepath, "utf-8");
|
|
1420
|
+
await writeFile2(filepath, existing + line);
|
|
1421
|
+
} catch {
|
|
1422
|
+
await writeFile2(filepath, line);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
function slugify(s) {
|
|
1427
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// src/traces/store.ts
|
|
1431
|
+
import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir3, readdir as readdir2 } from "fs/promises";
|
|
1432
|
+
import { join as join3 } from "path";
|
|
1433
|
+
var TraceStore = class {
|
|
1434
|
+
baseDir;
|
|
1435
|
+
constructor(storageDir) {
|
|
1436
|
+
this.baseDir = join3(storageDir ?? join3(process.cwd(), ".dopple"), "traces");
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Save a trace.
|
|
1440
|
+
*/
|
|
1441
|
+
async save(trace) {
|
|
1442
|
+
const dir = join3(this.baseDir, slugify2(trace.product));
|
|
1443
|
+
await mkdir3(dir, { recursive: true });
|
|
1444
|
+
await writeFile3(
|
|
1445
|
+
join3(dir, `${trace.id}.jsonl`),
|
|
1446
|
+
JSON.stringify(trace) + "\n"
|
|
1447
|
+
);
|
|
1448
|
+
await this.updateIndex(trace.product);
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Record an outcome for an existing trace.
|
|
1452
|
+
*/
|
|
1453
|
+
async recordOutcome(product, traceId, outcome, accuracy) {
|
|
1454
|
+
const dir = join3(this.baseDir, slugify2(product));
|
|
1455
|
+
const path = join3(dir, `${traceId}.jsonl`);
|
|
1456
|
+
const content = await readFile3(path, "utf-8");
|
|
1457
|
+
const trace = JSON.parse(content.trim());
|
|
1458
|
+
trace.outcome = outcome;
|
|
1459
|
+
trace.accuracy = accuracy;
|
|
1460
|
+
await writeFile3(path, JSON.stringify(trace) + "\n");
|
|
1461
|
+
await this.updateIndex(product);
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Get all traces for a product.
|
|
1465
|
+
*/
|
|
1466
|
+
async getProductHistory(product) {
|
|
1467
|
+
const dir = join3(this.baseDir, slugify2(product));
|
|
1468
|
+
const traces = await this.loadTraces(dir);
|
|
1469
|
+
return {
|
|
1470
|
+
product,
|
|
1471
|
+
traces,
|
|
1472
|
+
calibrationSummary: computeCalibrationSummary(traces, product)
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Get recent traces for a product (for prompt injection).
|
|
1477
|
+
*/
|
|
1478
|
+
async getRecentTraces(product, limit = 10) {
|
|
1479
|
+
const dir = join3(this.baseDir, slugify2(product));
|
|
1480
|
+
const traces = await this.loadTraces(dir);
|
|
1481
|
+
return traces.sort(
|
|
1482
|
+
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
1483
|
+
).slice(0, limit);
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* Get traces that have outcomes (for calibration history).
|
|
1487
|
+
*/
|
|
1488
|
+
async getTracesWithOutcomes(product) {
|
|
1489
|
+
const dir = join3(this.baseDir, slugify2(product));
|
|
1490
|
+
const traces = await this.loadTraces(dir);
|
|
1491
|
+
return traces.filter((t) => t.outcome !== null);
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Get the calibration narrative for prompt injection.
|
|
1495
|
+
* This is the key method — it tells the LLM how accurate past predictions were.
|
|
1496
|
+
*/
|
|
1497
|
+
async getCalibrationContext(product) {
|
|
1498
|
+
const history = await this.getProductHistory(product);
|
|
1499
|
+
return history.calibrationSummary.narrative;
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* List all products that have traces.
|
|
1503
|
+
*/
|
|
1504
|
+
async listProducts() {
|
|
1505
|
+
try {
|
|
1506
|
+
const dirs = await readdir2(this.baseDir);
|
|
1507
|
+
const indices = [];
|
|
1508
|
+
for (const dir of dirs) {
|
|
1509
|
+
const indexPath = join3(this.baseDir, dir, "index.jsonl");
|
|
1510
|
+
try {
|
|
1511
|
+
const content = await readFile3(indexPath, "utf-8");
|
|
1512
|
+
indices.push(JSON.parse(content.trim()));
|
|
1513
|
+
} catch {
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
return indices.sort(
|
|
1517
|
+
(a, b) => new Date(b.lastTraceAt).getTime() - new Date(a.lastTraceAt).getTime()
|
|
1518
|
+
);
|
|
1519
|
+
} catch {
|
|
1520
|
+
return [];
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
// -------------------------------------------------------------------------
|
|
1524
|
+
// Internal
|
|
1525
|
+
// -------------------------------------------------------------------------
|
|
1526
|
+
async loadTraces(dir) {
|
|
1527
|
+
try {
|
|
1528
|
+
const files = await readdir2(dir);
|
|
1529
|
+
const traces = [];
|
|
1530
|
+
for (const file of files) {
|
|
1531
|
+
if (file === "index.jsonl" || !file.endsWith(".jsonl")) continue;
|
|
1532
|
+
try {
|
|
1533
|
+
const content = await readFile3(join3(dir, file), "utf-8");
|
|
1534
|
+
traces.push(JSON.parse(content.trim()));
|
|
1535
|
+
} catch {
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
return traces;
|
|
1539
|
+
} catch {
|
|
1540
|
+
return [];
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
async updateIndex(product) {
|
|
1544
|
+
const dir = join3(this.baseDir, slugify2(product));
|
|
1545
|
+
const traces = await this.loadTraces(dir);
|
|
1546
|
+
const withOutcomes = traces.filter((t) => t.outcome !== null);
|
|
1547
|
+
const maes = withOutcomes.map((t) => t.accuracy?.mae).filter((m) => m !== void 0 && m !== null);
|
|
1548
|
+
const typeCounts = {};
|
|
1549
|
+
for (const t of traces) {
|
|
1550
|
+
typeCounts[t.type] = (typeCounts[t.type] ?? 0) + 1;
|
|
1551
|
+
}
|
|
1552
|
+
const index = {
|
|
1553
|
+
product,
|
|
1554
|
+
traceCount: traces.length,
|
|
1555
|
+
withOutcomes: withOutcomes.length,
|
|
1556
|
+
averageAccuracy: maes.length > 0 ? maes.reduce((a, b) => a + b, 0) / maes.length : null,
|
|
1557
|
+
lastTraceAt: traces.length > 0 ? traces.sort(
|
|
1558
|
+
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
1559
|
+
)[0].timestamp : (/* @__PURE__ */ new Date()).toISOString(),
|
|
1560
|
+
traceTypes: typeCounts
|
|
1561
|
+
};
|
|
1562
|
+
await writeFile3(join3(dir, "index.jsonl"), JSON.stringify(index) + "\n");
|
|
1563
|
+
}
|
|
1564
|
+
};
|
|
1565
|
+
function computeCalibrationSummary(traces, product) {
|
|
1566
|
+
const withOutcomes = traces.filter((t) => t.outcome !== null);
|
|
1567
|
+
if (withOutcomes.length === 0) {
|
|
1568
|
+
return {
|
|
1569
|
+
totalPredictions: traces.length,
|
|
1570
|
+
withOutcomes: 0,
|
|
1571
|
+
averageMAE: null,
|
|
1572
|
+
directionAccuracy: null,
|
|
1573
|
+
narrative: traces.length > 0 ? `${traces.length} predictions made for "${product}" but no outcomes recorded yet. Predictions are uncalibrated.` : `No prediction history for "${product}".`
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
const maes = withOutcomes.map((t) => t.accuracy?.mae).filter((m) => m !== void 0 && m !== null);
|
|
1577
|
+
const directionResults = withOutcomes.map((t) => t.accuracy?.directionCorrect).filter((d) => d !== void 0 && d !== null);
|
|
1578
|
+
const averageMAE = maes.length > 0 ? maes.reduce((a, b) => a + b, 0) / maes.length : null;
|
|
1579
|
+
const directionAccuracy = directionResults.length > 0 ? directionResults.filter(Boolean).length / directionResults.length : null;
|
|
1580
|
+
const parts = [];
|
|
1581
|
+
parts.push(
|
|
1582
|
+
`CALIBRATION HISTORY for "${product}": ${withOutcomes.length} predictions have been verified against real outcomes.`
|
|
1583
|
+
);
|
|
1584
|
+
if (averageMAE !== null) {
|
|
1585
|
+
if (averageMAE <= 0.05) {
|
|
1586
|
+
parts.push(
|
|
1587
|
+
`Average error: ${(averageMAE * 100).toFixed(1)}pp \u2014 excellent accuracy. Your predictions closely match reality.`
|
|
1588
|
+
);
|
|
1589
|
+
} else if (averageMAE <= 0.1) {
|
|
1590
|
+
parts.push(
|
|
1591
|
+
`Average error: ${(averageMAE * 100).toFixed(1)}pp \u2014 good accuracy. Minor adjustments may improve predictions.`
|
|
1592
|
+
);
|
|
1593
|
+
} else {
|
|
1594
|
+
parts.push(
|
|
1595
|
+
`Average error: ${(averageMAE * 100).toFixed(1)}pp \u2014 moderate accuracy. Be conservative with confidence levels.`
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
if (directionAccuracy !== null) {
|
|
1600
|
+
parts.push(
|
|
1601
|
+
`Directional accuracy: ${(directionAccuracy * 100).toFixed(0)}% of predictions got the direction right.`
|
|
1602
|
+
);
|
|
1603
|
+
}
|
|
1604
|
+
const overEstimates = withOutcomes.filter(
|
|
1605
|
+
(t) => t.accuracy?.notes?.toLowerCase().includes("over") || t.accuracy?.mae && t.accuracy.mae > 0.05
|
|
1606
|
+
);
|
|
1607
|
+
const underEstimates = withOutcomes.filter(
|
|
1608
|
+
(t) => t.accuracy?.notes?.toLowerCase().includes("under")
|
|
1609
|
+
);
|
|
1610
|
+
if (overEstimates.length > underEstimates.length) {
|
|
1611
|
+
parts.push(
|
|
1612
|
+
"Tendency: you tend to OVER-estimate negative outcomes (churn, dissatisfaction). Adjust predictions slightly more optimistic."
|
|
1613
|
+
);
|
|
1614
|
+
} else if (underEstimates.length > overEstimates.length) {
|
|
1615
|
+
parts.push(
|
|
1616
|
+
"Tendency: you tend to UNDER-estimate negative outcomes. Adjust predictions slightly more conservative."
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
const mostRecent = withOutcomes.sort(
|
|
1620
|
+
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
1621
|
+
)[0];
|
|
1622
|
+
if (mostRecent) {
|
|
1623
|
+
parts.push(
|
|
1624
|
+
`Most recent verified prediction: "${mostRecent.prediction.summary}" \u2192 Actual: "${mostRecent.outcome.actual}" (${mostRecent.accuracy?.notes ?? "no notes"})`
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1627
|
+
return {
|
|
1628
|
+
totalPredictions: traces.length,
|
|
1629
|
+
withOutcomes: withOutcomes.length,
|
|
1630
|
+
averageMAE,
|
|
1631
|
+
directionAccuracy,
|
|
1632
|
+
narrative: parts.join("\n")
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
function slugify2(s) {
|
|
1636
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// src/survey/survey.ts
|
|
1640
|
+
var Survey = class {
|
|
1641
|
+
name;
|
|
1642
|
+
questions = [];
|
|
1643
|
+
constructor(name) {
|
|
1644
|
+
this.name = name;
|
|
1645
|
+
}
|
|
1646
|
+
freeText(name, text) {
|
|
1647
|
+
this.questions.push({ name, text, type: "free_text" });
|
|
1648
|
+
return this;
|
|
1649
|
+
}
|
|
1650
|
+
multipleChoice(name, text, options) {
|
|
1651
|
+
this.questions.push({ name, text, type: "multiple_choice", options });
|
|
1652
|
+
return this;
|
|
1653
|
+
}
|
|
1654
|
+
likert5(name, text) {
|
|
1655
|
+
this.questions.push({
|
|
1656
|
+
name,
|
|
1657
|
+
text,
|
|
1658
|
+
type: "likert_5",
|
|
1659
|
+
options: [
|
|
1660
|
+
"Strongly disagree",
|
|
1661
|
+
"Disagree",
|
|
1662
|
+
"Neutral",
|
|
1663
|
+
"Agree",
|
|
1664
|
+
"Strongly agree"
|
|
1665
|
+
]
|
|
1666
|
+
});
|
|
1667
|
+
return this;
|
|
1668
|
+
}
|
|
1669
|
+
likert7(name, text) {
|
|
1670
|
+
this.questions.push({
|
|
1671
|
+
name,
|
|
1672
|
+
text,
|
|
1673
|
+
type: "likert_7",
|
|
1674
|
+
options: [
|
|
1675
|
+
"Strongly disagree",
|
|
1676
|
+
"Disagree",
|
|
1677
|
+
"Somewhat disagree",
|
|
1678
|
+
"Neutral",
|
|
1679
|
+
"Somewhat agree",
|
|
1680
|
+
"Agree",
|
|
1681
|
+
"Strongly agree"
|
|
1682
|
+
]
|
|
1683
|
+
});
|
|
1684
|
+
return this;
|
|
1685
|
+
}
|
|
1686
|
+
yesNo(name, text) {
|
|
1687
|
+
this.questions.push({ name, text, type: "yes_no" });
|
|
1688
|
+
return this;
|
|
1689
|
+
}
|
|
1690
|
+
numerical(name, text, min, max) {
|
|
1691
|
+
this.questions.push({ name, text, type: "numerical", min, max });
|
|
1692
|
+
return this;
|
|
1693
|
+
}
|
|
1694
|
+
ranking(name, text, options) {
|
|
1695
|
+
this.questions.push({ name, text, type: "ranking", options });
|
|
1696
|
+
return this;
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Run the survey against a panel of personas.
|
|
1700
|
+
*/
|
|
1701
|
+
async run(personas, llm) {
|
|
1702
|
+
const allResponses = [];
|
|
1703
|
+
for (const persona of personas) {
|
|
1704
|
+
for (const question of this.questions) {
|
|
1705
|
+
const response = await askSurveyQuestion(persona, question, llm);
|
|
1706
|
+
allResponses.push(response);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
const summary = {};
|
|
1710
|
+
for (const q of this.questions) {
|
|
1711
|
+
const qResponses = allResponses.filter((r) => r.questionName === q.name);
|
|
1712
|
+
summary[q.name] = {
|
|
1713
|
+
questionText: q.text,
|
|
1714
|
+
type: q.type,
|
|
1715
|
+
distribution: buildDistribution(q, qResponses),
|
|
1716
|
+
themes: q.type === "free_text" ? extractThemes(qResponses) : void 0
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
return {
|
|
1720
|
+
surveyName: this.name,
|
|
1721
|
+
questions: this.questions,
|
|
1722
|
+
responses: allResponses,
|
|
1723
|
+
summary,
|
|
1724
|
+
personaCount: personas.length,
|
|
1725
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
getQuestions() {
|
|
1729
|
+
return [...this.questions];
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
async function askSurveyQuestion(persona, question, llm) {
|
|
1733
|
+
const formatInstruction = getFormatInstruction(question);
|
|
1734
|
+
const prompt = `Survey question: "${question.text}"
|
|
1735
|
+
|
|
1736
|
+
${formatInstruction}
|
|
1737
|
+
|
|
1738
|
+
Respond as JSON with this exact structure:
|
|
1739
|
+
{
|
|
1740
|
+
"answer": <your answer matching the format above>,
|
|
1741
|
+
"reasoning": "<1-2 sentences explaining WHY you answered this way, referencing your personality traits or experiences>",
|
|
1742
|
+
"citations": [
|
|
1743
|
+
{"source": "<data source>", "detail": "<specific data point>", "weight": <0-1>}
|
|
1744
|
+
]
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
For citations.source, use one of: "trait-model", "behavioral-data", "support-data", "payment-data", "general-knowledge"
|
|
1748
|
+
If you have no specific data to cite, use "trait-model" and reference which personality trait drives this answer.`;
|
|
1749
|
+
const systemPrompt = persona.toPrompt();
|
|
1750
|
+
const raw = await llm.generateJSON(systemPrompt, prompt);
|
|
1751
|
+
return {
|
|
1752
|
+
personaId: persona.id,
|
|
1753
|
+
personaName: persona.name,
|
|
1754
|
+
questionName: question.name,
|
|
1755
|
+
questionText: question.text,
|
|
1756
|
+
answer: raw.answer,
|
|
1757
|
+
confidence: persona.definition.confidence,
|
|
1758
|
+
citations: raw.citations ?? [],
|
|
1759
|
+
reasoning: raw.reasoning ?? ""
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
function getFormatInstruction(q) {
|
|
1763
|
+
switch (q.type) {
|
|
1764
|
+
case "free_text":
|
|
1765
|
+
return 'Answer in 1-3 sentences. Set "answer" to your text response.';
|
|
1766
|
+
case "multiple_choice":
|
|
1767
|
+
return `Choose ONE of these options exactly: ${q.options.map((o) => `"${o}"`).join(", ")}. Set "answer" to the chosen option string.`;
|
|
1768
|
+
case "likert_5":
|
|
1769
|
+
return `Rate on this scale: ${q.options.join(", ")}. Set "answer" to one of these exact strings.`;
|
|
1770
|
+
case "likert_7":
|
|
1771
|
+
return `Rate on this scale: ${q.options.join(", ")}. Set "answer" to one of these exact strings.`;
|
|
1772
|
+
case "yes_no":
|
|
1773
|
+
return 'Answer "Yes" or "No". Set "answer" to "Yes" or "No".';
|
|
1774
|
+
case "numerical":
|
|
1775
|
+
return `Provide a number${q.min != null ? ` (min: ${q.min}` : ""}${q.max != null ? `, max: ${q.max})` : q.min != null ? ")" : ""}. Set "answer" to the number.`;
|
|
1776
|
+
case "ranking":
|
|
1777
|
+
return `Rank these from most to least important: ${q.options.join(", ")}. Set "answer" to an array of strings in your preferred order.`;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
function buildDistribution(q, responses) {
|
|
1781
|
+
const dist = {};
|
|
1782
|
+
if (q.type === "free_text") {
|
|
1783
|
+
dist["total_responses"] = responses.length;
|
|
1784
|
+
return dist;
|
|
1785
|
+
}
|
|
1786
|
+
for (const r of responses) {
|
|
1787
|
+
const key = String(r.answer);
|
|
1788
|
+
dist[key] = (dist[key] ?? 0) + 1;
|
|
1789
|
+
}
|
|
1790
|
+
return dist;
|
|
1791
|
+
}
|
|
1792
|
+
function extractThemes(responses) {
|
|
1793
|
+
const wordCounts = {};
|
|
1794
|
+
for (const r of responses) {
|
|
1795
|
+
const words = String(r.answer).toLowerCase().split(/\W+/).filter((w) => w.length > 4);
|
|
1796
|
+
for (const w of words) {
|
|
1797
|
+
wordCounts[w] = (wordCounts[w] ?? 0) + 1;
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
return Object.entries(wordCounts).filter(([, count]) => count >= 2).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([word]) => word);
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// src/simulation/focus-group.ts
|
|
1804
|
+
var FocusGroup = class {
|
|
1805
|
+
name;
|
|
1806
|
+
config;
|
|
1807
|
+
personas = [];
|
|
1808
|
+
rounds = [];
|
|
1809
|
+
currentRound = 0;
|
|
1810
|
+
injectedContextQueue = [];
|
|
1811
|
+
privateContext = {};
|
|
1812
|
+
constructor(name, config) {
|
|
1813
|
+
this.name = name;
|
|
1814
|
+
this.config = config;
|
|
1815
|
+
if (config.personaContext) {
|
|
1816
|
+
this.privateContext = { ...config.personaContext };
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
addPersonas(personas) {
|
|
1820
|
+
this.personas.push(...personas);
|
|
1821
|
+
}
|
|
1822
|
+
/**
|
|
1823
|
+
* Set private context for a specific persona.
|
|
1824
|
+
* This information is only visible to them — other participants don't see it.
|
|
1825
|
+
*/
|
|
1826
|
+
setPersonaContext(nameOrId, context) {
|
|
1827
|
+
this.privateContext[nameOrId] = context;
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Inject new context visible to ALL personas at the start of the next round.
|
|
1831
|
+
*/
|
|
1832
|
+
inject(context) {
|
|
1833
|
+
this.injectedContextQueue.push(context);
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Run one round of discussion.
|
|
1837
|
+
* Each persona speaks in turn, seeing what others said before them.
|
|
1838
|
+
* Personas can change their minds if persuaded.
|
|
1839
|
+
*/
|
|
1840
|
+
async discuss(llm) {
|
|
1841
|
+
this.currentRound++;
|
|
1842
|
+
const maxRounds = this.config.maxRounds ?? 5;
|
|
1843
|
+
if (this.currentRound > maxRounds) {
|
|
1844
|
+
throw new Error(
|
|
1845
|
+
`Maximum rounds (${maxRounds}) reached. Create a new focus group or increase maxRounds.`
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
const injected = this.injectedContextQueue.length > 0 ? this.injectedContextQueue.splice(0).join("\n") : null;
|
|
1849
|
+
const messages = [];
|
|
1850
|
+
const roundMessages = [];
|
|
1851
|
+
const speakingOrder = [...this.personas].sort(() => Math.random() - 0.5);
|
|
1852
|
+
for (const persona of speakingOrder) {
|
|
1853
|
+
const personalContext = this.privateContext[persona.name] ?? this.privateContext[persona.id] ?? null;
|
|
1854
|
+
const prompt = buildGroupPrompt(
|
|
1855
|
+
this.config,
|
|
1856
|
+
this.currentRound,
|
|
1857
|
+
this.getRecentHistory(3),
|
|
1858
|
+
roundMessages,
|
|
1859
|
+
injected,
|
|
1860
|
+
persona.name,
|
|
1861
|
+
personalContext
|
|
1862
|
+
);
|
|
1863
|
+
const response = await llm.generateJSON(
|
|
1864
|
+
persona.toPrompt() + GROUP_INSTRUCTIONS,
|
|
1865
|
+
prompt
|
|
1866
|
+
);
|
|
1867
|
+
const msg = {
|
|
1868
|
+
round: this.currentRound,
|
|
1869
|
+
personaId: persona.id,
|
|
1870
|
+
personaName: persona.name,
|
|
1871
|
+
message: response.message,
|
|
1872
|
+
reasoning: response.reasoning,
|
|
1873
|
+
citations: response.citations ?? [],
|
|
1874
|
+
reactingTo: roundMessages.length > 0 ? roundMessages[roundMessages.length - 1] : null,
|
|
1875
|
+
opinionShift: response.opinionShift ?? null,
|
|
1876
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1877
|
+
};
|
|
1878
|
+
messages.push(msg);
|
|
1879
|
+
roundMessages.push(`${persona.name}: ${response.message}`);
|
|
1880
|
+
}
|
|
1881
|
+
const round2 = {
|
|
1882
|
+
round: this.currentRound,
|
|
1883
|
+
messages,
|
|
1884
|
+
injectedContext: injected
|
|
1885
|
+
};
|
|
1886
|
+
this.rounds.push(round2);
|
|
1887
|
+
return round2;
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Get all opinion shifts across all rounds.
|
|
1891
|
+
*/
|
|
1892
|
+
getOpinionShifts() {
|
|
1893
|
+
const shifts = [];
|
|
1894
|
+
for (const round2 of this.rounds) {
|
|
1895
|
+
for (const msg of round2.messages) {
|
|
1896
|
+
if (msg.opinionShift) {
|
|
1897
|
+
shifts.push({
|
|
1898
|
+
personaId: msg.personaId,
|
|
1899
|
+
personaName: msg.personaName,
|
|
1900
|
+
previousPosition: msg.opinionShift.previousPosition,
|
|
1901
|
+
newPosition: msg.opinionShift.newPosition,
|
|
1902
|
+
trigger: {
|
|
1903
|
+
personaName: msg.opinionShift.triggeredBy,
|
|
1904
|
+
message: round2.messages.find(
|
|
1905
|
+
(m) => m.personaName === msg.opinionShift.triggeredBy
|
|
1906
|
+
)?.message ?? ""
|
|
1907
|
+
},
|
|
1908
|
+
round: round2.round,
|
|
1909
|
+
reasoning: msg.reasoning
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
return shifts;
|
|
1915
|
+
}
|
|
1916
|
+
getTranscript() {
|
|
1917
|
+
return [...this.rounds];
|
|
1918
|
+
}
|
|
1919
|
+
getAllMessages() {
|
|
1920
|
+
return this.rounds.flatMap((r) => r.messages);
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* Summarize the discussion — themes, agreements, disagreements, insights, opinion shifts.
|
|
1924
|
+
*/
|
|
1925
|
+
async summarize(llm) {
|
|
1926
|
+
const transcript = this.rounds.flatMap((r) => {
|
|
1927
|
+
const lines = [];
|
|
1928
|
+
if (r.injectedContext) {
|
|
1929
|
+
lines.push(`[MODERATOR: ${r.injectedContext}]`);
|
|
1930
|
+
}
|
|
1931
|
+
for (const m of r.messages) {
|
|
1932
|
+
let line = `${m.personaName}: ${m.message}`;
|
|
1933
|
+
if (m.opinionShift) {
|
|
1934
|
+
line += ` [SHIFTED: from "${m.opinionShift.previousPosition}" to "${m.opinionShift.newPosition}", triggered by ${m.opinionShift.triggeredBy}]`;
|
|
1935
|
+
}
|
|
1936
|
+
lines.push(line);
|
|
1937
|
+
}
|
|
1938
|
+
return lines;
|
|
1939
|
+
}).join("\n");
|
|
1940
|
+
const analysis = await llm.generateJSON(
|
|
1941
|
+
"You are a focus group moderator analyzing a discussion transcript.",
|
|
1942
|
+
`Analyze this focus group discussion about "${this.config.topic}" for ${this.config.product ?? "a product"}.
|
|
1943
|
+
|
|
1944
|
+
Transcript:
|
|
1945
|
+
${transcript}
|
|
1946
|
+
|
|
1947
|
+
Return JSON with:
|
|
1948
|
+
- themes: top 3-5 recurring themes
|
|
1949
|
+
- agreements: points where most participants agreed
|
|
1950
|
+
- disagreements: points of contention or split opinions
|
|
1951
|
+
- insights: 2-3 non-obvious insights that emerged from the discussion (pay special attention to opinion shifts \u2014 when someone changed their mind, that's often the most revealing moment)`
|
|
1952
|
+
);
|
|
1953
|
+
return {
|
|
1954
|
+
...analysis,
|
|
1955
|
+
opinionShifts: this.getOpinionShifts()
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
get roundCount() {
|
|
1959
|
+
return this.currentRound;
|
|
1960
|
+
}
|
|
1961
|
+
get size() {
|
|
1962
|
+
return this.personas.length;
|
|
1963
|
+
}
|
|
1964
|
+
// -------------------------------------------------------------------------
|
|
1965
|
+
// Internal
|
|
1966
|
+
// -------------------------------------------------------------------------
|
|
1967
|
+
getRecentHistory(rounds) {
|
|
1968
|
+
const recent = this.rounds.slice(-rounds);
|
|
1969
|
+
const lines = [];
|
|
1970
|
+
for (const r of recent) {
|
|
1971
|
+
if (r.injectedContext) {
|
|
1972
|
+
lines.push(`[New information: ${r.injectedContext}]`);
|
|
1973
|
+
}
|
|
1974
|
+
for (const m of r.messages) {
|
|
1975
|
+
lines.push(`${m.personaName}: ${m.message}`);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
return lines;
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1981
|
+
function buildGroupPrompt(config, round2, history, currentRoundMessages, injectedContext, speakerName, personalContext) {
|
|
1982
|
+
const parts = [];
|
|
1983
|
+
parts.push(
|
|
1984
|
+
`You are in a focus group discussion (round ${round2}) about: "${config.topic}"`
|
|
1985
|
+
);
|
|
1986
|
+
if (config.product) {
|
|
1987
|
+
parts.push(`Product: ${config.product}`);
|
|
1988
|
+
}
|
|
1989
|
+
if (personalContext && personalContext.length > 0) {
|
|
1990
|
+
parts.push(
|
|
1991
|
+
`INFORMATION ONLY YOU HAVE (other participants don't know this):
|
|
1992
|
+
${personalContext.map((c) => `- ${c}`).join("\n")}`
|
|
1993
|
+
);
|
|
1994
|
+
}
|
|
1995
|
+
if (history.length > 0) {
|
|
1996
|
+
parts.push(`Previous discussion:
|
|
1997
|
+
${history.join("\n")}`);
|
|
1998
|
+
}
|
|
1999
|
+
if (injectedContext) {
|
|
2000
|
+
parts.push(
|
|
2001
|
+
`NEW INFORMATION just shared by the moderator:
|
|
2002
|
+
"${injectedContext}"
|
|
2003
|
+
|
|
2004
|
+
React to this new information in your response.`
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
2007
|
+
if (currentRoundMessages.length > 0) {
|
|
2008
|
+
parts.push(
|
|
2009
|
+
`Others have already spoken this round:
|
|
2010
|
+
${currentRoundMessages.join("\n")}
|
|
2011
|
+
|
|
2012
|
+
You may agree, disagree, or build on what they said.`
|
|
2013
|
+
);
|
|
2014
|
+
}
|
|
2015
|
+
parts.push(`Now it's YOUR turn to speak, ${speakerName}.
|
|
2016
|
+
|
|
2017
|
+
Respond as JSON:
|
|
2018
|
+
{
|
|
2019
|
+
"message": "<your contribution, 2-4 sentences. Be natural \u2014 agree, disagree, ask questions, share experiences>",
|
|
2020
|
+
"reasoning": "<which personality traits drive your perspective here>",
|
|
2021
|
+
"citations": [{"source": "<source>", "detail": "<detail>", "weight": <0-1>}],
|
|
2022
|
+
"opinionShift": null
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
If someone genuinely changed your mind this round, set opinionShift to:
|
|
2026
|
+
{
|
|
2027
|
+
"previousPosition": "<what you believed before>",
|
|
2028
|
+
"newPosition": "<what you believe now>",
|
|
2029
|
+
"triggeredBy": "<name of person whose argument convinced you>"
|
|
2030
|
+
}
|
|
2031
|
+
Otherwise keep opinionShift as null. Opinion shifts should be rare and genuine.`);
|
|
2032
|
+
return parts.join("\n\n");
|
|
2033
|
+
}
|
|
2034
|
+
var GROUP_INSTRUCTIONS = `
|
|
2035
|
+
|
|
2036
|
+
FOCUS GROUP RULES:
|
|
2037
|
+
- You are in a group discussion with other people. React to what others say.
|
|
2038
|
+
- Be natural \u2014 agree, disagree, interrupt, ask questions, share personal experiences.
|
|
2039
|
+
- Don't just answer the moderator's question. Engage with other participants.
|
|
2040
|
+
- If someone says something you disagree with, push back respectfully.
|
|
2041
|
+
- If new information is introduced, react honestly \u2014 it might change your mind or reinforce your position.
|
|
2042
|
+
- If someone makes a genuinely persuasive point, you ARE allowed to change your mind. Be honest about it.
|
|
2043
|
+
- Opinion shifts should be rare and meaningful. Don't flip-flop \u2014 only shift when truly convinced.
|
|
2044
|
+
- If you have private information, you may reference it naturally without explicitly saying "I have private info."
|
|
2045
|
+
- Stay in character. Your personality traits should be obvious from how you interact.
|
|
2046
|
+
- Keep responses to 2-4 sentences. This is a conversation, not a monologue.`;
|
|
2047
|
+
|
|
2048
|
+
// src/insights/analyzer.ts
|
|
2049
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
2050
|
+
async function discoverInsights(llm, userData, segments, personas, product, graph, graphPatterns) {
|
|
2051
|
+
const questions = await generateQuestions(
|
|
2052
|
+
llm,
|
|
2053
|
+
userData,
|
|
2054
|
+
segments,
|
|
2055
|
+
product,
|
|
2056
|
+
graphPatterns
|
|
2057
|
+
);
|
|
2058
|
+
const personaResponses = [];
|
|
2059
|
+
for (const q of questions) {
|
|
2060
|
+
for (const persona of personas) {
|
|
2061
|
+
const r = await persona.ask(q.question);
|
|
2062
|
+
const segment = segments.find((s) => s.persona.id === persona.id);
|
|
2063
|
+
personaResponses.push({
|
|
2064
|
+
question: q.question,
|
|
2065
|
+
personaName: persona.name,
|
|
2066
|
+
segmentName: segment?.name ?? "unknown",
|
|
2067
|
+
response: r.response
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
const insights = await synthesizeInsights(
|
|
2072
|
+
llm,
|
|
2073
|
+
userData,
|
|
2074
|
+
segments,
|
|
2075
|
+
personaResponses,
|
|
2076
|
+
product,
|
|
2077
|
+
graphPatterns
|
|
2078
|
+
);
|
|
2079
|
+
return insights.sort((a, b) => b.impact * b.confidence - a.impact * a.confidence).slice(0, 7);
|
|
2080
|
+
}
|
|
2081
|
+
async function generateQuestions(llm, userData, segments, product, graphPatterns) {
|
|
2082
|
+
const dataSummary = buildDataSummary2(userData);
|
|
2083
|
+
const segmentSummary = segments.map(
|
|
2084
|
+
(s) => `"${s.name}" (${s.percentage}%): ${s.description}. Patterns: ${s.patterns.join(", ")}`
|
|
2085
|
+
).join("\n");
|
|
2086
|
+
const patternSummary = graphPatterns ? graphPatterns.slice(0, 5).map((p) => p.description).join("\n") : "No graph patterns available.";
|
|
2087
|
+
const questions = await llm.generateJSON(
|
|
2088
|
+
`You are a product analyst. Given user data, segments, and patterns, you generate the most important questions to ask users to uncover actionable insights. Do NOT use generic questions \u2014 every question must be grounded in something specific from the data.`,
|
|
2089
|
+
`Product: ${product}
|
|
2090
|
+
|
|
2091
|
+
User Data Summary:
|
|
2092
|
+
${dataSummary}
|
|
2093
|
+
|
|
2094
|
+
Discovered Segments:
|
|
2095
|
+
${segmentSummary}
|
|
2096
|
+
|
|
2097
|
+
Graph Patterns:
|
|
2098
|
+
${patternSummary}
|
|
2099
|
+
|
|
2100
|
+
Generate 5-8 targeted questions that would reveal the most valuable product insights. Each question should be grounded in a specific data pattern or anomaly you noticed.
|
|
2101
|
+
|
|
2102
|
+
Return JSON array:
|
|
2103
|
+
[{
|
|
2104
|
+
"question": "<specific question grounded in the data>",
|
|
2105
|
+
"rationale": "<why this question matters \u2014 what data pattern prompted it>",
|
|
2106
|
+
"relevantSegments": ["<segment names this question is most relevant to>"]
|
|
2107
|
+
}]
|
|
2108
|
+
|
|
2109
|
+
Focus on questions that would reveal:
|
|
2110
|
+
- Why users behave the way they do (motivation behind data patterns)
|
|
2111
|
+
- What would change their behavior (actionable)
|
|
2112
|
+
- Where the biggest opportunities or risks are
|
|
2113
|
+
- Discrepancies between what different segments might want`
|
|
2114
|
+
);
|
|
2115
|
+
return questions ?? [];
|
|
2116
|
+
}
|
|
2117
|
+
async function synthesizeInsights(llm, userData, segments, personaResponses, product, graphPatterns) {
|
|
2118
|
+
const responsesSummary = personaResponses.map(
|
|
2119
|
+
(r) => `[${r.segmentName}] ${r.personaName}: "${r.response}" (Q: ${r.question})`
|
|
2120
|
+
).join("\n");
|
|
2121
|
+
const dataSummary = buildDataSummary2(userData);
|
|
2122
|
+
const patternSummary = graphPatterns ? graphPatterns.slice(0, 5).map((p) => p.description).join("\n") : "";
|
|
2123
|
+
const raw = await llm.generateJSON(
|
|
2124
|
+
`You are a senior product analyst synthesizing insights from user data, persona responses, and behavioral patterns. Your insights must be:
|
|
2125
|
+
1. ACTIONABLE \u2014 each insight has a clear "do this" recommendation
|
|
2126
|
+
2. GROUNDED \u2014 every claim cites specific data or persona responses
|
|
2127
|
+
3. NON-OBVIOUS \u2014 don't just restate the data, find the "so what?"
|
|
2128
|
+
4. HONEST \u2014 if confidence is low, say so. Don't inflate.`,
|
|
2129
|
+
`Product: ${product}
|
|
2130
|
+
|
|
2131
|
+
Real User Data:
|
|
2132
|
+
${dataSummary}
|
|
2133
|
+
|
|
2134
|
+
${patternSummary ? `Graph Patterns:
|
|
2135
|
+
${patternSummary}
|
|
2136
|
+
` : ""}
|
|
2137
|
+
|
|
2138
|
+
Persona Responses:
|
|
2139
|
+
${responsesSummary}
|
|
2140
|
+
|
|
2141
|
+
Synthesize 3-7 actionable insights. For each insight:
|
|
2142
|
+
- Cross-reference what personas said with what the real data shows
|
|
2143
|
+
- Look for discrepancies (persona says X but data shows Y \u2014 these are gold)
|
|
2144
|
+
- Look for agreement across segments (strong signal)
|
|
2145
|
+
- Look for polarization between segments (opportunity for segmented approach)
|
|
2146
|
+
|
|
2147
|
+
Return JSON array:
|
|
2148
|
+
[{
|
|
2149
|
+
"title": "<short, punchy insight title>",
|
|
2150
|
+
"description": "<2-3 sentence explanation>",
|
|
2151
|
+
"confidence": <0.0-1.0>,
|
|
2152
|
+
"impact": <0.0-1.0>,
|
|
2153
|
+
"evidence": [{"type": "data_pattern|persona_response|graph_pattern|cross_reference", "source": "<source>", "detail": "<specific evidence>", "weight": <0-1>}],
|
|
2154
|
+
"recommendation": "<specific action to take>",
|
|
2155
|
+
"segments": ["<which segments this applies to>"]
|
|
2156
|
+
}]`
|
|
2157
|
+
);
|
|
2158
|
+
return (raw ?? []).map((r) => ({
|
|
2159
|
+
id: randomUUID4(),
|
|
2160
|
+
...r
|
|
2161
|
+
}));
|
|
2162
|
+
}
|
|
2163
|
+
function buildDataSummary2(records) {
|
|
2164
|
+
const lines = [`Total users: ${records.length}`];
|
|
2165
|
+
const propCounts = {};
|
|
2166
|
+
for (const r of records) {
|
|
2167
|
+
for (const [key, value] of Object.entries(r.properties)) {
|
|
2168
|
+
if (key.startsWith("_")) continue;
|
|
2169
|
+
if (!propCounts[key]) propCounts[key] = {};
|
|
2170
|
+
const v = String(value);
|
|
2171
|
+
propCounts[key][v] = (propCounts[key][v] ?? 0) + 1;
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
for (const [prop, values] of Object.entries(propCounts)) {
|
|
2175
|
+
const sorted = Object.entries(values).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
2176
|
+
const total = records.length;
|
|
2177
|
+
const dist = sorted.map(([v, c]) => `${v} (${(c / total * 100).toFixed(0)}%)`).join(", ");
|
|
2178
|
+
lines.push(`${prop}: ${dist}`);
|
|
2179
|
+
}
|
|
2180
|
+
const eventCounts = {};
|
|
2181
|
+
for (const r of records) {
|
|
2182
|
+
for (const e of r.events ?? []) {
|
|
2183
|
+
eventCounts[e.name] = (eventCounts[e.name] ?? 0) + 1;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
if (Object.keys(eventCounts).length > 0) {
|
|
2187
|
+
const top = Object.entries(eventCounts).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([n, c]) => `${n} (${c}x)`).join(", ");
|
|
2188
|
+
lines.push(`Top events: ${top}`);
|
|
2189
|
+
}
|
|
2190
|
+
const statuses = {};
|
|
2191
|
+
const cancelReasons = [];
|
|
2192
|
+
for (const r of records) {
|
|
2193
|
+
for (const p of r.payments ?? []) {
|
|
2194
|
+
statuses[p.status] = (statuses[p.status] ?? 0) + 1;
|
|
2195
|
+
if (p.cancelReason) cancelReasons.push(p.cancelReason);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
if (Object.keys(statuses).length > 0) {
|
|
2199
|
+
lines.push(
|
|
2200
|
+
`Payments: ${Object.entries(statuses).map(([s, c]) => `${s}: ${c}`).join(", ")}`
|
|
2201
|
+
);
|
|
2202
|
+
}
|
|
2203
|
+
if (cancelReasons.length > 0) {
|
|
2204
|
+
lines.push(`Cancel reasons: ${cancelReasons.slice(0, 5).join("; ")}`);
|
|
2205
|
+
}
|
|
2206
|
+
return lines.join("\n");
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// src/insights/insights.ts
|
|
2210
|
+
async function runInsightsPipeline(dopple, llm, options) {
|
|
2211
|
+
const startTime = Date.now();
|
|
2212
|
+
const userData = await dopple.fetchData();
|
|
2213
|
+
let graph;
|
|
2214
|
+
let graphPatterns;
|
|
2215
|
+
if (options.useGraph && userData.length > 0) {
|
|
2216
|
+
const doppleGraph = await dopple.buildGraph(options.context);
|
|
2217
|
+
graph = doppleGraph.raw;
|
|
2218
|
+
graphPatterns = doppleGraph.patterns();
|
|
2219
|
+
}
|
|
2220
|
+
const discovery = await dopple.discover({ product: options.product });
|
|
2221
|
+
const personas = [];
|
|
2222
|
+
for (const segment of discovery.segments) {
|
|
2223
|
+
const persona = new Persona(segment.persona, llm);
|
|
2224
|
+
if (graph) {
|
|
2225
|
+
const { getPersonaContext } = await import("./query-GEL76KSF.js");
|
|
2226
|
+
const graphContext = getPersonaContext(graph, segment.persona.traits);
|
|
2227
|
+
persona.memory.addFacts(
|
|
2228
|
+
graphContext.map((c) => ({
|
|
2229
|
+
source: "knowledge-graph",
|
|
2230
|
+
content: c,
|
|
2231
|
+
salience: 0.7
|
|
2232
|
+
}))
|
|
2233
|
+
);
|
|
2234
|
+
}
|
|
2235
|
+
personas.push(persona);
|
|
2236
|
+
}
|
|
2237
|
+
const insights = await discoverInsights(
|
|
2238
|
+
llm,
|
|
2239
|
+
userData,
|
|
2240
|
+
discovery.segments,
|
|
2241
|
+
personas,
|
|
2242
|
+
options.product,
|
|
2243
|
+
graph,
|
|
2244
|
+
graphPatterns
|
|
2245
|
+
);
|
|
2246
|
+
const durationMs = Date.now() - startTime;
|
|
2247
|
+
return {
|
|
2248
|
+
product: options.product,
|
|
2249
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2250
|
+
dataQuality: {
|
|
2251
|
+
sources: [...new Set(userData.flatMap(() => ["user-data"]))],
|
|
2252
|
+
userCount: userData.length,
|
|
2253
|
+
confidence: discovery.confidence,
|
|
2254
|
+
gaps: discovery.suggestions
|
|
2255
|
+
},
|
|
2256
|
+
graph: graph ? {
|
|
2257
|
+
nodeCount: graph.metadata.nodeCount,
|
|
2258
|
+
edgeCount: graph.metadata.edgeCount,
|
|
2259
|
+
patterns: (graphPatterns ?? []).map((p) => p.description)
|
|
2260
|
+
} : void 0,
|
|
2261
|
+
segments: discovery.segments,
|
|
2262
|
+
insights,
|
|
2263
|
+
metadata: {
|
|
2264
|
+
model: llm.providerId,
|
|
2265
|
+
segmentCount: discovery.segments.length,
|
|
2266
|
+
personaCount: personas.length,
|
|
2267
|
+
durationMs
|
|
2268
|
+
}
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
// src/calibration/extract.ts
|
|
2273
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
2274
|
+
async function extractRules(llm, source) {
|
|
2275
|
+
const prompt = getExtractionPrompt(source);
|
|
2276
|
+
const extracted = await llm.generateJSON(
|
|
2277
|
+
`You extract testable rules and expectations from documents. Each rule should be something you can verify by asking a synthetic persona a question or putting them in a scenario.
|
|
2278
|
+
|
|
2279
|
+
Focus on rules that are:
|
|
2280
|
+
1. TESTABLE \u2014 you can design a scenario that checks if someone follows/understands this
|
|
2281
|
+
2. BEHAVIORAL \u2014 about what people would do, think, or expect
|
|
2282
|
+
3. SPECIFIC \u2014 not vague platitudes, but concrete predictions`,
|
|
2283
|
+
prompt
|
|
2284
|
+
);
|
|
2285
|
+
return (extracted ?? []).map((r) => ({
|
|
2286
|
+
id: randomUUID5(),
|
|
2287
|
+
rule: r.rule,
|
|
2288
|
+
source: source.name,
|
|
2289
|
+
testScenario: r.testScenario
|
|
2290
|
+
}));
|
|
2291
|
+
}
|
|
2292
|
+
function getExtractionPrompt(source) {
|
|
2293
|
+
switch (source.type) {
|
|
2294
|
+
case "policy":
|
|
2295
|
+
return `Extract testable rules from this policy document. For each rule, create a scenario that tests whether a user/citizen understands and would comply with this policy.
|
|
2296
|
+
|
|
2297
|
+
Policy document "${source.name}":
|
|
2298
|
+
${source.content}
|
|
2299
|
+
|
|
2300
|
+
Return JSON array:
|
|
2301
|
+
[{"rule": "<the policy rule or expectation>", "testScenario": "<a scenario/question that tests if a persona follows this rule>"}]
|
|
2302
|
+
|
|
2303
|
+
Extract 5-15 rules. Focus on rules that affect user behavior, not internal procedures.`;
|
|
2304
|
+
case "survey":
|
|
2305
|
+
return `Extract expected response patterns from this survey data. Each "rule" is a known finding \u2014 something real users actually said/chose.
|
|
2306
|
+
|
|
2307
|
+
Survey data "${source.name}":
|
|
2308
|
+
${source.content}
|
|
2309
|
+
|
|
2310
|
+
Return JSON array:
|
|
2311
|
+
[{"rule": "<the known finding from real users, e.g. '62% prefer monthly billing'>", "testScenario": "<ask the same question to check if synthetic personas match>"}]
|
|
2312
|
+
|
|
2313
|
+
Extract the most significant findings as rules.`;
|
|
2314
|
+
case "outcomes":
|
|
2315
|
+
return `Extract behavioral outcomes from this data. Each "rule" is something that actually happened \u2014 a measurable result.
|
|
2316
|
+
|
|
2317
|
+
Outcome data "${source.name}":
|
|
2318
|
+
${source.content}
|
|
2319
|
+
|
|
2320
|
+
Return JSON array:
|
|
2321
|
+
[{"rule": "<what actually happened, e.g. '22% churned after price increase'>", "testScenario": "<a scenario that tests if personas would predict this outcome>"}]
|
|
2322
|
+
|
|
2323
|
+
Focus on outcomes that reveal user motivations and decision-making.`;
|
|
2324
|
+
case "benchmarks":
|
|
2325
|
+
return `Extract industry benchmarks from this data. Each "rule" is a known metric or standard.
|
|
2326
|
+
|
|
2327
|
+
Benchmark data "${source.name}":
|
|
2328
|
+
${source.content}
|
|
2329
|
+
|
|
2330
|
+
Return JSON array:
|
|
2331
|
+
[{"rule": "<the benchmark, e.g. 'average SaaS NPS is 31'>", "testScenario": "<a scenario that tests if personas align with this benchmark>"}]`;
|
|
2332
|
+
case "document":
|
|
2333
|
+
default:
|
|
2334
|
+
return `Extract testable expectations from this document. Identify anything that makes a claim about how users/people behave, think, or make decisions.
|
|
2335
|
+
|
|
2336
|
+
Document "${source.name}":
|
|
2337
|
+
${source.content}
|
|
2338
|
+
|
|
2339
|
+
Return JSON array:
|
|
2340
|
+
[{"rule": "<the expectation or claim>", "testScenario": "<a scenario that tests if a persona matches this expectation>"}]`;
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
// src/calibration/test.ts
|
|
2345
|
+
async function testRule(llm, rule, personas) {
|
|
2346
|
+
const responses = [];
|
|
2347
|
+
for (const persona of personas) {
|
|
2348
|
+
const r = await persona.ask(rule.testScenario);
|
|
2349
|
+
responses.push(`${persona.name}: ${r.response}`);
|
|
2350
|
+
}
|
|
2351
|
+
const evaluation = await llm.generateJSON(
|
|
2352
|
+
`You evaluate whether synthetic persona responses align with a known rule or expectation. Be rigorous \u2014 a vague match is not alignment. Look for specific behavioral alignment.`,
|
|
2353
|
+
`Rule: "${rule.rule}"
|
|
2354
|
+
Source: ${rule.source}
|
|
2355
|
+
|
|
2356
|
+
Persona responses to the scenario "${rule.testScenario}":
|
|
2357
|
+
${responses.join("\n")}
|
|
2358
|
+
|
|
2359
|
+
Evaluate alignment. Return JSON:
|
|
2360
|
+
{
|
|
2361
|
+
"aligned": <true if the majority of personas match the rule's expectation>,
|
|
2362
|
+
"score": <0.0-1.0 alignment score. 1.0 = perfect match, 0.0 = complete mismatch>,
|
|
2363
|
+
"personaSummary": "<what the personas collectively said/did>",
|
|
2364
|
+
"expected": "<what was expected based on the rule>",
|
|
2365
|
+
"gapAnalysis": "<if misaligned: WHY the gap exists and what it means. If aligned: what confirms it>"
|
|
2366
|
+
}`
|
|
2367
|
+
);
|
|
2368
|
+
return {
|
|
2369
|
+
ruleId: rule.id,
|
|
2370
|
+
rule: rule.rule,
|
|
2371
|
+
aligned: evaluation.aligned,
|
|
2372
|
+
score: Math.max(0, Math.min(1, evaluation.score)),
|
|
2373
|
+
personaResponse: evaluation.personaSummary,
|
|
2374
|
+
expected: evaluation.expected,
|
|
2375
|
+
gapAnalysis: evaluation.gapAnalysis
|
|
2376
|
+
};
|
|
2377
|
+
}
|
|
2378
|
+
async function testAllRules(llm, rules, personas) {
|
|
2379
|
+
const results = [];
|
|
2380
|
+
for (const rule of rules) {
|
|
2381
|
+
for (const p of personas) p.clearHistory();
|
|
2382
|
+
const result = await testRule(llm, rule, personas);
|
|
2383
|
+
results.push(result);
|
|
2384
|
+
}
|
|
2385
|
+
return results;
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
// src/calibration/calibrate.ts
|
|
2389
|
+
async function calibrate(llm, personas, sources, options = {}) {
|
|
2390
|
+
const startTime = Date.now();
|
|
2391
|
+
const maxRules = options.maxRulesPerSource ?? 10;
|
|
2392
|
+
const allRules = [];
|
|
2393
|
+
for (const source of sources) {
|
|
2394
|
+
const rules = await extractRules(llm, source);
|
|
2395
|
+
allRules.push(...rules.slice(0, maxRules));
|
|
2396
|
+
}
|
|
2397
|
+
if (allRules.length === 0) {
|
|
2398
|
+
return emptyReport(sources, personas.length, Date.now() - startTime);
|
|
2399
|
+
}
|
|
2400
|
+
const results = await testAllRules(llm, allRules, personas);
|
|
2401
|
+
const alignedCount = results.filter((r) => r.aligned).length;
|
|
2402
|
+
const misalignedCount = results.length - alignedCount;
|
|
2403
|
+
const overallScore = results.length > 0 ? results.reduce((sum, r) => sum + r.score, 0) / results.length : 0;
|
|
2404
|
+
const topMisalignments = results.filter((r) => !r.aligned).sort((a, b) => a.score - b.score).slice(0, 5);
|
|
2405
|
+
const recommendations = await generateRecommendations(
|
|
2406
|
+
llm,
|
|
2407
|
+
topMisalignments,
|
|
2408
|
+
sources
|
|
2409
|
+
);
|
|
2410
|
+
return {
|
|
2411
|
+
overallScore,
|
|
2412
|
+
totalRules: results.length,
|
|
2413
|
+
alignedCount,
|
|
2414
|
+
misalignedCount,
|
|
2415
|
+
results,
|
|
2416
|
+
topMisalignments,
|
|
2417
|
+
recommendations,
|
|
2418
|
+
sources: sources.map((s) => ({ type: s.type, name: s.name })),
|
|
2419
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2420
|
+
metadata: {
|
|
2421
|
+
personaCount: personas.length,
|
|
2422
|
+
ruleCount: allRules.length,
|
|
2423
|
+
durationMs: Date.now() - startTime
|
|
2424
|
+
}
|
|
2425
|
+
};
|
|
2426
|
+
}
|
|
2427
|
+
async function generateRecommendations(llm, misalignments, sources) {
|
|
2428
|
+
if (misalignments.length === 0) {
|
|
2429
|
+
return ["All rules aligned. Persona panel matches the calibration sources well."];
|
|
2430
|
+
}
|
|
2431
|
+
const gapSummary = misalignments.map(
|
|
2432
|
+
(m) => `Rule: "${m.rule}"
|
|
2433
|
+
Expected: ${m.expected}
|
|
2434
|
+
Actual: ${m.personaResponse}
|
|
2435
|
+
Gap: ${m.gapAnalysis}`
|
|
2436
|
+
).join("\n\n");
|
|
2437
|
+
const sourceTypes = sources.map((s) => `${s.type}: "${s.name}"`).join(", ");
|
|
2438
|
+
const recs = await llm.generateJSON(
|
|
2439
|
+
"You generate actionable recommendations from calibration gaps between synthetic personas and ground truth data.",
|
|
2440
|
+
`These are the top misalignments between synthetic personas and calibration sources (${sourceTypes}):
|
|
2441
|
+
|
|
2442
|
+
${gapSummary}
|
|
2443
|
+
|
|
2444
|
+
Generate 3-5 actionable recommendations. Each should:
|
|
2445
|
+
1. Name the specific gap
|
|
2446
|
+
2. Explain what it means for the product/policy
|
|
2447
|
+
3. Suggest a concrete action
|
|
2448
|
+
|
|
2449
|
+
Return a JSON array of strings.`
|
|
2450
|
+
);
|
|
2451
|
+
return recs ?? [];
|
|
2452
|
+
}
|
|
2453
|
+
function emptyReport(sources, personaCount, durationMs) {
|
|
2454
|
+
return {
|
|
2455
|
+
overallScore: 0,
|
|
2456
|
+
totalRules: 0,
|
|
2457
|
+
alignedCount: 0,
|
|
2458
|
+
misalignedCount: 0,
|
|
2459
|
+
results: [],
|
|
2460
|
+
topMisalignments: [],
|
|
2461
|
+
recommendations: [
|
|
2462
|
+
"No testable rules could be extracted from the provided sources. Try providing more detailed policy documents or survey data."
|
|
2463
|
+
],
|
|
2464
|
+
sources: sources.map((s) => ({ type: s.type, name: s.name })),
|
|
2465
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2466
|
+
metadata: { personaCount, ruleCount: 0, durationMs }
|
|
2467
|
+
};
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
// src/calibration/structured.ts
|
|
2471
|
+
function calibrateStructured(realData, syntheticResults, options = {}) {
|
|
2472
|
+
const startTime = Date.now();
|
|
2473
|
+
const threshold = options.threshold ?? 0.1;
|
|
2474
|
+
const questionResults = [];
|
|
2475
|
+
const allRealPcts = [];
|
|
2476
|
+
const allSyntheticPcts = [];
|
|
2477
|
+
for (const realQ of realData.questions) {
|
|
2478
|
+
const syntheticSummary = syntheticResults.summary[realQ.name];
|
|
2479
|
+
if (!syntheticSummary) continue;
|
|
2480
|
+
const realTotal = realQ.totalRespondents;
|
|
2481
|
+
const realPcts = {};
|
|
2482
|
+
for (const [opt, count] of Object.entries(realQ.distribution)) {
|
|
2483
|
+
realPcts[opt] = realTotal > 0 ? count / realTotal : 0;
|
|
2484
|
+
}
|
|
2485
|
+
const syntheticDist = syntheticSummary.distribution;
|
|
2486
|
+
const syntheticTotal = Object.values(syntheticDist).reduce(
|
|
2487
|
+
(a, b) => a + b,
|
|
2488
|
+
0
|
|
2489
|
+
);
|
|
2490
|
+
const syntheticPcts = {};
|
|
2491
|
+
for (const [opt, count] of Object.entries(syntheticDist)) {
|
|
2492
|
+
syntheticPcts[opt] = syntheticTotal > 0 ? count / syntheticTotal : 0;
|
|
2493
|
+
}
|
|
2494
|
+
const allOptions = [
|
|
2495
|
+
.../* @__PURE__ */ new Set([...Object.keys(realPcts), ...Object.keys(syntheticPcts)])
|
|
2496
|
+
];
|
|
2497
|
+
const perOptionError = {};
|
|
2498
|
+
let totalError = 0;
|
|
2499
|
+
for (const opt of allOptions) {
|
|
2500
|
+
const realPct = realPcts[opt] ?? 0;
|
|
2501
|
+
const synthPct = syntheticPcts[opt] ?? 0;
|
|
2502
|
+
const error = Math.abs(realPct - synthPct);
|
|
2503
|
+
perOptionError[opt] = error;
|
|
2504
|
+
totalError += error;
|
|
2505
|
+
allRealPcts.push(realPct);
|
|
2506
|
+
allSyntheticPcts.push(synthPct);
|
|
2507
|
+
}
|
|
2508
|
+
const mae = allOptions.length > 0 ? totalError / allOptions.length : 0;
|
|
2509
|
+
let largest = { option: "", realPct: 0, syntheticPct: 0, error: 0 };
|
|
2510
|
+
for (const opt of allOptions) {
|
|
2511
|
+
const err = perOptionError[opt];
|
|
2512
|
+
if (err > largest.error) {
|
|
2513
|
+
largest = {
|
|
2514
|
+
option: opt,
|
|
2515
|
+
realPct: realPcts[opt] ?? 0,
|
|
2516
|
+
syntheticPct: syntheticPcts[opt] ?? 0,
|
|
2517
|
+
error: err
|
|
2518
|
+
};
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
questionResults.push({
|
|
2522
|
+
questionName: realQ.name,
|
|
2523
|
+
questionText: realQ.text,
|
|
2524
|
+
type: realQ.type,
|
|
2525
|
+
mae,
|
|
2526
|
+
withinThreshold: mae <= threshold,
|
|
2527
|
+
realDistribution: Object.fromEntries(
|
|
2528
|
+
Object.entries(realPcts).map(([k, v]) => [k, round(v * 100)])
|
|
2529
|
+
),
|
|
2530
|
+
syntheticDistribution: Object.fromEntries(
|
|
2531
|
+
Object.entries(syntheticPcts).map(([k, v]) => [k, round(v * 100)])
|
|
2532
|
+
),
|
|
2533
|
+
perOptionError: Object.fromEntries(
|
|
2534
|
+
Object.entries(perOptionError).map(([k, v]) => [k, round(v * 100)])
|
|
2535
|
+
),
|
|
2536
|
+
largestDivergence: {
|
|
2537
|
+
option: largest.option,
|
|
2538
|
+
realPct: round(largest.realPct * 100),
|
|
2539
|
+
syntheticPct: round(largest.syntheticPct * 100),
|
|
2540
|
+
error: round(largest.error * 100)
|
|
2541
|
+
}
|
|
2542
|
+
});
|
|
2543
|
+
}
|
|
2544
|
+
questionResults.sort((a, b) => b.mae - a.mae);
|
|
2545
|
+
const overallMAE = questionResults.length > 0 ? questionResults.reduce((sum, q) => sum + q.mae, 0) / questionResults.length : 0;
|
|
2546
|
+
const correlation = pearsonCorrelation(allRealPcts, allSyntheticPcts);
|
|
2547
|
+
const questionsWithinThreshold = questionResults.filter(
|
|
2548
|
+
(q) => q.withinThreshold
|
|
2549
|
+
).length;
|
|
2550
|
+
const sortedByMAE = [...questionResults].sort((a, b) => a.mae - b.mae);
|
|
2551
|
+
const medianIdx = Math.floor(sortedByMAE.length / 2);
|
|
2552
|
+
const maxRespondents = Math.max(
|
|
2553
|
+
...realData.questions.map((q) => q.totalRespondents),
|
|
2554
|
+
0
|
|
2555
|
+
);
|
|
2556
|
+
return {
|
|
2557
|
+
name: realData.name,
|
|
2558
|
+
overallMAE: round(overallMAE * 100) / 100,
|
|
2559
|
+
correlation: round(correlation * 1e3) / 1e3,
|
|
2560
|
+
questionsWithinThreshold,
|
|
2561
|
+
totalQuestions: questionResults.length,
|
|
2562
|
+
threshold,
|
|
2563
|
+
questions: questionResults,
|
|
2564
|
+
stats: {
|
|
2565
|
+
bestQuestion: sortedByMAE.length > 0 ? {
|
|
2566
|
+
name: sortedByMAE[0].questionName,
|
|
2567
|
+
mae: round(sortedByMAE[0].mae * 100)
|
|
2568
|
+
} : { name: "none", mae: 0 },
|
|
2569
|
+
worstQuestion: sortedByMAE.length > 0 ? {
|
|
2570
|
+
name: sortedByMAE[sortedByMAE.length - 1].questionName,
|
|
2571
|
+
mae: round(
|
|
2572
|
+
sortedByMAE[sortedByMAE.length - 1].mae * 100
|
|
2573
|
+
)
|
|
2574
|
+
} : { name: "none", mae: 0 },
|
|
2575
|
+
medianMAE: sortedByMAE.length > 0 ? round(sortedByMAE[medianIdx].mae * 100) : 0,
|
|
2576
|
+
personaCount: syntheticResults.personaCount,
|
|
2577
|
+
realRespondentCount: maxRespondents
|
|
2578
|
+
},
|
|
2579
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2580
|
+
durationMs: Date.now() - startTime
|
|
2581
|
+
};
|
|
2582
|
+
}
|
|
2583
|
+
function pearsonCorrelation(x, y) {
|
|
2584
|
+
const n = x.length;
|
|
2585
|
+
if (n === 0 || n !== y.length) return 0;
|
|
2586
|
+
const meanX = x.reduce((a, b) => a + b, 0) / n;
|
|
2587
|
+
const meanY = y.reduce((a, b) => a + b, 0) / n;
|
|
2588
|
+
let numerator = 0;
|
|
2589
|
+
let denomX = 0;
|
|
2590
|
+
let denomY = 0;
|
|
2591
|
+
for (let i = 0; i < n; i++) {
|
|
2592
|
+
const dx = x[i] - meanX;
|
|
2593
|
+
const dy = y[i] - meanY;
|
|
2594
|
+
numerator += dx * dy;
|
|
2595
|
+
denomX += dx * dx;
|
|
2596
|
+
denomY += dy * dy;
|
|
2597
|
+
}
|
|
2598
|
+
const denom = Math.sqrt(denomX * denomY);
|
|
2599
|
+
if (denom === 0) return 0;
|
|
2600
|
+
return numerator / denom;
|
|
2601
|
+
}
|
|
2602
|
+
function round(n) {
|
|
2603
|
+
return Math.round(n * 100) / 100;
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
// src/adapters/posthog.ts
|
|
2607
|
+
var PostHogAdapter = class {
|
|
2608
|
+
type = "posthog";
|
|
2609
|
+
config;
|
|
2610
|
+
baseUrl;
|
|
2611
|
+
constructor(config) {
|
|
2612
|
+
this.config = config;
|
|
2613
|
+
this.baseUrl = (config.host ?? "https://us.posthog.com").replace(
|
|
2614
|
+
/\/$/,
|
|
2615
|
+
""
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
async fetch() {
|
|
2619
|
+
const persons = await this.fetchPersons();
|
|
2620
|
+
const records = [];
|
|
2621
|
+
for (const person of persons) {
|
|
2622
|
+
const events = await this.fetchPersonEvents(person.distinct_ids[0]);
|
|
2623
|
+
records.push({
|
|
2624
|
+
id: person.distinct_ids[0] ?? person.id,
|
|
2625
|
+
properties: person.properties ?? {},
|
|
2626
|
+
events: events.map(
|
|
2627
|
+
(e) => ({
|
|
2628
|
+
name: e.event,
|
|
2629
|
+
timestamp: e.timestamp,
|
|
2630
|
+
properties: e.properties ?? {}
|
|
2631
|
+
})
|
|
2632
|
+
)
|
|
2633
|
+
});
|
|
2634
|
+
}
|
|
2635
|
+
return records;
|
|
2636
|
+
}
|
|
2637
|
+
async fetchPersons() {
|
|
2638
|
+
const limit = this.config.limit ?? 100;
|
|
2639
|
+
const url = new URL(`${this.baseUrl}/api/projects/@current/persons/`);
|
|
2640
|
+
url.searchParams.set("limit", String(limit));
|
|
2641
|
+
if (this.config.propertyFilters) {
|
|
2642
|
+
url.searchParams.set(
|
|
2643
|
+
"properties",
|
|
2644
|
+
JSON.stringify(
|
|
2645
|
+
this.config.propertyFilters.map((f) => ({
|
|
2646
|
+
key: f.key,
|
|
2647
|
+
value: f.value,
|
|
2648
|
+
operator: f.operator ?? "exact",
|
|
2649
|
+
type: "person"
|
|
2650
|
+
}))
|
|
2651
|
+
)
|
|
2652
|
+
);
|
|
2653
|
+
}
|
|
2654
|
+
const res = await globalThis.fetch(url.toString(), {
|
|
2655
|
+
headers: {
|
|
2656
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
2657
|
+
"Content-Type": "application/json"
|
|
2658
|
+
}
|
|
2659
|
+
});
|
|
2660
|
+
if (!res.ok) {
|
|
2661
|
+
throw new Error(
|
|
2662
|
+
`PostHog API error: ${res.status} ${res.statusText}`
|
|
2663
|
+
);
|
|
2664
|
+
}
|
|
2665
|
+
const data = await res.json();
|
|
2666
|
+
return data.results ?? [];
|
|
2667
|
+
}
|
|
2668
|
+
async fetchPersonEvents(distinctId) {
|
|
2669
|
+
const url = new URL(`${this.baseUrl}/api/projects/@current/events/`);
|
|
2670
|
+
url.searchParams.set("person_id", distinctId);
|
|
2671
|
+
url.searchParams.set("limit", "50");
|
|
2672
|
+
url.searchParams.set("orderBy", JSON.stringify(["-timestamp"]));
|
|
2673
|
+
const res = await globalThis.fetch(url.toString(), {
|
|
2674
|
+
headers: {
|
|
2675
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
2676
|
+
"Content-Type": "application/json"
|
|
2677
|
+
}
|
|
2678
|
+
});
|
|
2679
|
+
if (!res.ok) {
|
|
2680
|
+
return [];
|
|
2681
|
+
}
|
|
2682
|
+
const data = await res.json();
|
|
2683
|
+
return data.results ?? [];
|
|
2684
|
+
}
|
|
2685
|
+
};
|
|
2686
|
+
registerAdapter("posthog", (config) => new PostHogAdapter(config));
|
|
2687
|
+
|
|
2688
|
+
// src/adapters/csv.ts
|
|
2689
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
2690
|
+
import { parse } from "csv-parse/sync";
|
|
2691
|
+
var FileAdapter = class {
|
|
2692
|
+
type;
|
|
2693
|
+
config;
|
|
2694
|
+
constructor(config) {
|
|
2695
|
+
this.type = config.type;
|
|
2696
|
+
this.config = config;
|
|
2697
|
+
}
|
|
2698
|
+
async fetch() {
|
|
2699
|
+
const content = await readFile4(this.config.path, "utf-8");
|
|
2700
|
+
if (this.config.type === "json") {
|
|
2701
|
+
return this.parseJSON(content);
|
|
2702
|
+
}
|
|
2703
|
+
return this.parseCSV(content);
|
|
2704
|
+
}
|
|
2705
|
+
parseJSON(content) {
|
|
2706
|
+
const data = JSON.parse(content);
|
|
2707
|
+
const rows = Array.isArray(data) ? data : [data];
|
|
2708
|
+
return rows.map((row, i) => {
|
|
2709
|
+
const obj = row;
|
|
2710
|
+
const id = String(obj.id ?? obj.user_id ?? obj.email ?? `row-${i}`);
|
|
2711
|
+
return {
|
|
2712
|
+
id,
|
|
2713
|
+
properties: obj
|
|
2714
|
+
};
|
|
2715
|
+
});
|
|
2716
|
+
}
|
|
2717
|
+
parseCSV(content) {
|
|
2718
|
+
const records = parse(content, {
|
|
2719
|
+
columns: true,
|
|
2720
|
+
skip_empty_lines: true,
|
|
2721
|
+
trim: true
|
|
2722
|
+
});
|
|
2723
|
+
return records.map((row, i) => {
|
|
2724
|
+
const id = (this.config.idColumn ? row[this.config.idColumn] : void 0) ?? row.id ?? row.user_id ?? row.email ?? `row-${i}`;
|
|
2725
|
+
return {
|
|
2726
|
+
id,
|
|
2727
|
+
properties: row
|
|
2728
|
+
};
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
};
|
|
2732
|
+
registerAdapter("csv", (config) => new FileAdapter(config));
|
|
2733
|
+
registerAdapter("json", (config) => new FileAdapter(config));
|
|
2734
|
+
|
|
2735
|
+
// src/adapters/context.ts
|
|
2736
|
+
var ContextAdapter = class {
|
|
2737
|
+
type = "context";
|
|
2738
|
+
config;
|
|
2739
|
+
constructor(config) {
|
|
2740
|
+
this.config = config;
|
|
2741
|
+
}
|
|
2742
|
+
async fetch() {
|
|
2743
|
+
const contextParts = [];
|
|
2744
|
+
if (this.config.text) {
|
|
2745
|
+
contextParts.push(this.config.text);
|
|
2746
|
+
}
|
|
2747
|
+
if (this.config.urls) {
|
|
2748
|
+
for (const url of this.config.urls) {
|
|
2749
|
+
try {
|
|
2750
|
+
const res = await globalThis.fetch(url);
|
|
2751
|
+
if (res.ok) {
|
|
2752
|
+
const html = await res.text();
|
|
2753
|
+
const text = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 5e3);
|
|
2754
|
+
contextParts.push(`Content from ${url}:
|
|
2755
|
+
${text}`);
|
|
2756
|
+
}
|
|
2757
|
+
} catch {
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
if (contextParts.length === 0) return [];
|
|
2762
|
+
return [
|
|
2763
|
+
{
|
|
2764
|
+
id: "context",
|
|
2765
|
+
properties: {
|
|
2766
|
+
_type: "context",
|
|
2767
|
+
_content: contextParts.join("\n\n")
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
];
|
|
2771
|
+
}
|
|
2772
|
+
};
|
|
2773
|
+
registerAdapter("context", (config) => new ContextAdapter(config));
|
|
2774
|
+
|
|
2775
|
+
// src/adapters/stripe.ts
|
|
2776
|
+
var StripeAdapter = class {
|
|
2777
|
+
type = "stripe";
|
|
2778
|
+
config;
|
|
2779
|
+
constructor(config) {
|
|
2780
|
+
this.config = config;
|
|
2781
|
+
}
|
|
2782
|
+
async fetch() {
|
|
2783
|
+
const customers = await this.fetchCustomers();
|
|
2784
|
+
const records = [];
|
|
2785
|
+
for (const customer of customers) {
|
|
2786
|
+
const subscriptions = await this.fetchSubscriptions(customer.id);
|
|
2787
|
+
const payments = subscriptions.map((sub) => ({
|
|
2788
|
+
amount: sub.plan?.amount ? sub.plan.amount / 100 : 0,
|
|
2789
|
+
currency: sub.currency ?? "usd",
|
|
2790
|
+
status: mapStatus(sub.status),
|
|
2791
|
+
plan: sub.plan?.nickname ?? sub.plan?.id ?? void 0,
|
|
2792
|
+
cancelReason: sub.cancellation_details?.reason ?? void 0,
|
|
2793
|
+
createdAt: new Date(sub.created * 1e3).toISOString()
|
|
2794
|
+
}));
|
|
2795
|
+
records.push({
|
|
2796
|
+
id: customer.id,
|
|
2797
|
+
properties: {
|
|
2798
|
+
email: customer.email,
|
|
2799
|
+
name: customer.name,
|
|
2800
|
+
created: new Date(customer.created * 1e3).toISOString(),
|
|
2801
|
+
currency: customer.currency,
|
|
2802
|
+
delinquent: customer.delinquent
|
|
2803
|
+
},
|
|
2804
|
+
payments
|
|
2805
|
+
});
|
|
2806
|
+
}
|
|
2807
|
+
return records;
|
|
2808
|
+
}
|
|
2809
|
+
async fetchCustomers() {
|
|
2810
|
+
const limit = this.config.limit ?? 100;
|
|
2811
|
+
const res = await fetch(
|
|
2812
|
+
`https://api.stripe.com/v1/customers?limit=${limit}`,
|
|
2813
|
+
{
|
|
2814
|
+
headers: { Authorization: `Bearer ${this.config.apiKey}` }
|
|
2815
|
+
}
|
|
2816
|
+
);
|
|
2817
|
+
if (!res.ok) {
|
|
2818
|
+
throw new Error(`Stripe API error: ${res.status} ${res.statusText}`);
|
|
2819
|
+
}
|
|
2820
|
+
const data = await res.json();
|
|
2821
|
+
return data.data ?? [];
|
|
2822
|
+
}
|
|
2823
|
+
async fetchSubscriptions(customerId) {
|
|
2824
|
+
const res = await fetch(
|
|
2825
|
+
`https://api.stripe.com/v1/subscriptions?customer=${customerId}&limit=10&status=all`,
|
|
2826
|
+
{
|
|
2827
|
+
headers: { Authorization: `Bearer ${this.config.apiKey}` }
|
|
2828
|
+
}
|
|
2829
|
+
);
|
|
2830
|
+
if (!res.ok) return [];
|
|
2831
|
+
const data = await res.json();
|
|
2832
|
+
return data.data ?? [];
|
|
2833
|
+
}
|
|
2834
|
+
};
|
|
2835
|
+
function mapStatus(status) {
|
|
2836
|
+
switch (status) {
|
|
2837
|
+
case "active":
|
|
2838
|
+
return "active";
|
|
2839
|
+
case "canceled":
|
|
2840
|
+
return "cancelled";
|
|
2841
|
+
case "past_due":
|
|
2842
|
+
return "past_due";
|
|
2843
|
+
case "trialing":
|
|
2844
|
+
return "trialing";
|
|
2845
|
+
default:
|
|
2846
|
+
return "cancelled";
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
registerAdapter(
|
|
2850
|
+
"stripe",
|
|
2851
|
+
(config) => new StripeAdapter(config)
|
|
2852
|
+
);
|
|
2853
|
+
|
|
2854
|
+
// src/adapters/document.ts
|
|
2855
|
+
import { readFile as readFile5, readdir as readdir3, stat } from "fs/promises";
|
|
2856
|
+
import { join as join4, extname, basename } from "path";
|
|
2857
|
+
var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
2858
|
+
".pdf",
|
|
2859
|
+
".md",
|
|
2860
|
+
".mdx",
|
|
2861
|
+
".txt",
|
|
2862
|
+
".html",
|
|
2863
|
+
".htm",
|
|
2864
|
+
".doc",
|
|
2865
|
+
".rtf",
|
|
2866
|
+
".csv",
|
|
2867
|
+
".json"
|
|
2868
|
+
]);
|
|
2869
|
+
var DocumentAdapter = class {
|
|
2870
|
+
type = "document";
|
|
2871
|
+
config;
|
|
2872
|
+
constructor(config) {
|
|
2873
|
+
this.config = config;
|
|
2874
|
+
}
|
|
2875
|
+
async fetch() {
|
|
2876
|
+
const maxChars = this.config.maxChars ?? 5e4;
|
|
2877
|
+
const allowedExts = this.config.extensions ? new Set(this.config.extensions.map((e) => e.startsWith(".") ? e : `.${e}`)) : SUPPORTED_EXTENSIONS;
|
|
2878
|
+
const files = await this.resolveFiles(allowedExts);
|
|
2879
|
+
const records = [];
|
|
2880
|
+
for (const filePath of files) {
|
|
2881
|
+
const ext = extname(filePath).toLowerCase();
|
|
2882
|
+
const name = basename(filePath);
|
|
2883
|
+
let text;
|
|
2884
|
+
try {
|
|
2885
|
+
text = await extractText(filePath, ext);
|
|
2886
|
+
} catch {
|
|
2887
|
+
continue;
|
|
2888
|
+
}
|
|
2889
|
+
if (!text.trim()) continue;
|
|
2890
|
+
const content = text.length > maxChars ? text.slice(0, maxChars) : text;
|
|
2891
|
+
records.push({
|
|
2892
|
+
id: `doc-${name}`,
|
|
2893
|
+
properties: {
|
|
2894
|
+
_type: "document",
|
|
2895
|
+
_source: filePath,
|
|
2896
|
+
_filename: name,
|
|
2897
|
+
_extension: ext,
|
|
2898
|
+
_charCount: content.length,
|
|
2899
|
+
_content: content
|
|
2900
|
+
}
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
return records;
|
|
2904
|
+
}
|
|
2905
|
+
async resolveFiles(allowedExts) {
|
|
2906
|
+
const p = this.config.path;
|
|
2907
|
+
const info = await stat(p);
|
|
2908
|
+
if (info.isFile()) {
|
|
2909
|
+
return [p];
|
|
2910
|
+
}
|
|
2911
|
+
if (info.isDirectory()) {
|
|
2912
|
+
const entries = await readdir3(p);
|
|
2913
|
+
return entries.filter((e) => allowedExts.has(extname(e).toLowerCase())).map((e) => join4(p, e));
|
|
2914
|
+
}
|
|
2915
|
+
return [];
|
|
2916
|
+
}
|
|
2917
|
+
};
|
|
2918
|
+
async function extractText(filePath, ext) {
|
|
2919
|
+
switch (ext) {
|
|
2920
|
+
case ".pdf":
|
|
2921
|
+
return extractPDF(filePath);
|
|
2922
|
+
case ".html":
|
|
2923
|
+
case ".htm":
|
|
2924
|
+
return extractHTML(filePath);
|
|
2925
|
+
case ".md":
|
|
2926
|
+
case ".mdx":
|
|
2927
|
+
case ".txt":
|
|
2928
|
+
case ".rtf":
|
|
2929
|
+
default:
|
|
2930
|
+
return readFile5(filePath, "utf-8");
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
async function extractPDF(filePath) {
|
|
2934
|
+
const mod = await import("pdf-parse");
|
|
2935
|
+
const pdfParse = mod.default ?? mod;
|
|
2936
|
+
const buffer = await readFile5(filePath);
|
|
2937
|
+
const data = await pdfParse(buffer);
|
|
2938
|
+
return data.text;
|
|
2939
|
+
}
|
|
2940
|
+
async function extractHTML(filePath) {
|
|
2941
|
+
const html = await readFile5(filePath, "utf-8");
|
|
2942
|
+
return html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
2943
|
+
}
|
|
2944
|
+
registerAdapter(
|
|
2945
|
+
"document",
|
|
2946
|
+
(config) => new DocumentAdapter(config)
|
|
2947
|
+
);
|
|
2948
|
+
|
|
2949
|
+
// src/adapters/amplitude.ts
|
|
2950
|
+
var AmplitudeAdapter = class {
|
|
2951
|
+
type = "amplitude";
|
|
2952
|
+
config;
|
|
2953
|
+
constructor(config) {
|
|
2954
|
+
this.config = config;
|
|
2955
|
+
}
|
|
2956
|
+
async fetch() {
|
|
2957
|
+
const now = /* @__PURE__ */ new Date();
|
|
2958
|
+
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
|
|
2959
|
+
const start = this.config.startDate ?? formatDate(thirtyDaysAgo) + "T00";
|
|
2960
|
+
const end = this.config.endDate ?? formatDate(now) + "T23";
|
|
2961
|
+
const auth = Buffer.from(
|
|
2962
|
+
`${this.config.apiKey}:${this.config.secretKey}`
|
|
2963
|
+
).toString("base64");
|
|
2964
|
+
const res = await fetch(
|
|
2965
|
+
`https://amplitude.com/api/2/export?start=${start}&end=${end}`,
|
|
2966
|
+
{
|
|
2967
|
+
headers: { Authorization: `Basic ${auth}` }
|
|
2968
|
+
}
|
|
2969
|
+
);
|
|
2970
|
+
if (!res.ok) {
|
|
2971
|
+
throw new Error(`Amplitude API error: ${res.status} ${res.statusText}`);
|
|
2972
|
+
}
|
|
2973
|
+
const text = await res.text();
|
|
2974
|
+
const lines = text.trim().split("\n").filter(Boolean);
|
|
2975
|
+
const userMap = /* @__PURE__ */ new Map();
|
|
2976
|
+
for (const line of lines) {
|
|
2977
|
+
try {
|
|
2978
|
+
const event = JSON.parse(line);
|
|
2979
|
+
const userId = event.user_id ?? event.device_id ?? "anonymous";
|
|
2980
|
+
if (!userMap.has(userId)) {
|
|
2981
|
+
userMap.set(userId, {
|
|
2982
|
+
properties: event.user_properties ?? {},
|
|
2983
|
+
events: []
|
|
2984
|
+
});
|
|
2985
|
+
}
|
|
2986
|
+
const user = userMap.get(userId);
|
|
2987
|
+
user.events.push({
|
|
2988
|
+
name: event.event_type,
|
|
2989
|
+
timestamp: event.event_time ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
2990
|
+
properties: event.event_properties ?? {}
|
|
2991
|
+
});
|
|
2992
|
+
} catch {
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
return [...userMap.entries()].map(([id, data]) => ({
|
|
2996
|
+
id,
|
|
2997
|
+
properties: data.properties,
|
|
2998
|
+
events: data.events
|
|
2999
|
+
}));
|
|
3000
|
+
}
|
|
3001
|
+
};
|
|
3002
|
+
function formatDate(d) {
|
|
3003
|
+
return d.toISOString().slice(0, 10).replace(/-/g, "");
|
|
3004
|
+
}
|
|
3005
|
+
registerAdapter(
|
|
3006
|
+
"amplitude",
|
|
3007
|
+
(config) => new AmplitudeAdapter(config)
|
|
3008
|
+
);
|
|
3009
|
+
|
|
3010
|
+
// src/adapters/mixpanel.ts
|
|
3011
|
+
var MixpanelAdapter = class {
|
|
3012
|
+
type = "mixpanel";
|
|
3013
|
+
config;
|
|
3014
|
+
constructor(config) {
|
|
3015
|
+
this.config = config;
|
|
3016
|
+
}
|
|
3017
|
+
async fetch() {
|
|
3018
|
+
const profiles = await this.fetchProfiles();
|
|
3019
|
+
const events = await this.fetchEvents();
|
|
3020
|
+
const eventsByUser = /* @__PURE__ */ new Map();
|
|
3021
|
+
for (const event of events) {
|
|
3022
|
+
const userId = event.properties?.distinct_id ?? "anonymous";
|
|
3023
|
+
if (!eventsByUser.has(userId)) eventsByUser.set(userId, []);
|
|
3024
|
+
eventsByUser.get(userId).push({
|
|
3025
|
+
name: event.event,
|
|
3026
|
+
timestamp: event.properties?.time ? new Date(event.properties.time * 1e3).toISOString() : (/* @__PURE__ */ new Date()).toISOString(),
|
|
3027
|
+
properties: event.properties ?? {}
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
3030
|
+
const userMap = /* @__PURE__ */ new Map();
|
|
3031
|
+
for (const profile of profiles) {
|
|
3032
|
+
const id = String(profile.$distinct_id ?? profile.$properties?.$email ?? "unknown");
|
|
3033
|
+
userMap.set(id, {
|
|
3034
|
+
id,
|
|
3035
|
+
properties: profile.$properties ?? {},
|
|
3036
|
+
events: eventsByUser.get(String(id)) ?? []
|
|
3037
|
+
});
|
|
3038
|
+
}
|
|
3039
|
+
for (const [userId, userEvents] of eventsByUser) {
|
|
3040
|
+
if (!userMap.has(userId)) {
|
|
3041
|
+
userMap.set(userId, {
|
|
3042
|
+
id: userId,
|
|
3043
|
+
properties: {},
|
|
3044
|
+
events: userEvents
|
|
3045
|
+
});
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
return [...userMap.values()];
|
|
3049
|
+
}
|
|
3050
|
+
async fetchProfiles() {
|
|
3051
|
+
const auth = Buffer.from(`${this.config.apiSecret}:`).toString("base64");
|
|
3052
|
+
const res = await fetch(
|
|
3053
|
+
"https://mixpanel.com/api/2.0/engage?page_size=100",
|
|
3054
|
+
{
|
|
3055
|
+
headers: {
|
|
3056
|
+
Authorization: `Basic ${auth}`,
|
|
3057
|
+
Accept: "application/json"
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
);
|
|
3061
|
+
if (!res.ok) return [];
|
|
3062
|
+
const data = await res.json();
|
|
3063
|
+
return data.results ?? [];
|
|
3064
|
+
}
|
|
3065
|
+
async fetchEvents() {
|
|
3066
|
+
const days = this.config.days ?? 30;
|
|
3067
|
+
const now = /* @__PURE__ */ new Date();
|
|
3068
|
+
const from = new Date(now.getTime() - days * 24 * 60 * 60 * 1e3);
|
|
3069
|
+
const fromStr = from.toISOString().slice(0, 10);
|
|
3070
|
+
const toStr = now.toISOString().slice(0, 10);
|
|
3071
|
+
const auth = Buffer.from(`${this.config.apiSecret}:`).toString("base64");
|
|
3072
|
+
const res = await fetch(
|
|
3073
|
+
`https://data.mixpanel.com/api/2.0/export?from_date=${fromStr}&to_date=${toStr}`,
|
|
3074
|
+
{
|
|
3075
|
+
headers: { Authorization: `Basic ${auth}` }
|
|
3076
|
+
}
|
|
3077
|
+
);
|
|
3078
|
+
if (!res.ok) return [];
|
|
3079
|
+
const text = await res.text();
|
|
3080
|
+
return text.trim().split("\n").filter(Boolean).map((line) => {
|
|
3081
|
+
try {
|
|
3082
|
+
return JSON.parse(line);
|
|
3083
|
+
} catch {
|
|
3084
|
+
return null;
|
|
3085
|
+
}
|
|
3086
|
+
}).filter((e) => e !== null);
|
|
3087
|
+
}
|
|
3088
|
+
};
|
|
3089
|
+
registerAdapter(
|
|
3090
|
+
"mixpanel",
|
|
3091
|
+
(config) => new MixpanelAdapter(config)
|
|
3092
|
+
);
|
|
3093
|
+
|
|
3094
|
+
// src/adapters/hubspot.ts
|
|
3095
|
+
var HubSpotAdapter = class {
|
|
3096
|
+
type = "hubspot";
|
|
3097
|
+
config;
|
|
3098
|
+
constructor(config) {
|
|
3099
|
+
this.config = config;
|
|
3100
|
+
}
|
|
3101
|
+
async fetch() {
|
|
3102
|
+
const contacts = await this.fetchContacts();
|
|
3103
|
+
const records = [];
|
|
3104
|
+
for (const contact of contacts) {
|
|
3105
|
+
const props = contact.properties ?? {};
|
|
3106
|
+
const record = {
|
|
3107
|
+
id: contact.id,
|
|
3108
|
+
properties: {
|
|
3109
|
+
email: props.email,
|
|
3110
|
+
firstName: props.firstname,
|
|
3111
|
+
lastName: props.lastname,
|
|
3112
|
+
company: props.company,
|
|
3113
|
+
jobTitle: props.jobtitle,
|
|
3114
|
+
phone: props.phone,
|
|
3115
|
+
city: props.city,
|
|
3116
|
+
state: props.state,
|
|
3117
|
+
country: props.country,
|
|
3118
|
+
lifecycleStage: props.lifecyclestage,
|
|
3119
|
+
leadStatus: props.hs_lead_status,
|
|
3120
|
+
lastActivityDate: props.notes_last_updated,
|
|
3121
|
+
createDate: props.createdate
|
|
3122
|
+
}
|
|
3123
|
+
};
|
|
3124
|
+
if (this.config.includeDeals) {
|
|
3125
|
+
const deals = await this.fetchContactDeals(contact.id);
|
|
3126
|
+
if (deals.length > 0) {
|
|
3127
|
+
record.payments = deals.map((deal) => ({
|
|
3128
|
+
amount: parseFloat(deal.properties?.amount ?? "0"),
|
|
3129
|
+
currency: deal.properties?.deal_currency_code ?? "USD",
|
|
3130
|
+
status: deal.properties?.dealstage === "closedwon" ? "active" : deal.properties?.dealstage === "closedlost" ? "cancelled" : "trialing",
|
|
3131
|
+
plan: deal.properties?.dealname ?? void 0,
|
|
3132
|
+
cancelReason: deal.properties?.closed_lost_reason ?? void 0,
|
|
3133
|
+
createdAt: deal.properties?.createdate ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
3134
|
+
}));
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
records.push(record);
|
|
3138
|
+
}
|
|
3139
|
+
return records;
|
|
3140
|
+
}
|
|
3141
|
+
async fetchContacts() {
|
|
3142
|
+
const limit = this.config.limit ?? 100;
|
|
3143
|
+
const res = await fetch(
|
|
3144
|
+
`https://api.hubapi.com/crm/v3/objects/contacts?limit=${limit}&properties=email,firstname,lastname,company,jobtitle,phone,city,state,country,lifecyclestage,hs_lead_status,notes_last_updated,createdate`,
|
|
3145
|
+
{
|
|
3146
|
+
headers: {
|
|
3147
|
+
Authorization: `Bearer ${this.config.accessToken}`,
|
|
3148
|
+
"Content-Type": "application/json"
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
);
|
|
3152
|
+
if (!res.ok) {
|
|
3153
|
+
throw new Error(`HubSpot API error: ${res.status} ${res.statusText}`);
|
|
3154
|
+
}
|
|
3155
|
+
const data = await res.json();
|
|
3156
|
+
return data.results ?? [];
|
|
3157
|
+
}
|
|
3158
|
+
async fetchContactDeals(contactId) {
|
|
3159
|
+
const res = await fetch(
|
|
3160
|
+
`https://api.hubapi.com/crm/v3/objects/contacts/${contactId}/associations/deals`,
|
|
3161
|
+
{
|
|
3162
|
+
headers: {
|
|
3163
|
+
Authorization: `Bearer ${this.config.accessToken}`
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
);
|
|
3167
|
+
if (!res.ok) return [];
|
|
3168
|
+
const data = await res.json();
|
|
3169
|
+
const deals = [];
|
|
3170
|
+
for (const assoc of data.results ?? []) {
|
|
3171
|
+
const dealRes = await fetch(
|
|
3172
|
+
`https://api.hubapi.com/crm/v3/objects/deals/${assoc.id}?properties=dealname,amount,dealstage,deal_currency_code,closed_lost_reason,createdate`,
|
|
3173
|
+
{
|
|
3174
|
+
headers: {
|
|
3175
|
+
Authorization: `Bearer ${this.config.accessToken}`
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
);
|
|
3179
|
+
if (dealRes.ok) {
|
|
3180
|
+
deals.push(await dealRes.json());
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
return deals;
|
|
3184
|
+
}
|
|
3185
|
+
};
|
|
3186
|
+
registerAdapter(
|
|
3187
|
+
"hubspot",
|
|
3188
|
+
(config) => new HubSpotAdapter(config)
|
|
3189
|
+
);
|
|
3190
|
+
|
|
3191
|
+
// src/adapters/intercom.ts
|
|
3192
|
+
var IntercomAdapter = class {
|
|
3193
|
+
type = "intercom";
|
|
3194
|
+
config;
|
|
3195
|
+
baseUrl = "https://api.intercom.io";
|
|
3196
|
+
constructor(config) {
|
|
3197
|
+
this.config = config;
|
|
3198
|
+
}
|
|
3199
|
+
async fetch() {
|
|
3200
|
+
const contacts = await this.fetchContacts();
|
|
3201
|
+
const records = [];
|
|
3202
|
+
for (const contact of contacts) {
|
|
3203
|
+
const record = {
|
|
3204
|
+
id: contact.id,
|
|
3205
|
+
properties: {
|
|
3206
|
+
email: contact.email,
|
|
3207
|
+
name: contact.name,
|
|
3208
|
+
role: contact.role,
|
|
3209
|
+
signedUpAt: contact.signed_up_at,
|
|
3210
|
+
lastSeenAt: contact.last_seen_at,
|
|
3211
|
+
lastContactedAt: contact.last_contacted_at,
|
|
3212
|
+
browser: contact.browser,
|
|
3213
|
+
os: contact.os,
|
|
3214
|
+
city: contact.location?.city,
|
|
3215
|
+
country: contact.location?.country,
|
|
3216
|
+
unsubscribedFromEmails: contact.unsubscribed_from_emails,
|
|
3217
|
+
tags: contact.tags?.tags?.map((t) => t.name) ?? [],
|
|
3218
|
+
customAttributes: contact.custom_attributes
|
|
3219
|
+
}
|
|
3220
|
+
};
|
|
3221
|
+
if (this.config.includeConversations) {
|
|
3222
|
+
const conversations = await this.fetchContactConversations(contact.id);
|
|
3223
|
+
record.conversations = conversations;
|
|
3224
|
+
}
|
|
3225
|
+
records.push(record);
|
|
3226
|
+
}
|
|
3227
|
+
return records;
|
|
3228
|
+
}
|
|
3229
|
+
async fetchContacts() {
|
|
3230
|
+
const res = await fetch(`${this.baseUrl}/contacts?per_page=${this.config.limit ?? 50}`, {
|
|
3231
|
+
headers: {
|
|
3232
|
+
Authorization: `Bearer ${this.config.accessToken}`,
|
|
3233
|
+
"Content-Type": "application/json",
|
|
3234
|
+
Accept: "application/json"
|
|
3235
|
+
}
|
|
3236
|
+
});
|
|
3237
|
+
if (!res.ok) {
|
|
3238
|
+
throw new Error(`Intercom API error: ${res.status} ${res.statusText}`);
|
|
3239
|
+
}
|
|
3240
|
+
const data = await res.json();
|
|
3241
|
+
return data.data ?? [];
|
|
3242
|
+
}
|
|
3243
|
+
async fetchContactConversations(contactId) {
|
|
3244
|
+
const res = await fetch(
|
|
3245
|
+
`${this.baseUrl}/conversations/search`,
|
|
3246
|
+
{
|
|
3247
|
+
method: "POST",
|
|
3248
|
+
headers: {
|
|
3249
|
+
Authorization: `Bearer ${this.config.accessToken}`,
|
|
3250
|
+
"Content-Type": "application/json",
|
|
3251
|
+
Accept: "application/json"
|
|
3252
|
+
},
|
|
3253
|
+
body: JSON.stringify({
|
|
3254
|
+
query: {
|
|
3255
|
+
field: "contact_ids",
|
|
3256
|
+
operator: "=",
|
|
3257
|
+
value: contactId
|
|
3258
|
+
}
|
|
3259
|
+
})
|
|
3260
|
+
}
|
|
3261
|
+
);
|
|
3262
|
+
if (!res.ok) return [];
|
|
3263
|
+
const data = await res.json();
|
|
3264
|
+
const messages = [];
|
|
3265
|
+
for (const conv of data.conversations ?? []) {
|
|
3266
|
+
if (conv.source?.body) {
|
|
3267
|
+
const text = conv.source.body.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
3268
|
+
if (text) messages.push(text);
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
return messages.slice(0, 10);
|
|
3272
|
+
}
|
|
3273
|
+
};
|
|
3274
|
+
registerAdapter(
|
|
3275
|
+
"intercom",
|
|
3276
|
+
(config) => new IntercomAdapter(config)
|
|
3277
|
+
);
|
|
3278
|
+
|
|
3279
|
+
// src/engine.ts
|
|
3280
|
+
var Dopple = class {
|
|
3281
|
+
llm;
|
|
3282
|
+
adapters;
|
|
3283
|
+
contextStrings;
|
|
3284
|
+
cachedUserData = null;
|
|
3285
|
+
store;
|
|
3286
|
+
traces;
|
|
3287
|
+
cachedGraph = null;
|
|
3288
|
+
constructor(config = {}) {
|
|
3289
|
+
this.llm = createProvider(config.model, config.apiKey, config.baseUrl);
|
|
3290
|
+
this.adapters = (config.adapters ?? []).map((c) => createAdapter(c));
|
|
3291
|
+
this.contextStrings = config.context ?? [];
|
|
3292
|
+
this.store = new DoppleStore(config.storageDir);
|
|
3293
|
+
this.traces = new TraceStore(config.storageDir);
|
|
3294
|
+
}
|
|
3295
|
+
/**
|
|
3296
|
+
* Add an adapter dynamically.
|
|
3297
|
+
*/
|
|
3298
|
+
addAdapter(config) {
|
|
3299
|
+
this.adapters.push(createAdapter(config));
|
|
3300
|
+
this.cachedUserData = null;
|
|
3301
|
+
}
|
|
3302
|
+
/**
|
|
3303
|
+
* Add freeform context.
|
|
3304
|
+
*/
|
|
3305
|
+
addContext(context) {
|
|
3306
|
+
this.contextStrings.push(context);
|
|
3307
|
+
}
|
|
3308
|
+
/**
|
|
3309
|
+
* Fetch all user data from configured adapters.
|
|
3310
|
+
*/
|
|
3311
|
+
async fetchData() {
|
|
3312
|
+
if (this.cachedUserData) return this.cachedUserData;
|
|
3313
|
+
const allRecords = [];
|
|
3314
|
+
for (const adapter of this.adapters) {
|
|
3315
|
+
const records = await adapter.fetch();
|
|
3316
|
+
allRecords.push(...records);
|
|
3317
|
+
}
|
|
3318
|
+
this.cachedUserData = allRecords;
|
|
3319
|
+
return allRecords;
|
|
3320
|
+
}
|
|
3321
|
+
/**
|
|
3322
|
+
* Generate personas from context and/or data.
|
|
3323
|
+
*/
|
|
3324
|
+
async generate(options = {}) {
|
|
3325
|
+
const userData = await this.fetchData();
|
|
3326
|
+
const definitions = await generatePersonas(this.llm, {
|
|
3327
|
+
product: options.product,
|
|
3328
|
+
count: options.count ?? 5,
|
|
3329
|
+
context: [...this.contextStrings, ...options.context ?? []],
|
|
3330
|
+
userData: userData.length > 0 ? userData : void 0,
|
|
3331
|
+
diversityThreshold: options.diversityThreshold
|
|
3332
|
+
});
|
|
3333
|
+
const personas = definitions.map((d) => new Persona(d, this.llm));
|
|
3334
|
+
if (options.save) {
|
|
3335
|
+
await this.store.savePanel(options.save, definitions, options.product);
|
|
3336
|
+
}
|
|
3337
|
+
return personas;
|
|
3338
|
+
}
|
|
3339
|
+
/**
|
|
3340
|
+
* Load a previously saved persona panel.
|
|
3341
|
+
*/
|
|
3342
|
+
async loadPanel(nameOrId) {
|
|
3343
|
+
const panel = await this.store.loadPanel(nameOrId);
|
|
3344
|
+
if (!panel) return null;
|
|
3345
|
+
return panel.personas.map((d) => new Persona(d, this.llm));
|
|
3346
|
+
}
|
|
3347
|
+
/**
|
|
3348
|
+
* Discover user segments from data.
|
|
3349
|
+
* Requires at least one data adapter to be configured.
|
|
3350
|
+
*/
|
|
3351
|
+
async discover(options = {}) {
|
|
3352
|
+
const userData = await this.fetchData();
|
|
3353
|
+
return discoverSegments(this.llm, userData, {
|
|
3354
|
+
product: options.product,
|
|
3355
|
+
segmentCount: options.segmentCount
|
|
3356
|
+
});
|
|
3357
|
+
}
|
|
3358
|
+
/**
|
|
3359
|
+
* Create a survey to run against personas.
|
|
3360
|
+
*/
|
|
3361
|
+
createSurvey(name) {
|
|
3362
|
+
return new Survey(name);
|
|
3363
|
+
}
|
|
3364
|
+
/**
|
|
3365
|
+
* Create a focus group — multiple personas discussing a topic together.
|
|
3366
|
+
* Supports context injection mid-discussion.
|
|
3367
|
+
*
|
|
3368
|
+
* @example
|
|
3369
|
+
* ```ts
|
|
3370
|
+
* const group = dopple.createFocusGroup("pricing", {
|
|
3371
|
+
* topic: "Should we raise prices?",
|
|
3372
|
+
* product: "my SaaS",
|
|
3373
|
+
* });
|
|
3374
|
+
* group.addPersonas(personas);
|
|
3375
|
+
* const round1 = await group.discuss(this.llm);
|
|
3376
|
+
* group.inject("A competitor just launched a free tier");
|
|
3377
|
+
* const round2 = await group.discuss(this.llm);
|
|
3378
|
+
* const summary = await group.summarize(this.llm);
|
|
3379
|
+
* ```
|
|
3380
|
+
*/
|
|
3381
|
+
createFocusGroup(name, config) {
|
|
3382
|
+
return new FocusGroup(name, config);
|
|
3383
|
+
}
|
|
3384
|
+
/**
|
|
3385
|
+
* Run a focus group discussion and return the full transcript.
|
|
3386
|
+
* Convenience method that creates personas, runs N rounds, and summarizes.
|
|
3387
|
+
*/
|
|
3388
|
+
async runFocusGroup(config) {
|
|
3389
|
+
const personas = config.personas ?? await this.generate({
|
|
3390
|
+
product: config.product,
|
|
3391
|
+
count: config.personaCount ?? 5
|
|
3392
|
+
});
|
|
3393
|
+
const group = new FocusGroup("focus-group", config);
|
|
3394
|
+
group.addPersonas(personas);
|
|
3395
|
+
const roundCount = config.rounds ?? 3;
|
|
3396
|
+
const allRounds = [];
|
|
3397
|
+
for (let i = 0; i < roundCount; i++) {
|
|
3398
|
+
const round2 = await group.discuss(this.llm);
|
|
3399
|
+
allRounds.push(round2);
|
|
3400
|
+
}
|
|
3401
|
+
const summary = await group.summarize(this.llm);
|
|
3402
|
+
return { rounds: allRounds, summary };
|
|
3403
|
+
}
|
|
3404
|
+
/**
|
|
3405
|
+
* Run a survey against a persona panel and save results.
|
|
3406
|
+
*/
|
|
3407
|
+
async runSurvey(survey, personas) {
|
|
3408
|
+
const result = await survey.run(personas, this.llm);
|
|
3409
|
+
await this.store.saveSurvey(result);
|
|
3410
|
+
return result;
|
|
3411
|
+
}
|
|
3412
|
+
/**
|
|
3413
|
+
* Validate a persona's psychometric accuracy.
|
|
3414
|
+
*/
|
|
3415
|
+
async validate(persona) {
|
|
3416
|
+
return validatePersona(persona.definition, this.llm);
|
|
3417
|
+
}
|
|
3418
|
+
/**
|
|
3419
|
+
* Ask all personas in a panel the same question.
|
|
3420
|
+
*/
|
|
3421
|
+
async askPanel(personas, question) {
|
|
3422
|
+
const results = await Promise.all(
|
|
3423
|
+
personas.map(async (p) => {
|
|
3424
|
+
const r = await p.ask(question);
|
|
3425
|
+
return {
|
|
3426
|
+
persona: p.name,
|
|
3427
|
+
response: r.response,
|
|
3428
|
+
confidence: r.confidence,
|
|
3429
|
+
reasoning: r.reasoning,
|
|
3430
|
+
citations: r.citations
|
|
3431
|
+
};
|
|
3432
|
+
})
|
|
3433
|
+
);
|
|
3434
|
+
return results;
|
|
3435
|
+
}
|
|
3436
|
+
/**
|
|
3437
|
+
* Get data quality status and improvement suggestions.
|
|
3438
|
+
*/
|
|
3439
|
+
async status() {
|
|
3440
|
+
const userData = await this.fetchData();
|
|
3441
|
+
const discovery = await discoverSegments(this.llm, userData, {});
|
|
3442
|
+
const panels = await this.store.listPanels();
|
|
3443
|
+
return {
|
|
3444
|
+
adapters: this.adapters.map((a) => a.type),
|
|
3445
|
+
userCount: userData.length,
|
|
3446
|
+
confidence: discovery.confidence,
|
|
3447
|
+
suggestions: discovery.suggestions,
|
|
3448
|
+
panels
|
|
3449
|
+
};
|
|
3450
|
+
}
|
|
3451
|
+
/**
|
|
3452
|
+
* Run the insights pipeline — THE main product feature.
|
|
3453
|
+
* Data in → actionable insights out.
|
|
3454
|
+
* Automatically records a trace and injects calibration history.
|
|
3455
|
+
*/
|
|
3456
|
+
async runInsights(options) {
|
|
3457
|
+
const calibrationContext = await this.traces.getCalibrationContext(
|
|
3458
|
+
options.product
|
|
3459
|
+
);
|
|
3460
|
+
const enrichedOptions = {
|
|
3461
|
+
...options,
|
|
3462
|
+
context: [
|
|
3463
|
+
...options.context ?? [],
|
|
3464
|
+
...calibrationContext ? [calibrationContext] : []
|
|
3465
|
+
]
|
|
3466
|
+
};
|
|
3467
|
+
const report = await runInsightsPipeline(this, this.llm, enrichedOptions);
|
|
3468
|
+
const { randomUUID: randomUUID6 } = await import("crypto");
|
|
3469
|
+
const trace = {
|
|
3470
|
+
id: randomUUID6(),
|
|
3471
|
+
product: options.product,
|
|
3472
|
+
type: "insight",
|
|
3473
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3474
|
+
input: {
|
|
3475
|
+
command: "insights",
|
|
3476
|
+
dataSources: report.dataQuality.sources,
|
|
3477
|
+
userCount: report.dataQuality.userCount,
|
|
3478
|
+
context: options.context
|
|
3479
|
+
},
|
|
3480
|
+
prediction: {
|
|
3481
|
+
summary: report.insights.slice(0, 3).map((i) => i.title).join("; "),
|
|
3482
|
+
details: {
|
|
3483
|
+
insightCount: report.insights.length,
|
|
3484
|
+
topInsight: report.insights[0] ?? null,
|
|
3485
|
+
segmentCount: report.segments.length
|
|
3486
|
+
},
|
|
3487
|
+
confidence: report.insights.length > 0 ? report.insights.reduce((s, i) => s + i.confidence, 0) / report.insights.length : 0,
|
|
3488
|
+
model: report.metadata.model,
|
|
3489
|
+
personaCount: report.metadata.personaCount
|
|
3490
|
+
},
|
|
3491
|
+
outcome: null,
|
|
3492
|
+
accuracy: null
|
|
3493
|
+
};
|
|
3494
|
+
await this.traces.save(trace).catch(() => {
|
|
3495
|
+
});
|
|
3496
|
+
return report;
|
|
3497
|
+
}
|
|
3498
|
+
/**
|
|
3499
|
+
* Calibrate personas against a source of truth.
|
|
3500
|
+
* Policy documents, survey data, outcome data, benchmarks — any ground truth.
|
|
3501
|
+
*/
|
|
3502
|
+
async calibrate(personas, sources, options) {
|
|
3503
|
+
return calibrate(this.llm, personas, sources, options);
|
|
3504
|
+
}
|
|
3505
|
+
/**
|
|
3506
|
+
* Structured calibration — compare synthetic survey results against real data.
|
|
3507
|
+
* Uses real math (MAE, Pearson correlation), not LLM judgment.
|
|
3508
|
+
*
|
|
3509
|
+
* 1. Run the same survey through Dopple personas
|
|
3510
|
+
* 2. Provide real survey data with response distributions
|
|
3511
|
+
* 3. Get statistical comparison: MAE per question, correlation, threshold pass/fail
|
|
3512
|
+
*/
|
|
3513
|
+
calibrateStructured(realData, syntheticResults, options) {
|
|
3514
|
+
return calibrateStructured(realData, syntheticResults, options);
|
|
3515
|
+
}
|
|
3516
|
+
/**
|
|
3517
|
+
* Build a knowledge graph from all adapter data.
|
|
3518
|
+
* Graph is cached — call clearCache() to rebuild.
|
|
3519
|
+
*/
|
|
3520
|
+
async buildGraph(contextTexts) {
|
|
3521
|
+
if (this.cachedGraph) return this.cachedGraph;
|
|
3522
|
+
const userData = await this.fetchData();
|
|
3523
|
+
const graph = new DoppleGraph();
|
|
3524
|
+
await graph.build(this.llm, userData, contextTexts);
|
|
3525
|
+
this.cachedGraph = graph;
|
|
3526
|
+
return graph;
|
|
3527
|
+
}
|
|
3528
|
+
/**
|
|
3529
|
+
* Record a real outcome for a past prediction.
|
|
3530
|
+
* This closes the calibration loop — next predictions will be informed by this.
|
|
3531
|
+
*/
|
|
3532
|
+
async recordOutcome(product, traceId, actual, source, accuracy) {
|
|
3533
|
+
await this.traces.recordOutcome(product, traceId, {
|
|
3534
|
+
actual,
|
|
3535
|
+
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3536
|
+
source
|
|
3537
|
+
}, {
|
|
3538
|
+
mae: accuracy?.mae,
|
|
3539
|
+
directionCorrect: accuracy?.directionCorrect,
|
|
3540
|
+
notes: accuracy?.notes ?? ""
|
|
3541
|
+
});
|
|
3542
|
+
}
|
|
3543
|
+
/**
|
|
3544
|
+
* Get prediction history and calibration summary for a product.
|
|
3545
|
+
*/
|
|
3546
|
+
async getTraceHistory(product) {
|
|
3547
|
+
return this.traces.getProductHistory(product);
|
|
3548
|
+
}
|
|
3549
|
+
/**
|
|
3550
|
+
* Get the underlying LLM provider info.
|
|
3551
|
+
*/
|
|
3552
|
+
get provider() {
|
|
3553
|
+
return this.llm.providerId;
|
|
3554
|
+
}
|
|
3555
|
+
/**
|
|
3556
|
+
* Invalidate cached data (call after adapter changes).
|
|
3557
|
+
*/
|
|
3558
|
+
clearCache() {
|
|
3559
|
+
this.cachedUserData = null;
|
|
3560
|
+
this.cachedGraph = null;
|
|
3561
|
+
}
|
|
3562
|
+
};
|
|
3563
|
+
|
|
3564
|
+
// src/cli/format.ts
|
|
3565
|
+
import pc from "picocolors";
|
|
3566
|
+
var PERSONA_COLORS = [pc.cyan, pc.magenta, pc.green, pc.yellow, pc.blue, pc.red];
|
|
3567
|
+
function personaColor(name) {
|
|
3568
|
+
let hash = 0;
|
|
3569
|
+
for (let i = 0; i < name.length; i++) {
|
|
3570
|
+
hash = hash * 31 + name.charCodeAt(i) | 0;
|
|
3571
|
+
}
|
|
3572
|
+
return PERSONA_COLORS[Math.abs(hash) % PERSONA_COLORS.length];
|
|
3573
|
+
}
|
|
3574
|
+
function confidenceColor(level) {
|
|
3575
|
+
switch (level) {
|
|
3576
|
+
case "low":
|
|
3577
|
+
return pc.red;
|
|
3578
|
+
case "medium":
|
|
3579
|
+
return pc.yellow;
|
|
3580
|
+
case "high":
|
|
3581
|
+
return pc.green;
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
function formatPersonaName(name) {
|
|
3585
|
+
const color = personaColor(name);
|
|
3586
|
+
return color(pc.bold(name));
|
|
3587
|
+
}
|
|
3588
|
+
function formatConfidence(level) {
|
|
3589
|
+
const color = confidenceColor(level);
|
|
3590
|
+
return color(level.toUpperCase());
|
|
3591
|
+
}
|
|
3592
|
+
function formatCitation(c) {
|
|
3593
|
+
return pc.dim(` [${c.source}] ${c.detail} (${(c.weight * 100).toFixed(0)}%)`);
|
|
3594
|
+
}
|
|
3595
|
+
function formatOcean(traits) {
|
|
3596
|
+
const fmt = (label, val) => {
|
|
3597
|
+
const bar = "\u2588".repeat(Math.round(val * 10));
|
|
3598
|
+
const empty = "\u2591".repeat(10 - Math.round(val * 10));
|
|
3599
|
+
return `${pc.dim(label)} ${pc.cyan(bar)}${pc.dim(empty)} ${val.toFixed(2)}`;
|
|
3600
|
+
};
|
|
3601
|
+
return [
|
|
3602
|
+
fmt("O", traits.openness),
|
|
3603
|
+
fmt("C", traits.conscientiousness),
|
|
3604
|
+
fmt("E", traits.extraversion),
|
|
3605
|
+
fmt("A", traits.agreeableness),
|
|
3606
|
+
fmt("N", traits.neuroticism)
|
|
3607
|
+
].join("\n");
|
|
3608
|
+
}
|
|
3609
|
+
function progressBar(percent, width = 20) {
|
|
3610
|
+
const filled = Math.round(percent / 100 * width);
|
|
3611
|
+
const empty = width - filled;
|
|
3612
|
+
return `${pc.green("\u2588".repeat(filled))}${pc.dim("\u2591".repeat(empty))} ${percent}%`;
|
|
3613
|
+
}
|
|
3614
|
+
function printPersonas(personas) {
|
|
3615
|
+
console.log(`
|
|
3616
|
+
${pc.bold(`Generated ${personas.length} personas:`)}
|
|
3617
|
+
`);
|
|
3618
|
+
for (const p of personas) {
|
|
3619
|
+
const d = p.definition;
|
|
3620
|
+
console.log(` ${formatPersonaName(p.name)} ${pc.dim("\u2014")} ${p.summary()}`);
|
|
3621
|
+
console.log(` ${formatOcean(d.traits).split("\n").join("\n ")}`);
|
|
3622
|
+
console.log(` Confidence: ${formatConfidence(d.confidence)}`);
|
|
3623
|
+
console.log();
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
function printPanelResponse(results) {
|
|
3627
|
+
for (const r of results) {
|
|
3628
|
+
console.log(
|
|
3629
|
+
`
|
|
3630
|
+
${formatPersonaName(r.persona)} ${pc.dim(`[${r.confidence}]`)}:`
|
|
3631
|
+
);
|
|
3632
|
+
console.log(` ${r.response}`);
|
|
3633
|
+
if (r.reasoning) {
|
|
3634
|
+
console.log(` ${pc.dim("Why:")} ${pc.dim(r.reasoning)}`);
|
|
3635
|
+
}
|
|
3636
|
+
for (const c of r.citations) {
|
|
3637
|
+
console.log(formatCitation(c));
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
console.log();
|
|
3641
|
+
}
|
|
3642
|
+
function printDiscovery(result) {
|
|
3643
|
+
console.log(
|
|
3644
|
+
`
|
|
3645
|
+
${pc.bold(`Discovered ${result.segments.length} segments`)} from ${result.totalUsersAnalyzed} users:
|
|
3646
|
+
`
|
|
3647
|
+
);
|
|
3648
|
+
for (const seg of result.segments) {
|
|
3649
|
+
const pct = progressBar(seg.percentage, 15);
|
|
3650
|
+
console.log(
|
|
3651
|
+
` ${formatPersonaName(seg.name)} ${pc.dim("\u2014")} ${pct} (${seg.userCount} users)`
|
|
3652
|
+
);
|
|
3653
|
+
console.log(` ${pc.dim(seg.description)}`);
|
|
3654
|
+
for (const p of seg.patterns) {
|
|
3655
|
+
console.log(` ${pc.dim("\u2022")} ${p}`);
|
|
3656
|
+
}
|
|
3657
|
+
console.log(
|
|
3658
|
+
` Representative: ${formatPersonaName(seg.persona.name)}`
|
|
3659
|
+
);
|
|
3660
|
+
console.log();
|
|
3661
|
+
}
|
|
3662
|
+
if (result.suggestions.length > 0) {
|
|
3663
|
+
console.log(` ${pc.bold("Suggestions to improve accuracy:")}`);
|
|
3664
|
+
for (const s of result.suggestions) {
|
|
3665
|
+
const impact = s.impact === "high" ? pc.red(s.impact.toUpperCase()) : s.impact === "medium" ? pc.yellow(s.impact.toUpperCase()) : pc.dim(s.impact.toUpperCase());
|
|
3666
|
+
console.log(` [${impact}] ${pc.bold(s.source)}: ${s.suggestion}`);
|
|
3667
|
+
}
|
|
3668
|
+
console.log();
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
function printSurvey(result) {
|
|
3672
|
+
console.log(
|
|
3673
|
+
`
|
|
3674
|
+
${pc.bold(`Survey: "${result.surveyName}"`)} \u2014 ${result.personaCount} personas, ${result.questions.length} questions
|
|
3675
|
+
`
|
|
3676
|
+
);
|
|
3677
|
+
for (const [qName, data] of Object.entries(result.summary)) {
|
|
3678
|
+
console.log(` ${pc.bold(data.questionText)}`);
|
|
3679
|
+
if (data.type === "free_text") {
|
|
3680
|
+
const responses = result.responses.filter(
|
|
3681
|
+
(r) => r.questionName === qName
|
|
3682
|
+
);
|
|
3683
|
+
for (const r of responses) {
|
|
3684
|
+
console.log(
|
|
3685
|
+
` ${formatPersonaName(r.personaName)}: ${pc.dim(`"${r.answer}"`)}`
|
|
3686
|
+
);
|
|
3687
|
+
}
|
|
3688
|
+
} else {
|
|
3689
|
+
const total = Object.values(data.distribution).reduce(
|
|
3690
|
+
(a, b) => a + b,
|
|
3691
|
+
0
|
|
3692
|
+
);
|
|
3693
|
+
for (const [answer, count] of Object.entries(data.distribution)) {
|
|
3694
|
+
const pct = total > 0 ? Math.round(count / total * 100) : 0;
|
|
3695
|
+
const barLen = Math.round(pct / 5);
|
|
3696
|
+
console.log(
|
|
3697
|
+
` ${answer.padEnd(25)} ${pc.cyan("\u2593".repeat(barLen))}${pc.dim("\u2591".repeat(20 - barLen))} ${pct}%`
|
|
3698
|
+
);
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
console.log();
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
3704
|
+
function printFocusGroupRound(round2) {
|
|
3705
|
+
console.log(`
|
|
3706
|
+
${pc.bold(`Round ${round2.round}`)}`);
|
|
3707
|
+
console.log(pc.dim("\u2500".repeat(50)));
|
|
3708
|
+
if (round2.injectedContext) {
|
|
3709
|
+
console.log(
|
|
3710
|
+
` ${pc.bgYellow(pc.black(" MODERATOR "))} ${round2.injectedContext}`
|
|
3711
|
+
);
|
|
3712
|
+
console.log();
|
|
3713
|
+
}
|
|
3714
|
+
for (const msg of round2.messages) {
|
|
3715
|
+
console.log(` ${formatPersonaName(msg.personaName)}:`);
|
|
3716
|
+
console.log(` ${msg.message}`);
|
|
3717
|
+
if (msg.opinionShift) {
|
|
3718
|
+
console.log(
|
|
3719
|
+
` ${pc.yellow("\u27F3 SHIFTED:")} ${pc.dim(msg.opinionShift.previousPosition)} \u2192 ${pc.bold(msg.opinionShift.newPosition)} ${pc.dim(`(convinced by ${msg.opinionShift.triggeredBy})`)}`
|
|
3720
|
+
);
|
|
3721
|
+
}
|
|
3722
|
+
if (msg.reasoning) {
|
|
3723
|
+
console.log(` ${pc.dim(`Why: ${msg.reasoning}`)}`);
|
|
3724
|
+
}
|
|
3725
|
+
}
|
|
3726
|
+
console.log();
|
|
3727
|
+
}
|
|
3728
|
+
function printValidation(name, report) {
|
|
3729
|
+
console.log(`
|
|
3730
|
+
${pc.bold(`Validation Report for ${name}`)}`);
|
|
3731
|
+
console.log(pc.dim("\u2550".repeat(50)));
|
|
3732
|
+
const overall = report.overall.passed ? pc.green(pc.bold("PASS")) : pc.red(pc.bold("FAIL"));
|
|
3733
|
+
console.log(
|
|
3734
|
+
`Overall: ${overall} (${report.overall.score}/${report.overall.maxScore})`
|
|
3735
|
+
);
|
|
3736
|
+
for (const test of report.tests) {
|
|
3737
|
+
const icon = test.passed ? pc.green("+") : pc.red("-");
|
|
3738
|
+
const score = test.score >= test.maxScore * 0.7 ? pc.green(`${test.score}/${test.maxScore}`) : test.score >= test.maxScore * 0.4 ? pc.yellow(`${test.score}/${test.maxScore}`) : pc.red(`${test.score}/${test.maxScore}`);
|
|
3739
|
+
console.log(`
|
|
3740
|
+
[${icon}] ${pc.bold(test.testName)}: ${score}`);
|
|
3741
|
+
console.log(` ${pc.dim(test.details)}`);
|
|
3742
|
+
}
|
|
3743
|
+
console.log();
|
|
3744
|
+
}
|
|
3745
|
+
function printInsightReport(report) {
|
|
3746
|
+
console.log(`
|
|
3747
|
+
${pc.bold("Dopple Insights")}`);
|
|
3748
|
+
console.log(pc.dim("\u2550".repeat(60)));
|
|
3749
|
+
console.log(` Product: ${pc.bold(report.product)}`);
|
|
3750
|
+
console.log(
|
|
3751
|
+
` Data: ${report.dataQuality.userCount} users from ${report.dataQuality.sources.join(", ") || "none"}`
|
|
3752
|
+
);
|
|
3753
|
+
console.log(
|
|
3754
|
+
` Confidence: ${formatConfidence(report.dataQuality.confidence)}`
|
|
3755
|
+
);
|
|
3756
|
+
if (report.graph) {
|
|
3757
|
+
console.log(
|
|
3758
|
+
` Graph: ${pc.cyan(String(report.graph.nodeCount))} nodes, ${pc.cyan(String(report.graph.edgeCount))} edges`
|
|
3759
|
+
);
|
|
3760
|
+
}
|
|
3761
|
+
console.log(
|
|
3762
|
+
` Segments: ${report.segments.length} | Duration: ${(report.metadata.durationMs / 1e3).toFixed(1)}s`
|
|
3763
|
+
);
|
|
3764
|
+
if (report.dataQuality.gaps.length > 0) {
|
|
3765
|
+
console.log(`
|
|
3766
|
+
${pc.dim("Data gaps:")}`);
|
|
3767
|
+
for (const g of report.dataQuality.gaps.slice(0, 3)) {
|
|
3768
|
+
const impact = g.impact === "high" ? pc.red("HIGH") : g.impact === "medium" ? pc.yellow("MED") : pc.dim("LOW");
|
|
3769
|
+
console.log(` [${impact}] ${g.suggestion}`);
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
console.log(`
|
|
3773
|
+
${pc.bold(`${report.insights.length} Insights:`)}
|
|
3774
|
+
`);
|
|
3775
|
+
for (let i = 0; i < report.insights.length; i++) {
|
|
3776
|
+
const insight = report.insights[i];
|
|
3777
|
+
const confBar = progressBar(Math.round(insight.confidence * 100), 10);
|
|
3778
|
+
const impactBar = progressBar(Math.round(insight.impact * 100), 10);
|
|
3779
|
+
console.log(` ${pc.bold(`${i + 1}. ${insight.title}`)}`);
|
|
3780
|
+
console.log(` ${insight.description}`);
|
|
3781
|
+
console.log(` Confidence: ${confBar} Impact: ${impactBar}`);
|
|
3782
|
+
console.log(
|
|
3783
|
+
` Segments: ${insight.segments.map((s) => pc.cyan(s)).join(", ")}`
|
|
3784
|
+
);
|
|
3785
|
+
if (insight.evidence.length > 0) {
|
|
3786
|
+
for (const e of insight.evidence.slice(0, 3)) {
|
|
3787
|
+
console.log(
|
|
3788
|
+
` ${pc.dim(`[${e.type}] ${e.detail}`)}`
|
|
3789
|
+
);
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
console.log(` ${pc.green("\u2192")} ${pc.green(insight.recommendation)}`);
|
|
3793
|
+
console.log();
|
|
3794
|
+
}
|
|
3795
|
+
}
|
|
3796
|
+
function printCalibrationReport(report) {
|
|
3797
|
+
console.log(`
|
|
3798
|
+
${pc.bold("Calibration Report")}`);
|
|
3799
|
+
console.log(pc.dim("\u2550".repeat(60)));
|
|
3800
|
+
const scoreColor = report.overallScore >= 0.7 ? pc.green : report.overallScore >= 0.4 ? pc.yellow : pc.red;
|
|
3801
|
+
console.log(
|
|
3802
|
+
` Overall alignment: ${scoreColor(pc.bold(`${(report.overallScore * 100).toFixed(0)}%`))} ${progressBar(Math.round(report.overallScore * 100), 15)}`
|
|
3803
|
+
);
|
|
3804
|
+
console.log(
|
|
3805
|
+
` Rules tested: ${report.totalRules} | ${pc.green(`${report.alignedCount} aligned`)} | ${pc.red(`${report.misalignedCount} misaligned`)}`
|
|
3806
|
+
);
|
|
3807
|
+
console.log(
|
|
3808
|
+
` Sources: ${report.sources.map((s) => `${s.type}:"${s.name}"`).join(", ")}`
|
|
3809
|
+
);
|
|
3810
|
+
console.log(
|
|
3811
|
+
` Duration: ${(report.metadata.durationMs / 1e3).toFixed(1)}s`
|
|
3812
|
+
);
|
|
3813
|
+
if (report.topMisalignments.length > 0) {
|
|
3814
|
+
console.log(`
|
|
3815
|
+
${pc.bold(pc.red("Top Misalignments:"))}
|
|
3816
|
+
`);
|
|
3817
|
+
for (const m of report.topMisalignments) {
|
|
3818
|
+
const bar = progressBar(Math.round(m.score * 100), 10);
|
|
3819
|
+
console.log(` ${pc.red("\u2717")} ${pc.bold(m.rule)}`);
|
|
3820
|
+
console.log(` Score: ${bar}`);
|
|
3821
|
+
console.log(` Expected: ${pc.dim(m.expected)}`);
|
|
3822
|
+
console.log(` Got: ${m.personaResponse}`);
|
|
3823
|
+
console.log(` Gap: ${pc.yellow(m.gapAnalysis)}`);
|
|
3824
|
+
console.log();
|
|
3825
|
+
}
|
|
3826
|
+
}
|
|
3827
|
+
const aligned = report.results.filter((r) => r.aligned);
|
|
3828
|
+
if (aligned.length > 0) {
|
|
3829
|
+
console.log(`${pc.bold(pc.green("Aligned Rules:"))}`);
|
|
3830
|
+
for (const r of aligned) {
|
|
3831
|
+
console.log(` ${pc.green("\u2713")} ${r.rule} ${pc.dim(`(${(r.score * 100).toFixed(0)}%)`)}`);
|
|
3832
|
+
}
|
|
3833
|
+
console.log();
|
|
3834
|
+
}
|
|
3835
|
+
if (report.recommendations.length > 0) {
|
|
3836
|
+
console.log(`${pc.bold("Recommendations:")}`);
|
|
3837
|
+
for (const rec of report.recommendations) {
|
|
3838
|
+
console.log(` ${pc.yellow("\u2192")} ${rec}`);
|
|
3839
|
+
}
|
|
3840
|
+
console.log();
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
function printStructuredCalibration(report) {
|
|
3844
|
+
console.log(`
|
|
3845
|
+
${pc.bold("Structured Calibration Report")}`);
|
|
3846
|
+
console.log(pc.dim("\u2550".repeat(60)));
|
|
3847
|
+
console.log(` ${pc.bold(report.name)}`);
|
|
3848
|
+
const maeColor = report.overallMAE <= 0.05 ? pc.green : report.overallMAE <= 0.1 ? pc.yellow : pc.red;
|
|
3849
|
+
const corrColor = report.correlation >= 0.8 ? pc.green : report.correlation >= 0.5 ? pc.yellow : pc.red;
|
|
3850
|
+
console.log(
|
|
3851
|
+
` Overall MAE: ${maeColor(pc.bold(`${(report.overallMAE * 100).toFixed(1)}pp`))} ${pc.dim("(lower = better, <10pp is good)")}`
|
|
3852
|
+
);
|
|
3853
|
+
console.log(
|
|
3854
|
+
` Correlation: ${corrColor(pc.bold(`r=${report.correlation.toFixed(3)}`))} ${pc.dim("(higher = better, >0.8 is strong)")}`
|
|
3855
|
+
);
|
|
3856
|
+
console.log(
|
|
3857
|
+
` Questions within ${(report.threshold * 100).toFixed(0)}pp threshold: ${report.questionsWithinThreshold}/${report.totalQuestions}`
|
|
3858
|
+
);
|
|
3859
|
+
console.log(
|
|
3860
|
+
` Personas: ${report.stats.personaCount} | Real respondents: ${report.stats.realRespondentCount}`
|
|
3861
|
+
);
|
|
3862
|
+
console.log(
|
|
3863
|
+
`
|
|
3864
|
+
Best: ${pc.green(report.stats.bestQuestion.name)} (${report.stats.bestQuestion.mae}pp MAE)`
|
|
3865
|
+
);
|
|
3866
|
+
console.log(
|
|
3867
|
+
` Worst: ${pc.red(report.stats.worstQuestion.name)} (${report.stats.worstQuestion.mae}pp MAE)`
|
|
3868
|
+
);
|
|
3869
|
+
console.log(
|
|
3870
|
+
` Median: ${report.stats.medianMAE}pp MAE`
|
|
3871
|
+
);
|
|
3872
|
+
console.log(`
|
|
3873
|
+
${pc.bold("Per-Question Results:")}
|
|
3874
|
+
`);
|
|
3875
|
+
for (const q of report.questions) {
|
|
3876
|
+
const icon = q.withinThreshold ? pc.green("\u2713") : pc.red("\u2717");
|
|
3877
|
+
const maeStr = q.mae <= report.threshold ? pc.green(`${(q.mae * 100).toFixed(1)}pp`) : pc.red(`${(q.mae * 100).toFixed(1)}pp`);
|
|
3878
|
+
console.log(` ${icon} ${pc.bold(q.questionName)} \u2014 ${maeStr} MAE`);
|
|
3879
|
+
console.log(` ${pc.dim(q.questionText)}`);
|
|
3880
|
+
const allOpts = [
|
|
3881
|
+
.../* @__PURE__ */ new Set([
|
|
3882
|
+
...Object.keys(q.realDistribution),
|
|
3883
|
+
...Object.keys(q.syntheticDistribution)
|
|
3884
|
+
])
|
|
3885
|
+
];
|
|
3886
|
+
for (const opt of allOpts) {
|
|
3887
|
+
const real = q.realDistribution[opt] ?? 0;
|
|
3888
|
+
const synth = q.syntheticDistribution[opt] ?? 0;
|
|
3889
|
+
const err = q.perOptionError[opt] ?? 0;
|
|
3890
|
+
const realBar = pc.cyan("\u2593".repeat(Math.round(real / 5)));
|
|
3891
|
+
const synthBar = pc.magenta("\u2593".repeat(Math.round(synth / 5)));
|
|
3892
|
+
const errStr = err > 5 ? pc.yellow(` \u0394${err.toFixed(0)}pp`) : "";
|
|
3893
|
+
const label = opt.length > 30 ? opt.slice(0, 30) + "..." : opt;
|
|
3894
|
+
console.log(
|
|
3895
|
+
` ${label.padEnd(33)} Real: ${realBar} ${String(real.toFixed(0)).padStart(3)}% Synth: ${synthBar} ${String(synth.toFixed(0)).padStart(3)}%${errStr}`
|
|
3896
|
+
);
|
|
3897
|
+
}
|
|
3898
|
+
console.log();
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3902
|
+
// src/cli.ts
|
|
3903
|
+
var program = new Command();
|
|
3904
|
+
program.name("dopple").description(
|
|
3905
|
+
"Synthetic user research engine. Generate grounded personas, query them, validate them."
|
|
3906
|
+
).version("0.1.0");
|
|
3907
|
+
var addGlobalOpts = (cmd) => cmd.option(
|
|
3908
|
+
"--model <provider/model>",
|
|
3909
|
+
'LLM model (e.g. "anthropic/claude-sonnet-4-20250514", "openai/gpt-4o", "ollama/llama3")'
|
|
3910
|
+
).option("--api-key <key>", "API key (or use env vars)").option("--base-url <url>", "Custom API base URL").option("--agent", "Structured JSON output for AI agents (single-line, envelope format)", false);
|
|
3911
|
+
function output(command, data, opts, prettyFn) {
|
|
3912
|
+
if (opts.agent) {
|
|
3913
|
+
const envelope = { ok: true, command, data };
|
|
3914
|
+
process.stdout.write(JSON.stringify(envelope) + "\n");
|
|
3915
|
+
} else if (opts.pretty && prettyFn) {
|
|
3916
|
+
prettyFn();
|
|
3917
|
+
} else {
|
|
3918
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
function buildCommandSchema(prog) {
|
|
3922
|
+
return {
|
|
3923
|
+
name: prog.name(),
|
|
3924
|
+
version: prog.version(),
|
|
3925
|
+
description: prog.description(),
|
|
3926
|
+
commands: prog.commands.map((cmd) => ({
|
|
3927
|
+
name: cmd.name(),
|
|
3928
|
+
description: cmd.description(),
|
|
3929
|
+
options: cmd.options.map((opt) => ({
|
|
3930
|
+
flags: opt.flags,
|
|
3931
|
+
description: opt.description,
|
|
3932
|
+
required: opt.required,
|
|
3933
|
+
defaultValue: opt.defaultValue
|
|
3934
|
+
}))
|
|
3935
|
+
}))
|
|
3936
|
+
};
|
|
3937
|
+
}
|
|
3938
|
+
if (process.argv.includes("--agent") && process.argv.includes("--help")) {
|
|
3939
|
+
process.on("beforeExit", () => {
|
|
3940
|
+
});
|
|
3941
|
+
}
|
|
3942
|
+
addGlobalOpts(
|
|
3943
|
+
program.command("generate").description("Generate personas from product context and/or data").requiredOption("--product <description>", "Product or brand description").option("-n, --count <number>", "Number of personas", "5").option("--context <text...>", "Additional context strings").option("--posthog-key <key>", "PostHog API key").option("--posthog-host <url>", "PostHog host").option("--csv <path>", "Path to CSV file with user data").option("--json-file <path>", "Path to JSON file with user data").option("--save <name>", "Save panel to .dopple/ storage").option("-o, --output <path>", "Save personas to file").option("--pretty", "Pretty-print output for humans", false)
|
|
3944
|
+
).action(async (opts) => {
|
|
3945
|
+
const config = buildConfig(opts);
|
|
3946
|
+
const dopple = new Dopple(config);
|
|
3947
|
+
const personas = await dopple.generate({
|
|
3948
|
+
product: opts.product,
|
|
3949
|
+
count: parseInt(opts.count, 10),
|
|
3950
|
+
context: opts.context,
|
|
3951
|
+
save: opts.save
|
|
3952
|
+
});
|
|
3953
|
+
const data = personas.map((p) => p.toJSON());
|
|
3954
|
+
if (opts.output) {
|
|
3955
|
+
const lines = data.map((p) => JSON.stringify(p));
|
|
3956
|
+
await writeFile4(opts.output, lines.join("\n") + "\n");
|
|
3957
|
+
}
|
|
3958
|
+
output("generate", data, opts, () => printPersonas(personas));
|
|
3959
|
+
});
|
|
3960
|
+
addGlobalOpts(
|
|
3961
|
+
program.command("discover").description("Discover user segments from your data").option("--product <description>", "Product description").option("--posthog-key <key>", "PostHog API key").option("--posthog-host <url>", "PostHog host").option("--csv <path>", "Path to CSV file").option("--json-file <path>", "Path to JSON file").option("-n, --segments <number>", "Number of segments to discover").option("-o, --output <path>", "Save results to file").option("--pretty", "Pretty-print output", false)
|
|
3962
|
+
).action(async (opts) => {
|
|
3963
|
+
const config = buildConfig(opts);
|
|
3964
|
+
const dopple = new Dopple(config);
|
|
3965
|
+
const result = await dopple.discover({
|
|
3966
|
+
product: opts.product,
|
|
3967
|
+
segmentCount: opts.segments ? parseInt(opts.segments, 10) : void 0
|
|
3968
|
+
});
|
|
3969
|
+
if (opts.output) {
|
|
3970
|
+
await writeFile4(opts.output, JSON.stringify(result, null, 2));
|
|
3971
|
+
}
|
|
3972
|
+
output("discover", result, opts, () => printDiscovery(result));
|
|
3973
|
+
});
|
|
3974
|
+
addGlobalOpts(
|
|
3975
|
+
program.command("ask").description("Ask a persona or panel a question").argument("<question>", "The question to ask").option("--persona <path>", "Path to persona JSONL file or saved panel name").option("--product <description>", "Product context").option("--posthog-key <key>", "PostHog API key").option("-n, --count <number>", "Generate N personas if no file given", "5").option("--pretty", "Pretty-print output", false)
|
|
3976
|
+
).action(async (question, opts) => {
|
|
3977
|
+
const config = buildConfig(opts);
|
|
3978
|
+
const dopple = new Dopple(config);
|
|
3979
|
+
let personas;
|
|
3980
|
+
if (opts.persona) {
|
|
3981
|
+
const fromStorage = await dopple.loadPanel(opts.persona);
|
|
3982
|
+
if (fromStorage) {
|
|
3983
|
+
personas = fromStorage;
|
|
3984
|
+
} else {
|
|
3985
|
+
const content = await readFile6(opts.persona, "utf-8");
|
|
3986
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
3987
|
+
const definitions = lines.map((l) => JSON.parse(l)).filter((d) => d.id && d.traits);
|
|
3988
|
+
const { createProvider: createProvider2 } = await import("./provider-7PWDG74H.js");
|
|
3989
|
+
const llm = createProvider2(opts.model, opts.apiKey, opts.baseUrl);
|
|
3990
|
+
personas = definitions.map((d) => new Persona(d, llm));
|
|
3991
|
+
}
|
|
3992
|
+
} else {
|
|
3993
|
+
personas = await dopple.generate({
|
|
3994
|
+
product: opts.product,
|
|
3995
|
+
count: parseInt(opts.count, 10)
|
|
3996
|
+
});
|
|
3997
|
+
}
|
|
3998
|
+
const results = await dopple.askPanel(personas, question);
|
|
3999
|
+
output("ask", results, opts, () => printPanelResponse(results));
|
|
4000
|
+
});
|
|
4001
|
+
addGlobalOpts(
|
|
4002
|
+
program.command("survey").description("Run a structured survey against personas").requiredOption("--product <description>", "Product description").option("--questions <path>", "Path to survey questions JSON file").option("--persona <path>", "Path to persona JSONL or saved panel name").option("-n, --count <number>", "Persona count if generating", "5").option("--posthog-key <key>", "PostHog API key").option("-o, --output <path>", "Save results to file").option("--pretty", "Pretty-print output", false)
|
|
4003
|
+
).action(async (opts) => {
|
|
4004
|
+
const config = buildConfig(opts);
|
|
4005
|
+
const dopple = new Dopple(config);
|
|
4006
|
+
let personas;
|
|
4007
|
+
if (opts.persona) {
|
|
4008
|
+
const fromStorage = await dopple.loadPanel(opts.persona);
|
|
4009
|
+
if (fromStorage) {
|
|
4010
|
+
personas = fromStorage;
|
|
4011
|
+
} else {
|
|
4012
|
+
const content = await readFile6(opts.persona, "utf-8");
|
|
4013
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
4014
|
+
const definitions = lines.map((l) => JSON.parse(l)).filter((d) => d.id && d.traits);
|
|
4015
|
+
const { createProvider: createProvider2 } = await import("./provider-7PWDG74H.js");
|
|
4016
|
+
const llm = createProvider2(opts.model, opts.apiKey, opts.baseUrl);
|
|
4017
|
+
personas = definitions.map((d) => new Persona(d, llm));
|
|
4018
|
+
}
|
|
4019
|
+
} else {
|
|
4020
|
+
personas = await dopple.generate({
|
|
4021
|
+
product: opts.product,
|
|
4022
|
+
count: parseInt(opts.count, 10)
|
|
4023
|
+
});
|
|
4024
|
+
}
|
|
4025
|
+
let survey;
|
|
4026
|
+
if (opts.questions) {
|
|
4027
|
+
const qData = JSON.parse(await readFile6(opts.questions, "utf-8"));
|
|
4028
|
+
survey = new Survey(qData.name ?? "survey");
|
|
4029
|
+
for (const q of qData.questions) {
|
|
4030
|
+
switch (q.type) {
|
|
4031
|
+
case "free_text":
|
|
4032
|
+
survey.freeText(q.name, q.text);
|
|
4033
|
+
break;
|
|
4034
|
+
case "multiple_choice":
|
|
4035
|
+
survey.multipleChoice(q.name, q.text, q.options ?? []);
|
|
4036
|
+
break;
|
|
4037
|
+
case "likert_5":
|
|
4038
|
+
survey.likert5(q.name, q.text);
|
|
4039
|
+
break;
|
|
4040
|
+
case "likert_7":
|
|
4041
|
+
survey.likert7(q.name, q.text);
|
|
4042
|
+
break;
|
|
4043
|
+
case "yes_no":
|
|
4044
|
+
survey.yesNo(q.name, q.text);
|
|
4045
|
+
break;
|
|
4046
|
+
case "numerical":
|
|
4047
|
+
survey.numerical(q.name, q.text, q.min, q.max);
|
|
4048
|
+
break;
|
|
4049
|
+
case "ranking":
|
|
4050
|
+
survey.ranking(q.name, q.text, q.options ?? []);
|
|
4051
|
+
break;
|
|
4052
|
+
}
|
|
4053
|
+
}
|
|
4054
|
+
} else {
|
|
4055
|
+
survey = new Survey("quick-feedback").freeText("pain", "What's the biggest problem you face in this area?").likert5("satisfaction", "How satisfied are you with current solutions?").yesNo("switch", "Would you try a new product in this space?").numerical("budget", "What's the most you'd pay monthly for a great solution?", 0, 200);
|
|
4056
|
+
}
|
|
4057
|
+
const result = await dopple.runSurvey(survey, personas);
|
|
4058
|
+
if (opts.output) {
|
|
4059
|
+
await writeFile4(opts.output, JSON.stringify(result, null, 2));
|
|
4060
|
+
}
|
|
4061
|
+
output("survey", result, opts, () => printSurvey(result));
|
|
4062
|
+
});
|
|
4063
|
+
addGlobalOpts(
|
|
4064
|
+
program.command("validate").description("Run validation suite on a persona").requiredOption("--persona <path>", "Path to persona JSONL file").option("--pretty", "Pretty-print output", false)
|
|
4065
|
+
).action(async (opts) => {
|
|
4066
|
+
const content = await readFile6(opts.persona, "utf-8");
|
|
4067
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
4068
|
+
const definitions = lines.map((l) => JSON.parse(l)).filter((d) => d.id && d.traits);
|
|
4069
|
+
if (definitions.length === 0) {
|
|
4070
|
+
console.error("No valid persona definitions found in file.");
|
|
4071
|
+
process.exit(1);
|
|
4072
|
+
}
|
|
4073
|
+
const { createProvider: createProvider2 } = await import("./provider-7PWDG74H.js");
|
|
4074
|
+
const llm = createProvider2(opts.model, opts.apiKey, opts.baseUrl);
|
|
4075
|
+
const definition = definitions[0];
|
|
4076
|
+
const persona = new Persona(definition, llm);
|
|
4077
|
+
const dopple = new Dopple(buildConfig(opts));
|
|
4078
|
+
const report = await dopple.validate(persona);
|
|
4079
|
+
output("validate", report, opts, () => printValidation(definition.name, report));
|
|
4080
|
+
});
|
|
4081
|
+
program.command("panels").description("List saved persona panels").option("--agent", "Structured JSON output for AI agents", false).option("--pretty", "Pretty-print output", false).action(async (opts) => {
|
|
4082
|
+
const dopple = new Dopple();
|
|
4083
|
+
const panels = await dopple.store.listPanels();
|
|
4084
|
+
output("panels", panels, opts, () => {
|
|
4085
|
+
if (panels.length === 0) {
|
|
4086
|
+
console.log("No saved panels. Use --save <name> with generate to save one.");
|
|
4087
|
+
return;
|
|
4088
|
+
}
|
|
4089
|
+
console.log("\nSaved panels:\n");
|
|
4090
|
+
for (const p of panels) {
|
|
4091
|
+
console.log(` ${p.name} (${p.personaCount} personas) \u2014 ${p.createdAt}`);
|
|
4092
|
+
console.log(` ID: ${p.id}`);
|
|
4093
|
+
}
|
|
4094
|
+
console.log();
|
|
4095
|
+
});
|
|
4096
|
+
});
|
|
4097
|
+
program.command("providers").description("List supported LLM providers").option("--agent", "Structured JSON output for AI agents", false).option("--pretty", "Pretty-print output", false).action((opts) => {
|
|
4098
|
+
const providers = listProviders();
|
|
4099
|
+
output("providers", providers, opts, () => {
|
|
4100
|
+
console.log("\nSupported providers:\n");
|
|
4101
|
+
for (const p of providers) {
|
|
4102
|
+
console.log(` ${p.id} \u2014 ${p.name}${p.envVar ? ` (env: ${p.envVar})` : ""}`);
|
|
4103
|
+
}
|
|
4104
|
+
console.log('\nUsage: --model "provider/model-name"');
|
|
4105
|
+
console.log('Example: --model "openai/gpt-4o"');
|
|
4106
|
+
console.log('Example: --model "ollama/llama3"');
|
|
4107
|
+
console.log();
|
|
4108
|
+
});
|
|
4109
|
+
});
|
|
4110
|
+
addGlobalOpts(
|
|
4111
|
+
program.command("status").description("Show data quality and improvement suggestions").option("--posthog-key <key>", "PostHog API key").option("--posthog-host <url>", "PostHog host").option("--csv <path>", "CSV file path").option("--pretty", "Pretty-print output", false)
|
|
4112
|
+
).action(async (opts) => {
|
|
4113
|
+
const config = buildConfig(opts);
|
|
4114
|
+
const dopple = new Dopple(config);
|
|
4115
|
+
const status = await dopple.status();
|
|
4116
|
+
output("status", status, opts, () => {
|
|
4117
|
+
console.log(`
|
|
4118
|
+
Dopple Status`);
|
|
4119
|
+
console.log("=".repeat(40));
|
|
4120
|
+
console.log(`Provider: ${dopple.provider}`);
|
|
4121
|
+
console.log(`Adapters: ${status.adapters.join(", ") || "none"}`);
|
|
4122
|
+
console.log(`Users: ${status.userCount}`);
|
|
4123
|
+
console.log(`Confidence: ${status.confidence.toUpperCase()}`);
|
|
4124
|
+
if (status.panels.length > 0) {
|
|
4125
|
+
console.log(`Saved panels: ${status.panels.length}`);
|
|
4126
|
+
}
|
|
4127
|
+
if (status.suggestions.length > 0) {
|
|
4128
|
+
console.log(`
|
|
4129
|
+
Suggestions to improve:`);
|
|
4130
|
+
for (const s of status.suggestions) {
|
|
4131
|
+
console.log(
|
|
4132
|
+
` [${s.impact.toUpperCase()}] ${s.source}: ${s.suggestion}`
|
|
4133
|
+
);
|
|
4134
|
+
}
|
|
4135
|
+
}
|
|
4136
|
+
console.log();
|
|
4137
|
+
});
|
|
4138
|
+
});
|
|
4139
|
+
addGlobalOpts(
|
|
4140
|
+
program.command("focus-group").description("Run a focus group discussion with multiple personas").requiredOption("--topic <topic>", "Discussion topic or question").option("--product <description>", "Product context").option("--persona <path>", "Path to persona JSONL or saved panel name").option("-n, --count <number>", "Persona count if generating", "5").option("--rounds <number>", "Number of discussion rounds", "3").option("--inject <context...>", "Context to inject between rounds").option("--context-file <path>", "JSON file mapping persona names to private context arrays").option("--posthog-key <key>", "PostHog API key").option("-o, --output <path>", "Save transcript to file").option("--pretty", "Pretty-print output", false)
|
|
4141
|
+
).action(async (opts) => {
|
|
4142
|
+
const config = buildConfig(opts);
|
|
4143
|
+
const dopple = new Dopple(config);
|
|
4144
|
+
let personas;
|
|
4145
|
+
if (opts.persona) {
|
|
4146
|
+
const fromStorage = await dopple.loadPanel(opts.persona);
|
|
4147
|
+
if (fromStorage) {
|
|
4148
|
+
personas = fromStorage;
|
|
4149
|
+
} else {
|
|
4150
|
+
const content = await readFile6(opts.persona, "utf-8");
|
|
4151
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
4152
|
+
const definitions = lines.map((l) => JSON.parse(l)).filter((d) => d.id && d.traits);
|
|
4153
|
+
const { createProvider: createProvider2 } = await import("./provider-7PWDG74H.js");
|
|
4154
|
+
const llm = createProvider2(opts.model, opts.apiKey, opts.baseUrl);
|
|
4155
|
+
personas = definitions.map((d) => new Persona(d, llm));
|
|
4156
|
+
}
|
|
4157
|
+
} else {
|
|
4158
|
+
personas = await dopple.generate({
|
|
4159
|
+
product: opts.product,
|
|
4160
|
+
count: parseInt(opts.count, 10)
|
|
4161
|
+
});
|
|
4162
|
+
}
|
|
4163
|
+
let personaContext;
|
|
4164
|
+
if (opts.contextFile) {
|
|
4165
|
+
personaContext = JSON.parse(await readFile6(opts.contextFile, "utf-8"));
|
|
4166
|
+
}
|
|
4167
|
+
const group = dopple.createFocusGroup("cli-focus-group", {
|
|
4168
|
+
topic: opts.topic,
|
|
4169
|
+
product: opts.product,
|
|
4170
|
+
maxRounds: parseInt(opts.rounds, 10) + 1,
|
|
4171
|
+
personaContext
|
|
4172
|
+
});
|
|
4173
|
+
group.addPersonas(personas);
|
|
4174
|
+
const injectContexts = opts.inject ?? [];
|
|
4175
|
+
const rounds = parseInt(opts.rounds, 10);
|
|
4176
|
+
for (let i = 0; i < rounds; i++) {
|
|
4177
|
+
if (injectContexts[i]) {
|
|
4178
|
+
group.inject(injectContexts[i]);
|
|
4179
|
+
}
|
|
4180
|
+
const round2 = await group.discuss(
|
|
4181
|
+
// Get the LLM from engine — create a fresh provider
|
|
4182
|
+
(await import("./provider-7PWDG74H.js")).createProvider(
|
|
4183
|
+
opts.model,
|
|
4184
|
+
opts.apiKey,
|
|
4185
|
+
opts.baseUrl
|
|
4186
|
+
)
|
|
4187
|
+
);
|
|
4188
|
+
if (opts.pretty) {
|
|
4189
|
+
printFocusGroupRound(round2);
|
|
4190
|
+
}
|
|
4191
|
+
}
|
|
4192
|
+
const summary = await group.summarize(
|
|
4193
|
+
(await import("./provider-7PWDG74H.js")).createProvider(
|
|
4194
|
+
opts.model,
|
|
4195
|
+
opts.apiKey,
|
|
4196
|
+
opts.baseUrl
|
|
4197
|
+
)
|
|
4198
|
+
);
|
|
4199
|
+
const fullResult = {
|
|
4200
|
+
topic: opts.topic,
|
|
4201
|
+
product: opts.product,
|
|
4202
|
+
rounds: group.getTranscript(),
|
|
4203
|
+
summary
|
|
4204
|
+
};
|
|
4205
|
+
if (opts.output) {
|
|
4206
|
+
await writeFile4(opts.output, JSON.stringify(fullResult, null, 2));
|
|
4207
|
+
}
|
|
4208
|
+
output("focus-group", fullResult, opts, async () => {
|
|
4209
|
+
const pc2 = (await import("picocolors")).default;
|
|
4210
|
+
console.log(pc2.bold("\nFocus Group Summary"));
|
|
4211
|
+
console.log(pc2.dim("\u2550".repeat(50)));
|
|
4212
|
+
console.log(`
|
|
4213
|
+
${pc2.bold("Themes:")}`);
|
|
4214
|
+
for (const t of summary.themes) console.log(` ${pc2.cyan("\u2022")} ${t}`);
|
|
4215
|
+
console.log(`
|
|
4216
|
+
${pc2.bold("Agreements:")}`);
|
|
4217
|
+
for (const a of summary.agreements)
|
|
4218
|
+
console.log(` ${pc2.green("\u2713")} ${a}`);
|
|
4219
|
+
console.log(`
|
|
4220
|
+
${pc2.bold("Disagreements:")}`);
|
|
4221
|
+
for (const d of summary.disagreements)
|
|
4222
|
+
console.log(` ${pc2.red("\u2717")} ${d}`);
|
|
4223
|
+
console.log(`
|
|
4224
|
+
${pc2.bold("Key Insights:")}`);
|
|
4225
|
+
for (const i of summary.insights)
|
|
4226
|
+
console.log(` ${pc2.yellow("\u2192")} ${i}`);
|
|
4227
|
+
console.log();
|
|
4228
|
+
});
|
|
4229
|
+
});
|
|
4230
|
+
addGlobalOpts(
|
|
4231
|
+
program.command("insights").description("Generate actionable product insights from your data").requiredOption("--product <description>", "Product description").option("--posthog-key <key>", "PostHog API key").option("--posthog-host <url>", "PostHog host").option("--csv <path>", "Path to CSV file").option("--json-file <path>", "Path to JSON file").option("--context <text...>", "Additional context strings").option("--graph", "Use knowledge graph for deeper analysis", false).option("-o, --output <path>", "Save report to file").option("--pretty", "Pretty-print output", false)
|
|
4232
|
+
).action(async (opts) => {
|
|
4233
|
+
const config = buildConfig(opts);
|
|
4234
|
+
const dopple = new Dopple(config);
|
|
4235
|
+
const report = await dopple.runInsights({
|
|
4236
|
+
product: opts.product,
|
|
4237
|
+
useGraph: opts.graph,
|
|
4238
|
+
context: opts.context
|
|
4239
|
+
});
|
|
4240
|
+
if (opts.output) {
|
|
4241
|
+
await writeFile4(opts.output, JSON.stringify(report, null, 2));
|
|
4242
|
+
}
|
|
4243
|
+
output("insights", report, opts, () => printInsightReport(report));
|
|
4244
|
+
});
|
|
4245
|
+
addGlobalOpts(
|
|
4246
|
+
program.command("review").description("Have personas review a design, messaging, landing page, or any content").argument("<content>", "URL, text, or file path to review").requiredOption(
|
|
4247
|
+
"--type <type>",
|
|
4248
|
+
"Content type: design, messaging, landing_page, email, ad, pricing_page, onboarding, feature"
|
|
4249
|
+
).option("--description <text>", "Additional context about what's being reviewed").option("--questions <q...>", "Specific questions to ask about the content").option("--persona <path>", "Path to persona JSONL or saved panel name").option("--product <description>", "Product context (for generating personas)").option("-n, --count <number>", "Persona count if generating", "5").option("--figma-token <token>", "Figma access token (if content is a Figma URL)").option("-o, --output <path>", "Save review to file").option("--pretty", "Pretty-print output", false)
|
|
4250
|
+
).action(async (content, opts) => {
|
|
4251
|
+
const config = buildConfig(opts);
|
|
4252
|
+
const dopple = new Dopple(config);
|
|
4253
|
+
let personas;
|
|
4254
|
+
if (opts.persona) {
|
|
4255
|
+
const fromStorage = await dopple.loadPanel(opts.persona);
|
|
4256
|
+
if (fromStorage) {
|
|
4257
|
+
personas = fromStorage;
|
|
4258
|
+
} else {
|
|
4259
|
+
const fileContent = await readFile6(opts.persona, "utf-8");
|
|
4260
|
+
const lines = fileContent.trim().split("\n").filter(Boolean);
|
|
4261
|
+
const definitions = lines.map((l) => JSON.parse(l)).filter((d) => d.id && d.traits);
|
|
4262
|
+
const { createProvider: createProvider2 } = await import("./provider-7PWDG74H.js");
|
|
4263
|
+
const llm = createProvider2(opts.model, opts.apiKey, opts.baseUrl);
|
|
4264
|
+
personas = definitions.map((d) => new Persona(d, llm));
|
|
4265
|
+
}
|
|
4266
|
+
} else {
|
|
4267
|
+
personas = await dopple.generate({
|
|
4268
|
+
product: opts.product,
|
|
4269
|
+
count: parseInt(opts.count, 10)
|
|
4270
|
+
});
|
|
4271
|
+
}
|
|
4272
|
+
const isFigma = content.includes("figma.com") && opts.figmaToken;
|
|
4273
|
+
let result;
|
|
4274
|
+
if (isFigma) {
|
|
4275
|
+
const match = content.match(/figma\.com\/(?:file|design)\/([^/]+)/);
|
|
4276
|
+
const nodeMatch = content.match(/node-id=([^&]+)/);
|
|
4277
|
+
if (!match) {
|
|
4278
|
+
console.error("Invalid Figma URL");
|
|
4279
|
+
process.exit(1);
|
|
4280
|
+
}
|
|
4281
|
+
const { reviewFigmaDesign } = await import("./figma-N554M5KW.js");
|
|
4282
|
+
const { createProvider: createProvider2 } = await import("./provider-7PWDG74H.js");
|
|
4283
|
+
const llm = createProvider2(opts.model, opts.apiKey, opts.baseUrl);
|
|
4284
|
+
result = await reviewFigmaDesign(llm, personas, {
|
|
4285
|
+
accessToken: opts.figmaToken,
|
|
4286
|
+
fileKey: match[1],
|
|
4287
|
+
nodeId: nodeMatch?.[1]?.replace("-", ":")
|
|
4288
|
+
});
|
|
4289
|
+
} else {
|
|
4290
|
+
const { reviewContent } = await import("./review-DNYYHU2M.js");
|
|
4291
|
+
const { createProvider: createProvider2 } = await import("./provider-7PWDG74H.js");
|
|
4292
|
+
const llm = createProvider2(opts.model, opts.apiKey, opts.baseUrl);
|
|
4293
|
+
result = await reviewContent(llm, personas, {
|
|
4294
|
+
type: opts.type,
|
|
4295
|
+
content,
|
|
4296
|
+
description: opts.description,
|
|
4297
|
+
questions: opts.questions
|
|
4298
|
+
});
|
|
4299
|
+
}
|
|
4300
|
+
if (opts.output) {
|
|
4301
|
+
await writeFile4(opts.output, JSON.stringify(result, null, 2));
|
|
4302
|
+
}
|
|
4303
|
+
output("review", result, opts, async () => {
|
|
4304
|
+
const pc2 = (await import("picocolors")).default;
|
|
4305
|
+
const r = result;
|
|
4306
|
+
console.log(`
|
|
4307
|
+
${pc2.bold(`Content Review: ${r.input.type}`)}`);
|
|
4308
|
+
console.log(pc2.dim("\u2550".repeat(50)));
|
|
4309
|
+
console.log(` Sentiment: ${r.consensus.overallSentiment.toUpperCase()}`);
|
|
4310
|
+
console.log(` Would act: ${(r.consensus.wouldActRate * 100).toFixed(0)}%
|
|
4311
|
+
`);
|
|
4312
|
+
for (const rev of r.reviews) {
|
|
4313
|
+
const color = rev.wouldAct ? pc2.green : pc2.red;
|
|
4314
|
+
console.log(` ${pc2.bold(rev.personaName)} [${rev.relevance}]`);
|
|
4315
|
+
console.log(` ${rev.firstImpression}`);
|
|
4316
|
+
console.log(` Would act: ${color(rev.wouldAct ? "Yes" : "No")} \u2014 ${pc2.dim(rev.reasoning)}`);
|
|
4317
|
+
for (const f of rev.feedback) console.log(` ${pc2.dim("\u2022")} ${f}`);
|
|
4318
|
+
console.log();
|
|
4319
|
+
}
|
|
4320
|
+
console.log(`${pc2.bold("Consensus:")}`);
|
|
4321
|
+
console.log(` ${pc2.green("Strengths:")} ${r.consensus.strengths.join(", ")}`);
|
|
4322
|
+
console.log(` ${pc2.red("Weaknesses:")} ${r.consensus.weaknesses.join(", ")}`);
|
|
4323
|
+
console.log(` ${pc2.yellow("Suggestions:")}`);
|
|
4324
|
+
for (const s of r.consensus.suggestions) console.log(` \u2192 ${s}`);
|
|
4325
|
+
console.log();
|
|
4326
|
+
});
|
|
4327
|
+
});
|
|
4328
|
+
addGlobalOpts(
|
|
4329
|
+
program.command("calibrate").description("Calibrate personas against a source of truth (policy, survey, outcomes)").requiredOption("--source <path>", "Path to calibration source file (text, JSON, or PDF)").requiredOption(
|
|
4330
|
+
"--source-type <type>",
|
|
4331
|
+
"Type of source: policy, survey, outcomes, benchmarks, document"
|
|
4332
|
+
).option("--source-name <name>", "Label for the source").option("--persona <path>", "Path to persona JSONL or saved panel name").option("--product <description>", "Product context (for generating personas)").option("-n, --count <number>", "Persona count if generating", "5").option("--posthog-key <key>", "PostHog API key").option("--stripe-key <key>", "Stripe API key").option("--csv <path>", "CSV file for persona generation").option("--max-rules <number>", "Max rules to extract per source", "10").option("-o, --output <path>", "Save report to file").option("--pretty", "Pretty-print output", false)
|
|
4333
|
+
).action(async (opts) => {
|
|
4334
|
+
const config = buildConfig(opts);
|
|
4335
|
+
const dopple = new Dopple(config);
|
|
4336
|
+
const sourceContent = await readFile6(opts.source, "utf-8");
|
|
4337
|
+
const source = {
|
|
4338
|
+
type: opts.sourceType,
|
|
4339
|
+
name: opts.sourceName ?? opts.source,
|
|
4340
|
+
content: sourceContent
|
|
4341
|
+
};
|
|
4342
|
+
let personas;
|
|
4343
|
+
if (opts.persona) {
|
|
4344
|
+
const fromStorage = await dopple.loadPanel(opts.persona);
|
|
4345
|
+
if (fromStorage) {
|
|
4346
|
+
personas = fromStorage;
|
|
4347
|
+
} else {
|
|
4348
|
+
const content = await readFile6(opts.persona, "utf-8");
|
|
4349
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
4350
|
+
const definitions = lines.map((l) => JSON.parse(l)).filter((d) => d.id && d.traits);
|
|
4351
|
+
const { createProvider: createProvider2 } = await import("./provider-7PWDG74H.js");
|
|
4352
|
+
const llm = createProvider2(opts.model, opts.apiKey, opts.baseUrl);
|
|
4353
|
+
personas = definitions.map((d) => new Persona(d, llm));
|
|
4354
|
+
}
|
|
4355
|
+
} else {
|
|
4356
|
+
personas = await dopple.generate({
|
|
4357
|
+
product: opts.product,
|
|
4358
|
+
count: parseInt(opts.count, 10)
|
|
4359
|
+
});
|
|
4360
|
+
}
|
|
4361
|
+
const report = await dopple.calibrate(personas, [source], {
|
|
4362
|
+
maxRulesPerSource: parseInt(opts.maxRules, 10)
|
|
4363
|
+
});
|
|
4364
|
+
if (opts.output) {
|
|
4365
|
+
await writeFile4(opts.output, JSON.stringify(report, null, 2));
|
|
4366
|
+
}
|
|
4367
|
+
output("calibrate", report, opts, () => printCalibrationReport(report));
|
|
4368
|
+
});
|
|
4369
|
+
addGlobalOpts(
|
|
4370
|
+
program.command("calibrate-data").description(
|
|
4371
|
+
"Compare synthetic survey results against real survey data using statistical measures (MAE, correlation)"
|
|
4372
|
+
).requiredOption(
|
|
4373
|
+
"--real <path>",
|
|
4374
|
+
"Path to real survey data JSON (StructuredCalibrationInput format)"
|
|
4375
|
+
).requiredOption(
|
|
4376
|
+
"--synthetic <path>",
|
|
4377
|
+
"Path to synthetic survey results JSON (from dopple survey -o)"
|
|
4378
|
+
).option(
|
|
4379
|
+
"--threshold <number>",
|
|
4380
|
+
"MAE threshold for pass/fail per question (default: 0.10 = 10pp)",
|
|
4381
|
+
"0.10"
|
|
4382
|
+
).option("-o, --output <path>", "Save report to file").option("--pretty", "Pretty-print output", false)
|
|
4383
|
+
).action(async (opts) => {
|
|
4384
|
+
const realData = JSON.parse(
|
|
4385
|
+
await readFile6(opts.real, "utf-8")
|
|
4386
|
+
);
|
|
4387
|
+
const syntheticResults = JSON.parse(
|
|
4388
|
+
await readFile6(opts.synthetic, "utf-8")
|
|
4389
|
+
);
|
|
4390
|
+
const dopple = new Dopple();
|
|
4391
|
+
const report = dopple.calibrateStructured(realData, syntheticResults, {
|
|
4392
|
+
threshold: parseFloat(opts.threshold)
|
|
4393
|
+
});
|
|
4394
|
+
if (opts.output) {
|
|
4395
|
+
await writeFile4(opts.output, JSON.stringify(report, null, 2));
|
|
4396
|
+
}
|
|
4397
|
+
output(
|
|
4398
|
+
"calibrate-data",
|
|
4399
|
+
report,
|
|
4400
|
+
opts,
|
|
4401
|
+
() => printStructuredCalibration(report)
|
|
4402
|
+
);
|
|
4403
|
+
});
|
|
4404
|
+
addGlobalOpts(
|
|
4405
|
+
program.command("query").description("Ask the knowledge graph a natural language question").argument("<question>", "The question to ask").option("--posthog-key <key>", "PostHog API key").option("--posthog-host <url>", "PostHog host").option("--stripe-key <key>", "Stripe API key").option("--csv <path>", "Path to CSV file").option("--json-file <path>", "Path to JSON file").option("--context <text...>", "Additional context strings").option("--graph-file <path>", "Load graph from saved JSONL instead of building").option("--pretty", "Pretty-print output", false)
|
|
4406
|
+
).action(async (question, opts) => {
|
|
4407
|
+
const config = buildConfig(opts);
|
|
4408
|
+
const dopple = new Dopple(config);
|
|
4409
|
+
let graph;
|
|
4410
|
+
if (opts.graphFile) {
|
|
4411
|
+
const { DoppleGraph: DoppleGraph2 } = await import("./graph-P5GYGDF7.js");
|
|
4412
|
+
graph = new DoppleGraph2();
|
|
4413
|
+
await graph.load(opts.graphFile);
|
|
4414
|
+
} else {
|
|
4415
|
+
graph = await dopple.buildGraph(opts.context);
|
|
4416
|
+
}
|
|
4417
|
+
const { createProvider: createProvider2 } = await import("./provider-7PWDG74H.js");
|
|
4418
|
+
const llm = createProvider2(opts.model, opts.apiKey, opts.baseUrl);
|
|
4419
|
+
const result = await graph.ask(llm, question);
|
|
4420
|
+
output("query", result, opts, async () => {
|
|
4421
|
+
const pc2 = (await import("picocolors")).default;
|
|
4422
|
+
console.log(`
|
|
4423
|
+
${pc2.bold("Graph Query")}`);
|
|
4424
|
+
console.log(pc2.dim("\u2550".repeat(50)));
|
|
4425
|
+
console.log(` Q: ${pc2.cyan(result.question)}`);
|
|
4426
|
+
console.log(` Confidence: ${result.confidence >= 0.7 ? pc2.green(result.confidence.toFixed(2)) : result.confidence >= 0.4 ? pc2.yellow(result.confidence.toFixed(2)) : pc2.red(result.confidence.toFixed(2))}`);
|
|
4427
|
+
console.log(`
|
|
4428
|
+
${result.answer}`);
|
|
4429
|
+
if (result.evidence.length > 0) {
|
|
4430
|
+
console.log(`
|
|
4431
|
+
${pc2.bold("Evidence:")}`);
|
|
4432
|
+
for (const e of result.evidence) {
|
|
4433
|
+
const rel = e.relevance >= 0.7 ? pc2.green("HIGH") : e.relevance >= 0.4 ? pc2.yellow("MED") : pc2.dim("LOW");
|
|
4434
|
+
console.log(` [${rel}] ${pc2.dim(`(${e.type})`)} ${e.label}: ${e.detail}`);
|
|
4435
|
+
}
|
|
4436
|
+
}
|
|
4437
|
+
if (result.relatedNodes.length > 0) {
|
|
4438
|
+
console.log(`
|
|
4439
|
+
${pc2.dim("Follow up on:")} ${result.relatedNodes.join(", ")}`);
|
|
4440
|
+
}
|
|
4441
|
+
console.log();
|
|
4442
|
+
});
|
|
4443
|
+
});
|
|
4444
|
+
addGlobalOpts(
|
|
4445
|
+
program.command("graph").description("Build and display a knowledge graph from your data").option("--product <description>", "Product context").option("--posthog-key <key>", "PostHog API key").option("--posthog-host <url>", "PostHog host").option("--csv <path>", "Path to CSV file").option("--json-file <path>", "Path to JSON file").option("--context <text...>", "Additional context strings").option("-o, --output <path>", "Save graph to JSONL file").option("--pretty", "Pretty-print output", false)
|
|
4446
|
+
).action(async (opts) => {
|
|
4447
|
+
const config = buildConfig(opts);
|
|
4448
|
+
const dopple = new Dopple(config);
|
|
4449
|
+
const graph = await dopple.buildGraph(opts.context);
|
|
4450
|
+
const raw = graph.raw;
|
|
4451
|
+
const patterns = graph.patterns();
|
|
4452
|
+
if (opts.output) {
|
|
4453
|
+
await graph.save(opts.output);
|
|
4454
|
+
}
|
|
4455
|
+
const data = {
|
|
4456
|
+
summary: graph.summary,
|
|
4457
|
+
patterns: patterns.map((p) => ({ description: p.description, significance: p.significance })),
|
|
4458
|
+
nodeCount: raw.metadata.nodeCount,
|
|
4459
|
+
edgeCount: raw.metadata.edgeCount,
|
|
4460
|
+
sources: raw.metadata.sources
|
|
4461
|
+
};
|
|
4462
|
+
output("graph", data, opts, async () => {
|
|
4463
|
+
const pc2 = (await import("picocolors")).default;
|
|
4464
|
+
console.log(`
|
|
4465
|
+
${pc2.bold("Knowledge Graph")}`);
|
|
4466
|
+
console.log(pc2.dim("\u2550".repeat(50)));
|
|
4467
|
+
console.log(` Nodes: ${pc2.cyan(String(raw.metadata.nodeCount))} Edges: ${pc2.cyan(String(raw.metadata.edgeCount))}`);
|
|
4468
|
+
console.log(` Sources: ${raw.metadata.sources.join(", ")}`);
|
|
4469
|
+
if (patterns.length > 0) {
|
|
4470
|
+
console.log(`
|
|
4471
|
+
${pc2.bold("Patterns discovered:")}`);
|
|
4472
|
+
for (const p of patterns) {
|
|
4473
|
+
const bar = "\u2588".repeat(Math.round(p.significance * 10));
|
|
4474
|
+
console.log(` ${pc2.yellow(bar)} ${p.description}`);
|
|
4475
|
+
}
|
|
4476
|
+
}
|
|
4477
|
+
const typeCounts = {};
|
|
4478
|
+
for (const node of raw.nodes) {
|
|
4479
|
+
typeCounts[node.type] = (typeCounts[node.type] ?? 0) + 1;
|
|
4480
|
+
}
|
|
4481
|
+
console.log(`
|
|
4482
|
+
${pc2.bold("Node types:")}`);
|
|
4483
|
+
for (const [type, count] of Object.entries(typeCounts).sort((a, b) => b[1] - a[1])) {
|
|
4484
|
+
console.log(` ${type.padEnd(12)} ${pc2.cyan(String(count))}`);
|
|
4485
|
+
}
|
|
4486
|
+
console.log();
|
|
4487
|
+
});
|
|
4488
|
+
});
|
|
4489
|
+
program.command("traces").description("View prediction history and calibration accuracy per product").option("--product <name>", "Show traces for a specific product").option("--agent", "Structured JSON output", false).option("--pretty", "Pretty-print output", false).action(async (opts) => {
|
|
4490
|
+
const dopple = new Dopple();
|
|
4491
|
+
if (opts.product) {
|
|
4492
|
+
const history = await dopple.getTraceHistory(opts.product);
|
|
4493
|
+
output("traces", history, opts, async () => {
|
|
4494
|
+
const pc2 = (await import("picocolors")).default;
|
|
4495
|
+
const cal = history.calibrationSummary;
|
|
4496
|
+
console.log(`
|
|
4497
|
+
${pc2.bold(`Traces: ${history.product}`)}`);
|
|
4498
|
+
console.log(pc2.dim("\u2550".repeat(50)));
|
|
4499
|
+
console.log(` Predictions: ${cal.totalPredictions}`);
|
|
4500
|
+
console.log(` With outcomes: ${cal.withOutcomes}`);
|
|
4501
|
+
if (cal.averageMAE !== null) {
|
|
4502
|
+
const maeColor = cal.averageMAE <= 0.05 ? pc2.green : cal.averageMAE <= 0.1 ? pc2.yellow : pc2.red;
|
|
4503
|
+
console.log(` Average MAE: ${maeColor(`${(cal.averageMAE * 100).toFixed(1)}pp`)}`);
|
|
4504
|
+
}
|
|
4505
|
+
if (cal.directionAccuracy !== null) {
|
|
4506
|
+
console.log(` Direction accuracy: ${(cal.directionAccuracy * 100).toFixed(0)}%`);
|
|
4507
|
+
}
|
|
4508
|
+
console.log(`
|
|
4509
|
+
${pc2.bold("Recent traces:")}
|
|
4510
|
+
`);
|
|
4511
|
+
for (const t of history.traces.slice(-10).reverse()) {
|
|
4512
|
+
const hasOutcome = t.outcome ? pc2.green("\u2713") : pc2.dim("\u25CB");
|
|
4513
|
+
console.log(` ${hasOutcome} ${pc2.dim(t.timestamp.slice(0, 10))} [${t.type}] ${t.prediction.summary}`);
|
|
4514
|
+
if (t.outcome) {
|
|
4515
|
+
console.log(` Actual: ${t.outcome.actual}`);
|
|
4516
|
+
}
|
|
4517
|
+
if (t.accuracy) {
|
|
4518
|
+
console.log(` ${pc2.dim(t.accuracy.notes)}`);
|
|
4519
|
+
}
|
|
4520
|
+
}
|
|
4521
|
+
console.log();
|
|
4522
|
+
});
|
|
4523
|
+
} else {
|
|
4524
|
+
const products = await dopple.traces.listProducts();
|
|
4525
|
+
output("traces", products, opts, async () => {
|
|
4526
|
+
const pc2 = (await import("picocolors")).default;
|
|
4527
|
+
if (products.length === 0) {
|
|
4528
|
+
console.log("No traces yet. Run dopple insights to start accumulating.");
|
|
4529
|
+
return;
|
|
4530
|
+
}
|
|
4531
|
+
console.log(`
|
|
4532
|
+
${pc2.bold("Products with traces:")}
|
|
4533
|
+
`);
|
|
4534
|
+
for (const p of products) {
|
|
4535
|
+
const acc = p.averageAccuracy !== null ? `MAE: ${(p.averageAccuracy * 100).toFixed(1)}pp` : "uncalibrated";
|
|
4536
|
+
console.log(` ${pc2.bold(p.product)} \u2014 ${p.traceCount} traces, ${p.withOutcomes} verified (${acc})`);
|
|
4537
|
+
}
|
|
4538
|
+
console.log();
|
|
4539
|
+
});
|
|
4540
|
+
}
|
|
4541
|
+
});
|
|
4542
|
+
program.command("record-outcome").description("Record a real outcome for a past prediction (closes the calibration loop)").requiredOption("--product <name>", "Product name").requiredOption("--trace-id <id>", "Trace ID to update").requiredOption("--actual <text>", "What actually happened").option("--source <source>", "Where the outcome data came from", "manual").option("--mae <number>", "Mean Absolute Error if known").option("--direction-correct", "Was the directional prediction correct?").option("--notes <text>", "Additional notes about accuracy").option("--agent", "Structured JSON output", false).option("--pretty", "Pretty-print output", false).action(async (opts) => {
|
|
4543
|
+
const dopple = new Dopple();
|
|
4544
|
+
await dopple.recordOutcome(
|
|
4545
|
+
opts.product,
|
|
4546
|
+
opts.traceId,
|
|
4547
|
+
opts.actual,
|
|
4548
|
+
opts.source,
|
|
4549
|
+
{
|
|
4550
|
+
mae: opts.mae ? parseFloat(opts.mae) : void 0,
|
|
4551
|
+
directionCorrect: opts.directionCorrect ?? void 0,
|
|
4552
|
+
notes: opts.notes ?? `Outcome recorded: ${opts.actual}`
|
|
4553
|
+
}
|
|
4554
|
+
);
|
|
4555
|
+
const data = { product: opts.product, traceId: opts.traceId, recorded: true };
|
|
4556
|
+
output("record-outcome", data, opts, async () => {
|
|
4557
|
+
const pc2 = (await import("picocolors")).default;
|
|
4558
|
+
console.log(`
|
|
4559
|
+
${pc2.green("\u2713")} Outcome recorded for trace ${opts.traceId}`);
|
|
4560
|
+
console.log(` Product: ${opts.product}`);
|
|
4561
|
+
console.log(` Actual: ${opts.actual}`);
|
|
4562
|
+
console.log(` ${pc2.dim("Next predictions for this product will be informed by this outcome.")}
|
|
4563
|
+
`);
|
|
4564
|
+
});
|
|
4565
|
+
});
|
|
4566
|
+
function buildConfig(opts) {
|
|
4567
|
+
const adapters = [];
|
|
4568
|
+
if (opts.posthogKey) {
|
|
4569
|
+
adapters.push({
|
|
4570
|
+
type: "posthog",
|
|
4571
|
+
apiKey: opts.posthogKey,
|
|
4572
|
+
host: opts.posthogHost ?? void 0
|
|
4573
|
+
});
|
|
4574
|
+
}
|
|
4575
|
+
if (opts.csv) {
|
|
4576
|
+
adapters.push({ type: "csv", path: opts.csv });
|
|
4577
|
+
}
|
|
4578
|
+
if (opts.jsonFile) {
|
|
4579
|
+
adapters.push({ type: "json", path: opts.jsonFile });
|
|
4580
|
+
}
|
|
4581
|
+
if (opts.stripeKey) {
|
|
4582
|
+
adapters.push({ type: "stripe", apiKey: opts.stripeKey });
|
|
4583
|
+
}
|
|
4584
|
+
if (opts.doc) {
|
|
4585
|
+
const docs = Array.isArray(opts.doc) ? opts.doc : [opts.doc];
|
|
4586
|
+
for (const d of docs) {
|
|
4587
|
+
adapters.push({ type: "document", path: d });
|
|
4588
|
+
}
|
|
4589
|
+
}
|
|
4590
|
+
return {
|
|
4591
|
+
model: opts.model,
|
|
4592
|
+
apiKey: opts.apiKey,
|
|
4593
|
+
baseUrl: opts.baseUrl,
|
|
4594
|
+
adapters
|
|
4595
|
+
};
|
|
4596
|
+
}
|
|
4597
|
+
if (process.argv.includes("--agent") && process.argv.includes("--help")) {
|
|
4598
|
+
const schema = buildCommandSchema(program);
|
|
4599
|
+
process.stdout.write(JSON.stringify(schema) + "\n");
|
|
4600
|
+
process.exit(0);
|
|
4601
|
+
}
|
|
4602
|
+
program.parse();
|
|
4603
|
+
//# sourceMappingURL=cli.js.map
|