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
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
findPatterns,
|
|
4
|
+
getNeighborhood,
|
|
5
|
+
getPersonaContext,
|
|
6
|
+
graphSummary,
|
|
7
|
+
queryGraph
|
|
8
|
+
} from "./chunk-PGZVVIL6.js";
|
|
9
|
+
|
|
10
|
+
// src/graph/graph.ts
|
|
11
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
12
|
+
import { dirname } from "path";
|
|
13
|
+
|
|
14
|
+
// src/graph/builder.ts
|
|
15
|
+
import { randomUUID } from "crypto";
|
|
16
|
+
async function buildGraph(llm, userData, contextTexts) {
|
|
17
|
+
const nodes = [];
|
|
18
|
+
const edges = [];
|
|
19
|
+
const sources = /* @__PURE__ */ new Set();
|
|
20
|
+
extractFromUserData(userData, nodes, edges, sources);
|
|
21
|
+
if (contextTexts && contextTexts.length > 0) {
|
|
22
|
+
await extractFromText(llm, contextTexts, nodes, edges, sources);
|
|
23
|
+
}
|
|
24
|
+
if (nodes.length > 2) {
|
|
25
|
+
await discoverRelationships(llm, nodes, edges);
|
|
26
|
+
}
|
|
27
|
+
const deduped = deduplicateNodes(nodes, edges);
|
|
28
|
+
return {
|
|
29
|
+
nodes: deduped.nodes,
|
|
30
|
+
edges: deduped.edges,
|
|
31
|
+
metadata: {
|
|
32
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
33
|
+
sources: [...sources],
|
|
34
|
+
nodeCount: deduped.nodes.length,
|
|
35
|
+
edgeCount: deduped.edges.length
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function extractFromUserData(records, nodes, edges, sources) {
|
|
40
|
+
const eventCounts = {};
|
|
41
|
+
const propertyValues = {};
|
|
42
|
+
for (const record of records) {
|
|
43
|
+
sources.add("user-data");
|
|
44
|
+
const userId = `user-${record.id}`;
|
|
45
|
+
nodes.push({
|
|
46
|
+
id: userId,
|
|
47
|
+
type: "user",
|
|
48
|
+
label: String(record.properties.name ?? record.properties.email ?? record.id),
|
|
49
|
+
properties: record.properties,
|
|
50
|
+
source: "user-data"
|
|
51
|
+
});
|
|
52
|
+
for (const event of record.events ?? []) {
|
|
53
|
+
eventCounts[event.name] = (eventCounts[event.name] ?? 0) + 1;
|
|
54
|
+
const eventNodeId = `event-${event.name}`;
|
|
55
|
+
if (!nodes.some((n) => n.id === eventNodeId)) {
|
|
56
|
+
nodes.push({
|
|
57
|
+
id: eventNodeId,
|
|
58
|
+
type: "event",
|
|
59
|
+
label: event.name,
|
|
60
|
+
properties: { totalOccurrences: 0 },
|
|
61
|
+
source: "user-data"
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
edges.push({
|
|
65
|
+
id: randomUUID(),
|
|
66
|
+
from: userId,
|
|
67
|
+
to: eventNodeId,
|
|
68
|
+
type: "triggered",
|
|
69
|
+
weight: 0.5,
|
|
70
|
+
properties: { timestamp: event.timestamp },
|
|
71
|
+
source: "user-data"
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
for (const payment of record.payments ?? []) {
|
|
75
|
+
const planId = `plan-${payment.plan ?? "unknown"}`;
|
|
76
|
+
if (!nodes.some((n) => n.id === planId)) {
|
|
77
|
+
nodes.push({
|
|
78
|
+
id: planId,
|
|
79
|
+
type: "product",
|
|
80
|
+
label: payment.plan ?? "unknown plan",
|
|
81
|
+
properties: { amount: payment.amount, currency: payment.currency },
|
|
82
|
+
source: "payment-data"
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
edges.push({
|
|
86
|
+
id: randomUUID(),
|
|
87
|
+
from: userId,
|
|
88
|
+
to: planId,
|
|
89
|
+
type: payment.status === "cancelled" ? "cancelled" : "subscribes_to",
|
|
90
|
+
weight: payment.status === "active" ? 0.8 : 0.3,
|
|
91
|
+
properties: {
|
|
92
|
+
status: payment.status,
|
|
93
|
+
cancelReason: payment.cancelReason
|
|
94
|
+
},
|
|
95
|
+
source: "payment-data"
|
|
96
|
+
});
|
|
97
|
+
if (payment.cancelReason) {
|
|
98
|
+
const complaintId = `complaint-${randomUUID().slice(0, 8)}`;
|
|
99
|
+
nodes.push({
|
|
100
|
+
id: complaintId,
|
|
101
|
+
type: "complaint",
|
|
102
|
+
label: payment.cancelReason,
|
|
103
|
+
properties: {},
|
|
104
|
+
source: "payment-data"
|
|
105
|
+
});
|
|
106
|
+
edges.push({
|
|
107
|
+
id: randomUUID(),
|
|
108
|
+
from: userId,
|
|
109
|
+
to: complaintId,
|
|
110
|
+
type: "complained_about",
|
|
111
|
+
weight: 0.9,
|
|
112
|
+
properties: {},
|
|
113
|
+
source: "payment-data"
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const conv of record.conversations ?? []) {
|
|
118
|
+
const convId = `conv-${randomUUID().slice(0, 8)}`;
|
|
119
|
+
nodes.push({
|
|
120
|
+
id: convId,
|
|
121
|
+
type: "complaint",
|
|
122
|
+
label: conv.slice(0, 100),
|
|
123
|
+
properties: { fullText: conv },
|
|
124
|
+
source: "support-data"
|
|
125
|
+
});
|
|
126
|
+
edges.push({
|
|
127
|
+
id: randomUUID(),
|
|
128
|
+
from: userId,
|
|
129
|
+
to: convId,
|
|
130
|
+
type: "said",
|
|
131
|
+
weight: 0.7,
|
|
132
|
+
properties: {},
|
|
133
|
+
source: "support-data"
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
for (const [key, value] of Object.entries(record.properties)) {
|
|
137
|
+
if (!propertyValues[key]) propertyValues[key] = {};
|
|
138
|
+
const v = String(value);
|
|
139
|
+
propertyValues[key][v] = (propertyValues[key][v] ?? 0) + 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const node of nodes) {
|
|
143
|
+
if (node.type === "event" && eventCounts[node.label]) {
|
|
144
|
+
node.properties.totalOccurrences = eventCounts[node.label];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const totalUsers = records.length;
|
|
148
|
+
if (totalUsers > 0) {
|
|
149
|
+
nodes.push({
|
|
150
|
+
id: "metric-total-users",
|
|
151
|
+
type: "metric",
|
|
152
|
+
label: `${totalUsers} total users`,
|
|
153
|
+
properties: { value: totalUsers },
|
|
154
|
+
source: "user-data"
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function extractFromText(llm, texts, nodes, edges, sources) {
|
|
159
|
+
sources.add("context");
|
|
160
|
+
const combined = texts.join("\n\n").slice(0, 8e3);
|
|
161
|
+
const extracted = await llm.generateJSON(
|
|
162
|
+
"You extract entities and relationships from text to build a knowledge graph.",
|
|
163
|
+
`Extract entities and relationships from this text.
|
|
164
|
+
|
|
165
|
+
${combined}
|
|
166
|
+
|
|
167
|
+
Return JSON:
|
|
168
|
+
{
|
|
169
|
+
"entities": [{"label": "<name>", "type": "user|behavior|feature|complaint|segment|event|product|metric|entity", "properties": {}}],
|
|
170
|
+
"relationships": [{"from": "<entity label>", "to": "<entity label>", "type": "<relationship>", "weight": <0-1>}]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
Focus on: people, products, features, complaints, behaviors, events, and how they relate.
|
|
174
|
+
Use descriptive relationship types like "uses", "complained_about", "wants", "avoids", "correlates_with".`
|
|
175
|
+
);
|
|
176
|
+
const labelToId = {};
|
|
177
|
+
for (const entity of extracted.entities ?? []) {
|
|
178
|
+
const id = `ctx-${randomUUID().slice(0, 8)}`;
|
|
179
|
+
labelToId[entity.label] = id;
|
|
180
|
+
nodes.push({
|
|
181
|
+
id,
|
|
182
|
+
type: entity.type,
|
|
183
|
+
label: entity.label,
|
|
184
|
+
properties: entity.properties ?? {},
|
|
185
|
+
source: "context"
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
for (const rel of extracted.relationships ?? []) {
|
|
189
|
+
const fromId = labelToId[rel.from];
|
|
190
|
+
const toId = labelToId[rel.to];
|
|
191
|
+
if (fromId && toId) {
|
|
192
|
+
edges.push({
|
|
193
|
+
id: randomUUID(),
|
|
194
|
+
from: fromId,
|
|
195
|
+
to: toId,
|
|
196
|
+
type: rel.type,
|
|
197
|
+
weight: rel.weight ?? 0.5,
|
|
198
|
+
properties: {},
|
|
199
|
+
source: "context"
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function discoverRelationships(llm, nodes, edges) {
|
|
205
|
+
const significantNodes = nodes.filter((n) => n.type !== "user").slice(0, 30).map((n) => `${n.type}:${n.label}`);
|
|
206
|
+
if (significantNodes.length < 3) return;
|
|
207
|
+
const discovered = await llm.generateJSON(
|
|
208
|
+
"You identify non-obvious relationships between entities in a product knowledge graph.",
|
|
209
|
+
`Given these entities from a product's user data, identify relationships between them that aren't already obvious.
|
|
210
|
+
|
|
211
|
+
Entities:
|
|
212
|
+
${significantNodes.join("\n")}
|
|
213
|
+
|
|
214
|
+
Return a JSON array of relationships:
|
|
215
|
+
[{"from": "<entity label>", "to": "<entity label>", "type": "<relationship>", "weight": <0-1>}]
|
|
216
|
+
|
|
217
|
+
Focus on: correlations ("users who do X also do Y"), causal patterns ("feature X leads to churn"), and segments ("these behaviors cluster together").
|
|
218
|
+
Return at most 10 relationships. Only include ones with real analytical value.`
|
|
219
|
+
);
|
|
220
|
+
for (const rel of discovered ?? []) {
|
|
221
|
+
const fromNode = nodes.find((n) => n.label === rel.from.replace(/^\w+:/, ""));
|
|
222
|
+
const toNode = nodes.find((n) => n.label === rel.to.replace(/^\w+:/, ""));
|
|
223
|
+
if (fromNode && toNode) {
|
|
224
|
+
edges.push({
|
|
225
|
+
id: randomUUID(),
|
|
226
|
+
from: fromNode.id,
|
|
227
|
+
to: toNode.id,
|
|
228
|
+
type: rel.type,
|
|
229
|
+
weight: rel.weight ?? 0.5,
|
|
230
|
+
properties: { discovered: true },
|
|
231
|
+
source: "llm-analysis"
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function deduplicateNodes(nodes, edges) {
|
|
237
|
+
const seen = /* @__PURE__ */ new Map();
|
|
238
|
+
const idMapping = /* @__PURE__ */ new Map();
|
|
239
|
+
for (const node of nodes) {
|
|
240
|
+
const key = `${node.type}:${node.label.toLowerCase()}`;
|
|
241
|
+
if (seen.has(key)) {
|
|
242
|
+
idMapping.set(node.id, seen.get(key).id);
|
|
243
|
+
} else {
|
|
244
|
+
seen.set(key, node);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const remappedEdges = edges.map((e) => ({
|
|
248
|
+
...e,
|
|
249
|
+
from: idMapping.get(e.from) ?? e.from,
|
|
250
|
+
to: idMapping.get(e.to) ?? e.to
|
|
251
|
+
}));
|
|
252
|
+
const cleanEdges = remappedEdges.filter((e) => e.from !== e.to);
|
|
253
|
+
return { nodes: [...seen.values()], edges: cleanEdges };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/graph/graph.ts
|
|
257
|
+
var DoppleGraph = class {
|
|
258
|
+
graph = null;
|
|
259
|
+
/**
|
|
260
|
+
* Build the graph from user data and optional context texts.
|
|
261
|
+
*/
|
|
262
|
+
async build(llm, userData, contextTexts) {
|
|
263
|
+
this.graph = await buildGraph(llm, userData, contextTexts);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Get nodes within N hops of a starting node.
|
|
267
|
+
*/
|
|
268
|
+
query(nodeId, depth = 2) {
|
|
269
|
+
this.ensureBuilt();
|
|
270
|
+
return getNeighborhood(this.graph, nodeId, depth);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Get context strings relevant to a persona's traits.
|
|
274
|
+
*/
|
|
275
|
+
getPersonaContext(traits) {
|
|
276
|
+
this.ensureBuilt();
|
|
277
|
+
return getPersonaContext(this.graph, traits);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Find patterns in the graph (hubs, churn, complaints, usage).
|
|
281
|
+
*/
|
|
282
|
+
patterns() {
|
|
283
|
+
this.ensureBuilt();
|
|
284
|
+
return findPatterns(this.graph);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Human-readable summary.
|
|
288
|
+
*/
|
|
289
|
+
get summary() {
|
|
290
|
+
this.ensureBuilt();
|
|
291
|
+
return graphSummary(this.graph);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Get the raw graph data.
|
|
295
|
+
*/
|
|
296
|
+
get raw() {
|
|
297
|
+
this.ensureBuilt();
|
|
298
|
+
return this.graph;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Ask the graph a natural language question.
|
|
302
|
+
* LLM interprets the question, finds relevant data, returns grounded answer.
|
|
303
|
+
*/
|
|
304
|
+
async ask(llm, question) {
|
|
305
|
+
this.ensureBuilt();
|
|
306
|
+
return queryGraph(llm, this.graph, question);
|
|
307
|
+
}
|
|
308
|
+
get isBuilt() {
|
|
309
|
+
return this.graph !== null;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Save graph to JSONL file.
|
|
313
|
+
*/
|
|
314
|
+
async save(path) {
|
|
315
|
+
this.ensureBuilt();
|
|
316
|
+
await mkdir(dirname(path), { recursive: true });
|
|
317
|
+
const lines = [];
|
|
318
|
+
lines.push(JSON.stringify({ _type: "metadata", ...this.graph.metadata }));
|
|
319
|
+
for (const node of this.graph.nodes) {
|
|
320
|
+
lines.push(JSON.stringify({ _type: "node", ...node }));
|
|
321
|
+
}
|
|
322
|
+
for (const edge of this.graph.edges) {
|
|
323
|
+
lines.push(JSON.stringify({ _type: "edge", ...edge }));
|
|
324
|
+
}
|
|
325
|
+
await writeFile(path, lines.join("\n") + "\n");
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Load graph from JSONL file.
|
|
329
|
+
*/
|
|
330
|
+
async load(path) {
|
|
331
|
+
const content = await readFile(path, "utf-8");
|
|
332
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
333
|
+
const nodes = [];
|
|
334
|
+
const edges = [];
|
|
335
|
+
let metadata = {
|
|
336
|
+
createdAt: "",
|
|
337
|
+
sources: [],
|
|
338
|
+
nodeCount: 0,
|
|
339
|
+
edgeCount: 0
|
|
340
|
+
};
|
|
341
|
+
for (const line of lines) {
|
|
342
|
+
const obj = JSON.parse(line);
|
|
343
|
+
const type = obj._type;
|
|
344
|
+
delete obj._type;
|
|
345
|
+
if (type === "metadata") {
|
|
346
|
+
metadata = obj;
|
|
347
|
+
} else if (type === "node") {
|
|
348
|
+
nodes.push(obj);
|
|
349
|
+
} else if (type === "edge") {
|
|
350
|
+
edges.push(obj);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
this.graph = { nodes, edges, metadata };
|
|
354
|
+
}
|
|
355
|
+
ensureBuilt() {
|
|
356
|
+
if (!this.graph) {
|
|
357
|
+
throw new Error("Graph not built yet. Call build() or load() first.");
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
export {
|
|
363
|
+
DoppleGraph
|
|
364
|
+
};
|
|
365
|
+
//# sourceMappingURL=chunk-FA7ZWJOA.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/graph/graph.ts","../src/graph/builder.ts"],"sourcesContent":["/**\n * DoppleGraph — in-memory knowledge graph with persistence.\n */\n\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport type { LLMProvider, UserRecord, OceanVector } from \"../types.js\";\nimport type { KnowledgeGraph, GraphNode, GraphPattern } from \"./types.js\";\nimport { buildGraph } from \"./builder.js\";\nimport {\n getNeighborhood,\n findPatterns,\n getPersonaContext,\n graphSummary,\n queryGraph,\n type GraphQueryResult,\n} from \"./query.js\";\n\nexport class DoppleGraph {\n private graph: KnowledgeGraph | null = null;\n\n /**\n * Build the graph from user data and optional context texts.\n */\n async build(\n llm: LLMProvider,\n userData: UserRecord[],\n contextTexts?: string[]\n ): Promise<void> {\n this.graph = await buildGraph(llm, userData, contextTexts);\n }\n\n /**\n * Get nodes within N hops of a starting node.\n */\n query(nodeId: string, depth: number = 2): GraphNode[] {\n this.ensureBuilt();\n return getNeighborhood(this.graph!, nodeId, depth);\n }\n\n /**\n * Get context strings relevant to a persona's traits.\n */\n getPersonaContext(traits: OceanVector): string[] {\n this.ensureBuilt();\n return getPersonaContext(this.graph!, traits);\n }\n\n /**\n * Find patterns in the graph (hubs, churn, complaints, usage).\n */\n patterns(): GraphPattern[] {\n this.ensureBuilt();\n return findPatterns(this.graph!);\n }\n\n /**\n * Human-readable summary.\n */\n get summary(): string {\n this.ensureBuilt();\n return graphSummary(this.graph!);\n }\n\n /**\n * Get the raw graph data.\n */\n get raw(): KnowledgeGraph {\n this.ensureBuilt();\n return this.graph!;\n }\n\n /**\n * Ask the graph a natural language question.\n * LLM interprets the question, finds relevant data, returns grounded answer.\n */\n async ask(llm: LLMProvider, question: string): Promise<GraphQueryResult> {\n this.ensureBuilt();\n return queryGraph(llm, this.graph!, question);\n }\n\n get isBuilt(): boolean {\n return this.graph !== null;\n }\n\n /**\n * Save graph to JSONL file.\n */\n async save(path: string): Promise<void> {\n this.ensureBuilt();\n await mkdir(dirname(path), { recursive: true });\n\n const lines: string[] = [];\n lines.push(JSON.stringify({ _type: \"metadata\", ...this.graph!.metadata }));\n for (const node of this.graph!.nodes) {\n lines.push(JSON.stringify({ _type: \"node\", ...node }));\n }\n for (const edge of this.graph!.edges) {\n lines.push(JSON.stringify({ _type: \"edge\", ...edge }));\n }\n\n await writeFile(path, lines.join(\"\\n\") + \"\\n\");\n }\n\n /**\n * Load graph from JSONL file.\n */\n async load(path: string): Promise<void> {\n const content = await readFile(path, \"utf-8\");\n const lines = content.trim().split(\"\\n\").filter(Boolean);\n\n const nodes: GraphNode[] = [];\n const edges: import(\"./types.js\").GraphEdge[] = [];\n let metadata: KnowledgeGraph[\"metadata\"] = {\n createdAt: \"\",\n sources: [],\n nodeCount: 0,\n edgeCount: 0,\n };\n\n for (const line of lines) {\n const obj = JSON.parse(line) as Record<string, unknown>;\n const type = obj._type;\n delete obj._type;\n\n if (type === \"metadata\") {\n metadata = obj as unknown as KnowledgeGraph[\"metadata\"];\n } else if (type === \"node\") {\n nodes.push(obj as unknown as GraphNode);\n } else if (type === \"edge\") {\n edges.push(obj as unknown as import(\"./types.js\").GraphEdge);\n }\n }\n\n this.graph = { nodes, edges, metadata };\n }\n\n private ensureBuilt(): void {\n if (!this.graph) {\n throw new Error(\"Graph not built yet. Call build() or load() first.\");\n }\n }\n}\n","/**\n * Knowledge graph builder.\n *\n * Two modes:\n * 1. Structured: UserRecord[] from adapters → deterministic entity extraction → graph\n * 2. Unstructured: freeform text → LLM entity extraction → graph\n *\n * No external graph database dependency — in-memory with JSONL persistence.\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport type { LLMProvider, UserRecord } from \"../types.js\";\nimport type { GraphNode, GraphEdge, KnowledgeGraph, NodeType } from \"./types.js\";\n\n/**\n * Build a knowledge graph from structured user data and optional unstructured context.\n */\nexport async function buildGraph(\n llm: LLMProvider,\n userData: UserRecord[],\n contextTexts?: string[]\n): Promise<KnowledgeGraph> {\n const nodes: GraphNode[] = [];\n const edges: GraphEdge[] = [];\n const sources = new Set<string>();\n\n // Phase 1: Extract entities from structured data (deterministic)\n extractFromUserData(userData, nodes, edges, sources);\n\n // Phase 2: Extract entities from unstructured text (LLM-assisted)\n if (contextTexts && contextTexts.length > 0) {\n await extractFromText(llm, contextTexts, nodes, edges, sources);\n }\n\n // Phase 3: Ask LLM to discover relationships between existing nodes\n if (nodes.length > 2) {\n await discoverRelationships(llm, nodes, edges);\n }\n\n // Deduplicate nodes by label (case-insensitive)\n const deduped = deduplicateNodes(nodes, edges);\n\n return {\n nodes: deduped.nodes,\n edges: deduped.edges,\n metadata: {\n createdAt: new Date().toISOString(),\n sources: [...sources],\n nodeCount: deduped.nodes.length,\n edgeCount: deduped.edges.length,\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// Phase 1: Structured data extraction\n// ---------------------------------------------------------------------------\n\nfunction extractFromUserData(\n records: UserRecord[],\n nodes: GraphNode[],\n edges: GraphEdge[],\n sources: Set<string>\n): void {\n // Track feature/event usage across users\n const eventCounts: Record<string, number> = {};\n const propertyValues: Record<string, Record<string, number>> = {};\n\n for (const record of records) {\n sources.add(\"user-data\");\n\n // Create user node\n const userId = `user-${record.id}`;\n nodes.push({\n id: userId,\n type: \"user\",\n label: String(record.properties.name ?? record.properties.email ?? record.id),\n properties: record.properties,\n source: \"user-data\",\n });\n\n // Extract events as behavior/feature nodes\n for (const event of record.events ?? []) {\n eventCounts[event.name] = (eventCounts[event.name] ?? 0) + 1;\n\n const eventNodeId = `event-${event.name}`;\n if (!nodes.some((n) => n.id === eventNodeId)) {\n nodes.push({\n id: eventNodeId,\n type: \"event\",\n label: event.name,\n properties: { totalOccurrences: 0 },\n source: \"user-data\",\n });\n }\n\n // Edge: user → uses → event\n edges.push({\n id: randomUUID(),\n from: userId,\n to: eventNodeId,\n type: \"triggered\",\n weight: 0.5,\n properties: { timestamp: event.timestamp },\n source: \"user-data\",\n });\n }\n\n // Extract payment data\n for (const payment of record.payments ?? []) {\n const planId = `plan-${payment.plan ?? \"unknown\"}`;\n if (!nodes.some((n) => n.id === planId)) {\n nodes.push({\n id: planId,\n type: \"product\",\n label: payment.plan ?? \"unknown plan\",\n properties: { amount: payment.amount, currency: payment.currency },\n source: \"payment-data\",\n });\n }\n\n edges.push({\n id: randomUUID(),\n from: userId,\n to: planId,\n type: payment.status === \"cancelled\" ? \"cancelled\" : \"subscribes_to\",\n weight: payment.status === \"active\" ? 0.8 : 0.3,\n properties: {\n status: payment.status,\n cancelReason: payment.cancelReason,\n },\n source: \"payment-data\",\n });\n\n // Create complaint node from cancel reason\n if (payment.cancelReason) {\n const complaintId = `complaint-${randomUUID().slice(0, 8)}`;\n nodes.push({\n id: complaintId,\n type: \"complaint\",\n label: payment.cancelReason,\n properties: {},\n source: \"payment-data\",\n });\n edges.push({\n id: randomUUID(),\n from: userId,\n to: complaintId,\n type: \"complained_about\",\n weight: 0.9,\n properties: {},\n source: \"payment-data\",\n });\n }\n }\n\n // Extract conversations as complaint/feature nodes\n for (const conv of record.conversations ?? []) {\n const convId = `conv-${randomUUID().slice(0, 8)}`;\n nodes.push({\n id: convId,\n type: \"complaint\",\n label: conv.slice(0, 100),\n properties: { fullText: conv },\n source: \"support-data\",\n });\n edges.push({\n id: randomUUID(),\n from: userId,\n to: convId,\n type: \"said\",\n weight: 0.7,\n properties: {},\n source: \"support-data\",\n });\n }\n\n // Track property distributions\n for (const [key, value] of Object.entries(record.properties)) {\n if (!propertyValues[key]) propertyValues[key] = {};\n const v = String(value);\n propertyValues[key][v] = (propertyValues[key][v] ?? 0) + 1;\n }\n }\n\n // Update event occurrence counts\n for (const node of nodes) {\n if (node.type === \"event\" && eventCounts[node.label]) {\n node.properties.totalOccurrences = eventCounts[node.label];\n }\n }\n\n // Create metric nodes for key aggregates\n const totalUsers = records.length;\n if (totalUsers > 0) {\n nodes.push({\n id: \"metric-total-users\",\n type: \"metric\",\n label: `${totalUsers} total users`,\n properties: { value: totalUsers },\n source: \"user-data\",\n });\n }\n}\n\n// ---------------------------------------------------------------------------\n// Phase 2: Unstructured text extraction\n// ---------------------------------------------------------------------------\n\nasync function extractFromText(\n llm: LLMProvider,\n texts: string[],\n nodes: GraphNode[],\n edges: GraphEdge[],\n sources: Set<string>\n): Promise<void> {\n sources.add(\"context\");\n\n const combined = texts.join(\"\\n\\n\").slice(0, 8000); // Cap context length\n\n const extracted = await llm.generateJSON<{\n entities: Array<{\n label: string;\n type: NodeType;\n properties: Record<string, unknown>;\n }>;\n relationships: Array<{\n from: string;\n to: string;\n type: string;\n weight: number;\n }>;\n }>(\n \"You extract entities and relationships from text to build a knowledge graph.\",\n `Extract entities and relationships from this text.\n\n${combined}\n\nReturn JSON:\n{\n \"entities\": [{\"label\": \"<name>\", \"type\": \"user|behavior|feature|complaint|segment|event|product|metric|entity\", \"properties\": {}}],\n \"relationships\": [{\"from\": \"<entity label>\", \"to\": \"<entity label>\", \"type\": \"<relationship>\", \"weight\": <0-1>}]\n}\n\nFocus on: people, products, features, complaints, behaviors, events, and how they relate.\nUse descriptive relationship types like \"uses\", \"complained_about\", \"wants\", \"avoids\", \"correlates_with\".`\n );\n\n const labelToId: Record<string, string> = {};\n\n for (const entity of extracted.entities ?? []) {\n const id = `ctx-${randomUUID().slice(0, 8)}`;\n labelToId[entity.label] = id;\n nodes.push({\n id,\n type: entity.type,\n label: entity.label,\n properties: entity.properties ?? {},\n source: \"context\",\n });\n }\n\n for (const rel of extracted.relationships ?? []) {\n const fromId = labelToId[rel.from];\n const toId = labelToId[rel.to];\n if (fromId && toId) {\n edges.push({\n id: randomUUID(),\n from: fromId,\n to: toId,\n type: rel.type,\n weight: rel.weight ?? 0.5,\n properties: {},\n source: \"context\",\n });\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Phase 3: Relationship discovery\n// ---------------------------------------------------------------------------\n\nasync function discoverRelationships(\n llm: LLMProvider,\n nodes: GraphNode[],\n edges: GraphEdge[]\n): Promise<void> {\n // Only use non-user nodes for relationship discovery (to keep prompt small)\n const significantNodes = nodes\n .filter((n) => n.type !== \"user\")\n .slice(0, 30)\n .map((n) => `${n.type}:${n.label}`);\n\n if (significantNodes.length < 3) return;\n\n const discovered = await llm.generateJSON<\n Array<{ from: string; to: string; type: string; weight: number }>\n >(\n \"You identify non-obvious relationships between entities in a product knowledge graph.\",\n `Given these entities from a product's user data, identify relationships between them that aren't already obvious.\n\nEntities:\n${significantNodes.join(\"\\n\")}\n\nReturn a JSON array of relationships:\n[{\"from\": \"<entity label>\", \"to\": \"<entity label>\", \"type\": \"<relationship>\", \"weight\": <0-1>}]\n\nFocus on: correlations (\"users who do X also do Y\"), causal patterns (\"feature X leads to churn\"), and segments (\"these behaviors cluster together\").\nReturn at most 10 relationships. Only include ones with real analytical value.`\n );\n\n for (const rel of discovered ?? []) {\n const fromNode = nodes.find((n) => n.label === rel.from.replace(/^\\w+:/, \"\"));\n const toNode = nodes.find((n) => n.label === rel.to.replace(/^\\w+:/, \"\"));\n if (fromNode && toNode) {\n edges.push({\n id: randomUUID(),\n from: fromNode.id,\n to: toNode.id,\n type: rel.type,\n weight: rel.weight ?? 0.5,\n properties: { discovered: true },\n source: \"llm-analysis\",\n });\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Deduplication\n// ---------------------------------------------------------------------------\n\nfunction deduplicateNodes(\n nodes: GraphNode[],\n edges: GraphEdge[]\n): { nodes: GraphNode[]; edges: GraphEdge[] } {\n const seen = new Map<string, GraphNode>();\n const idMapping = new Map<string, string>();\n\n for (const node of nodes) {\n const key = `${node.type}:${node.label.toLowerCase()}`;\n if (seen.has(key)) {\n idMapping.set(node.id, seen.get(key)!.id);\n } else {\n seen.set(key, node);\n }\n }\n\n // Remap edge references\n const remappedEdges = edges.map((e) => ({\n ...e,\n from: idMapping.get(e.from) ?? e.from,\n to: idMapping.get(e.to) ?? e.to,\n }));\n\n // Remove self-referencing edges\n const cleanEdges = remappedEdges.filter((e) => e.from !== e.to);\n\n return { nodes: [...seen.values()], edges: cleanEdges };\n}\n"],"mappings":";;;;;;;;;;AAIA,SAAS,UAAU,WAAW,aAAa;AAC3C,SAAe,eAAe;;;ACK9B,SAAS,kBAAkB;AAO3B,eAAsB,WACpB,KACA,UACA,cACyB;AACzB,QAAM,QAAqB,CAAC;AAC5B,QAAM,QAAqB,CAAC;AAC5B,QAAM,UAAU,oBAAI,IAAY;AAGhC,sBAAoB,UAAU,OAAO,OAAO,OAAO;AAGnD,MAAI,gBAAgB,aAAa,SAAS,GAAG;AAC3C,UAAM,gBAAgB,KAAK,cAAc,OAAO,OAAO,OAAO;AAAA,EAChE;AAGA,MAAI,MAAM,SAAS,GAAG;AACpB,UAAM,sBAAsB,KAAK,OAAO,KAAK;AAAA,EAC/C;AAGA,QAAM,UAAU,iBAAiB,OAAO,KAAK;AAE7C,SAAO;AAAA,IACL,OAAO,QAAQ;AAAA,IACf,OAAO,QAAQ;AAAA,IACf,UAAU;AAAA,MACR,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,MAClC,SAAS,CAAC,GAAG,OAAO;AAAA,MACpB,WAAW,QAAQ,MAAM;AAAA,MACzB,WAAW,QAAQ,MAAM;AAAA,IAC3B;AAAA,EACF;AACF;AAMA,SAAS,oBACP,SACA,OACA,OACA,SACM;AAEN,QAAM,cAAsC,CAAC;AAC7C,QAAM,iBAAyD,CAAC;AAEhE,aAAW,UAAU,SAAS;AAC5B,YAAQ,IAAI,WAAW;AAGvB,UAAM,SAAS,QAAQ,OAAO,EAAE;AAChC,UAAM,KAAK;AAAA,MACT,IAAI;AAAA,MACJ,MAAM;AAAA,MACN,OAAO,OAAO,OAAO,WAAW,QAAQ,OAAO,WAAW,SAAS,OAAO,EAAE;AAAA,MAC5E,YAAY,OAAO;AAAA,MACnB,QAAQ;AAAA,IACV,CAAC;AAGD,eAAW,SAAS,OAAO,UAAU,CAAC,GAAG;AACvC,kBAAY,MAAM,IAAI,KAAK,YAAY,MAAM,IAAI,KAAK,KAAK;AAE3D,YAAM,cAAc,SAAS,MAAM,IAAI;AACvC,UAAI,CAAC,MAAM,KAAK,CAAC,MAAM,EAAE,OAAO,WAAW,GAAG;AAC5C,cAAM,KAAK;AAAA,UACT,IAAI;AAAA,UACJ,MAAM;AAAA,UACN,OAAO,MAAM;AAAA,UACb,YAAY,EAAE,kBAAkB,EAAE;AAAA,UAClC,QAAQ;AAAA,QACV,CAAC;AAAA,MACH;AAGA,YAAM,KAAK;AAAA,QACT,IAAI,WAAW;AAAA,QACf,MAAM;AAAA,QACN,IAAI;AAAA,QACJ,MAAM;AAAA,QACN,QAAQ;AAAA,QACR,YAAY,EAAE,WAAW,MAAM,UAAU;AAAA,QACzC,QAAQ;AAAA,MACV,CAAC;AAAA,IACH;AAGA,eAAW,WAAW,OAAO,YAAY,CAAC,GAAG;AAC3C,YAAM,SAAS,QAAQ,QAAQ,QAAQ,SAAS;AAChD,UAAI,CAAC,MAAM,KAAK,CAAC,MAAM,EAAE,OAAO,MAAM,GAAG;AACvC,cAAM,KAAK;AAAA,UACT,IAAI;AAAA,UACJ,MAAM;AAAA,UACN,OAAO,QAAQ,QAAQ;AAAA,UACvB,YAAY,EAAE,QAAQ,QAAQ,QAAQ,UAAU,QAAQ,SAAS;AAAA,UACjE,QAAQ;AAAA,QACV,CAAC;AAAA,MACH;AAEA,YAAM,KAAK;AAAA,QACT,IAAI,WAAW;AAAA,QACf,MAAM;AAAA,QACN,IAAI;AAAA,QACJ,MAAM,QAAQ,WAAW,cAAc,cAAc;AAAA,QACrD,QAAQ,QAAQ,WAAW,WAAW,MAAM;AAAA,QAC5C,YAAY;AAAA,UACV,QAAQ,QAAQ;AAAA,UAChB,cAAc,QAAQ;AAAA,QACxB;AAAA,QACA,QAAQ;AAAA,MACV,CAAC;AAGD,UAAI,QAAQ,cAAc;AACxB,cAAM,cAAc,aAAa,WAAW,EAAE,MAAM,GAAG,CAAC,CAAC;AACzD,cAAM,KAAK;AAAA,UACT,IAAI;AAAA,UACJ,MAAM;AAAA,UACN,OAAO,QAAQ;AAAA,UACf,YAAY,CAAC;AAAA,UACb,QAAQ;AAAA,QACV,CAAC;AACD,cAAM,KAAK;AAAA,UACT,IAAI,WAAW;AAAA,UACf,MAAM;AAAA,UACN,IAAI;AAAA,UACJ,MAAM;AAAA,UACN,QAAQ;AAAA,UACR,YAAY,CAAC;AAAA,UACb,QAAQ;AAAA,QACV,CAAC;AAAA,MACH;AAAA,IACF;AAGA,eAAW,QAAQ,OAAO,iBAAiB,CAAC,GAAG;AAC7C,YAAM,SAAS,QAAQ,WAAW,EAAE,MAAM,GAAG,CAAC,CAAC;AAC/C,YAAM,KAAK;AAAA,QACT,IAAI;AAAA,QACJ,MAAM;AAAA,QACN,OAAO,KAAK,MAAM,GAAG,GAAG;AAAA,QACxB,YAAY,EAAE,UAAU,KAAK;AAAA,QAC7B,QAAQ;AAAA,MACV,CAAC;AACD,YAAM,KAAK;AAAA,QACT,IAAI,WAAW;AAAA,QACf,MAAM;AAAA,QACN,IAAI;AAAA,QACJ,MAAM;AAAA,QACN,QAAQ;AAAA,QACR,YAAY,CAAC;AAAA,QACb,QAAQ;AAAA,MACV,CAAC;AAAA,IACH;AAGA,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,OAAO,UAAU,GAAG;AAC5D,UAAI,CAAC,eAAe,GAAG,EAAG,gBAAe,GAAG,IAAI,CAAC;AACjD,YAAM,IAAI,OAAO,KAAK;AACtB,qBAAe,GAAG,EAAE,CAAC,KAAK,eAAe,GAAG,EAAE,CAAC,KAAK,KAAK;AAAA,IAC3D;AAAA,EACF;AAGA,aAAW,QAAQ,OAAO;AACxB,QAAI,KAAK,SAAS,WAAW,YAAY,KAAK,KAAK,GAAG;AACpD,WAAK,WAAW,mBAAmB,YAAY,KAAK,KAAK;AAAA,IAC3D;AAAA,EACF;AAGA,QAAM,aAAa,QAAQ;AAC3B,MAAI,aAAa,GAAG;AAClB,UAAM,KAAK;AAAA,MACT,IAAI;AAAA,MACJ,MAAM;AAAA,MACN,OAAO,GAAG,UAAU;AAAA,MACpB,YAAY,EAAE,OAAO,WAAW;AAAA,MAChC,QAAQ;AAAA,IACV,CAAC;AAAA,EACH;AACF;AAMA,eAAe,gBACb,KACA,OACA,OACA,OACA,SACe;AACf,UAAQ,IAAI,SAAS;AAErB,QAAM,WAAW,MAAM,KAAK,MAAM,EAAE,MAAM,GAAG,GAAI;AAEjD,QAAM,YAAY,MAAM,IAAI;AAAA,IAa1B;AAAA,IACA;AAAA;AAAA,EAEF,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUR;AAEA,QAAM,YAAoC,CAAC;AAE3C,aAAW,UAAU,UAAU,YAAY,CAAC,GAAG;AAC7C,UAAM,KAAK,OAAO,WAAW,EAAE,MAAM,GAAG,CAAC,CAAC;AAC1C,cAAU,OAAO,KAAK,IAAI;AAC1B,UAAM,KAAK;AAAA,MACT;AAAA,MACA,MAAM,OAAO;AAAA,MACb,OAAO,OAAO;AAAA,MACd,YAAY,OAAO,cAAc,CAAC;AAAA,MAClC,QAAQ;AAAA,IACV,CAAC;AAAA,EACH;AAEA,aAAW,OAAO,UAAU,iBAAiB,CAAC,GAAG;AAC/C,UAAM,SAAS,UAAU,IAAI,IAAI;AACjC,UAAM,OAAO,UAAU,IAAI,EAAE;AAC7B,QAAI,UAAU,MAAM;AAClB,YAAM,KAAK;AAAA,QACT,IAAI,WAAW;AAAA,QACf,MAAM;AAAA,QACN,IAAI;AAAA,QACJ,MAAM,IAAI;AAAA,QACV,QAAQ,IAAI,UAAU;AAAA,QACtB,YAAY,CAAC;AAAA,QACb,QAAQ;AAAA,MACV,CAAC;AAAA,IACH;AAAA,EACF;AACF;AAMA,eAAe,sBACb,KACA,OACA,OACe;AAEf,QAAM,mBAAmB,MACtB,OAAO,CAAC,MAAM,EAAE,SAAS,MAAM,EAC/B,MAAM,GAAG,EAAE,EACX,IAAI,CAAC,MAAM,GAAG,EAAE,IAAI,IAAI,EAAE,KAAK,EAAE;AAEpC,MAAI,iBAAiB,SAAS,EAAG;AAEjC,QAAM,aAAa,MAAM,IAAI;AAAA,IAG3B;AAAA,IACA;AAAA;AAAA;AAAA,EAGF,iBAAiB,KAAK,IAAI,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAO3B;AAEA,aAAW,OAAO,cAAc,CAAC,GAAG;AAClC,UAAM,WAAW,MAAM,KAAK,CAAC,MAAM,EAAE,UAAU,IAAI,KAAK,QAAQ,SAAS,EAAE,CAAC;AAC5E,UAAM,SAAS,MAAM,KAAK,CAAC,MAAM,EAAE,UAAU,IAAI,GAAG,QAAQ,SAAS,EAAE,CAAC;AACxE,QAAI,YAAY,QAAQ;AACtB,YAAM,KAAK;AAAA,QACT,IAAI,WAAW;AAAA,QACf,MAAM,SAAS;AAAA,QACf,IAAI,OAAO;AAAA,QACX,MAAM,IAAI;AAAA,QACV,QAAQ,IAAI,UAAU;AAAA,QACtB,YAAY,EAAE,YAAY,KAAK;AAAA,QAC/B,QAAQ;AAAA,MACV,CAAC;AAAA,IACH;AAAA,EACF;AACF;AAMA,SAAS,iBACP,OACA,OAC4C;AAC5C,QAAM,OAAO,oBAAI,IAAuB;AACxC,QAAM,YAAY,oBAAI,IAAoB;AAE1C,aAAW,QAAQ,OAAO;AACxB,UAAM,MAAM,GAAG,KAAK,IAAI,IAAI,KAAK,MAAM,YAAY,CAAC;AACpD,QAAI,KAAK,IAAI,GAAG,GAAG;AACjB,gBAAU,IAAI,KAAK,IAAI,KAAK,IAAI,GAAG,EAAG,EAAE;AAAA,IAC1C,OAAO;AACL,WAAK,IAAI,KAAK,IAAI;AAAA,IACpB;AAAA,EACF;AAGA,QAAM,gBAAgB,MAAM,IAAI,CAAC,OAAO;AAAA,IACtC,GAAG;AAAA,IACH,MAAM,UAAU,IAAI,EAAE,IAAI,KAAK,EAAE;AAAA,IACjC,IAAI,UAAU,IAAI,EAAE,EAAE,KAAK,EAAE;AAAA,EAC/B,EAAE;AAGF,QAAM,aAAa,cAAc,OAAO,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE;AAE9D,SAAO,EAAE,OAAO,CAAC,GAAG,KAAK,OAAO,CAAC,GAAG,OAAO,WAAW;AACxD;;;ADtVO,IAAM,cAAN,MAAkB;AAAA,EACf,QAA+B;AAAA;AAAA;AAAA;AAAA,EAKvC,MAAM,MACJ,KACA,UACA,cACe;AACf,SAAK,QAAQ,MAAM,WAAW,KAAK,UAAU,YAAY;AAAA,EAC3D;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,QAAgB,QAAgB,GAAgB;AACpD,SAAK,YAAY;AACjB,WAAO,gBAAgB,KAAK,OAAQ,QAAQ,KAAK;AAAA,EACnD;AAAA;AAAA;AAAA;AAAA,EAKA,kBAAkB,QAA+B;AAC/C,SAAK,YAAY;AACjB,WAAO,kBAAkB,KAAK,OAAQ,MAAM;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA,EAKA,WAA2B;AACzB,SAAK,YAAY;AACjB,WAAO,aAAa,KAAK,KAAM;AAAA,EACjC;AAAA;AAAA;AAAA;AAAA,EAKA,IAAI,UAAkB;AACpB,SAAK,YAAY;AACjB,WAAO,aAAa,KAAK,KAAM;AAAA,EACjC;AAAA;AAAA;AAAA;AAAA,EAKA,IAAI,MAAsB;AACxB,SAAK,YAAY;AACjB,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,IAAI,KAAkB,UAA6C;AACvE,SAAK,YAAY;AACjB,WAAO,WAAW,KAAK,KAAK,OAAQ,QAAQ;AAAA,EAC9C;AAAA,EAEA,IAAI,UAAmB;AACrB,WAAO,KAAK,UAAU;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,KAAK,MAA6B;AACtC,SAAK,YAAY;AACjB,UAAM,MAAM,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;AAE9C,UAAM,QAAkB,CAAC;AACzB,UAAM,KAAK,KAAK,UAAU,EAAE,OAAO,YAAY,GAAG,KAAK,MAAO,SAAS,CAAC,CAAC;AACzE,eAAW,QAAQ,KAAK,MAAO,OAAO;AACpC,YAAM,KAAK,KAAK,UAAU,EAAE,OAAO,QAAQ,GAAG,KAAK,CAAC,CAAC;AAAA,IACvD;AACA,eAAW,QAAQ,KAAK,MAAO,OAAO;AACpC,YAAM,KAAK,KAAK,UAAU,EAAE,OAAO,QAAQ,GAAG,KAAK,CAAC,CAAC;AAAA,IACvD;AAEA,UAAM,UAAU,MAAM,MAAM,KAAK,IAAI,IAAI,IAAI;AAAA,EAC/C;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,KAAK,MAA6B;AACtC,UAAM,UAAU,MAAM,SAAS,MAAM,OAAO;AAC5C,UAAM,QAAQ,QAAQ,KAAK,EAAE,MAAM,IAAI,EAAE,OAAO,OAAO;AAEvD,UAAM,QAAqB,CAAC;AAC5B,UAAM,QAA0C,CAAC;AACjD,QAAI,WAAuC;AAAA,MACzC,WAAW;AAAA,MACX,SAAS,CAAC;AAAA,MACV,WAAW;AAAA,MACX,WAAW;AAAA,IACb;AAEA,eAAW,QAAQ,OAAO;AACxB,YAAM,MAAM,KAAK,MAAM,IAAI;AAC3B,YAAM,OAAO,IAAI;AACjB,aAAO,IAAI;AAEX,UAAI,SAAS,YAAY;AACvB,mBAAW;AAAA,MACb,WAAW,SAAS,QAAQ;AAC1B,cAAM,KAAK,GAA2B;AAAA,MACxC,WAAW,SAAS,QAAQ;AAC1B,cAAM,KAAK,GAAgD;AAAA,MAC7D;AAAA,IACF;AAEA,SAAK,QAAQ,EAAE,OAAO,OAAO,SAAS;AAAA,EACxC;AAAA,EAEQ,cAAoB;AAC1B,QAAI,CAAC,KAAK,OAAO;AACf,YAAM,IAAI,MAAM,oDAAoD;AAAA,IACtE;AAAA,EACF;AACF;","names":[]}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// src/graph/query.ts
|
|
2
|
+
function getNeighborhood(graph, nodeId, depth = 2) {
|
|
3
|
+
const visited = /* @__PURE__ */ new Set();
|
|
4
|
+
const queue = [{ id: nodeId, d: 0 }];
|
|
5
|
+
while (queue.length > 0) {
|
|
6
|
+
const { id, d } = queue.shift();
|
|
7
|
+
if (visited.has(id) || d > depth) continue;
|
|
8
|
+
visited.add(id);
|
|
9
|
+
for (const edge of graph.edges) {
|
|
10
|
+
if (edge.from === id && !visited.has(edge.to)) {
|
|
11
|
+
queue.push({ id: edge.to, d: d + 1 });
|
|
12
|
+
}
|
|
13
|
+
if (edge.to === id && !visited.has(edge.from)) {
|
|
14
|
+
queue.push({ id: edge.from, d: d + 1 });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return graph.nodes.filter((n) => visited.has(n.id));
|
|
19
|
+
}
|
|
20
|
+
function findPatterns(graph) {
|
|
21
|
+
const patterns = [];
|
|
22
|
+
const degree = {};
|
|
23
|
+
for (const edge of graph.edges) {
|
|
24
|
+
degree[edge.from] = (degree[edge.from] ?? 0) + 1;
|
|
25
|
+
degree[edge.to] = (degree[edge.to] ?? 0) + 1;
|
|
26
|
+
}
|
|
27
|
+
const hubs = Object.entries(degree).filter(([, d]) => d >= 3).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
28
|
+
for (const [nodeId, d] of hubs) {
|
|
29
|
+
const node = graph.nodes.find((n) => n.id === nodeId);
|
|
30
|
+
if (node) {
|
|
31
|
+
patterns.push({
|
|
32
|
+
description: `"${node.label}" is a hub node (${d} connections) \u2014 many users/entities relate to it`,
|
|
33
|
+
nodes: [nodeId],
|
|
34
|
+
edges: graph.edges.filter((e) => e.from === nodeId || e.to === nodeId).map((e) => e.id),
|
|
35
|
+
frequency: d,
|
|
36
|
+
significance: Math.min(1, d / 10)
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const churnEdges = graph.edges.filter((e) => e.type === "cancelled" || e.type === "churned_because");
|
|
41
|
+
if (churnEdges.length > 0) {
|
|
42
|
+
const churnReasons = churnEdges.map((e) => {
|
|
43
|
+
const target = graph.nodes.find((n) => n.id === e.to);
|
|
44
|
+
return target?.label;
|
|
45
|
+
}).filter(Boolean);
|
|
46
|
+
if (churnReasons.length > 0) {
|
|
47
|
+
patterns.push({
|
|
48
|
+
description: `Churn pattern: ${churnReasons.slice(0, 3).join(", ")}`,
|
|
49
|
+
nodes: churnEdges.map((e) => e.to),
|
|
50
|
+
edges: churnEdges.map((e) => e.id),
|
|
51
|
+
frequency: churnEdges.length,
|
|
52
|
+
significance: Math.min(1, churnEdges.length / 5)
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const complaintNodes = graph.nodes.filter((n) => n.type === "complaint");
|
|
57
|
+
if (complaintNodes.length >= 2) {
|
|
58
|
+
patterns.push({
|
|
59
|
+
description: `${complaintNodes.length} complaints identified: ${complaintNodes.slice(0, 3).map((n) => `"${n.label}"`).join(", ")}`,
|
|
60
|
+
nodes: complaintNodes.map((n) => n.id),
|
|
61
|
+
edges: [],
|
|
62
|
+
frequency: complaintNodes.length,
|
|
63
|
+
significance: Math.min(1, complaintNodes.length / 5)
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const eventNodes = graph.nodes.filter((n) => n.type === "event");
|
|
67
|
+
const topEvents = eventNodes.sort((a, b) => (Number(b.properties.totalOccurrences) ?? 0) - (Number(a.properties.totalOccurrences) ?? 0)).slice(0, 5);
|
|
68
|
+
if (topEvents.length >= 2) {
|
|
69
|
+
patterns.push({
|
|
70
|
+
description: `Top features: ${topEvents.map((n) => `${n.label} (${n.properties.totalOccurrences}x)`).join(", ")}`,
|
|
71
|
+
nodes: topEvents.map((n) => n.id),
|
|
72
|
+
edges: [],
|
|
73
|
+
frequency: topEvents.length,
|
|
74
|
+
significance: 0.7
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return patterns.sort((a, b) => b.significance - a.significance);
|
|
78
|
+
}
|
|
79
|
+
function getPersonaContext(graph, traits) {
|
|
80
|
+
const context = [];
|
|
81
|
+
const metricNodes = graph.nodes.filter((n) => n.type === "metric");
|
|
82
|
+
for (const m of metricNodes) {
|
|
83
|
+
context.push(m.label);
|
|
84
|
+
}
|
|
85
|
+
if (traits.openness > 0.6) {
|
|
86
|
+
const featureNodes = graph.nodes.filter(
|
|
87
|
+
(n) => n.type === "feature" || n.type === "event"
|
|
88
|
+
);
|
|
89
|
+
for (const f of featureNodes.slice(0, 5)) {
|
|
90
|
+
const count = f.properties.totalOccurrences;
|
|
91
|
+
context.push(
|
|
92
|
+
count ? `Feature "${f.label}" is used ${count} times` : `Feature: ${f.label}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (traits.neuroticism > 0.6) {
|
|
97
|
+
const complaints = graph.nodes.filter((n) => n.type === "complaint");
|
|
98
|
+
for (const c of complaints.slice(0, 5)) {
|
|
99
|
+
context.push(`User complaint: "${c.label}"`);
|
|
100
|
+
}
|
|
101
|
+
const churnEdges = graph.edges.filter(
|
|
102
|
+
(e) => e.type === "cancelled" || e.type === "churned_because"
|
|
103
|
+
);
|
|
104
|
+
if (churnEdges.length > 0) {
|
|
105
|
+
context.push(`${churnEdges.length} users have cancelled`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (traits.conscientiousness > 0.6) {
|
|
109
|
+
const discovered = graph.edges.filter(
|
|
110
|
+
(e) => e.properties.discovered === true
|
|
111
|
+
);
|
|
112
|
+
for (const d of discovered.slice(0, 3)) {
|
|
113
|
+
const from = graph.nodes.find((n) => n.id === d.from);
|
|
114
|
+
const to = graph.nodes.find((n) => n.id === d.to);
|
|
115
|
+
if (from && to) {
|
|
116
|
+
context.push(`Pattern: "${from.label}" ${d.type} "${to.label}"`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (traits.agreeableness > 0.6) {
|
|
121
|
+
const hubEdges = Object.entries(
|
|
122
|
+
graph.edges.reduce(
|
|
123
|
+
(acc, e) => {
|
|
124
|
+
acc[e.to] = (acc[e.to] ?? 0) + 1;
|
|
125
|
+
return acc;
|
|
126
|
+
},
|
|
127
|
+
{}
|
|
128
|
+
)
|
|
129
|
+
).sort((a, b) => b[1] - a[1]).slice(0, 3);
|
|
130
|
+
for (const [nodeId, count] of hubEdges) {
|
|
131
|
+
const node = graph.nodes.find((n) => n.id === nodeId);
|
|
132
|
+
if (node && count >= 2) {
|
|
133
|
+
context.push(`${count} users interact with "${node.label}"`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (traits.extraversion > 0.6) {
|
|
138
|
+
const socialEvents = graph.nodes.filter(
|
|
139
|
+
(n) => n.type === "event" && /share|invite|collab|team|social/i.test(n.label)
|
|
140
|
+
);
|
|
141
|
+
for (const s of socialEvents.slice(0, 3)) {
|
|
142
|
+
context.push(`Social feature: "${s.label}" (${s.properties.totalOccurrences ?? 0} uses)`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return context;
|
|
146
|
+
}
|
|
147
|
+
function graphSummary(graph) {
|
|
148
|
+
const typeCounts = {};
|
|
149
|
+
for (const node of graph.nodes) {
|
|
150
|
+
typeCounts[node.type] = (typeCounts[node.type] ?? 0) + 1;
|
|
151
|
+
}
|
|
152
|
+
const edgeTypes = {};
|
|
153
|
+
for (const edge of graph.edges) {
|
|
154
|
+
edgeTypes[edge.type] = (edgeTypes[edge.type] ?? 0) + 1;
|
|
155
|
+
}
|
|
156
|
+
const lines = [
|
|
157
|
+
`Knowledge graph: ${graph.metadata.nodeCount} nodes, ${graph.metadata.edgeCount} edges`,
|
|
158
|
+
`Sources: ${graph.metadata.sources.join(", ")}`,
|
|
159
|
+
`Node types: ${Object.entries(typeCounts).map(([t, c]) => `${t}(${c})`).join(", ")}`,
|
|
160
|
+
`Edge types: ${Object.entries(edgeTypes).map(([t, c]) => `${t}(${c})`).join(", ")}`
|
|
161
|
+
];
|
|
162
|
+
return lines.join("\n");
|
|
163
|
+
}
|
|
164
|
+
async function queryGraph(llm, graph, question) {
|
|
165
|
+
const graphContext = serializeGraphForLLM(graph);
|
|
166
|
+
const patterns = findPatterns(graph);
|
|
167
|
+
const patternText = patterns.length > 0 ? `
|
|
168
|
+
Discovered patterns:
|
|
169
|
+
${patterns.map((p) => `- ${p.description} (significance: ${p.significance.toFixed(2)})`).join("\n")}` : "";
|
|
170
|
+
const result = await llm.generateJSON(
|
|
171
|
+
`You are a data analyst answering questions about a product's user base using a knowledge graph. The graph contains real user data \u2014 users, events, features, complaints, payments, and their relationships.
|
|
172
|
+
|
|
173
|
+
Ground every claim in specific graph data. If the graph doesn't contain enough information to answer confidently, say so and explain what data would help.`,
|
|
174
|
+
`Knowledge Graph:
|
|
175
|
+
${graphContext}
|
|
176
|
+
${patternText}
|
|
177
|
+
|
|
178
|
+
Question: "${question}"
|
|
179
|
+
|
|
180
|
+
Analyze the graph to answer this question. Return JSON:
|
|
181
|
+
{
|
|
182
|
+
"answer": "<2-4 sentence answer grounded in the graph data>",
|
|
183
|
+
"evidence": [
|
|
184
|
+
{"type": "node|edge|pattern", "label": "<entity or relationship>", "detail": "<how this supports the answer>", "relevance": <0-1>}
|
|
185
|
+
],
|
|
186
|
+
"confidence": <0.0-1.0 based on how much graph data supports the answer>,
|
|
187
|
+
"relatedNodes": ["<labels of the most relevant nodes for follow-up questions>"]
|
|
188
|
+
}`
|
|
189
|
+
);
|
|
190
|
+
return {
|
|
191
|
+
question,
|
|
192
|
+
...result
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function serializeGraphForLLM(graph) {
|
|
196
|
+
const lines = [];
|
|
197
|
+
lines.push(`${graph.metadata.nodeCount} nodes, ${graph.metadata.edgeCount} edges`);
|
|
198
|
+
const byType = {};
|
|
199
|
+
for (const node of graph.nodes) {
|
|
200
|
+
if (!byType[node.type]) byType[node.type] = [];
|
|
201
|
+
byType[node.type].push(node);
|
|
202
|
+
}
|
|
203
|
+
for (const [type, nodes] of Object.entries(byType)) {
|
|
204
|
+
const limit = type === "user" ? 5 : 15;
|
|
205
|
+
const shown = nodes.slice(0, limit);
|
|
206
|
+
const remaining = nodes.length - shown.length;
|
|
207
|
+
lines.push(`
|
|
208
|
+
[${type.toUpperCase()}] (${nodes.length} total)`);
|
|
209
|
+
for (const n of shown) {
|
|
210
|
+
const props = Object.entries(n.properties).filter(([k]) => !k.startsWith("_") && k !== "fullText").slice(0, 3).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(", ");
|
|
211
|
+
lines.push(` - ${n.label}${props ? ` (${props})` : ""}`);
|
|
212
|
+
}
|
|
213
|
+
if (remaining > 0) {
|
|
214
|
+
lines.push(` ... and ${remaining} more`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const edgesByType = {};
|
|
218
|
+
for (const edge of graph.edges) {
|
|
219
|
+
if (!edgesByType[edge.type]) edgesByType[edge.type] = [];
|
|
220
|
+
const from = graph.nodes.find((n) => n.id === edge.from);
|
|
221
|
+
const to = graph.nodes.find((n) => n.id === edge.to);
|
|
222
|
+
if (from && to) {
|
|
223
|
+
edgesByType[edge.type].push({ from: from.label, to: to.label, weight: edge.weight });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
lines.push("\n[RELATIONSHIPS]");
|
|
227
|
+
for (const [type, edges] of Object.entries(edgesByType)) {
|
|
228
|
+
const shown = edges.slice(0, 8);
|
|
229
|
+
lines.push(` ${type} (${edges.length} total):`);
|
|
230
|
+
for (const e of shown) {
|
|
231
|
+
lines.push(` "${e.from}" \u2192 "${e.to}" (weight: ${e.weight.toFixed(1)})`);
|
|
232
|
+
}
|
|
233
|
+
if (edges.length > shown.length) {
|
|
234
|
+
lines.push(` ... and ${edges.length - shown.length} more`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return lines.join("\n");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export {
|
|
241
|
+
getNeighborhood,
|
|
242
|
+
findPatterns,
|
|
243
|
+
getPersonaContext,
|
|
244
|
+
graphSummary,
|
|
245
|
+
queryGraph
|
|
246
|
+
};
|
|
247
|
+
//# sourceMappingURL=chunk-KEWXLWAO.js.map
|