@chainlesschain/personal-data-hub 0.4.28 → 0.4.30

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.
Files changed (204) hide show
  1. package/README.md +13 -5
  2. package/lib/adapters/social-douyin-adb/usage-profile-reader.js +253 -0
  3. package/lib/adapters/social-douyin-adb/watch-history-reader.js +104 -31
  4. package/lib/adapters/social-toutiao-adb/article-reader.js +202 -0
  5. package/lib/analysis-skills/overview.js +24 -4
  6. package/lib/analysis-skills/spending.js +63 -2
  7. package/lib/analysis-skills/timeline.js +11 -6
  8. package/lib/prompt-builder.js +15 -1
  9. package/lib/query-parser.js +38 -8
  10. package/package.json +4 -1
  11. package/__tests__/adapter-guide.test.js +0 -47
  12. package/__tests__/adapter-spec.test.js +0 -78
  13. package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +0 -211
  14. package/__tests__/adapters/ai-chat-health-checker.test.js +0 -262
  15. package/__tests__/adapters/ai-chat-history.test.js +0 -396
  16. package/__tests__/adapters/ai-chat-http-client.test.js +0 -242
  17. package/__tests__/adapters/ai-chat-vendors.test.js +0 -874
  18. package/__tests__/adapters/alipay-bill-adapter.test.js +0 -538
  19. package/__tests__/adapters/apple-health.test.js +0 -95
  20. package/__tests__/adapters/bank-family.test.js +0 -125
  21. package/__tests__/adapters/biz-tianyancha.test.js +0 -159
  22. package/__tests__/adapters/browser-history-chrome.test.js +0 -377
  23. package/__tests__/adapters/browser-history-edge.test.js +0 -159
  24. package/__tests__/adapters/car-mercedesme.test.js +0 -74
  25. package/__tests__/adapters/doc-baidu-netdisk.test.js +0 -102
  26. package/__tests__/adapters/doc-camscanner.test.js +0 -147
  27. package/__tests__/adapters/doc-platforms.test.js +0 -177
  28. package/__tests__/adapters/edu-huawei-learning-live.test.js +0 -198
  29. package/__tests__/adapters/edu-zuoyebang-live.test.js +0 -226
  30. package/__tests__/adapters/email-adapter-snapshot.test.js +0 -237
  31. package/__tests__/adapters/email-adapter.test.js +0 -742
  32. package/__tests__/adapters/email-classifier.test.js +0 -347
  33. package/__tests__/adapters/email-imap-session.test.js +0 -334
  34. package/__tests__/adapters/email-parser.test.js +0 -244
  35. package/__tests__/adapters/email-pdf-extractor.test.js +0 -529
  36. package/__tests__/adapters/email-providers.test.js +0 -84
  37. package/__tests__/adapters/email-retry-progress.test.js +0 -294
  38. package/__tests__/adapters/email-templates.test.js +0 -822
  39. package/__tests__/adapters/family-23-collectors-scaffold.test.js +0 -182
  40. package/__tests__/adapters/finance-alipay-live.test.js +0 -258
  41. package/__tests__/adapters/finance-dcep.test.js +0 -74
  42. package/__tests__/adapters/fitness-joyrun.test.js +0 -82
  43. package/__tests__/adapters/game-genshin-live.test.js +0 -238
  44. package/__tests__/adapters/game-genshin-scaffold.test.js +0 -108
  45. package/__tests__/adapters/game-honor-of-kings-live.test.js +0 -230
  46. package/__tests__/adapters/git-activity.test.js +0 -222
  47. package/__tests__/adapters/gov-12123.test.js +0 -103
  48. package/__tests__/adapters/gov-ixiamen.test.js +0 -150
  49. package/__tests__/adapters/gov-tax.test.js +0 -135
  50. package/__tests__/adapters/health-meiyou.test.js +0 -125
  51. package/__tests__/adapters/local-files.test.js +0 -264
  52. package/__tests__/adapters/local-im-pc.test.js +0 -154
  53. package/__tests__/adapters/messaging-whatsapp.test.js +0 -289
  54. package/__tests__/adapters/music-kugou.test.js +0 -187
  55. package/__tests__/adapters/music-qq.test.js +0 -112
  56. package/__tests__/adapters/netease-music-live.test.js +0 -244
  57. package/__tests__/adapters/netease-music.test.js +0 -74
  58. package/__tests__/adapters/pc-local-discovery.test.js +0 -141
  59. package/__tests__/adapters/qq-pc-direct-read.test.js +0 -227
  60. package/__tests__/adapters/reading-family.test.js +0 -108
  61. package/__tests__/adapters/recruit-boss.test.js +0 -180
  62. package/__tests__/adapters/shell-history.test.js +0 -180
  63. package/__tests__/adapters/shopping-base.test.js +0 -179
  64. package/__tests__/adapters/shopping-dianping.test.js +0 -239
  65. package/__tests__/adapters/social-bilibili-adb-api-client.test.js +0 -721
  66. package/__tests__/adapters/social-bilibili-adb-chromium-cookies-reader.test.js +0 -346
  67. package/__tests__/adapters/social-bilibili-adb-collector.test.js +0 -284
  68. package/__tests__/adapters/social-bilibili-adb-cookies-extension.test.js +0 -343
  69. package/__tests__/adapters/social-bilibili-adb-snapshot-builder.test.js +0 -296
  70. package/__tests__/adapters/social-csdn.test.js +0 -175
  71. package/__tests__/adapters/social-dongchedi.test.js +0 -165
  72. package/__tests__/adapters/social-douyin-adb-aweme-detail.test.js +0 -165
  73. package/__tests__/adapters/social-douyin-adb-collector.test.js +0 -254
  74. package/__tests__/adapters/social-douyin-adb-db-extension.test.js +0 -114
  75. package/__tests__/adapters/social-douyin-adb-im-db-parser.test.js +0 -304
  76. package/__tests__/adapters/social-douyin-adb-snapshot-builder.test.js +0 -216
  77. package/__tests__/adapters/social-douyin-adb-watch-history.test.js +0 -192
  78. package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +0 -496
  79. package/__tests__/adapters/social-kuaishou-adb-collector.test.js +0 -276
  80. package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +0 -152
  81. package/__tests__/adapters/social-kuaishou-adb-snapshot-builder.test.js +0 -178
  82. package/__tests__/adapters/social-toutiao-adb-account-reader.test.js +0 -135
  83. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +0 -626
  84. package/__tests__/adapters/social-toutiao-adb-collector.test.js +0 -378
  85. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +0 -193
  86. package/__tests__/adapters/social-toutiao-adb-snapshot-builder.test.js +0 -196
  87. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +0 -311
  88. package/__tests__/adapters/social-weibo-adb-api-client.test.js +0 -362
  89. package/__tests__/adapters/social-weibo-adb-collector.test.js +0 -201
  90. package/__tests__/adapters/social-weibo-adb-cookies-extension.test.js +0 -167
  91. package/__tests__/adapters/social-weibo-adb-snapshot-builder.test.js +0 -189
  92. package/__tests__/adapters/social-xiaohongshu-adb-api-client.test.js +0 -431
  93. package/__tests__/adapters/social-xiaohongshu-adb-collector.test.js +0 -207
  94. package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
  95. package/__tests__/adapters/social-xiaohongshu-adb-sign-provider-injection.test.js +0 -351
  96. package/__tests__/adapters/social-xiaohongshu-adb-sign.test.js +0 -130
  97. package/__tests__/adapters/social-xiaohongshu-adb-snapshot-builder.test.js +0 -200
  98. package/__tests__/adapters/social-zhihu.test.js +0 -246
  99. package/__tests__/adapters/system-data-adapter.test.js +0 -443
  100. package/__tests__/adapters/system-data-android-ingest.test.js +0 -144
  101. package/__tests__/adapters/system-data-android.test.js +0 -519
  102. package/__tests__/adapters/system-data-disclosure.test.js +0 -153
  103. package/__tests__/adapters/travel-12306.test.js +0 -512
  104. package/__tests__/adapters/travel-amap.test.js +0 -219
  105. package/__tests__/adapters/travel-baidu-map.test.js +0 -305
  106. package/__tests__/adapters/travel-base.test.js +0 -205
  107. package/__tests__/adapters/travel-ctrip.test.js +0 -377
  108. package/__tests__/adapters/travel-didi-consumer.test.js +0 -66
  109. package/__tests__/adapters/travel-didi.test.js +0 -204
  110. package/__tests__/adapters/travel-tencent-map.test.js +0 -207
  111. package/__tests__/adapters/travel-tongcheng.test.js +0 -289
  112. package/__tests__/adapters/video-platforms.test.js +0 -152
  113. package/__tests__/adapters/video-xigua.test.js +0 -106
  114. package/__tests__/adapters/vscode.test.js +0 -299
  115. package/__tests__/adapters/wechat-bootstrap.test.js +0 -240
  116. package/__tests__/adapters/wechat-env-probe.test.js +0 -162
  117. package/__tests__/adapters/wechat-frida-agent.test.js +0 -322
  118. package/__tests__/adapters/wechat-frida-integration.test.js +0 -149
  119. package/__tests__/adapters/wechat-frida-key-provider.test.js +0 -188
  120. package/__tests__/adapters/wechat-md5-key-provider.test.js +0 -101
  121. package/__tests__/adapters/wechat-pc-direct-read.test.js +0 -365
  122. package/__tests__/adapters/wechat-pc-group-topic.test.js +0 -63
  123. package/__tests__/adapters/wechat-pc-v4-sidecar.test.js +0 -72
  124. package/__tests__/adapters/weread.test.js +0 -123
  125. package/__tests__/adapters/wework-pc.test.js +0 -124
  126. package/__tests__/adapters/win-recent.test.js +0 -192
  127. package/__tests__/analysis-skills.test.js +0 -679
  128. package/__tests__/analysis.test.js +0 -1845
  129. package/__tests__/audio-ximalaya-snapshot.test.js +0 -279
  130. package/__tests__/batch.test.js +0 -133
  131. package/__tests__/bridges-cc-kg.test.js +0 -231
  132. package/__tests__/bridges-cc-llm.test.js +0 -191
  133. package/__tests__/bridges-cc-rag.test.js +0 -162
  134. package/__tests__/categories.test.js +0 -92
  135. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +0 -213
  136. package/__tests__/e2e/full-user-journey.test.js +0 -188
  137. package/__tests__/e2e/local-data-adapters-cli.e2e.test.js +0 -146
  138. package/__tests__/entity-resolver-ingest-hook.test.js +0 -177
  139. package/__tests__/entity-resolver-stages.test.js +0 -411
  140. package/__tests__/entity-resolver-vault.test.js +0 -249
  141. package/__tests__/entity-resolver.test.js +0 -526
  142. package/__tests__/fitness-keep-snapshot.test.js +0 -224
  143. package/__tests__/fixtures/entity-resolver-200-mock.json +0 -96
  144. package/__tests__/ids.test.js +0 -45
  145. package/__tests__/integration/ai-chat-history-registry.test.js +0 -228
  146. package/__tests__/integration/aichat-wizard-end-to-end.test.js +0 -282
  147. package/__tests__/integration/cross-adapter-pipelines.test.js +0 -396
  148. package/__tests__/integration/local-data-adapters-pipeline.test.js +0 -373
  149. package/__tests__/integration/social-bilibili-pipeline.test.js +0 -261
  150. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +0 -390
  151. package/__tests__/key-providers.test.js +0 -126
  152. package/__tests__/kg-derive.test.js +0 -219
  153. package/__tests__/llm-client.test.js +0 -122
  154. package/__tests__/longtail-adapters.test.js +0 -281
  155. package/__tests__/messaging-qq-snapshot.test.js +0 -294
  156. package/__tests__/mobile-extractor-encrypted.test.js +0 -460
  157. package/__tests__/mobile-extractor.test.js +0 -288
  158. package/__tests__/mock-adapter.test.js +0 -93
  159. package/__tests__/prompt-builder.test.js +0 -249
  160. package/__tests__/query-parser.test.js +0 -302
  161. package/__tests__/rag-derive.test.js +0 -169
  162. package/__tests__/registry-readiness.test.js +0 -292
  163. package/__tests__/registry.test.js +0 -420
  164. package/__tests__/salvage-ingest.test.js +0 -97
  165. package/__tests__/schemas.test.js +0 -331
  166. package/__tests__/shopping-adapters.test.js +0 -392
  167. package/__tests__/shopping-eleme-snapshot.test.js +0 -454
  168. package/__tests__/shopping-pinduoduo-snapshot.test.js +0 -484
  169. package/__tests__/shopping-snapshot.test.js +0 -438
  170. package/__tests__/shopping-vipshop-snapshot.test.js +0 -425
  171. package/__tests__/shopping-xianyu-snapshot.test.js +0 -451
  172. package/__tests__/sidecar-contacts-cross-validate.test.js +0 -186
  173. package/__tests__/sidecar-supervisor.test.js +0 -128
  174. package/__tests__/sign-providers.test.js +0 -62
  175. package/__tests__/social-adapters.test.js +0 -280
  176. package/__tests__/social-bilibili-snapshot.test.js +0 -278
  177. package/__tests__/social-douban-snapshot.test.js +0 -351
  178. package/__tests__/social-douyin-im-direct-read.test.js +0 -377
  179. package/__tests__/social-douyin-salvage-collector.test.js +0 -98
  180. package/__tests__/social-douyin-salvage-mapper.test.js +0 -90
  181. package/__tests__/social-douyin-snapshot.test.js +0 -256
  182. package/__tests__/social-kuaishou-snapshot.test.js +0 -362
  183. package/__tests__/social-toutiao-snapshot.test.js +0 -366
  184. package/__tests__/social-weibo-snapshot.test.js +0 -234
  185. package/__tests__/social-weibo-sqlite-device.test.js +0 -174
  186. package/__tests__/social-xiaohongshu-snapshot.test.js +0 -232
  187. package/__tests__/sqlite-leaf-salvage.test.js +0 -97
  188. package/__tests__/travel-adapters.test.js +0 -483
  189. package/__tests__/travel-maps-snapshot.test.js +0 -426
  190. package/__tests__/vault-driver-error.test.js +0 -74
  191. package/__tests__/vault-search-helpers.test.js +0 -104
  192. package/__tests__/vault-search.test.js +0 -423
  193. package/__tests__/vault.test.js +0 -767
  194. package/__tests__/wechat-adapter.test.js +0 -594
  195. package/__tests__/whatsapp-adapter.test.js +0 -138
  196. package/scripts/_make-fixture-all.js +0 -126
  197. package/scripts/_make-fixture-contacts.js +0 -84
  198. package/scripts/evaluate-entity-resolver.js +0 -213
  199. package/scripts/run-native-tests-sandbox.sh +0 -55
  200. package/scripts/smoke-phase-5-5.js +0 -196
  201. package/scripts/smoke-phase-5-7.js +0 -181
  202. package/scripts/smoke-system-data-contacts.js +0 -309
  203. package/scripts/smoke-system-data.js +0 -312
  204. package/vitest.config.js +0 -88
@@ -1,1845 +0,0 @@
1
- "use strict";
2
-
3
- import { describe, it, expect, afterEach, vi } from "vitest";
4
-
5
- const fs = require("node:fs");
6
- const os = require("node:os");
7
- const path = require("node:path");
8
-
9
- const { LocalVault } = require("../lib/vault");
10
- const { generateKeyHex } = require("../lib/key-providers");
11
- const { newId } = require("../lib/ids");
12
- const { AnalysisEngine } = require("../lib/analysis");
13
- const { MockLLMClient } = require("../lib/llm-client");
14
-
15
- // ─── Scaffolding ─────────────────────────────────────────────────────────
16
-
17
- let tmpDir;
18
- let vault;
19
-
20
- function freshVault() {
21
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdh-ana-"));
22
- vault = new LocalVault({
23
- path: path.join(tmpDir, "vault.db"),
24
- key: generateKeyHex(),
25
- skipAudit: true,
26
- });
27
- vault.open();
28
- }
29
-
30
- afterEach(() => {
31
- if (vault) { try { vault.close(); } catch (_e) {} vault = null; }
32
- if (tmpDir && fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
33
- });
34
-
35
- const ts = (year, month0, day, hour = 12) =>
36
- new Date(year, month0, day, hour, 0, 0, 0).getTime();
37
-
38
- const source = (adapter = "taobao", originalId) => ({
39
- adapter,
40
- adapterVersion: "0.1.0",
41
- capturedAt: ts(2026, 3, 15),
42
- capturedBy: "api",
43
- ...(originalId ? { originalId } : {}),
44
- });
45
-
46
- function seedOrders(vault) {
47
- // 3 April-2026 orders to mom, 1 in May-2026 to self.
48
- const e1 = {
49
- id: newId(),
50
- type: "event",
51
- subtype: "order",
52
- occurredAt: ts(2026, 3, 9),
53
- actor: "person-self",
54
- participants: ["person-self", "person-mom"],
55
- content: {
56
- title: "蛋白粉 给妈妈",
57
- amount: { value: 288.5, currency: "CNY", direction: "out" },
58
- },
59
- ingestedAt: Date.now(),
60
- source: source("taobao", "ord-1"),
61
- };
62
- const e2 = {
63
- id: newId(),
64
- type: "event",
65
- subtype: "order",
66
- occurredAt: ts(2026, 3, 12),
67
- actor: "person-self",
68
- content: {
69
- title: "按摩仪 给妈妈",
70
- amount: { value: 459, currency: "CNY", direction: "out" },
71
- },
72
- ingestedAt: Date.now(),
73
- source: source("taobao", "ord-2"),
74
- };
75
- const e3 = {
76
- id: newId(),
77
- type: "event",
78
- subtype: "order",
79
- occurredAt: ts(2026, 3, 12, 10),
80
- actor: "person-self",
81
- content: {
82
- title: "鲜花 给妈妈生日",
83
- amount: { value: 199, currency: "CNY", direction: "out" },
84
- },
85
- ingestedAt: Date.now(),
86
- source: source("taobao", "ord-3"),
87
- };
88
- const e4 = {
89
- id: newId(),
90
- type: "event",
91
- subtype: "order",
92
- occurredAt: ts(2026, 4, 5), // May (out of window for "上个月" if now=mid-May)
93
- actor: "person-self",
94
- content: {
95
- title: "电子产品",
96
- amount: { value: 1599, currency: "CNY", direction: "out" },
97
- },
98
- ingestedAt: Date.now(),
99
- source: source("taobao", "ord-4"),
100
- };
101
- vault.putBatch({ events: [e1, e2, e3, e4], persons: [], places: [], items: [], topics: [] });
102
- return [e1, e2, e3, e4];
103
- }
104
-
105
- const NOW = new Date("2026-05-19T12:00:00Z").getTime();
106
-
107
- // ─── Construction ────────────────────────────────────────────────────────
108
-
109
- describe("AnalysisEngine construction", () => {
110
- it("requires vault + llm + llm.isLocal", () => {
111
- expect(() => new AnalysisEngine({})).toThrow(/vault/);
112
- expect(() => new AnalysisEngine({ vault: {} })).toThrow(/llm/);
113
- expect(() => new AnalysisEngine({ vault: {}, llm: {} })).toThrow(/chat/);
114
- expect(() => new AnalysisEngine({
115
- vault: {},
116
- llm: { chat: () => {} },
117
- })).toThrow(/isLocal/);
118
- });
119
-
120
- it("constructs cleanly with mock LLM", () => {
121
- freshVault();
122
- const llm = new MockLLMClient({ reply: "" });
123
- const e = new AnalysisEngine({ vault, llm });
124
- expect(e.maxFacts).toBe(80);
125
- });
126
- });
127
-
128
- // ─── Privacy gate ────────────────────────────────────────────────────────
129
-
130
- describe("AnalysisEngine privacy gate", () => {
131
- it("refuses non-local LLM without acceptNonLocal opt-in", async () => {
132
- freshVault();
133
- const llm = new MockLLMClient({ reply: "" });
134
- llm.isLocal = false; // simulate cloud
135
- const e = new AnalysisEngine({ vault, llm });
136
- await expect(e.ask("hello")).rejects.toThrow(/non-local/);
137
- // Explicit opt-in unlocks
138
- await expect(e.ask("hello", { acceptNonLocal: true })).resolves.toBeDefined();
139
- });
140
- });
141
-
142
- // ─── E2E: 5 typical questions from architecture-doc §8.1 / §15.1 ────────
143
-
144
- describe("AnalysisEngine E2E (mock LLM, real vault)", () => {
145
- it("Q1 sum: '上个月在淘宝总共花了多少?' — facts gathered + cited", async () => {
146
- freshVault();
147
- const [e1, e2, e3] = seedOrders(vault);
148
-
149
- // Mock LLM that cites e1+e2+e3 with the total. We don't compute the sum
150
- // here — the LLM would do that in production. We assert the engine
151
- // hands the right facts and correctly validates the citations.
152
- const llm = new MockLLMClient({
153
- reply: `上个月你在淘宝下了 3 单:[${e1.id}] [${e2.id}] [${e3.id}],共 ¥946.50。`,
154
- });
155
- const engine = new AnalysisEngine({ vault, llm });
156
- const r = await engine.ask("上个月在淘宝总共花了多少?", { now: NOW });
157
-
158
- expect(r.warning).toBeNull();
159
- expect(r.citations.length).toBe(3);
160
- expect(r.citations).toContain(e1.id);
161
- expect(r.citations).toContain(e2.id);
162
- expect(r.citations).toContain(e3.id);
163
- expect(r.hallucinatedCitations).toEqual([]);
164
- // facts: exactly the 3 April orders (May order excluded by time window)
165
- expect(r.facts.length).toBe(3);
166
- expect(r.facts.every((f) => f.subtype === "order")).toBe(true);
167
- expect(r.parsed.filters.adapter).toBe("taobao");
168
- expect(r.parsed.intent).toBe("sum-amount");
169
- });
170
-
171
- it("Q2 list: '我妈生日那周买了啥' — wider window, mocked LLM cites facts", async () => {
172
- freshVault();
173
- const orders = seedOrders(vault);
174
- const llm = new MockLLMClient({
175
- reply: `你给妈妈准备了:蛋白粉 [${orders[0].id}]、按摩仪 [${orders[1].id}]、鲜花 [${orders[2].id}]。`,
176
- });
177
- const engine = new AnalysisEngine({ vault, llm });
178
- const r = await engine.ask("2026 年 4 月买了什么给妈妈?", { now: NOW });
179
-
180
- expect(r.facts.length).toBe(3); // April orders
181
- expect(r.citations.length).toBe(3);
182
- expect(r.parsed.timeWindow).not.toBeNull();
183
- });
184
-
185
- it("Q3 no-facts: empty vault yields warning='no-facts'", async () => {
186
- freshVault();
187
- const llm = new MockLLMClient({
188
- reply: "你的本月开销记录是空的。",
189
- });
190
- const engine = new AnalysisEngine({ vault, llm });
191
- const r = await engine.ask("本月总共花了多少?", { now: NOW });
192
-
193
- expect(r.warning).toBe("no-facts");
194
- expect(r.facts).toEqual([]);
195
- expect(r.citations).toEqual([]);
196
- // The mocked answer should still come through unchanged.
197
- expect(r.answer).toContain("空的");
198
- });
199
-
200
- it("Q4 hallucination detection: LLM cites unknown ids → warning='hallucinated-citations'", async () => {
201
- freshVault();
202
- seedOrders(vault);
203
- const llm = new MockLLMClient({
204
- reply: "总计 ¥1234 [evt-fake-id-1] [evt-also-fake-2]。",
205
- });
206
- const engine = new AnalysisEngine({ vault, llm });
207
- const r = await engine.ask("上个月在淘宝总共花了多少?", { now: NOW });
208
-
209
- expect(r.warning).toBe("hallucinated-citations");
210
- expect(r.hallucinatedCitations).toContain("evt-fake-id-1");
211
- expect(r.hallucinatedCitations).toContain("evt-also-fake-2");
212
- expect(r.citations).toEqual([]); // no known ids cited
213
- });
214
-
215
- it("Q5 LLM error propagates: vault stays intact, audit recorded", async () => {
216
- freshVault();
217
- seedOrders(vault);
218
- const llm = new MockLLMClient({});
219
- llm.chat = async () => { throw new Error("Ollama down"); };
220
- llm.isLocal = true;
221
- const engine = new AnalysisEngine({ vault, llm });
222
-
223
- await expect(engine.ask("test", { now: NOW })).rejects.toThrow(/Ollama down/);
224
-
225
- const audits = vault.queryAudit({ action: "analysis.llm_failed" });
226
- expect(audits.length).toBe(1);
227
- });
228
-
229
- it("audits every successful ask with fact + citation counts", async () => {
230
- freshVault();
231
- const orders = seedOrders(vault);
232
- const llm = new MockLLMClient({ reply: `cited [${orders[0].id}]` });
233
- const engine = new AnalysisEngine({ vault, llm });
234
- await engine.ask("上个月在淘宝总共花了多少?", { now: NOW });
235
-
236
- const audits = vault.queryAudit({ action: "analysis.ask" });
237
- expect(audits.length).toBe(1);
238
- const details = JSON.parse(audits[0].details);
239
- expect(details.factCount).toBe(3);
240
- expect(details.citationsKnown).toBe(1);
241
- expect(details.citationsUnknown).toBe(0);
242
- expect(details.warning).toBeNull();
243
- });
244
-
245
- it("skipAudit option suppresses audit row", async () => {
246
- freshVault();
247
- seedOrders(vault);
248
- const llm = new MockLLMClient({ reply: "ok" });
249
- const engine = new AnalysisEngine({ vault, llm });
250
- await engine.ask("test", { now: NOW, skipAudit: true });
251
- expect(vault.queryAudit({ action: "analysis.ask" }).length).toBe(0);
252
- });
253
- });
254
-
255
- // ─── RAG augmentation ────────────────────────────────────────────────────
256
-
257
- describe("AnalysisEngine RAG retriever", () => {
258
- it("adds RAG-retrieved events to facts (by id lookup in vault)", async () => {
259
- freshVault();
260
- const orders = seedOrders(vault);
261
-
262
- // RAG returns the May order (which falls OUTSIDE the "上个月" time window)
263
- // — engine should still include it because RAG marks it semantically
264
- // relevant.
265
- const ragRetriever = async () => [{ id: orders[3].id, text: "fake", metadata: {} }];
266
-
267
- const llm = new MockLLMClient({ reply: "ok" });
268
- const engine = new AnalysisEngine({ vault, llm, ragRetriever });
269
- const r = await engine.ask("上个月在淘宝总共花了多少?", { now: NOW });
270
-
271
- // Original 3 April orders + 1 May order pulled by RAG.
272
- expect(r.facts.length).toBe(4);
273
- expect(r.ragContextIds).toEqual([orders[3].id]);
274
- });
275
-
276
- it("RAG failure is captured but doesn't abort the ask", async () => {
277
- freshVault();
278
- seedOrders(vault);
279
- const ragRetriever = async () => { throw new Error("qdrant unreachable"); };
280
- const llm = new MockLLMClient({ reply: "ok" });
281
- const engine = new AnalysisEngine({ vault, llm, ragRetriever });
282
-
283
- const r = await engine.ask("test", { now: NOW });
284
- expect(r.answer).toBe("ok");
285
- const audits = vault.queryAudit({ action: "analysis.rag_failed" });
286
- expect(audits.length).toBe(1);
287
- });
288
- });
289
-
290
- // ─── TOTALS block — authoritative counts beat FACTS sample length ─────
291
- //
292
- // Bug 2026-05-21: even after _gatherFacts pulled persons + items into the
293
- // prompt, the LLM still said "32 contacts" because FACTS is capped at 80
294
- // items and the LLM was counting the array. Real vault had ~500 contacts.
295
- // Fix: stick vault.stats() totals at the head of the user message so the
296
- // model has an authoritative ground-truth number to quote.
297
-
298
- describe("AnalysisEngine emits TOTALS preamble", () => {
299
- it("includes vault.stats() totals in the prompt", async () => {
300
- const fakeVault = {
301
- queryEvents: () => [],
302
- queryPersons: () => [],
303
- queryItems: () => [],
304
- stats: () => ({ events: 12, persons: 512, places: 3, items: 89, topics: 0 }),
305
- getEvent: () => null,
306
- audit: () => {},
307
- };
308
- const chatCalls = [];
309
- const llm = {
310
- isLocal: true,
311
- chat: async (msgs) => {
312
- chatCalls.push(msgs);
313
- return { text: "ok", usage: {} };
314
- },
315
- };
316
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
317
- await engine.ask("几个联系人");
318
- const userMsg = chatCalls[0][1].content;
319
- expect(userMsg).toContain("TOTALS");
320
- expect(userMsg).toContain('"persons": 512');
321
- expect(userMsg).toContain('"items": 89');
322
- // System prompt tells LLM to trust TOTALS for counts.
323
- expect(chatCalls[0][0].content).toMatch(/TOTALS.*authoritative/i);
324
- });
325
-
326
- it("intent=count for '几个联系人' and '几个 app' and '多少个 X'", () => {
327
- const { parseQuery } = require("../lib/query-parser");
328
- expect(parseQuery("几个联系人").intent).toBe("count");
329
- expect(parseQuery("几个 app").intent).toBe("count");
330
- expect(parseQuery("我有多少个联系人?").intent).toBe("count");
331
- expect(parseQuery("how many contacts do I have").intent).toBe("count");
332
- expect(parseQuery("列出我的联系人").intent).toBe("list");
333
- });
334
-
335
- it("legacy vault without stats() falls back gracefully — no TOTALS block", async () => {
336
- const legacyVault = {
337
- queryEvents: () => [],
338
- // no stats()
339
- getEvent: () => null,
340
- audit: () => {},
341
- };
342
- const chatCalls = [];
343
- const llm = {
344
- isLocal: true,
345
- chat: async (msgs) => {
346
- chatCalls.push(msgs);
347
- return { text: "ok", usage: {} };
348
- },
349
- };
350
- const engine = new AnalysisEngine({ vault: legacyVault, llm });
351
- await engine.ask("test");
352
- const userMsg = chatCalls[0][1].content;
353
- expect(userMsg).not.toContain("TOTALS");
354
- });
355
- });
356
-
357
- // ─── intent=sum-amount Phase 2 — AMOUNT_SUM authoritative total ──────────
358
- describe("AnalysisEngine emits AMOUNT_SUM preamble (intent=sum-amount Phase 2)", () => {
359
- const baseVault = (over) => ({
360
- queryEvents: () => [],
361
- queryPersons: () => [],
362
- queryItems: () => [],
363
- stats: () => ({ events: 5, persons: 0, places: 0, items: 0, topics: 0 }),
364
- getEvent: () => null,
365
- audit: () => {},
366
- ...over,
367
- });
368
- const captureLlm = (calls) => ({
369
- isLocal: true,
370
- chat: async (msgs) => {
371
- calls.push(msgs);
372
- return { text: "ok", usage: {} };
373
- },
374
- });
375
-
376
- it("calls sumEventAmount for sum-amount intent and puts AMOUNT_SUM in prompt", async () => {
377
- const sumCalls = [];
378
- const fakeVault = baseVault({
379
- sumEventAmount: (f) => {
380
- sumCalls.push(f);
381
- return { total: 888.8, currency: "CNY", count: 5, byDirection: { out: 888.8, in: 0 } };
382
- },
383
- });
384
- const chatCalls = [];
385
- const engine = new AnalysisEngine({ vault: fakeVault, llm: captureLlm(chatCalls) });
386
- await engine.ask("我总共花了多少钱");
387
- expect(sumCalls.length).toBe(1);
388
- const userMsg = chatCalls[0][1].content;
389
- expect(userMsg).toContain("AMOUNT_SUM");
390
- expect(userMsg).toContain('"total": 888.8');
391
- expect(chatCalls[0][0].content).toMatch(/AMOUNT_SUM.*authoritative/i);
392
- });
393
-
394
- it("does NOT call sumEventAmount for non-sum-amount intent", async () => {
395
- const sumCalls = [];
396
- const fakeVault = baseVault({
397
- sumEventAmount: (f) => {
398
- sumCalls.push(f);
399
- return { total: 0, currency: "CNY", count: 0, byDirection: { out: 0, in: 0 } };
400
- },
401
- });
402
- const engine = new AnalysisEngine({ vault: fakeVault, llm: captureLlm([]) });
403
- await engine.ask("列出我的联系人"); // intent=list
404
- expect(sumCalls.length).toBe(0);
405
- });
406
-
407
- it("omits AMOUNT_SUM block when sumEventAmount returns count 0", async () => {
408
- const fakeVault = baseVault({
409
- sumEventAmount: () => ({ total: 0, currency: "CNY", count: 0, byDirection: { out: 0, in: 0 } }),
410
- });
411
- const chatCalls = [];
412
- const engine = new AnalysisEngine({ vault: fakeVault, llm: captureLlm(chatCalls) });
413
- await engine.ask("我总共花了多少钱");
414
- expect(chatCalls[0][1].content).not.toContain("AMOUNT_SUM");
415
- });
416
-
417
- it("legacy vault without sumEventAmount falls back gracefully", async () => {
418
- const fakeVault = baseVault({}); // no sumEventAmount
419
- const chatCalls = [];
420
- const engine = new AnalysisEngine({ vault: fakeVault, llm: captureLlm(chatCalls) });
421
- await engine.ask("我总共花了多少钱");
422
- expect(chatCalls[0][1].content).not.toContain("AMOUNT_SUM");
423
- });
424
- });
425
-
426
- // ─── Cache bypass — PDH ask must always go to LLM, never cached ───────
427
- //
428
- // Bug 2026-05-21: desktop ResponseCache (7-day TTL) served a stale
429
- // hallucinated answer ("32 contacts") even after _gatherFacts fix put real
430
- // persons in the prompt — same sha256(messages) hit from an earlier session.
431
- // AnalysisEngine.ask must pass skipCache:true so LLMManager bypasses cache.
432
-
433
- describe("AnalysisEngine.ask cache bypass", () => {
434
- it("passes skipCache:true to llm.chat options", async () => {
435
- freshVault();
436
- seedOrders(vault);
437
- const chatCalls = [];
438
- const llm = {
439
- isLocal: true,
440
- chat: async (messages, opts) => {
441
- chatCalls.push({ messages, opts });
442
- return { text: "ok", usage: {} };
443
- },
444
- };
445
- const engine = new AnalysisEngine({ vault, llm });
446
- await engine.ask("test", { now: NOW });
447
- expect(chatCalls).toHaveLength(1);
448
- expect(chatCalls[0].opts.skipCache).toBe(true);
449
- });
450
-
451
- it("retrieveContext does NOT need skipCache (no LLM call)", async () => {
452
- freshVault();
453
- seedOrders(vault);
454
- const llm = {
455
- isLocal: true,
456
- chat: () => {
457
- throw new Error("must not be called");
458
- },
459
- };
460
- const engine = new AnalysisEngine({ vault, llm });
461
- // retrieveContext is Path Y — caller hosts the LLM, so cache concerns
462
- // belong to the caller, not us. Don't pass skipCache here.
463
- const r = await engine.retrieveContext("test");
464
- expect(r.factCount).toBeGreaterThanOrEqual(0);
465
- });
466
- });
467
-
468
- // ─── Path C follow-up — persons / items show up as facts ───────────────
469
- //
470
- // Bug 2026-05-21: "我有几个联系人" hallucinated "2" because contacts ingest
471
- // into persons table but _gatherFacts only queried events. Fix: pull persons
472
- // + items into facts within the maxFacts budget.
473
-
474
- describe("AnalysisEngine._gatherFacts includes persons and items", () => {
475
- it("contact question routes via entityFocus=persons — persons only, no items competition", async () => {
476
- freshVault();
477
- // 2026-05-27 fix: "我有几个联系人" now matches parseEntityFocus → "persons",
478
- // which intentionally skips the items table to give the full prompt
479
- // budget to contacts. Pre-fix this test asserted 5 persons + 3 items
480
- // (8 facts) because _gatherFacts always pulled both tables; post-fix
481
- // items are deliberately excluded — the user asked about contacts, not
482
- // apps. Items still surface for generic "what's in my vault" questions
483
- // (entityFocus=null) and for explicit "我装了哪些 app" (entityFocus=
484
- // "items"). Verified at __tests__:_gatherFacts entityFocus routing.
485
- const fakeVault = {
486
- queryEvents: () => [],
487
- queryPersons: ({ limit }) => {
488
- const n = Math.min(limit ?? 100, 5);
489
- return Array.from({ length: n }, (_, i) => ({
490
- id: "person-android-" + i,
491
- type: "person",
492
- subtype: "contact",
493
- names: ["联系人" + i],
494
- ingestedAt: Date.now(),
495
- source: {
496
- adapter: "system-data-android",
497
- adapterVersion: "0.1.0",
498
- capturedAt: Date.now(),
499
- capturedBy: "api",
500
- },
501
- }));
502
- },
503
- queryItems: ({ limit }) => {
504
- const n = Math.min(limit ?? 100, 3);
505
- return Array.from({ length: n }, (_, i) => ({
506
- id: "item-android-app-com.foo" + i,
507
- type: "item",
508
- subtype: "other",
509
- name: "App" + i,
510
- ingestedAt: Date.now(),
511
- source: {
512
- adapter: "system-data-android",
513
- adapterVersion: "0.1.0",
514
- capturedAt: Date.now(),
515
- capturedBy: "api",
516
- },
517
- }));
518
- },
519
- getEvent: () => null,
520
- audit: () => {},
521
- };
522
- const llm = new MockLLMClient({ reply: "你共有 5 个联系人" });
523
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
524
- const r = await engine.ask("我有几个联系人");
525
- expect(r.facts.filter((f) => f.type === "person").length).toBe(5);
526
- expect(r.facts.filter((f) => f.type === "item").length).toBe(0);
527
- });
528
-
529
- it("respects maxFacts budget — events get majority, persons + items split remainder", async () => {
530
- const fakeVault = {
531
- queryEvents: () => Array.from({ length: 60 }, (_, i) => ({
532
- id: "event-" + i, type: "event", subtype: "order",
533
- occurredAt: Date.now(), actor: "person-self",
534
- ingestedAt: Date.now(), source: {
535
- adapter: "taobao", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api",
536
- },
537
- })),
538
- queryPersons: ({ limit }) => Array.from({ length: Math.min(limit, 100) }, (_, i) => ({
539
- id: "p-" + i, type: "person", subtype: "contact",
540
- names: ["P" + i], ingestedAt: Date.now(),
541
- source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
542
- })),
543
- queryItems: ({ limit }) => Array.from({ length: Math.min(limit, 100) }, (_, i) => ({
544
- id: "i-" + i, type: "item", subtype: "other", name: "Item" + i,
545
- ingestedAt: Date.now(),
546
- source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
547
- })),
548
- getEvent: () => null,
549
- audit: () => {},
550
- };
551
- const llm = new MockLLMClient({ reply: "" });
552
- const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 80 });
553
- const r = await engine.retrieveContext("hello");
554
- // 60 events + budget for the rest. remaining = 80-60 = 20 → 10 persons + 10 items
555
- expect(r.facts.filter((f) => f.type === "event").length).toBe(60);
556
- expect(r.facts.filter((f) => f.type === "person").length).toBe(10);
557
- expect(r.facts.filter((f) => f.type === "item").length).toBe(10);
558
- });
559
-
560
- it("gracefully degrades when vault lacks queryPersons / queryItems (legacy fork)", async () => {
561
- const legacyVault = {
562
- queryEvents: () => [],
563
- // no queryPersons / queryItems methods
564
- getEvent: () => null,
565
- audit: () => {},
566
- };
567
- const llm = new MockLLMClient({ reply: "" });
568
- const engine = new AnalysisEngine({ vault: legacyVault, llm });
569
- const r = await engine.ask("hello");
570
- expect(r.facts.length).toBe(0);
571
- expect(r.warning).toBe("no-facts");
572
- });
573
-
574
- it("events overflow + empty side tables → events refill the reserved slots", async () => {
575
- // 2026-05-27 fix: when events would monopolize effMaxFacts the engine
576
- // reserves slots for persons + items; if BOTH side tables return 0 rows
577
- // the reserve is refilled with events so a contact-less vault still
578
- // sees the full event budget.
579
- const fakeVault = {
580
- queryEvents: () => Array.from({ length: 80 }, (_, i) => ({
581
- id: "e" + i, type: "event", subtype: "order",
582
- occurredAt: Date.now(), actor: "self",
583
- ingestedAt: Date.now(),
584
- source: { adapter: "taobao", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
585
- })),
586
- queryPersons: vi.fn(() => []),
587
- queryItems: vi.fn(() => []),
588
- getEvent: () => null,
589
- audit: () => {},
590
- };
591
- const llm = new MockLLMClient({ reply: "" });
592
- const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 80 });
593
- const r = await engine.ask("hi");
594
- expect(r.facts.length).toBe(80);
595
- expect(r.facts.filter((f) => f.type === "event").length).toBe(80);
596
- // Side queries WERE called (different from pre-fix); they just returned [].
597
- expect(fakeVault.queryPersons).toHaveBeenCalledWith({ limit: 16 });
598
- expect(fakeVault.queryItems).toHaveBeenCalledWith({ limit: 8 });
599
- });
600
-
601
- it("Android small-model budget — events overflow cap, persons survive", async () => {
602
- // Regression: Android local path (effMaxFacts=20, effMaxQueryLimit=50).
603
- // Vault returns 50 events; pre-fix _gatherFacts shipped 50 events,
604
- // buildPrompt sliced to first 20 events, persons = 0 → "几个联系人"
605
- // hallucinated zero. Now events cap at 14 (20*0.7), persons get 3,
606
- // items get 3 → contact rows reach the LLM.
607
- const fakeVault = {
608
- queryEvents: () => Array.from({ length: 50 }, (_, i) => ({
609
- id: "e" + i, type: "event", subtype: "message",
610
- occurredAt: Date.now(), actor: "self",
611
- ingestedAt: Date.now(),
612
- source: { adapter: "wechat", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
613
- })),
614
- queryPersons: ({ limit }) => Array.from({ length: limit }, (_, i) => ({
615
- id: "p" + i, type: "person", subtype: "contact",
616
- names: ["联系人" + i], ingestedAt: Date.now(),
617
- source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
618
- })),
619
- queryItems: ({ limit }) => Array.from({ length: limit }, (_, i) => ({
620
- id: "i" + i, type: "item", subtype: "other", name: "App" + i,
621
- ingestedAt: Date.now(),
622
- source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
623
- })),
624
- getEvent: () => null,
625
- audit: () => {},
626
- };
627
- const llm = new MockLLMClient({ reply: "" });
628
- const engine = new AnalysisEngine({
629
- vault: fakeVault, llm,
630
- maxFacts: 20, maxQueryLimit: 50,
631
- });
632
- const r = await engine.ask("hi"); // generic question — default path
633
- // 20 * 0.2 = 4 persons, 20 * 0.1 = 2 items, remainder 14 for events.
634
- expect(r.facts.filter((f) => f.type === "event").length).toBe(14);
635
- expect(r.facts.filter((f) => f.type === "person").length).toBe(4);
636
- expect(r.facts.filter((f) => f.type === "item").length).toBe(2);
637
- });
638
- });
639
-
640
- // ─── entityFocus routing — persons / items table priority ────────────────
641
- //
642
- // 2026-05-27 fix: when the question is explicitly about contacts ("我有
643
- // 哪些联系人", "妈手机号"), _gatherFacts must NOT compete persons against
644
- // the events pool. Pre-fix Android small-model budgets (20 facts / 50 row
645
- // cap) had events drown out the contact slice → user saw "没数据" even
646
- // when the vault held hundreds of contacts.
647
-
648
- describe("AnalysisEngine._gatherFacts entityFocus routing", () => {
649
- it("entityFocus=persons skips events broad scan, prioritizes persons", async () => {
650
- const fakeVault = {
651
- queryEvents: vi.fn(() => Array.from({ length: 50 }, (_, i) => ({
652
- id: "e" + i, type: "event", subtype: "message",
653
- occurredAt: Date.now(), actor: "self",
654
- ingestedAt: Date.now(),
655
- source: { adapter: "wechat", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
656
- }))),
657
- queryPersons: vi.fn(({ limit }) => Array.from({ length: limit }, (_, i) => ({
658
- id: "p" + i, type: "person", subtype: "contact",
659
- names: ["联系人" + i], ingestedAt: Date.now(),
660
- source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
661
- }))),
662
- queryItems: vi.fn(() => []),
663
- getEvent: () => null,
664
- audit: () => {},
665
- };
666
- const llm = new MockLLMClient({ reply: "" });
667
- const engine = new AnalysisEngine({
668
- vault: fakeVault, llm,
669
- maxFacts: 20, maxQueryLimit: 50,
670
- });
671
- const r = await engine.ask("我有哪些联系人");
672
- // 95% goes to persons (19), 5% headroom = 1 event slot.
673
- expect(r.facts.filter((f) => f.type === "person").length).toBe(19);
674
- expect(r.facts.filter((f) => f.type === "event").length).toBeLessThanOrEqual(1);
675
- expect(fakeVault.queryPersons).toHaveBeenCalledWith({ limit: 19 });
676
- });
677
-
678
- it("entityFocus=persons falls through to default path when persons table is empty", async () => {
679
- const fakeVault = {
680
- queryEvents: () => Array.from({ length: 5 }, (_, i) => ({
681
- id: "e" + i, type: "event", subtype: "message",
682
- occurredAt: Date.now(), actor: "self",
683
- ingestedAt: Date.now(),
684
- source: { adapter: "wechat", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
685
- })),
686
- queryPersons: () => [], // empty contacts table
687
- queryItems: () => [],
688
- getEvent: () => null,
689
- audit: () => {},
690
- };
691
- const llm = new MockLLMClient({ reply: "" });
692
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
693
- const r = await engine.ask("我有哪些联系人");
694
- // Fell through to default → 5 events surfaced (no cap since 5 < 80).
695
- expect(r.facts.filter((f) => f.type === "event").length).toBe(5);
696
- });
697
-
698
- it("entityFocus=persons with name candidate → searchPersons short-circuit", async () => {
699
- // 2026-05-27 S3 治本 — "妈手机号" must hit searchPersons LIKE search
700
- // even when vault holds 500 contacts. Pre-S3 _gatherFacts dumped the
701
- // first N by ingest_at; the target person rarely landed in the slice.
702
- const fakeVault = {
703
- queryEvents: () => [],
704
- queryPersons: vi.fn(() => [
705
- { id: "p-other", type: "person", subtype: "contact", names: ["张三"], ingestedAt: 0,
706
- source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: 0, capturedBy: "api" } },
707
- ]),
708
- searchPersons: vi.fn(({ q, limit }) => {
709
- if (q === "妈") {
710
- return [{
711
- id: "p-mom", type: "person", subtype: "contact", names: ["妈妈"],
712
- identifiers: { phone: ["13800138000"] }, ingestedAt: 0,
713
- source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: 0, capturedBy: "api" },
714
- }];
715
- }
716
- return [];
717
- }),
718
- queryItems: () => [],
719
- getEvent: () => null,
720
- audit: () => {},
721
- };
722
- const llm = new MockLLMClient({ reply: "妈手机号是 13800138000" });
723
- const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 20 });
724
- const r = await engine.ask("妈手机号是多少");
725
- expect(fakeVault.searchPersons).toHaveBeenCalledWith({ q: "妈", limit: 19 });
726
- expect(fakeVault.queryPersons).not.toHaveBeenCalled(); // search hit → skip fallback
727
- expect(r.facts.filter((f) => f.type === "person").length).toBe(1);
728
- expect(r.facts.find((f) => f.id === "p-mom")).toBeDefined();
729
- });
730
-
731
- it("entityFocus=persons with name candidate but 0 search hits → falls back to queryPersons", async () => {
732
- const fakeVault = {
733
- queryEvents: () => [],
734
- queryPersons: vi.fn(({ limit }) => Array.from({ length: limit }, (_, i) => ({
735
- id: "p" + i, type: "person", subtype: "contact", names: ["P" + i], ingestedAt: 0,
736
- source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: 0, capturedBy: "api" },
737
- }))),
738
- searchPersons: vi.fn(() => []), // 0 hits
739
- queryItems: () => [],
740
- getEvent: () => null,
741
- audit: () => {},
742
- };
743
- const llm = new MockLLMClient({ reply: "" });
744
- const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 20 });
745
- await engine.ask("张三的电话号码");
746
- expect(fakeVault.searchPersons).toHaveBeenCalled();
747
- expect(fakeVault.queryPersons).toHaveBeenCalledWith({ limit: 19 });
748
- });
749
-
750
- it("entityFocus=persons without name candidate (pure list) skips searchPersons", async () => {
751
- const fakeVault = {
752
- queryEvents: () => [],
753
- queryPersons: vi.fn(({ limit }) => Array.from({ length: limit }, (_, i) => ({
754
- id: "p" + i, type: "person", subtype: "contact", names: ["P" + i], ingestedAt: 0,
755
- source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: 0, capturedBy: "api" },
756
- }))),
757
- searchPersons: vi.fn(() => []),
758
- queryItems: () => [],
759
- getEvent: () => null,
760
- audit: () => {},
761
- };
762
- const llm = new MockLLMClient({ reply: "" });
763
- const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 20 });
764
- await engine.ask("我有哪些联系人");
765
- // Pure list — no name in question → skip searchPersons, go straight to queryPersons.
766
- expect(fakeVault.searchPersons).not.toHaveBeenCalled();
767
- expect(fakeVault.queryPersons).toHaveBeenCalledWith({ limit: 19 });
768
- });
769
-
770
- it("entityFocus=persons tolerates vault without searchPersons (legacy)", async () => {
771
- const fakeVault = {
772
- queryEvents: () => [],
773
- queryPersons: vi.fn(({ limit }) => Array.from({ length: Math.min(limit, 3) }, (_, i) => ({
774
- id: "p" + i, type: "person", subtype: "contact", names: ["P" + i], ingestedAt: 0,
775
- source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: 0, capturedBy: "api" },
776
- }))),
777
- // No searchPersons method
778
- queryItems: () => [],
779
- getEvent: () => null,
780
- audit: () => {},
781
- };
782
- const llm = new MockLLMClient({ reply: "" });
783
- const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 20 });
784
- const r = await engine.ask("妈手机号");
785
- expect(fakeVault.queryPersons).toHaveBeenCalled();
786
- expect(r.facts.filter((f) => f.type === "person").length).toBe(3);
787
- });
788
-
789
- it("entityFocus=items prioritizes items table over events", async () => {
790
- const fakeVault = {
791
- queryEvents: () => Array.from({ length: 100 }, (_, i) => ({
792
- id: "e" + i, type: "event", subtype: "browse",
793
- occurredAt: Date.now(), actor: "self",
794
- ingestedAt: Date.now(),
795
- source: { adapter: "browser-history", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
796
- })),
797
- queryPersons: () => [],
798
- queryItems: vi.fn(({ limit }) => Array.from({ length: limit }, (_, i) => ({
799
- id: "i" + i, type: "item", subtype: "other", name: "App" + i,
800
- ingestedAt: Date.now(),
801
- source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
802
- }))),
803
- getEvent: () => null,
804
- audit: () => {},
805
- };
806
- const llm = new MockLLMClient({ reply: "" });
807
- const engine = new AnalysisEngine({
808
- vault: fakeVault, llm,
809
- maxFacts: 20, maxQueryLimit: 50,
810
- });
811
- const r = await engine.ask("我装了哪些 app");
812
- expect(r.facts.filter((f) => f.type === "item").length).toBe(19);
813
- expect(fakeVault.queryItems).toHaveBeenCalledWith({ limit: 19 });
814
- });
815
- });
816
-
817
- // ─── Empty / bad input ────────────────────────────────────────────────────
818
-
819
- describe("AnalysisEngine input validation", () => {
820
- it("rejects empty / non-string question", async () => {
821
- freshVault();
822
- const engine = new AnalysisEngine({
823
- vault,
824
- llm: new MockLLMClient({ reply: "" }),
825
- });
826
- await expect(engine.ask("")).rejects.toThrow(/non-empty/);
827
- await expect(engine.ask(null)).rejects.toThrow();
828
- });
829
- });
830
-
831
- // ─── retrieveContext: prompt assembly without LLM call ───────────────────
832
- //
833
- // Path Y wiring lets a mobile front-end host the LLM call locally (e.g. the
834
- // Android-side Volcengine Doubao adapter) while keeping vault + retrieval on
835
- // the desktop. retrieveContext mirrors the front half of ask() and returns
836
- // the assembled messages so the caller can hand them straight to its own LLM.
837
-
838
- describe("AnalysisEngine.retrieveContext", () => {
839
- it("returns parsed + facts + messages without invoking the LLM", async () => {
840
- freshVault();
841
- const [e1, e2, e3] = seedOrders(vault);
842
-
843
- // LLM that would throw if called — proves retrieveContext is LLM-free.
844
- const llm = {
845
- isLocal: true,
846
- chat: () => { throw new Error("LLM must not be called by retrieveContext"); },
847
- };
848
- const engine = new AnalysisEngine({ vault, llm });
849
- const r = await engine.retrieveContext("上个月在淘宝总共花了多少?", { now: NOW });
850
-
851
- expect(r.question).toBe("上个月在淘宝总共花了多少?");
852
- expect(r.parsed.filters.adapter).toBe("taobao");
853
- expect(r.facts.length).toBe(3);
854
- expect(r.factIds).toEqual(expect.arrayContaining([e1.id, e2.id, e3.id]));
855
- expect(r.factCount).toBe(3);
856
- // `truncated` is the count of dropped facts (Number), not a boolean.
857
- // 3 gathered, all kept (no maxFacts cap) → 0 dropped.
858
- expect(r.truncated).toBe(0);
859
- expect(Array.isArray(r.messages)).toBe(true);
860
- expect(r.messages.length).toBeGreaterThan(0);
861
- expect(r.messages[0]).toHaveProperty("role");
862
- expect(r.messages[0]).toHaveProperty("content");
863
- expect(r.systemPrompt).toBeTypeOf("string");
864
- expect(r.retrievedAt).toBeTypeOf("number");
865
- expect(r.durationMs).toBeGreaterThanOrEqual(0);
866
- });
867
-
868
- it("ignores acceptNonLocal — privacy gate does not apply (no LLM contacted)", async () => {
869
- freshVault();
870
- seedOrders(vault);
871
- // Non-local LLM declared on the engine, but retrieveContext doesn't call it.
872
- const llm = {
873
- isLocal: false,
874
- chat: () => { throw new Error("must not be called"); },
875
- };
876
- const engine = new AnalysisEngine({ vault, llm });
877
- // No acceptNonLocal option needed.
878
- const r = await engine.retrieveContext("test", { now: NOW });
879
- expect(r.factCount).toBeGreaterThanOrEqual(0);
880
- });
881
-
882
- it("incorporates RAG retriever results into facts", async () => {
883
- freshVault();
884
- const orders = seedOrders(vault);
885
- const ragRetriever = async () => [{ id: orders[3].id, text: "fake", metadata: {} }];
886
- const llm = { isLocal: true, chat: () => { throw new Error("nope"); } };
887
- const engine = new AnalysisEngine({ vault, llm, ragRetriever });
888
- const r = await engine.retrieveContext("上个月在淘宝总共花了多少?", { now: NOW });
889
- expect(r.facts.length).toBe(4);
890
- expect(r.ragContextIds).toEqual([orders[3].id]);
891
- });
892
-
893
- it("RAG failure is captured but doesn't abort retrieval", async () => {
894
- freshVault();
895
- seedOrders(vault);
896
- const ragRetriever = async () => { throw new Error("qdrant unreachable"); };
897
- const llm = { isLocal: true, chat: () => { throw new Error("nope"); } };
898
- const engine = new AnalysisEngine({ vault, llm, ragRetriever });
899
- const r = await engine.retrieveContext("test", { now: NOW });
900
- expect(r.factCount).toBeGreaterThanOrEqual(0);
901
- const audits = vault.queryAudit({ action: "analysis.rag_failed" });
902
- expect(audits.length).toBe(1);
903
- });
904
-
905
- it("writes analysis.retrieve_context audit row by default", async () => {
906
- freshVault();
907
- seedOrders(vault);
908
- const llm = { isLocal: true, chat: () => { throw new Error("nope"); } };
909
- const engine = new AnalysisEngine({ vault, llm });
910
- await engine.retrieveContext("test", { now: NOW });
911
- const audits = vault.queryAudit({ action: "analysis.retrieve_context" });
912
- expect(audits.length).toBe(1);
913
- });
914
-
915
- it("rejects empty / non-string question", async () => {
916
- freshVault();
917
- const engine = new AnalysisEngine({
918
- vault,
919
- llm: new MockLLMClient({ reply: "" }),
920
- });
921
- await expect(engine.retrieveContext("")).rejects.toThrow(/non-empty/);
922
- await expect(engine.retrieveContext(null)).rejects.toThrow();
923
- });
924
- });
925
-
926
- // ─── Per-call budget overrides (small-model callers) ─────────────────────
927
- //
928
- // On-device Qwen2.5-1.5B has an effective instruction-following window of
929
- // 2-4K tokens, much tighter than the 80-fact / 200-row default sized for
930
- // desktop 7B+ models. Android passes `maxFacts=20 maxQueryLimit=50` per
931
- // call to keep the prompt ~1.5K tokens. Construction stays untouched so
932
- // the desktop default path is unaffected.
933
- describe("AnalysisEngine per-call budget overrides", () => {
934
- it("ask() honors options.maxFacts and options.maxQueryLimit", async () => {
935
- const queryEventsCalls = [];
936
- const fakeVault = {
937
- queryEvents: (q) => {
938
- queryEventsCalls.push(q);
939
- // Return exactly q.limit rows so we can detect the cap.
940
- return Array.from({ length: q.limit }, (_, i) => ({
941
- id: "e" + i, type: "event", subtype: "order",
942
- occurredAt: Date.now(), actor: "self", ingestedAt: Date.now(),
943
- source: { adapter: "taobao", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
944
- }));
945
- },
946
- queryPersons: () => [],
947
- queryItems: () => [],
948
- getEvent: () => null,
949
- audit: () => {},
950
- stats: () => ({ events: 30, persons: 0, places: 0, items: 0, topics: 0 }),
951
- };
952
- const llm = new MockLLMClient({ reply: "ok" });
953
- // Default constructor (maxFacts=80, maxQueryLimit=200) — overridden per call.
954
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
955
- await engine.ask("hi", { maxFacts: 10, maxQueryLimit: 50 });
956
- // queryEvents.limit must reflect the per-call override, not the default 200.
957
- expect(queryEventsCalls).toHaveLength(1);
958
- expect(queryEventsCalls[0].limit).toBe(50);
959
- });
960
-
961
- it("ask() retrieveContext-level maxFacts bounds factCount via buildPrompt", async () => {
962
- const fakeVault = {
963
- queryEvents: (q) => Array.from({ length: q.limit }, (_, i) => ({
964
- id: "e" + i, type: "event", subtype: "order",
965
- occurredAt: Date.now(), actor: "self", ingestedAt: Date.now(),
966
- source: { adapter: "taobao", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
967
- })),
968
- queryPersons: () => [],
969
- queryItems: () => [],
970
- getEvent: () => null,
971
- audit: () => {},
972
- stats: () => ({ events: 200, persons: 0, places: 0, items: 0, topics: 0 }),
973
- };
974
- const llm = { isLocal: true, chat: () => { throw new Error("nope"); } };
975
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
976
- const r = await engine.retrieveContext("hi", { maxFacts: 10, maxQueryLimit: 50 });
977
- // 2026-05-27 fix: _gatherFacts now respects effMaxFacts upstream
978
- // (events would have overflowed → reservation branch; persons/items
979
- // returned [] → refill back to events.slice(0,10)). buildPrompt sees
980
- // exactly 10 facts, nothing to truncate.
981
- expect(r.factCount).toBe(10);
982
- expect(r.truncated).toBe(0);
983
- });
984
-
985
- it("retrieveContext() honors options.maxFacts and options.maxQueryLimit", async () => {
986
- const queryEventsCalls = [];
987
- const fakeVault = {
988
- queryEvents: (q) => {
989
- queryEventsCalls.push(q);
990
- return [];
991
- },
992
- queryPersons: () => [],
993
- queryItems: () => [],
994
- getEvent: () => null,
995
- audit: () => {},
996
- stats: () => ({ events: 0, persons: 0, places: 0, items: 0, topics: 0 }),
997
- };
998
- const llm = { isLocal: true, chat: () => { throw new Error("nope"); } };
999
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1000
- await engine.retrieveContext("hi", { maxFacts: 15, maxQueryLimit: 40 });
1001
- expect(queryEventsCalls).toHaveLength(1);
1002
- expect(queryEventsCalls[0].limit).toBe(40);
1003
- });
1004
-
1005
- it("ignores non-positive / non-integer overrides → falls back to constructor defaults", async () => {
1006
- const queryEventsCalls = [];
1007
- const fakeVault = {
1008
- queryEvents: (q) => { queryEventsCalls.push(q); return []; },
1009
- queryPersons: () => [],
1010
- queryItems: () => [],
1011
- getEvent: () => null,
1012
- audit: () => {},
1013
- stats: () => ({ events: 0, persons: 0, places: 0, items: 0, topics: 0 }),
1014
- };
1015
- const llm = { isLocal: true, chat: () => { throw new Error("nope"); } };
1016
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1017
- await engine.retrieveContext("hi", { maxFacts: 0, maxQueryLimit: -5 });
1018
- // Both bogus → fall back to ctor defaults (maxQueryLimit=200)
1019
- expect(queryEventsCalls[0].limit).toBe(200);
1020
- });
1021
- });
1022
-
1023
- // ─── intent=latest routing — newest-few path ─────────────────────────────
1024
- //
1025
- // 2026-05-24 follow-up — _gatherFacts now routes intent=latest WITHOUT a
1026
- // time window to a hard-capped queryEvents({ limit: 3 }) and skips
1027
- // persons/items entirely. Frees prompt budget for the LLM to actually read
1028
- // row content instead of skimming 200 rows. Memory:
1029
- // pdh_analysis_engine_intent_routing.md.
1030
- //
1031
- // Guards covered:
1032
- // (a) intent=latest + no timeWindow → ≤3 events, persons/items NOT touched
1033
- // (b) intent=latest + timeWindow ("最近 30 天") → fall through (list semantics)
1034
- // (c) intent=latest + 0 results → fall back to default (persons+items pulled)
1035
- // (d) intent=latest + adapter filter → respects filter on the narrow path
1036
- // (e) parseQuery sanity: "最近的订单" → intent=latest, timeWindow=null
1037
-
1038
- describe("AnalysisEngine._gatherFacts intent=latest routing", () => {
1039
- it("(a) latest without timeWindow → ≤3 events, persons/items NOT queried", async () => {
1040
- const queryEventsCalls = [];
1041
- const fakeVault = {
1042
- queryEvents: (q) => {
1043
- queryEventsCalls.push(q);
1044
- return Array.from({ length: 10 }, (_, i) => ({
1045
- id: "e-" + i, type: "event", subtype: "order",
1046
- occurredAt: Date.now() - i * 1000, actor: "self",
1047
- ingestedAt: Date.now(),
1048
- source: { adapter: "taobao", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
1049
- })).slice(0, q.limit);
1050
- },
1051
- queryPersons: vi.fn(() => []),
1052
- queryItems: vi.fn(() => []),
1053
- getEvent: () => null,
1054
- audit: () => {},
1055
- stats: () => ({ events: 10, persons: 0, places: 0, items: 0, topics: 0 }),
1056
- };
1057
- const llm = new MockLLMClient({ reply: "ok" });
1058
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1059
- const r = await engine.ask("最近的订单");
1060
-
1061
- expect(r.parsed.intent).toBe("latest");
1062
- expect(r.parsed.timeWindow).toBeNull();
1063
- expect(queryEventsCalls).toHaveLength(1);
1064
- expect(queryEventsCalls[0].limit).toBe(3);
1065
- expect(r.facts).toHaveLength(3);
1066
- expect(r.facts.every((f) => f.type === "event")).toBe(true);
1067
- expect(fakeVault.queryPersons).not.toHaveBeenCalled();
1068
- expect(fakeVault.queryItems).not.toHaveBeenCalled();
1069
- });
1070
-
1071
- it("(b) latest WITH timeWindow ('最近 30 天') → falls through to default broader path", async () => {
1072
- const queryEventsCalls = [];
1073
- const fakeVault = {
1074
- queryEvents: (q) => {
1075
- queryEventsCalls.push(q);
1076
- return [];
1077
- },
1078
- queryPersons: vi.fn(() => []),
1079
- queryItems: vi.fn(() => []),
1080
- getEvent: () => null,
1081
- audit: () => {},
1082
- stats: () => ({ events: 0, persons: 0, places: 0, items: 0, topics: 0 }),
1083
- };
1084
- const llm = new MockLLMClient({ reply: "ok" });
1085
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1086
- const r = await engine.ask("最近 30 天的消费", { now: NOW });
1087
-
1088
- expect(r.parsed.intent).toBe("latest");
1089
- expect(r.parsed.timeWindow).not.toBeNull();
1090
- // Default path: limit=200 (DEFAULT_MAX_QUERY_LIMIT), NOT 3.
1091
- expect(queryEventsCalls).toHaveLength(1);
1092
- expect(queryEventsCalls[0].limit).toBe(200);
1093
- // Default path also tries persons + items (budget remaining after 0 events).
1094
- expect(fakeVault.queryPersons).toHaveBeenCalled();
1095
- expect(fakeVault.queryItems).toHaveBeenCalled();
1096
- });
1097
-
1098
- it("(c) latest with 0 results → fallback pulls persons + items via default path", async () => {
1099
- const queryEventsCalls = [];
1100
- const fakeVault = {
1101
- queryEvents: (q) => {
1102
- queryEventsCalls.push(q);
1103
- return []; // both narrow + default calls return 0 events
1104
- },
1105
- queryPersons: ({ limit }) => Array.from({ length: Math.min(limit, 2) }, (_, i) => ({
1106
- id: "p-" + i, type: "person", subtype: "contact", names: ["P" + i],
1107
- ingestedAt: Date.now(),
1108
- source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
1109
- })),
1110
- queryItems: ({ limit }) => Array.from({ length: Math.min(limit, 2) }, (_, i) => ({
1111
- id: "i-" + i, type: "item", subtype: "other", name: "I" + i,
1112
- ingestedAt: Date.now(),
1113
- source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
1114
- })),
1115
- getEvent: () => null,
1116
- audit: () => {},
1117
- stats: () => ({ events: 0, persons: 2, places: 0, items: 2, topics: 0 }),
1118
- };
1119
- const llm = new MockLLMClient({ reply: "ok" });
1120
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1121
- const r = await engine.ask("最近的订单");
1122
-
1123
- // Narrow path (limit=3) called first, returned 0 → fall through to default
1124
- // (limit=200) — so we expect 2 queryEvents calls total.
1125
- expect(queryEventsCalls).toHaveLength(2);
1126
- expect(queryEventsCalls[0].limit).toBe(3);
1127
- expect(queryEventsCalls[1].limit).toBe(200);
1128
- // Default path pulled persons + items; user gets a useful answer instead of "no-facts".
1129
- expect(r.facts.filter((f) => f.type === "person").length).toBe(2);
1130
- expect(r.facts.filter((f) => f.type === "item").length).toBe(2);
1131
- });
1132
-
1133
- it("(d) latest passes adapter filter to the narrow queryEvents call", async () => {
1134
- const queryEventsCalls = [];
1135
- const fakeVault = {
1136
- queryEvents: (q) => {
1137
- queryEventsCalls.push(q);
1138
- return Array.from({ length: 3 }, (_, i) => ({
1139
- id: "e-" + i, type: "event", subtype: "order",
1140
- occurredAt: Date.now() - i * 1000, actor: "self",
1141
- ingestedAt: Date.now(),
1142
- source: { adapter: "taobao", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
1143
- }));
1144
- },
1145
- queryPersons: () => [],
1146
- queryItems: () => [],
1147
- getEvent: () => null,
1148
- audit: () => {},
1149
- stats: () => ({ events: 3, persons: 0, places: 0, items: 0, topics: 0 }),
1150
- };
1151
- const llm = new MockLLMClient({ reply: "ok" });
1152
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1153
- await engine.ask("最近在淘宝买的");
1154
-
1155
- expect(queryEventsCalls).toHaveLength(1);
1156
- expect(queryEventsCalls[0].adapter).toBe("taobao");
1157
- expect(queryEventsCalls[0].limit).toBe(3);
1158
- });
1159
-
1160
- it("(e) parseQuery sanity: '最近的订单' → intent=latest, timeWindow=null", () => {
1161
- const { parseQuery } = require("../lib/query-parser");
1162
- const q = parseQuery("最近的订单");
1163
- expect(q.intent).toBe("latest");
1164
- expect(q.timeWindow).toBeNull();
1165
- // Sanity: 最近 N 天 still produces both (list-with-window semantics on
1166
- // the engine side, but parser still tags intent=latest because "最近"
1167
- // matches. Engine's heuristic handles the disambiguation.)
1168
- const q2 = parseQuery("最近 30 天");
1169
- expect(q2.intent).toBe("latest");
1170
- expect(q2.timeWindow).not.toBeNull();
1171
- });
1172
-
1173
- it("(f) latest narrow path respects per-call maxFacts cap (Android small-model 20 budget)", async () => {
1174
- // If caller passes maxFacts=2 (tighter than LATEST_INTENT_FACT_LIMIT=3),
1175
- // honor the tighter cap — small-model callers know their budget best.
1176
- const queryEventsCalls = [];
1177
- const fakeVault = {
1178
- queryEvents: (q) => {
1179
- queryEventsCalls.push(q);
1180
- return Array.from({ length: q.limit }, (_, i) => ({
1181
- id: "e-" + i, type: "event", subtype: "order",
1182
- occurredAt: Date.now() - i * 1000, actor: "self",
1183
- ingestedAt: Date.now(),
1184
- source: { adapter: "taobao", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
1185
- }));
1186
- },
1187
- queryPersons: () => [],
1188
- queryItems: () => [],
1189
- getEvent: () => null,
1190
- audit: () => {},
1191
- stats: () => ({ events: 2, persons: 0, places: 0, items: 0, topics: 0 }),
1192
- };
1193
- const llm = new MockLLMClient({ reply: "ok" });
1194
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1195
- await engine.ask("最近消息", { maxFacts: 2 });
1196
- expect(queryEventsCalls[0].limit).toBe(2);
1197
- });
1198
- });
1199
-
1200
- // ─── intent=list + entity-name FTS5 augmentation ────────────────────────
1201
- //
1202
- // 2026-05-24 follow-up — when the parser pulls a probable entity name out
1203
- // of the question (extractEntityTerm), _gatherFacts appends FTS5 hits to
1204
- // the FACTS pool via vault.searchEvents. Strictly additive: wrong term →
1205
- // 0 rows wasted, never lost events. FTS unavailable / errors → main path
1206
- // (queryEvents + persons + items) unaffected. Memory:
1207
- // pdh_analysis_engine_intent_routing.md.
1208
-
1209
- describe("AnalysisEngine._gatherFacts intent=list + entity-name FTS augmentation", () => {
1210
- // Shared event row factory.
1211
- const mkEvent = (id, adapter = "wechat") => ({
1212
- id, type: "event", subtype: "message",
1213
- occurredAt: Date.now(), actor: "self",
1214
- ingestedAt: Date.now(),
1215
- source: { adapter, adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
1216
- });
1217
-
1218
- it("(a) entity extracted → searchEvents called with q + adapter + timeWindow passthrough", async () => {
1219
- const queryEventsCalls = [];
1220
- const searchEventsCalls = [];
1221
- const fakeVault = {
1222
- queryEvents: (qq) => {
1223
- queryEventsCalls.push(qq);
1224
- return [mkEvent("e-1", "wechat"), mkEvent("e-2", "wechat")];
1225
- },
1226
- searchEvents: (qq) => {
1227
- searchEventsCalls.push(qq);
1228
- return { rows: [mkEvent("fts-1", "wechat"), mkEvent("fts-2", "wechat")], nextCursor: null, mode: "fts5", shortQuery: false };
1229
- },
1230
- queryPersons: () => [],
1231
- queryItems: () => [],
1232
- getEvent: () => null,
1233
- audit: () => {},
1234
- stats: () => ({ events: 4, persons: 0, places: 0, items: 0, topics: 0 }),
1235
- };
1236
- const llm = new MockLLMClient({ reply: "ok" });
1237
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1238
- const r = await engine.ask("提到王老板的微信消息");
1239
-
1240
- expect(r.parsed.intent).toBe("list");
1241
- expect(searchEventsCalls).toHaveLength(1);
1242
- expect(searchEventsCalls[0].q).toBe("王老板");
1243
- expect(searchEventsCalls[0].adapter).toBe("wechat"); // parsed.filters.adapter passthrough
1244
- expect(searchEventsCalls[0].limit).toBe(10); // LIST_INTENT_FTS_LIMIT cap
1245
- // facts: 2 events + 2 FTS hits = 4 unique
1246
- expect(r.facts.filter((f) => f.type === "event")).toHaveLength(4);
1247
- expect(r.facts.map((f) => f.id)).toEqual(expect.arrayContaining(["e-1", "e-2", "fts-1", "fts-2"]));
1248
- });
1249
-
1250
- it("(b) no extractable entity → searchEvents NOT called", async () => {
1251
- const searchEventsCalls = [];
1252
- const fakeVault = {
1253
- queryEvents: () => [mkEvent("e-1")],
1254
- searchEvents: (qq) => { searchEventsCalls.push(qq); return { rows: [], mode: "fts5", shortQuery: false }; },
1255
- queryPersons: () => [],
1256
- queryItems: () => [],
1257
- getEvent: () => null,
1258
- audit: () => {},
1259
- stats: () => ({ events: 1, persons: 0, places: 0, items: 0, topics: 0 }),
1260
- };
1261
- const llm = new MockLLMClient({ reply: "ok" });
1262
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1263
- // "在淘宝买了什么" — extractEntityTerm strips everything → null
1264
- await engine.ask("在淘宝买了什么");
1265
- expect(searchEventsCalls).toHaveLength(0);
1266
- });
1267
-
1268
- it("(c) vault without searchEvents method → graceful skip, main path runs", async () => {
1269
- const fakeVault = {
1270
- queryEvents: () => [mkEvent("e-1")],
1271
- // no searchEvents — legacy vault fork
1272
- queryPersons: () => [],
1273
- queryItems: () => [],
1274
- getEvent: () => null,
1275
- audit: () => {},
1276
- stats: () => ({ events: 1, persons: 0, places: 0, items: 0, topics: 0 }),
1277
- };
1278
- const llm = new MockLLMClient({ reply: "ok" });
1279
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1280
- const r = await engine.ask("提到王老板的消息");
1281
- // Engine doesn't blow up; main path returns the 1 event.
1282
- expect(r.facts).toHaveLength(1);
1283
- });
1284
-
1285
- it("(d) FTS hits with overlapping ids are deduped (no double-count)", async () => {
1286
- const fakeVault = {
1287
- queryEvents: () => [mkEvent("e-1"), mkEvent("e-2")],
1288
- searchEvents: () => ({
1289
- rows: [mkEvent("e-1"), mkEvent("fts-3")], // e-1 overlaps with main query
1290
- nextCursor: null, mode: "fts5", shortQuery: false,
1291
- }),
1292
- queryPersons: () => [],
1293
- queryItems: () => [],
1294
- getEvent: () => null,
1295
- audit: () => {},
1296
- stats: () => ({ events: 3, persons: 0, places: 0, items: 0, topics: 0 }),
1297
- };
1298
- const llm = new MockLLMClient({ reply: "ok" });
1299
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1300
- const r = await engine.ask("提到王老板的消息");
1301
- // e-1, e-2, fts-3 — NOT 4 entries (e-1 dedup'd)
1302
- expect(r.facts.filter((f) => f.type === "event")).toHaveLength(3);
1303
- const ids = r.facts.map((f) => f.id);
1304
- expect(new Set(ids).size).toBe(ids.length); // no duplicates
1305
- });
1306
-
1307
- it("(e) intent=count / latest / sum-amount do NOT trigger FTS augmentation", async () => {
1308
- const searchEventsCalls = [];
1309
- const fakeVault = {
1310
- queryEvents: () => [mkEvent("e-1")],
1311
- searchEvents: (qq) => { searchEventsCalls.push(qq); return { rows: [], mode: "fts5", shortQuery: false }; },
1312
- queryPersons: () => [],
1313
- queryItems: () => [],
1314
- getEvent: () => null,
1315
- audit: () => {},
1316
- stats: () => ({ events: 1, persons: 0, places: 0, items: 0, topics: 0 }),
1317
- };
1318
- const llm = new MockLLMClient({ reply: "ok" });
1319
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1320
- // intent=count
1321
- await engine.ask("提到王老板的几个消息");
1322
- // intent=latest is short-circuited in narrow path; with extracted entity
1323
- // "王老板" wouldn't be hit because narrow returns 1 event and bails. Still
1324
- // verify the augmentation branch doesn't fire post-narrow.
1325
- await engine.ask("最近提到王老板的消息");
1326
- // intent=sum-amount
1327
- await engine.ask("总共花了多少在王老板这?");
1328
- expect(searchEventsCalls).toHaveLength(0);
1329
- });
1330
-
1331
- it("(f) searchEvents throwing does not block — main events still returned", async () => {
1332
- const fakeVault = {
1333
- queryEvents: () => [mkEvent("e-1"), mkEvent("e-2")],
1334
- searchEvents: () => { throw new Error("FTS5 module missing"); },
1335
- queryPersons: () => [],
1336
- queryItems: () => [],
1337
- getEvent: () => null,
1338
- audit: () => {},
1339
- stats: () => ({ events: 2, persons: 0, places: 0, items: 0, topics: 0 }),
1340
- };
1341
- const llm = new MockLLMClient({ reply: "ok" });
1342
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1343
- const r = await engine.ask("提到王老板的消息");
1344
- expect(r.facts).toHaveLength(2); // main path's events survive
1345
- });
1346
-
1347
- it("(g) FTS limit respects headroom — small maxFacts shrinks the FTS slice", async () => {
1348
- const searchEventsCalls = [];
1349
- const fakeVault = {
1350
- queryEvents: () => [mkEvent("e-1"), mkEvent("e-2"), mkEvent("e-3")],
1351
- searchEvents: (qq) => {
1352
- searchEventsCalls.push(qq);
1353
- return { rows: [mkEvent("fts-" + qq.limit)], mode: "fts5", shortQuery: false };
1354
- },
1355
- queryPersons: () => [],
1356
- queryItems: () => [],
1357
- getEvent: () => null,
1358
- audit: () => {},
1359
- stats: () => ({ events: 4, persons: 0, places: 0, items: 0, topics: 0 }),
1360
- };
1361
- const llm = new MockLLMClient({ reply: "ok" });
1362
- const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 5 });
1363
- await engine.ask("提到王老板的消息");
1364
- // maxFacts=5, events=3 → headroom=2, FTS limit = min(2, 10) = 2
1365
- expect(searchEventsCalls[0].limit).toBe(2);
1366
- });
1367
-
1368
- it("(h) when events already fill maxFacts, FTS skipped entirely", async () => {
1369
- const searchEventsCalls = [];
1370
- const fakeVault = {
1371
- queryEvents: () => Array.from({ length: 10 }, (_, i) => mkEvent("e-" + i)),
1372
- searchEvents: (qq) => { searchEventsCalls.push(qq); return { rows: [], mode: "fts5", shortQuery: false }; },
1373
- queryPersons: () => [],
1374
- queryItems: () => [],
1375
- getEvent: () => null,
1376
- audit: () => {},
1377
- stats: () => ({ events: 10, persons: 0, places: 0, items: 0, topics: 0 }),
1378
- };
1379
- const llm = new MockLLMClient({ reply: "ok" });
1380
- const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 10 });
1381
- await engine.ask("提到王老板的消息");
1382
- expect(searchEventsCalls).toHaveLength(0); // headroom = 0
1383
- });
1384
-
1385
- it("(i) FTS hit budget consumes persons/items remainder — not additive on top", async () => {
1386
- // FTS hits push events.length up → remaining budget for persons/items shrinks.
1387
- // Validates the FTS augment happens BEFORE persons/items calc.
1388
- const queryPersonsCalls = [];
1389
- const queryItemsCalls = [];
1390
- const fakeVault = {
1391
- queryEvents: () => [mkEvent("e-1"), mkEvent("e-2")], // 2 events
1392
- searchEvents: () => ({ rows: [mkEvent("fts-1"), mkEvent("fts-2")], mode: "fts5", shortQuery: false }),
1393
- queryPersons: ({ limit }) => { queryPersonsCalls.push(limit); return []; },
1394
- queryItems: ({ limit }) => { queryItemsCalls.push(limit); return []; },
1395
- getEvent: () => null,
1396
- audit: () => {},
1397
- stats: () => ({ events: 4, persons: 0, places: 0, items: 0, topics: 0 }),
1398
- };
1399
- const llm = new MockLLMClient({ reply: "ok" });
1400
- const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 10 });
1401
- await engine.ask("提到王老板的消息");
1402
- // 2 events + 2 FTS = 4 in events array → remaining = 10-4 = 6
1403
- // sideBudget = 3 → personBudget=3, itemBudget=3
1404
- expect(queryPersonsCalls[0]).toBe(3);
1405
- expect(queryItemsCalls[0]).toBe(3);
1406
- });
1407
- });
1408
-
1409
- // ─── intent=sum-amount routing — subtype-narrowed amount slice ──────────
1410
- //
1411
- // 2026-05-24 follow-up — "总共花了多少" / "在淘宝花了多少钱" only needs
1412
- // events from amount-bearing subtypes (order/payment/transfer/income).
1413
- // Pulling messages / visits / browses wastes prompt budget on rows the
1414
- // LLM can't sum. We split the budget across the 4 subtypes (min 20 each),
1415
- // union+dedup+sort by occurredAt DESC, skip persons/items entirely.
1416
- // 0 hits → fall through to default (defensive: empty-vault graceful).
1417
- // Memory: pdh_analysis_engine_intent_routing.md.
1418
-
1419
- describe("AnalysisEngine._gatherFacts intent=sum-amount routing", () => {
1420
- const mkEvent = (id, subtype, adapter = "taobao", occurredAt = Date.now()) => ({
1421
- id, type: "event", subtype, occurredAt, actor: "self",
1422
- content: { amount: { value: 100, currency: "CNY", direction: "out" } },
1423
- ingestedAt: Date.now(),
1424
- source: { adapter, adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
1425
- });
1426
-
1427
- it("(a) hits 4 subtype queries: order/payment/transfer/income → merged + deduped + sorted DESC", async () => {
1428
- const queryEventsCalls = [];
1429
- const fakeVault = {
1430
- queryEvents: (q) => {
1431
- queryEventsCalls.push(q);
1432
- // Return one event per subtype, occurredAt staggered so we can verify sort.
1433
- if (q.subtype === "order") return [mkEvent("o-1", "order", "taobao", 5000)];
1434
- if (q.subtype === "payment") return [mkEvent("p-1", "payment", "alipay-bill", 4000)];
1435
- if (q.subtype === "transfer") return [mkEvent("t-1", "transfer", "wechat", 3000)];
1436
- if (q.subtype === "income") return [mkEvent("i-1", "income", "email-imap", 2000)];
1437
- return [];
1438
- },
1439
- queryPersons: vi.fn(() => []),
1440
- queryItems: vi.fn(() => []),
1441
- getEvent: () => null,
1442
- audit: () => {},
1443
- stats: () => ({ events: 4, persons: 0, places: 0, items: 0, topics: 0 }),
1444
- };
1445
- const llm = new MockLLMClient({ reply: "ok" });
1446
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1447
- const r = await engine.ask("总共花了多少钱");
1448
-
1449
- expect(r.parsed.intent).toBe("sum-amount");
1450
- // 4 queryEvents calls, one per subtype.
1451
- expect(queryEventsCalls).toHaveLength(4);
1452
- expect(queryEventsCalls.map((c) => c.subtype).sort()).toEqual(
1453
- ["income", "order", "payment", "transfer"]
1454
- );
1455
- // facts: 4 unique events, sorted DESC by occurredAt → o-1 first.
1456
- expect(r.facts.map((f) => f.id)).toEqual(["o-1", "p-1", "t-1", "i-1"]);
1457
- // persons + items skipped — sum-amount doesn't need them.
1458
- expect(fakeVault.queryPersons).not.toHaveBeenCalled();
1459
- expect(fakeVault.queryItems).not.toHaveBeenCalled();
1460
- });
1461
-
1462
- it("(b) 0 amount events → return EMPTY (NOT fall through, prevents LLM summing unrelated rows)", async () => {
1463
- // Design change 2026-05-24: sum-amount narrow returning 0 used to fall
1464
- // through to the default broader path, which pulled persons/items.
1465
- // Bug: default path would also pull messages/visits/browsing — events
1466
- // the LLM might wrongly try to "sum" when asked total spending.
1467
- // Fix: return empty → warning="no-facts" → LLM uses TOTALS preamble to
1468
- // say "找不到相关花费记录" cleanly. Diverges from latest's fallback
1469
- // (which surfaces context); for sum-amount fallback actively misleads.
1470
- const queryEventsCalls = [];
1471
- const queryPersonsCalls = [];
1472
- const queryItemsCalls = [];
1473
- const fakeVault = {
1474
- queryEvents: (q) => { queryEventsCalls.push(q); return []; },
1475
- queryPersons: vi.fn(() => { queryPersonsCalls.push(true); return []; }),
1476
- queryItems: vi.fn(() => { queryItemsCalls.push(true); return []; }),
1477
- getEvent: () => null,
1478
- audit: () => {},
1479
- stats: () => ({ events: 0, persons: 5, places: 0, items: 2, topics: 0 }),
1480
- };
1481
- const llm = new MockLLMClient({ reply: "找不到相关花费记录" });
1482
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1483
- const r = await engine.ask("总共花了多少");
1484
-
1485
- // Only the 4 narrow (subtype-keyed) calls — NO default path call.
1486
- expect(queryEventsCalls).toHaveLength(4);
1487
- expect(queryEventsCalls.map((c) => c.subtype).sort()).toEqual(
1488
- ["income", "order", "payment", "transfer"]
1489
- );
1490
- // persons/items NOT pulled (sum-amount skips them; no fallback to default).
1491
- expect(fakeVault.queryPersons).not.toHaveBeenCalled();
1492
- expect(fakeVault.queryItems).not.toHaveBeenCalled();
1493
- // Empty facts + warning fired.
1494
- expect(r.facts).toHaveLength(0);
1495
- expect(r.warning).toBe("no-facts");
1496
- });
1497
-
1498
- it("(c) adapter filter passes through to all 4 subtype queries", async () => {
1499
- const queryEventsCalls = [];
1500
- const fakeVault = {
1501
- queryEvents: (q) => {
1502
- queryEventsCalls.push(q);
1503
- return q.subtype === "order" ? [mkEvent("o-1", "order", "taobao")] : [];
1504
- },
1505
- queryPersons: () => [],
1506
- queryItems: () => [],
1507
- getEvent: () => null,
1508
- audit: () => {},
1509
- stats: () => ({ events: 1, persons: 0, places: 0, items: 0, topics: 0 }),
1510
- };
1511
- const llm = new MockLLMClient({ reply: "ok" });
1512
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1513
- await engine.ask("在淘宝总共花了多少钱");
1514
-
1515
- expect(queryEventsCalls).toHaveLength(4);
1516
- for (const c of queryEventsCalls) {
1517
- expect(c.adapter).toBe("taobao");
1518
- }
1519
- });
1520
-
1521
- it("(d) timeWindow passes through to all 4 subtype queries", async () => {
1522
- const queryEventsCalls = [];
1523
- const fakeVault = {
1524
- queryEvents: (q) => {
1525
- queryEventsCalls.push(q);
1526
- return [];
1527
- },
1528
- queryPersons: () => [],
1529
- queryItems: () => [],
1530
- getEvent: () => null,
1531
- audit: () => {},
1532
- stats: () => ({ events: 0, persons: 0, places: 0, items: 0, topics: 0 }),
1533
- };
1534
- const llm = new MockLLMClient({ reply: "ok" });
1535
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1536
- await engine.ask("上个月总共花了多少", { now: NOW });
1537
-
1538
- // Narrow path's 4 subtype calls — NO default fallback since 2026-05-24
1539
- // sum-amount bug fix (empty narrow no longer falls through).
1540
- expect(queryEventsCalls).toHaveLength(4);
1541
- for (const c of queryEventsCalls) {
1542
- expect(c.since).toBeDefined();
1543
- expect(c.until).toBeDefined();
1544
- }
1545
- });
1546
-
1547
- it("(e) sum-amount does NOT trigger FTS augmentation (list-only branch)", async () => {
1548
- const searchEventsCalls = [];
1549
- const fakeVault = {
1550
- queryEvents: () => [mkEvent("o-1", "order")],
1551
- searchEvents: (q) => { searchEventsCalls.push(q); return { rows: [], mode: "fts5", shortQuery: false }; },
1552
- queryPersons: () => [],
1553
- queryItems: () => [],
1554
- getEvent: () => null,
1555
- audit: () => {},
1556
- stats: () => ({ events: 1, persons: 0, places: 0, items: 0, topics: 0 }),
1557
- };
1558
- const llm = new MockLLMClient({ reply: "ok" });
1559
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1560
- // Question carries a potential entity name "王老板", but intent=sum-amount
1561
- // must NOT call searchEvents (FTS is list-only).
1562
- await engine.ask("总共付给王老板多少钱");
1563
- expect(searchEventsCalls).toHaveLength(0);
1564
- });
1565
-
1566
- it("(f) per-subtype budget respects effMaxQueryLimit/4 with floor of 20", async () => {
1567
- const queryEventsCalls = [];
1568
- const fakeVault = {
1569
- queryEvents: (q) => { queryEventsCalls.push(q); return []; },
1570
- queryPersons: () => [],
1571
- queryItems: () => [],
1572
- getEvent: () => null,
1573
- audit: () => {},
1574
- stats: () => ({ events: 0, persons: 0, places: 0, items: 0, topics: 0 }),
1575
- };
1576
- const llm = new MockLLMClient({ reply: "ok" });
1577
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1578
- // effMaxQueryLimit = 200 (constructor default) → 200/4 = 50 per subtype
1579
- await engine.ask("总共花了多少");
1580
- // First 4 calls are narrow path (subtype-keyed).
1581
- expect(queryEventsCalls[0].limit).toBe(50);
1582
-
1583
- // Small-model budget: effMaxQueryLimit=50 → 50/4 = 12 → max(20, 12) = 20
1584
- queryEventsCalls.length = 0;
1585
- await engine.ask("总共花了多少", { maxQueryLimit: 50 });
1586
- expect(queryEventsCalls[0].limit).toBe(20);
1587
- });
1588
-
1589
- it("(g) dedup: same event id surfaced under multiple subtypes appears once", async () => {
1590
- // Defensive — events have unique subtype, but verify dedup if vault
1591
- // ever returns the same event from multiple subtype queries.
1592
- const fakeVault = {
1593
- queryEvents: (q) => {
1594
- // Both "order" and "payment" return e-shared (impossible in real
1595
- // vault but proves dedup logic).
1596
- if (q.subtype === "order" || q.subtype === "payment") {
1597
- return [mkEvent("e-shared", q.subtype)];
1598
- }
1599
- return [];
1600
- },
1601
- queryPersons: () => [],
1602
- queryItems: () => [],
1603
- getEvent: () => null,
1604
- audit: () => {},
1605
- stats: () => ({ events: 1, persons: 0, places: 0, items: 0, topics: 0 }),
1606
- };
1607
- const llm = new MockLLMClient({ reply: "ok" });
1608
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1609
- const r = await engine.ask("总共花了多少");
1610
-
1611
- expect(r.facts).toHaveLength(1);
1612
- expect(r.facts[0].id).toBe("e-shared");
1613
- });
1614
-
1615
- it("(h) result truncated to effMaxFacts (small-model 20 budget)", async () => {
1616
- const fakeVault = {
1617
- queryEvents: (q) => {
1618
- // Each subtype returns 50 events → 4*50 = 200 total before cap
1619
- return Array.from({ length: 50 }, (_, i) => mkEvent(
1620
- q.subtype + "-" + i, q.subtype, "taobao", Date.now() - i
1621
- ));
1622
- },
1623
- queryPersons: () => [],
1624
- queryItems: () => [],
1625
- getEvent: () => null,
1626
- audit: () => {},
1627
- stats: () => ({ events: 200, persons: 0, places: 0, items: 0, topics: 0 }),
1628
- };
1629
- const llm = new MockLLMClient({ reply: "ok" });
1630
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1631
- const r = await engine.ask("总共花了多少", { maxFacts: 20 });
1632
- expect(r.facts).toHaveLength(20);
1633
- });
1634
- });
1635
-
1636
- // ─── intent=count routing — isolated coverage ───────────────────────────
1637
- //
1638
- // 2026-05-24 — `intent=count` ("几个 X" / "多少个 Y") is handled by the
1639
- // TOTALS preamble (commit 19c11920e): vault.stats() is rendered before
1640
- // FACTS so the LLM quotes the real number instead of FACTS array length.
1641
- //
1642
- // 2026-06-02 — FACTS now ALSO hard-caps to COUNT_INTENT_FACT_LIMIT (5)
1643
- // illustrative rows instead of the full ≤80 default sample: TOTALS already
1644
- // carries the authoritative count (Rule 6), so a count question only needs a
1645
- // few examples — saves prompt budget on local small models. Scoped by reliable
1646
- // adapter+time filters; persons/items skipped (count-of-contacts/apps routes
1647
- // via entityFocus). 0 hits → fall through to the default broader path (safety
1648
- // net for a count misclassification of a list question). Memory:
1649
- // pdh_analysis_engine_intent_routing.md.
1650
-
1651
- describe("AnalysisEngine._gatherFacts intent=count routing", () => {
1652
- const mkEvent = (id, subtype = "order", adapter = "taobao") => ({
1653
- id, type: "event", subtype, occurredAt: Date.now(), actor: "self",
1654
- ingestedAt: Date.now(),
1655
- source: { adapter, adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
1656
- });
1657
-
1658
- it("(a) intent=count → ≤5 illustrative events (capped), persons/items NOT queried", async () => {
1659
- const queryEventsCalls = [];
1660
- const fakeVault = {
1661
- queryEvents: (q) => {
1662
- queryEventsCalls.push(q);
1663
- return Array.from({ length: 20 }, (_, i) => mkEvent("e-" + i)).slice(0, q.limit);
1664
- },
1665
- queryPersons: vi.fn(() => []),
1666
- queryItems: vi.fn(() => []),
1667
- getEvent: () => null,
1668
- audit: () => {},
1669
- stats: () => ({ events: 20, persons: 0, places: 0, items: 0, topics: 0 }),
1670
- };
1671
- const llm = new MockLLMClient({ reply: "ok" });
1672
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1673
- const r = await engine.ask("我有多少个订单");
1674
-
1675
- expect(r.parsed.intent).toBe("count");
1676
- // Capped to COUNT_INTENT_FACT_LIMIT (5), NOT the old default 200 — TOTALS
1677
- // carries the authoritative count, FACTS is just a few examples.
1678
- expect(queryEventsCalls).toHaveLength(1);
1679
- expect(queryEventsCalls[0].limit).toBe(5);
1680
- expect(queryEventsCalls[0].subtype).toBeUndefined(); // subtype NOT passed (unreliable)
1681
- expect(r.facts).toHaveLength(5);
1682
- // count-of-events doesn't need contacts/apps — skipped (those route via entityFocus).
1683
- expect(fakeVault.queryPersons).not.toHaveBeenCalled();
1684
- expect(fakeVault.queryItems).not.toHaveBeenCalled();
1685
- });
1686
-
1687
- it("(a2) intent=count with adapter scope → adapter passed through on the capped query", async () => {
1688
- const queryEventsCalls = [];
1689
- const fakeVault = {
1690
- queryEvents: (q) => {
1691
- queryEventsCalls.push(q);
1692
- return [mkEvent("e-1")];
1693
- },
1694
- queryPersons: () => [],
1695
- queryItems: () => [],
1696
- getEvent: () => null,
1697
- audit: () => {},
1698
- stats: () => ({ events: 1, persons: 0, places: 0, items: 0, topics: 0 }),
1699
- };
1700
- const llm = new MockLLMClient({ reply: "ok" });
1701
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1702
- const r = await engine.ask("我在淘宝有多少个订单");
1703
-
1704
- expect(r.parsed.intent).toBe("count");
1705
- expect(queryEventsCalls).toHaveLength(1);
1706
- expect(queryEventsCalls[0].limit).toBe(5);
1707
- expect(queryEventsCalls[0].adapter).toBe("taobao");
1708
- });
1709
-
1710
- it("(b) intent=count emits TOTALS block in prompt (authoritative ground truth)", async () => {
1711
- const chatCalls = [];
1712
- const fakeVault = {
1713
- queryEvents: () => [],
1714
- queryPersons: () => [],
1715
- queryItems: () => [],
1716
- getEvent: () => null,
1717
- audit: () => {},
1718
- stats: () => ({ events: 12, persons: 512, places: 3, items: 89, topics: 0 }),
1719
- };
1720
- const llm = {
1721
- isLocal: true,
1722
- chat: async (msgs) => { chatCalls.push(msgs); return { text: "你有 512 个联系人", usage: {} }; },
1723
- };
1724
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1725
- await engine.ask("我有多少个联系人");
1726
-
1727
- const userMsg = chatCalls[0][1].content;
1728
- expect(userMsg).toContain("TOTALS");
1729
- expect(userMsg).toContain('"persons": 512');
1730
- expect(userMsg).toContain('"items": 89');
1731
- // System prompt instructs LLM to trust TOTALS over FACTS length.
1732
- expect(chatCalls[0][0].content).toMatch(/TOTALS.*authoritative/i);
1733
- });
1734
-
1735
- it("(c) intent=count does NOT trigger FTS augmentation (even with entity name)", async () => {
1736
- const searchEventsCalls = [];
1737
- const fakeVault = {
1738
- queryEvents: () => [],
1739
- searchEvents: (q) => { searchEventsCalls.push(q); return { rows: [], mode: "fts5", shortQuery: false }; },
1740
- queryPersons: () => [],
1741
- queryItems: () => [],
1742
- getEvent: () => null,
1743
- audit: () => {},
1744
- stats: () => ({ events: 0, persons: 0, places: 0, items: 0, topics: 0 }),
1745
- };
1746
- const llm = new MockLLMClient({ reply: "ok" });
1747
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1748
- // Question carries an entity name "王老板" but intent=count must not call FTS
1749
- // (FTS is list-only; count uses TOTALS path).
1750
- await engine.ask("提到王老板的几个消息");
1751
- expect(searchEventsCalls).toHaveLength(0);
1752
- });
1753
-
1754
- it("(d) intent=count does NOT trigger sum-amount narrow (separate routing)", async () => {
1755
- const queryEventsCalls = [];
1756
- const fakeVault = {
1757
- queryEvents: (q) => { queryEventsCalls.push(q); return []; },
1758
- queryPersons: () => [],
1759
- queryItems: () => [],
1760
- getEvent: () => null,
1761
- audit: () => {},
1762
- stats: () => ({ events: 0, persons: 0, places: 0, items: 0, topics: 0 }),
1763
- };
1764
- const llm = new MockLLMClient({ reply: "ok" });
1765
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1766
- await engine.ask("几个订单");
1767
- // count branch (limit 5, 0 hits) → fall through to default (limit 200).
1768
- // Neither call carries a subtype filter — NOT the 4 subtype-narrowed calls
1769
- // that are sum-amount only.
1770
- expect(queryEventsCalls.map((q) => q.limit)).toEqual([5, 200]);
1771
- expect(queryEventsCalls.every((q) => q.subtype === undefined)).toBe(true);
1772
- });
1773
-
1774
- it("(e) intent=count with 0 events falls through → persons + items in FACTS (safety net)", async () => {
1775
- const fakeVault = {
1776
- queryEvents: () => [],
1777
- queryPersons: ({ limit }) => Array.from({ length: Math.min(limit, 5) }, (_, i) => ({
1778
- id: "p-" + i, type: "person", subtype: "contact", names: ["P" + i],
1779
- ingestedAt: Date.now(),
1780
- source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
1781
- })),
1782
- queryItems: ({ limit }) => Array.from({ length: Math.min(limit, 3) }, (_, i) => ({
1783
- id: "i-" + i, type: "item", subtype: "other", name: "App" + i,
1784
- ingestedAt: Date.now(),
1785
- source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
1786
- })),
1787
- getEvent: () => null,
1788
- audit: () => {},
1789
- stats: () => ({ events: 0, persons: 5, places: 0, items: 3, topics: 0 }),
1790
- };
1791
- const llm = new MockLLMClient({ reply: "ok" });
1792
- const engine = new AnalysisEngine({ vault: fakeVault, llm });
1793
- const r = await engine.ask("我有几个 app");
1794
-
1795
- expect(r.parsed.intent).toBe("count");
1796
- expect(r.facts.filter((f) => f.type === "person").length).toBe(5);
1797
- expect(r.facts.filter((f) => f.type === "item").length).toBe(3);
1798
- });
1799
- });
1800
-
1801
- // ─── ① cross-app overview injected into ask() prompt (decision grounding) ──
1802
- describe("AnalysisEngine.ask crossApp overview context", () => {
1803
- function seedMultiApp(vault) {
1804
- vault.putPerson({
1805
- id: "person-friend", type: "person", subtype: "contact",
1806
- names: ["小明"], identifiers: {}, ingestedAt: Date.now(), source: source("wechat-pc"),
1807
- });
1808
- vault.putEvent({
1809
- id: newId(), type: "event", subtype: "order", occurredAt: ts(2026, 3, 10),
1810
- actor: "person-self",
1811
- content: { title: "鞋", amount: { value: 200, currency: "CNY", direction: "out" } },
1812
- ingestedAt: Date.now(), source: source("shopping-taobao", "o1"),
1813
- });
1814
- vault.putEvent({
1815
- id: newId(), type: "event", subtype: "message", occurredAt: ts(2026, 3, 11),
1816
- actor: "person-self", participants: ["person-friend"],
1817
- content: { title: "hi", text: "hi" },
1818
- ingestedAt: Date.now(), source: source("wechat-pc", "m1"),
1819
- });
1820
- }
1821
-
1822
- it("injects CROSS_APP_OVERVIEW block when crossApp:true", async () => {
1823
- freshVault();
1824
- seedMultiApp(vault);
1825
- const llm = new MockLLMClient({ reply: "建议:…" });
1826
- const engine = new AnalysisEngine({ vault, llm });
1827
- await engine.ask("综合我各 app 的数据,我最近重心在哪?", { crossApp: true });
1828
- const userMsg = llm.calls[0].messages.find((m) => m.role === "user").content;
1829
- expect(userMsg).toContain("CROSS_APP_OVERVIEW");
1830
- expect(userMsg).toContain("活跃 app");
1831
- // both apps surface in the cross-app aggregation
1832
- expect(userMsg).toMatch(/shopping-taobao|wechat-pc/);
1833
- expect(userMsg).toContain("跨 app 消费合计");
1834
- });
1835
-
1836
- it("omits CROSS_APP_OVERVIEW when crossApp not set", async () => {
1837
- freshVault();
1838
- seedMultiApp(vault);
1839
- const llm = new MockLLMClient({ reply: "ok" });
1840
- const engine = new AnalysisEngine({ vault, llm });
1841
- await engine.ask("随便问问", {});
1842
- const userMsg = llm.calls[0].messages.find((m) => m.role === "user").content;
1843
- expect(userMsg).not.toContain("CROSS_APP_OVERVIEW");
1844
- });
1845
- });