@chainlesschain/personal-data-hub 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/README.md +241 -0
- package/__tests__/adapter-spec.test.js +78 -0
- package/__tests__/adapters/email-adapter.test.js +605 -0
- package/__tests__/adapters/email-imap-session.test.js +334 -0
- package/__tests__/adapters/email-parser.test.js +244 -0
- package/__tests__/adapters/email-providers.test.js +84 -0
- package/__tests__/analysis.test.js +302 -0
- package/__tests__/batch.test.js +133 -0
- package/__tests__/bridges-cc-kg.test.js +231 -0
- package/__tests__/bridges-cc-llm.test.js +191 -0
- package/__tests__/bridges-cc-rag.test.js +162 -0
- package/__tests__/ids.test.js +45 -0
- package/__tests__/key-providers.test.js +126 -0
- package/__tests__/kg-derive.test.js +219 -0
- package/__tests__/llm-client.test.js +122 -0
- package/__tests__/mock-adapter.test.js +93 -0
- package/__tests__/prompt-builder.test.js +204 -0
- package/__tests__/query-parser.test.js +150 -0
- package/__tests__/rag-derive.test.js +169 -0
- package/__tests__/registry.test.js +304 -0
- package/__tests__/schemas.test.js +331 -0
- package/__tests__/vault.test.js +506 -0
- package/lib/adapter-spec.js +155 -0
- package/lib/adapters/email-imap/email-adapter.js +398 -0
- package/lib/adapters/email-imap/email-parser.js +177 -0
- package/lib/adapters/email-imap/imap-session.js +294 -0
- package/lib/adapters/email-imap/index.js +26 -0
- package/lib/adapters/email-imap/providers.js +111 -0
- package/lib/analysis.js +226 -0
- package/lib/batch.js +123 -0
- package/lib/bridges/cc-kg-sink.js +264 -0
- package/lib/bridges/cc-llm-adapter.js +169 -0
- package/lib/bridges/cc-rag-sink.js +118 -0
- package/lib/bridges/index.js +44 -0
- package/lib/constants.js +92 -0
- package/lib/ids.js +103 -0
- package/lib/index.js +141 -0
- package/lib/key-providers.js +146 -0
- package/lib/kg-derive.js +214 -0
- package/lib/llm-client.js +171 -0
- package/lib/migrations.js +246 -0
- package/lib/mock-adapter.js +199 -0
- package/lib/prompt-builder.js +205 -0
- package/lib/query-parser.js +250 -0
- package/lib/rag-derive.js +186 -0
- package/lib/registry.js +398 -0
- package/lib/schemas.js +379 -0
- package/lib/vault.js +883 -0
- package/package.json +63 -0
- package/vitest.config.js +10 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
deriveEventTriples,
|
|
7
|
+
derivePersonTriples,
|
|
8
|
+
derivePlaceTriples,
|
|
9
|
+
deriveItemTriples,
|
|
10
|
+
deriveTopicTriples,
|
|
11
|
+
deriveBatchTriples,
|
|
12
|
+
deriveEntityTriples,
|
|
13
|
+
triple,
|
|
14
|
+
} = require("../lib/kg-derive");
|
|
15
|
+
|
|
16
|
+
const { newId } = require("../lib/ids");
|
|
17
|
+
|
|
18
|
+
const sourceOk = (adapter = "test") => ({
|
|
19
|
+
adapter,
|
|
20
|
+
adapterVersion: "0.1.0",
|
|
21
|
+
capturedAt: Date.now(),
|
|
22
|
+
capturedBy: "api",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("triple()", () => {
|
|
26
|
+
it("makes an object triple with subject + predicate + object", () => {
|
|
27
|
+
const t = triple("a", "knows", { object: "b" });
|
|
28
|
+
expect(t).toEqual({ subject: "a", predicate: "knows", object: "b" });
|
|
29
|
+
});
|
|
30
|
+
it("makes a literal triple", () => {
|
|
31
|
+
const t = triple("a", "name", { literal: "Alice" });
|
|
32
|
+
expect(t).toEqual({ subject: "a", predicate: "name", literal: "Alice" });
|
|
33
|
+
});
|
|
34
|
+
it("skips object/literal when null/undefined", () => {
|
|
35
|
+
const t = triple("a", "p", {});
|
|
36
|
+
expect(t).toEqual({ subject: "a", predicate: "p" });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("deriveEventTriples", () => {
|
|
41
|
+
it("emits the minimal set: rdf:type / subtype / occurred-at / source", () => {
|
|
42
|
+
const id = newId();
|
|
43
|
+
const e = {
|
|
44
|
+
id,
|
|
45
|
+
type: "event",
|
|
46
|
+
subtype: "message",
|
|
47
|
+
occurredAt: 1700000000000,
|
|
48
|
+
ingestedAt: 1700000000001,
|
|
49
|
+
content: { text: "hi" },
|
|
50
|
+
source: sourceOk("wechat"),
|
|
51
|
+
};
|
|
52
|
+
const ts = deriveEventTriples(e);
|
|
53
|
+
expect(ts).toContainEqual({ subject: id, predicate: "rdf:type", literal: "event" });
|
|
54
|
+
expect(ts).toContainEqual({ subject: id, predicate: "subtype", literal: "message" });
|
|
55
|
+
expect(ts).toContainEqual({ subject: id, predicate: "occurred-at", literal: 1700000000000 });
|
|
56
|
+
expect(ts).toContainEqual({ subject: id, predicate: "source", literal: "wechat" });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("emits 'by' for actor and 'involves' for each participant", () => {
|
|
60
|
+
const id = newId();
|
|
61
|
+
const e = {
|
|
62
|
+
id,
|
|
63
|
+
type: "event",
|
|
64
|
+
subtype: "payment",
|
|
65
|
+
occurredAt: 1,
|
|
66
|
+
ingestedAt: 1,
|
|
67
|
+
actor: "person-self",
|
|
68
|
+
participants: ["person-self", "person-mom"],
|
|
69
|
+
content: { text: "transfer" },
|
|
70
|
+
source: sourceOk(),
|
|
71
|
+
};
|
|
72
|
+
const ts = deriveEventTriples(e);
|
|
73
|
+
expect(ts).toContainEqual({ subject: id, predicate: "by", object: "person-self" });
|
|
74
|
+
expect(ts).toContainEqual({ subject: id, predicate: "involves", object: "person-self" });
|
|
75
|
+
expect(ts).toContainEqual({ subject: id, predicate: "involves", object: "person-mom" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("emits amount components when content.amount present", () => {
|
|
79
|
+
const id = newId();
|
|
80
|
+
const e = {
|
|
81
|
+
id,
|
|
82
|
+
type: "event",
|
|
83
|
+
subtype: "order",
|
|
84
|
+
occurredAt: 1,
|
|
85
|
+
ingestedAt: 1,
|
|
86
|
+
content: { amount: { value: 288.5, currency: "CNY", direction: "out" } },
|
|
87
|
+
source: sourceOk(),
|
|
88
|
+
};
|
|
89
|
+
const ts = deriveEventTriples(e);
|
|
90
|
+
expect(ts).toContainEqual({ subject: id, predicate: "amount-value", literal: 288.5 });
|
|
91
|
+
expect(ts).toContainEqual({ subject: id, predicate: "amount-currency", literal: "CNY" });
|
|
92
|
+
expect(ts).toContainEqual({ subject: id, predicate: "amount-direction", literal: "out" });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("derivePersonTriples", () => {
|
|
97
|
+
it("emits rdf:type + subtype + every name + identifier", () => {
|
|
98
|
+
const id = newId();
|
|
99
|
+
const p = {
|
|
100
|
+
id,
|
|
101
|
+
type: "person",
|
|
102
|
+
subtype: "contact",
|
|
103
|
+
names: ["妈妈", "陈某某"],
|
|
104
|
+
identifiers: { phone: ["13800001111", "13900002222"], wechatId: "wxid_xyz" },
|
|
105
|
+
ingestedAt: 1,
|
|
106
|
+
source: sourceOk(),
|
|
107
|
+
};
|
|
108
|
+
const ts = derivePersonTriples(p);
|
|
109
|
+
expect(ts).toContainEqual({ subject: id, predicate: "rdf:type", literal: "person" });
|
|
110
|
+
expect(ts).toContainEqual({ subject: id, predicate: "subtype", literal: "contact" });
|
|
111
|
+
expect(ts).toContainEqual({ subject: id, predicate: "has-name", literal: "妈妈" });
|
|
112
|
+
expect(ts).toContainEqual({ subject: id, predicate: "has-name", literal: "陈某某" });
|
|
113
|
+
expect(ts).toContainEqual({ subject: id, predicate: "id:phone", literal: "13800001111" });
|
|
114
|
+
expect(ts).toContainEqual({ subject: id, predicate: "id:phone", literal: "13900002222" });
|
|
115
|
+
expect(ts).toContainEqual({ subject: id, predicate: "id:wechatId", literal: "wxid_xyz" });
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("derivePlaceTriples", () => {
|
|
120
|
+
it("emits has-name + has-alias (deduped) + located-at + address", () => {
|
|
121
|
+
const id = newId();
|
|
122
|
+
const pl = {
|
|
123
|
+
id,
|
|
124
|
+
type: "place",
|
|
125
|
+
name: "妈妈家",
|
|
126
|
+
aliases: ["妈妈家", "妈家", "home"],
|
|
127
|
+
coordinates: { lat: 24.5, lng: 118.1 },
|
|
128
|
+
address: "厦门思明区",
|
|
129
|
+
category: "home",
|
|
130
|
+
ingestedAt: 1,
|
|
131
|
+
source: sourceOk(),
|
|
132
|
+
};
|
|
133
|
+
const ts = derivePlaceTriples(pl);
|
|
134
|
+
expect(ts).toContainEqual({ subject: id, predicate: "has-name", literal: "妈妈家" });
|
|
135
|
+
// The primary name shouldn't duplicate as alias
|
|
136
|
+
expect(ts.filter((t) => t.predicate === "has-name").length).toBe(1);
|
|
137
|
+
expect(ts.some((t) => t.predicate === "has-alias" && t.literal === "妈家")).toBe(true);
|
|
138
|
+
expect(ts).toContainEqual({ subject: id, predicate: "located-at", literal: "24.5,118.1" });
|
|
139
|
+
expect(ts).toContainEqual({ subject: id, predicate: "address", literal: "厦门思明区" });
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("deriveItemTriples", () => {
|
|
144
|
+
it("emits price + merchant", () => {
|
|
145
|
+
const id = newId();
|
|
146
|
+
const i = {
|
|
147
|
+
id,
|
|
148
|
+
type: "item",
|
|
149
|
+
subtype: "product",
|
|
150
|
+
name: "蛋白粉",
|
|
151
|
+
price: { value: 288, currency: "CNY" },
|
|
152
|
+
merchant: "person-xy-store",
|
|
153
|
+
ingestedAt: 1,
|
|
154
|
+
source: sourceOk(),
|
|
155
|
+
};
|
|
156
|
+
const ts = deriveItemTriples(i);
|
|
157
|
+
expect(ts).toContainEqual({ subject: id, predicate: "priced-at", literal: "288 CNY" });
|
|
158
|
+
expect(ts).toContainEqual({ subject: id, predicate: "sold-by", object: "person-xy-store" });
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("deriveTopicTriples", () => {
|
|
163
|
+
it("emits parent + derived-from for each event", () => {
|
|
164
|
+
const id = newId();
|
|
165
|
+
const e1 = newId();
|
|
166
|
+
const e2 = newId();
|
|
167
|
+
const t = {
|
|
168
|
+
id,
|
|
169
|
+
type: "topic",
|
|
170
|
+
name: "母亲健康",
|
|
171
|
+
parentTopic: "topic-family",
|
|
172
|
+
derivedFromEvents: [e1, e2],
|
|
173
|
+
ingestedAt: 1,
|
|
174
|
+
source: sourceOk(),
|
|
175
|
+
};
|
|
176
|
+
const ts = deriveTopicTriples(t);
|
|
177
|
+
expect(ts).toContainEqual({ subject: id, predicate: "parent", object: "topic-family" });
|
|
178
|
+
expect(ts).toContainEqual({ subject: id, predicate: "derived-from", object: e1 });
|
|
179
|
+
expect(ts).toContainEqual({ subject: id, predicate: "derived-from", object: e2 });
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("deriveBatchTriples + deriveEntityTriples", () => {
|
|
184
|
+
it("walks all 5 entity kinds in a batch", () => {
|
|
185
|
+
const batch = {
|
|
186
|
+
events: [{ id: "e", type: "event", subtype: "message", occurredAt: 1, ingestedAt: 1, content: {}, source: sourceOk() }],
|
|
187
|
+
persons: [{ id: "p", type: "person", subtype: "contact", names: ["x"], ingestedAt: 1, source: sourceOk() }],
|
|
188
|
+
places: [{ id: "pl", type: "place", name: "home", aliases: [], ingestedAt: 1, source: sourceOk() }],
|
|
189
|
+
items: [{ id: "i", type: "item", subtype: "product", name: "thing", ingestedAt: 1, source: sourceOk() }],
|
|
190
|
+
topics: [{ id: "t", type: "topic", name: "x", ingestedAt: 1, source: sourceOk() }],
|
|
191
|
+
};
|
|
192
|
+
const ts = deriveBatchTriples(batch);
|
|
193
|
+
expect(ts.length).toBeGreaterThan(5); // at least the 5 rdf:type rows
|
|
194
|
+
expect(ts.some((x) => x.subject === "e" && x.predicate === "rdf:type")).toBe(true);
|
|
195
|
+
expect(ts.some((x) => x.subject === "p" && x.predicate === "rdf:type")).toBe(true);
|
|
196
|
+
expect(ts.some((x) => x.subject === "pl" && x.predicate === "rdf:type")).toBe(true);
|
|
197
|
+
expect(ts.some((x) => x.subject === "i" && x.predicate === "rdf:type")).toBe(true);
|
|
198
|
+
expect(ts.some((x) => x.subject === "t" && x.predicate === "rdf:type")).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("deriveEntityTriples dispatches by .type and returns [] for unknown", () => {
|
|
202
|
+
expect(deriveEntityTriples(null)).toEqual([]);
|
|
203
|
+
expect(deriveEntityTriples({ type: "frobnicate" })).toEqual([]);
|
|
204
|
+
const ts = deriveEntityTriples({
|
|
205
|
+
id: "p",
|
|
206
|
+
type: "person",
|
|
207
|
+
subtype: "contact",
|
|
208
|
+
names: ["x"],
|
|
209
|
+
ingestedAt: 1,
|
|
210
|
+
source: sourceOk(),
|
|
211
|
+
});
|
|
212
|
+
expect(ts.length).toBeGreaterThan(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("returns [] for non-object batch input", () => {
|
|
216
|
+
expect(deriveBatchTriples(null)).toEqual([]);
|
|
217
|
+
expect(deriveBatchTriples("oops")).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
|
|
5
|
+
const { MockLLMClient, OllamaClient } = require("../lib/llm-client");
|
|
6
|
+
|
|
7
|
+
// ─── MockLLMClient ────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
describe("MockLLMClient", () => {
|
|
10
|
+
it("isLocal is always true", () => {
|
|
11
|
+
expect(new MockLLMClient().isLocal).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns the configured static reply", async () => {
|
|
15
|
+
const c = new MockLLMClient({ reply: "hello [evt-1]" });
|
|
16
|
+
const r = await c.chat([{ role: "user", content: "x" }]);
|
|
17
|
+
expect(r.text).toBe("hello [evt-1]");
|
|
18
|
+
expect(r.model).toBe("mock-llm");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("records every call for assertion", async () => {
|
|
22
|
+
const c = new MockLLMClient({ reply: "ok" });
|
|
23
|
+
await c.chat([{ role: "user", content: "a" }]);
|
|
24
|
+
await c.chat([{ role: "user", content: "b" }]);
|
|
25
|
+
expect(c.calls.length).toBe(2);
|
|
26
|
+
expect(c.calls[0].messages[0].content).toBe("a");
|
|
27
|
+
expect(c.calls[1].messages[0].content).toBe("b");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("function reply gets the messages", async () => {
|
|
31
|
+
const c = new MockLLMClient({ reply: (messages) => `you said: ${messages[0].content}` });
|
|
32
|
+
const r = await c.chat([{ role: "user", content: "hi" }]);
|
|
33
|
+
expect(r.text).toBe("you said: hi");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("replies array exhausts and throws after", async () => {
|
|
37
|
+
const c = new MockLLMClient({ replies: ["a", "b"] });
|
|
38
|
+
expect((await c.chat([])).text).toBe("a");
|
|
39
|
+
expect((await c.chat([])).text).toBe("b");
|
|
40
|
+
await expect(c.chat([])).rejects.toThrow(/exhausted/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns a usage object even if it's an approximation", async () => {
|
|
44
|
+
const c = new MockLLMClient({ reply: "reply text" });
|
|
45
|
+
const r = await c.chat([{ role: "user", content: "question content" }]);
|
|
46
|
+
expect(r.usage).toBeDefined();
|
|
47
|
+
expect(typeof r.usage.promptTokens).toBe("number");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ─── OllamaClient ─────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
describe("OllamaClient", () => {
|
|
54
|
+
it("declares isLocal = true unconditionally", () => {
|
|
55
|
+
const c = new OllamaClient({ fetch: async () => ({ ok: true }) });
|
|
56
|
+
expect(c.isLocal).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("uses sensible defaults (baseUrl + model)", () => {
|
|
60
|
+
const c = new OllamaClient({ fetch: async () => ({}) });
|
|
61
|
+
expect(c.baseUrl).toBe("http://localhost:11434");
|
|
62
|
+
expect(c.model).toContain("qwen2.5");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("posts to /api/chat with the configured model + messages", async () => {
|
|
66
|
+
let captured = null;
|
|
67
|
+
const fakeFetch = async (url, init) => {
|
|
68
|
+
captured = { url, body: JSON.parse(init.body) };
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
status: 200,
|
|
72
|
+
json: async () => ({
|
|
73
|
+
message: { role: "assistant", content: "hi" },
|
|
74
|
+
prompt_eval_count: 12,
|
|
75
|
+
eval_count: 7,
|
|
76
|
+
}),
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
const c = new OllamaClient({ fetch: fakeFetch, model: "llama3:8b" });
|
|
80
|
+
const r = await c.chat([{ role: "user", content: "ping" }]);
|
|
81
|
+
expect(captured.url).toBe("http://localhost:11434/api/chat");
|
|
82
|
+
expect(captured.body.model).toBe("llama3:8b");
|
|
83
|
+
expect(captured.body.stream).toBe(false);
|
|
84
|
+
expect(captured.body.messages[0].content).toBe("ping");
|
|
85
|
+
expect(r.text).toBe("hi");
|
|
86
|
+
expect(r.usage.promptTokens).toBe(12);
|
|
87
|
+
expect(r.usage.completionTokens).toBe(7);
|
|
88
|
+
expect(r.usage.totalTokens).toBe(19);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("wraps fetch errors with cause preserved", async () => {
|
|
92
|
+
const fakeFetch = async () => { throw new Error("ECONNREFUSED"); };
|
|
93
|
+
const c = new OllamaClient({ fetch: fakeFetch });
|
|
94
|
+
await expect(c.chat([{ role: "user", content: "x" }])).rejects.toThrow(/request failed/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("throws on non-OK status with body excerpt", async () => {
|
|
98
|
+
const fakeFetch = async () => ({
|
|
99
|
+
ok: false,
|
|
100
|
+
status: 500,
|
|
101
|
+
statusText: "Internal Server Error",
|
|
102
|
+
text: async () => "internal model crashed",
|
|
103
|
+
});
|
|
104
|
+
const c = new OllamaClient({ fetch: fakeFetch });
|
|
105
|
+
await expect(c.chat([{ role: "user", content: "x" }])).rejects.toThrow(/500/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("health() returns ok when /api/tags responds 200", async () => {
|
|
109
|
+
const fakeFetch = async () => ({ ok: true, status: 200 });
|
|
110
|
+
const c = new OllamaClient({ fetch: fakeFetch });
|
|
111
|
+
const h = await c.health();
|
|
112
|
+
expect(h.ok).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("health() returns ok=false on error", async () => {
|
|
116
|
+
const fakeFetch = async () => { throw new Error("down"); };
|
|
117
|
+
const c = new OllamaClient({ fetch: fakeFetch });
|
|
118
|
+
const h = await c.health();
|
|
119
|
+
expect(h.ok).toBe(false);
|
|
120
|
+
expect(h.error).toContain("down");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
|
|
5
|
+
const { MockAdapter } = require("../lib/mock-adapter");
|
|
6
|
+
const { validate } = require("../lib/schemas");
|
|
7
|
+
const { assertAdapter } = require("../lib/adapter-spec");
|
|
8
|
+
|
|
9
|
+
describe("MockAdapter", () => {
|
|
10
|
+
it("satisfies the PersonalDataAdapter contract", () => {
|
|
11
|
+
expect(assertAdapter(new MockAdapter()).ok).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("yields exactly `count` raw events", async () => {
|
|
15
|
+
const a = new MockAdapter({ count: 7 });
|
|
16
|
+
const raws = [];
|
|
17
|
+
for await (const r of a.sync()) raws.push(r);
|
|
18
|
+
expect(raws.length).toBe(7);
|
|
19
|
+
expect(raws[0].adapter).toBe("mock");
|
|
20
|
+
expect(raws[0].originalId).toMatch(/^mock-raw-/);
|
|
21
|
+
expect(raws[0].capturedAt).toBeGreaterThan(1_000_000_000_000);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("respects sinceWatermark by skipping seen items", async () => {
|
|
25
|
+
const a = new MockAdapter({ count: 10 });
|
|
26
|
+
const skipped = [];
|
|
27
|
+
for await (const r of a.sync({ sinceWatermark: 4 })) skipped.push(r);
|
|
28
|
+
expect(skipped.length).toBe(6); // 10 - 4
|
|
29
|
+
expect(skipped[0].payload.index).toBe(4);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("respects maxEvents", async () => {
|
|
33
|
+
const a = new MockAdapter({ count: 50 });
|
|
34
|
+
const got = [];
|
|
35
|
+
for await (const r of a.sync({ maxEvents: 5 })) got.push(r);
|
|
36
|
+
expect(got.length).toBe(5);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("normalize produces valid UnifiedSchema entities (v0, v1, v2 variants)", async () => {
|
|
40
|
+
const a = new MockAdapter({ count: 6, seed: 42 });
|
|
41
|
+
for await (const r of a.sync()) {
|
|
42
|
+
const batch = a.normalize(r);
|
|
43
|
+
expect(batch.events.length).toBeGreaterThanOrEqual(1);
|
|
44
|
+
for (const e of batch.events) expect(validate(e).valid).toBe(true);
|
|
45
|
+
for (const p of batch.persons) expect(validate(p).valid).toBe(true);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("healthCheck honors shouldFailHealth flag", async () => {
|
|
50
|
+
const a = new MockAdapter();
|
|
51
|
+
expect((await a.healthCheck()).ok).toBe(true);
|
|
52
|
+
a.shouldFailHealth = true;
|
|
53
|
+
const r = await a.healthCheck();
|
|
54
|
+
expect(r.ok).toBe(false);
|
|
55
|
+
expect(r.reason).toContain("unhealthy");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("failAfter throws mid-sync", async () => {
|
|
59
|
+
const a = new MockAdapter({ count: 10 });
|
|
60
|
+
a.failAfter = 3;
|
|
61
|
+
const got = [];
|
|
62
|
+
await expect(async () => {
|
|
63
|
+
for await (const r of a.sync()) got.push(r);
|
|
64
|
+
}).rejects.toThrow(/induced sync failure/);
|
|
65
|
+
expect(got.length).toBe(3); // 3 yielded before throw
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("normalizeShouldThrowAt fires on the right call", async () => {
|
|
69
|
+
const a = new MockAdapter({ count: 5 });
|
|
70
|
+
a.normalizeShouldThrowAt(2);
|
|
71
|
+
let callCount = 0;
|
|
72
|
+
for await (const r of a.sync()) {
|
|
73
|
+
callCount += 1;
|
|
74
|
+
if (callCount < 3) {
|
|
75
|
+
expect(() => a.normalize(r)).not.toThrow();
|
|
76
|
+
} else if (callCount === 3) {
|
|
77
|
+
expect(() => a.normalize(r)).toThrow(/induced normalize/);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("same seed produces same payloads (deterministic)", async () => {
|
|
83
|
+
const collect = async () => {
|
|
84
|
+
const a = new MockAdapter({ count: 5, seed: 99 });
|
|
85
|
+
const out = [];
|
|
86
|
+
for await (const r of a.sync()) out.push(r.payload);
|
|
87
|
+
return out;
|
|
88
|
+
};
|
|
89
|
+
const a = await collect();
|
|
90
|
+
const b = await collect();
|
|
91
|
+
expect(a).toEqual(b);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
DEFAULT_SYSTEM_PROMPT,
|
|
7
|
+
buildPrompt,
|
|
8
|
+
summarizeFact,
|
|
9
|
+
summarizeEvent,
|
|
10
|
+
summarizePerson,
|
|
11
|
+
parseCitations,
|
|
12
|
+
validateCitations,
|
|
13
|
+
} = require("../lib/prompt-builder");
|
|
14
|
+
|
|
15
|
+
// ─── summarize ────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
describe("summarizeFact", () => {
|
|
18
|
+
it("strips raw extra from events; keeps content + source.adapter", () => {
|
|
19
|
+
const e = {
|
|
20
|
+
id: "evt-1",
|
|
21
|
+
type: "event",
|
|
22
|
+
subtype: "order",
|
|
23
|
+
occurredAt: 1700000000000,
|
|
24
|
+
ingestedAt: 1700000000001,
|
|
25
|
+
content: {
|
|
26
|
+
title: "spam",
|
|
27
|
+
text: "delivery",
|
|
28
|
+
amount: { value: 100, currency: "CNY", direction: "out" },
|
|
29
|
+
},
|
|
30
|
+
source: { adapter: "taobao", adapterVersion: "0.1.0", capturedAt: 1, capturedBy: "api" },
|
|
31
|
+
extra: { secret: "hidden", tracking: "abc" },
|
|
32
|
+
};
|
|
33
|
+
const s = summarizeFact(e);
|
|
34
|
+
expect(s.id).toBe("evt-1");
|
|
35
|
+
expect(s.title).toBe("spam");
|
|
36
|
+
expect(s.amount).toEqual({ value: 100, currency: "CNY", dir: "out" });
|
|
37
|
+
expect(s.source).toBe("taobao");
|
|
38
|
+
expect(s).not.toHaveProperty("extra");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("packs person names + relation; omits source/identifiers", () => {
|
|
42
|
+
const p = summarizePerson({
|
|
43
|
+
id: "p1",
|
|
44
|
+
type: "person",
|
|
45
|
+
subtype: "contact",
|
|
46
|
+
names: ["妈妈", "陈某某"],
|
|
47
|
+
relation: "母亲",
|
|
48
|
+
identifiers: { phone: ["13800001111"] },
|
|
49
|
+
ingestedAt: 1,
|
|
50
|
+
source: { adapter: "x", adapterVersion: "0.1.0", capturedAt: 1, capturedBy: "api" },
|
|
51
|
+
});
|
|
52
|
+
expect(p.names).toEqual(["妈妈", "陈某某"]);
|
|
53
|
+
expect(p.relation).toBe("母亲");
|
|
54
|
+
expect(p).not.toHaveProperty("identifiers");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns null for non-object / unknown types just yields minimal shape", () => {
|
|
58
|
+
expect(summarizeFact(null)).toBeNull();
|
|
59
|
+
expect(summarizeFact({ id: "x", type: "unknown" })).toEqual({ id: "x", type: "unknown" });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ─── buildPrompt ──────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
describe("buildPrompt", () => {
|
|
66
|
+
const facts = [
|
|
67
|
+
{
|
|
68
|
+
id: "evt-1",
|
|
69
|
+
type: "event",
|
|
70
|
+
subtype: "order",
|
|
71
|
+
occurredAt: 1700000000000,
|
|
72
|
+
ingestedAt: 1,
|
|
73
|
+
content: { title: "蛋白粉", amount: { value: 288.5, currency: "CNY", direction: "out" } },
|
|
74
|
+
source: { adapter: "taobao", adapterVersion: "0.1.0", capturedAt: 1, capturedBy: "api" },
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "evt-2",
|
|
78
|
+
type: "event",
|
|
79
|
+
subtype: "order",
|
|
80
|
+
occurredAt: 1700000060000,
|
|
81
|
+
ingestedAt: 1,
|
|
82
|
+
content: { title: "按摩仪", amount: { value: 459, currency: "CNY", direction: "out" } },
|
|
83
|
+
source: { adapter: "taobao", adapterVersion: "0.1.0", capturedAt: 1, capturedBy: "api" },
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
it("returns system + user messages and factIds set", () => {
|
|
88
|
+
const { messages, factIds, factCount, truncated } = buildPrompt({
|
|
89
|
+
question: "上个月在淘宝总共花了多少?",
|
|
90
|
+
facts,
|
|
91
|
+
});
|
|
92
|
+
expect(messages.length).toBe(2);
|
|
93
|
+
expect(messages[0].role).toBe("system");
|
|
94
|
+
expect(messages[1].role).toBe("user");
|
|
95
|
+
expect(messages[1].content).toContain("上个月在淘宝总共花了多少");
|
|
96
|
+
expect(messages[1].content).toContain("evt-1");
|
|
97
|
+
expect(messages[1].content).toContain("evt-2");
|
|
98
|
+
expect(factIds.has("evt-1")).toBe(true);
|
|
99
|
+
expect(factIds.has("evt-2")).toBe(true);
|
|
100
|
+
expect(factCount).toBe(2);
|
|
101
|
+
expect(truncated).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("packs question into user role (not system)", () => {
|
|
105
|
+
const { messages } = buildPrompt({ question: "test", facts });
|
|
106
|
+
expect(messages[0].content).not.toContain("test"); // system stays untrusted-free
|
|
107
|
+
expect(messages[1].content).toContain("test");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("uses default system prompt unless overridden", () => {
|
|
111
|
+
const { messages } = buildPrompt({ question: "x", facts: [] });
|
|
112
|
+
expect(messages[0].content).toBe(DEFAULT_SYSTEM_PROMPT);
|
|
113
|
+
|
|
114
|
+
const { messages: m2 } = buildPrompt({
|
|
115
|
+
question: "x",
|
|
116
|
+
facts: [],
|
|
117
|
+
systemPrompt: "custom",
|
|
118
|
+
});
|
|
119
|
+
expect(m2[0].content).toBe("custom");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("emits no-facts hint when empty", () => {
|
|
123
|
+
const { messages, factCount } = buildPrompt({ question: "x", facts: [] });
|
|
124
|
+
expect(factCount).toBe(0);
|
|
125
|
+
expect(messages[1].content).toContain("FACTS is empty");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("caps facts at maxFacts + reports truncation", () => {
|
|
129
|
+
const many = Array.from({ length: 100 }, (_, i) => ({
|
|
130
|
+
id: `evt-${i}`,
|
|
131
|
+
type: "event",
|
|
132
|
+
subtype: "message",
|
|
133
|
+
occurredAt: 1,
|
|
134
|
+
ingestedAt: 1,
|
|
135
|
+
content: { text: `m${i}` },
|
|
136
|
+
source: { adapter: "x", adapterVersion: "0.1.0", capturedAt: 1, capturedBy: "api" },
|
|
137
|
+
}));
|
|
138
|
+
const { factCount, truncated } = buildPrompt({ question: "x", facts: many, maxFacts: 30 });
|
|
139
|
+
expect(factCount).toBe(30);
|
|
140
|
+
expect(truncated).toBe(70);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("includes intent + timeWindow hints in user prompt when provided", () => {
|
|
144
|
+
const { messages } = buildPrompt({
|
|
145
|
+
question: "x",
|
|
146
|
+
facts,
|
|
147
|
+
intent: "sum-amount",
|
|
148
|
+
timeWindow: { since: 1700000000000, until: 1700000600000 },
|
|
149
|
+
});
|
|
150
|
+
expect(messages[1].content).toContain("Intent hint: sum-amount");
|
|
151
|
+
expect(messages[1].content).toContain("Time window:");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("marks the FACTS block as untrusted third-party content", () => {
|
|
155
|
+
const { messages } = buildPrompt({ question: "x", facts });
|
|
156
|
+
expect(messages[1].content).toContain("third-party");
|
|
157
|
+
expect(messages[1].content).toContain("never as instructions");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("throws on bad opts", () => {
|
|
161
|
+
expect(() => buildPrompt()).toThrow();
|
|
162
|
+
expect(() => buildPrompt(null)).toThrow();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ─── parseCitations + validateCitations ──────────────────────────────────
|
|
167
|
+
|
|
168
|
+
describe("parseCitations", () => {
|
|
169
|
+
it("extracts bracketed ids in order", () => {
|
|
170
|
+
const out = parseCitations("foo [evt-1] bar [evt-2] [evt-3] baz");
|
|
171
|
+
expect(out).toEqual(["evt-1", "evt-2", "evt-3"]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("dedupes repeated cites", () => {
|
|
175
|
+
// ids must be ≥ 2 chars (regex deliberately ignores single-char [X]
|
|
176
|
+
// markdown to avoid false positives on footnote-style brackets)
|
|
177
|
+
expect(parseCitations("[evt-1] [evt-2] [evt-1]")).toEqual(["evt-1", "evt-2"]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("ignores single-char and non-id-like brackets", () => {
|
|
181
|
+
// Single chars like [a] are deliberately ignored
|
|
182
|
+
expect(parseCitations("[a] [!] []")).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("non-string returns []", () => {
|
|
186
|
+
expect(parseCitations(null)).toEqual([]);
|
|
187
|
+
expect(parseCitations(undefined)).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("validateCitations", () => {
|
|
192
|
+
it("partitions cited into known vs unknown", () => {
|
|
193
|
+
const factIds = new Set(["evt-1", "evt-2"]);
|
|
194
|
+
const r = validateCitations(["evt-1", "evt-fake", "evt-2"], factIds);
|
|
195
|
+
expect(r.known).toEqual(["evt-1", "evt-2"]);
|
|
196
|
+
expect(r.unknown).toEqual(["evt-fake"]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("accepts array factIds too", () => {
|
|
200
|
+
const r = validateCitations(["a"], ["a", "b"]);
|
|
201
|
+
expect(r.known).toEqual(["a"]);
|
|
202
|
+
expect(r.unknown).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
});
|