@chainlesschain/personal-data-hub 0.4.29 → 0.4.31

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 (199) hide show
  1. package/lib/forensics/qq-nt-collect.js +190 -0
  2. package/lib/prompt-builder.js +15 -1
  3. package/package.json +8 -3
  4. package/__tests__/adapter-guide.test.js +0 -47
  5. package/__tests__/adapter-spec.test.js +0 -78
  6. package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +0 -211
  7. package/__tests__/adapters/ai-chat-health-checker.test.js +0 -262
  8. package/__tests__/adapters/ai-chat-history.test.js +0 -396
  9. package/__tests__/adapters/ai-chat-http-client.test.js +0 -242
  10. package/__tests__/adapters/ai-chat-vendors.test.js +0 -874
  11. package/__tests__/adapters/alipay-bill-adapter.test.js +0 -538
  12. package/__tests__/adapters/apple-health.test.js +0 -95
  13. package/__tests__/adapters/bank-family.test.js +0 -125
  14. package/__tests__/adapters/biz-tianyancha.test.js +0 -159
  15. package/__tests__/adapters/browser-history-chrome.test.js +0 -377
  16. package/__tests__/adapters/browser-history-edge.test.js +0 -159
  17. package/__tests__/adapters/car-mercedesme.test.js +0 -74
  18. package/__tests__/adapters/doc-baidu-netdisk.test.js +0 -102
  19. package/__tests__/adapters/doc-camscanner.test.js +0 -147
  20. package/__tests__/adapters/doc-platforms.test.js +0 -177
  21. package/__tests__/adapters/edu-huawei-learning-live.test.js +0 -198
  22. package/__tests__/adapters/edu-zuoyebang-live.test.js +0 -226
  23. package/__tests__/adapters/email-adapter-snapshot.test.js +0 -237
  24. package/__tests__/adapters/email-adapter.test.js +0 -742
  25. package/__tests__/adapters/email-classifier.test.js +0 -347
  26. package/__tests__/adapters/email-imap-session.test.js +0 -334
  27. package/__tests__/adapters/email-parser.test.js +0 -244
  28. package/__tests__/adapters/email-pdf-extractor.test.js +0 -529
  29. package/__tests__/adapters/email-providers.test.js +0 -84
  30. package/__tests__/adapters/email-retry-progress.test.js +0 -294
  31. package/__tests__/adapters/email-templates.test.js +0 -822
  32. package/__tests__/adapters/family-23-collectors-scaffold.test.js +0 -182
  33. package/__tests__/adapters/finance-alipay-live.test.js +0 -258
  34. package/__tests__/adapters/finance-dcep.test.js +0 -74
  35. package/__tests__/adapters/fitness-joyrun.test.js +0 -82
  36. package/__tests__/adapters/game-genshin-live.test.js +0 -238
  37. package/__tests__/adapters/game-genshin-scaffold.test.js +0 -108
  38. package/__tests__/adapters/game-honor-of-kings-live.test.js +0 -230
  39. package/__tests__/adapters/git-activity.test.js +0 -222
  40. package/__tests__/adapters/gov-12123.test.js +0 -103
  41. package/__tests__/adapters/gov-ixiamen.test.js +0 -150
  42. package/__tests__/adapters/gov-tax.test.js +0 -135
  43. package/__tests__/adapters/health-meiyou.test.js +0 -125
  44. package/__tests__/adapters/local-files.test.js +0 -264
  45. package/__tests__/adapters/local-im-pc.test.js +0 -154
  46. package/__tests__/adapters/messaging-whatsapp.test.js +0 -289
  47. package/__tests__/adapters/music-kugou.test.js +0 -187
  48. package/__tests__/adapters/music-qq.test.js +0 -112
  49. package/__tests__/adapters/netease-music-live.test.js +0 -244
  50. package/__tests__/adapters/netease-music.test.js +0 -74
  51. package/__tests__/adapters/pc-local-discovery.test.js +0 -141
  52. package/__tests__/adapters/qq-pc-direct-read.test.js +0 -227
  53. package/__tests__/adapters/reading-family.test.js +0 -108
  54. package/__tests__/adapters/recruit-boss.test.js +0 -180
  55. package/__tests__/adapters/shell-history.test.js +0 -180
  56. package/__tests__/adapters/shopping-base.test.js +0 -179
  57. package/__tests__/adapters/shopping-dianping.test.js +0 -239
  58. package/__tests__/adapters/social-bilibili-adb-api-client.test.js +0 -721
  59. package/__tests__/adapters/social-bilibili-adb-chromium-cookies-reader.test.js +0 -346
  60. package/__tests__/adapters/social-bilibili-adb-collector.test.js +0 -284
  61. package/__tests__/adapters/social-bilibili-adb-cookies-extension.test.js +0 -343
  62. package/__tests__/adapters/social-bilibili-adb-snapshot-builder.test.js +0 -296
  63. package/__tests__/adapters/social-csdn.test.js +0 -175
  64. package/__tests__/adapters/social-dongchedi.test.js +0 -165
  65. package/__tests__/adapters/social-douyin-adb-aweme-detail.test.js +0 -165
  66. package/__tests__/adapters/social-douyin-adb-collector.test.js +0 -254
  67. package/__tests__/adapters/social-douyin-adb-db-extension.test.js +0 -114
  68. package/__tests__/adapters/social-douyin-adb-im-db-parser.test.js +0 -304
  69. package/__tests__/adapters/social-douyin-adb-snapshot-builder.test.js +0 -216
  70. package/__tests__/adapters/social-douyin-adb-usage-profile.test.js +0 -229
  71. package/__tests__/adapters/social-douyin-adb-watch-history.test.js +0 -269
  72. package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +0 -496
  73. package/__tests__/adapters/social-kuaishou-adb-collector.test.js +0 -276
  74. package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +0 -152
  75. package/__tests__/adapters/social-kuaishou-adb-snapshot-builder.test.js +0 -178
  76. package/__tests__/adapters/social-toutiao-adb-account-reader.test.js +0 -135
  77. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +0 -626
  78. package/__tests__/adapters/social-toutiao-adb-article.test.js +0 -155
  79. package/__tests__/adapters/social-toutiao-adb-collector.test.js +0 -378
  80. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +0 -193
  81. package/__tests__/adapters/social-toutiao-adb-snapshot-builder.test.js +0 -196
  82. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +0 -311
  83. package/__tests__/adapters/social-weibo-adb-api-client.test.js +0 -362
  84. package/__tests__/adapters/social-weibo-adb-collector.test.js +0 -201
  85. package/__tests__/adapters/social-weibo-adb-cookies-extension.test.js +0 -167
  86. package/__tests__/adapters/social-weibo-adb-snapshot-builder.test.js +0 -189
  87. package/__tests__/adapters/social-xiaohongshu-adb-api-client.test.js +0 -431
  88. package/__tests__/adapters/social-xiaohongshu-adb-collector.test.js +0 -207
  89. package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
  90. package/__tests__/adapters/social-xiaohongshu-adb-sign-provider-injection.test.js +0 -351
  91. package/__tests__/adapters/social-xiaohongshu-adb-sign.test.js +0 -130
  92. package/__tests__/adapters/social-xiaohongshu-adb-snapshot-builder.test.js +0 -200
  93. package/__tests__/adapters/social-zhihu.test.js +0 -246
  94. package/__tests__/adapters/system-data-adapter.test.js +0 -443
  95. package/__tests__/adapters/system-data-android-ingest.test.js +0 -144
  96. package/__tests__/adapters/system-data-android.test.js +0 -519
  97. package/__tests__/adapters/system-data-disclosure.test.js +0 -153
  98. package/__tests__/adapters/travel-12306.test.js +0 -512
  99. package/__tests__/adapters/travel-amap.test.js +0 -219
  100. package/__tests__/adapters/travel-baidu-map.test.js +0 -305
  101. package/__tests__/adapters/travel-base.test.js +0 -205
  102. package/__tests__/adapters/travel-ctrip.test.js +0 -377
  103. package/__tests__/adapters/travel-didi-consumer.test.js +0 -66
  104. package/__tests__/adapters/travel-didi.test.js +0 -204
  105. package/__tests__/adapters/travel-tencent-map.test.js +0 -207
  106. package/__tests__/adapters/travel-tongcheng.test.js +0 -289
  107. package/__tests__/adapters/video-platforms.test.js +0 -152
  108. package/__tests__/adapters/video-xigua.test.js +0 -106
  109. package/__tests__/adapters/vscode.test.js +0 -299
  110. package/__tests__/adapters/wechat-bootstrap.test.js +0 -240
  111. package/__tests__/adapters/wechat-env-probe.test.js +0 -162
  112. package/__tests__/adapters/wechat-frida-agent.test.js +0 -322
  113. package/__tests__/adapters/wechat-frida-integration.test.js +0 -149
  114. package/__tests__/adapters/wechat-frida-key-provider.test.js +0 -188
  115. package/__tests__/adapters/wechat-md5-key-provider.test.js +0 -101
  116. package/__tests__/adapters/wechat-pc-direct-read.test.js +0 -365
  117. package/__tests__/adapters/wechat-pc-group-topic.test.js +0 -63
  118. package/__tests__/adapters/wechat-pc-v4-sidecar.test.js +0 -72
  119. package/__tests__/adapters/weread.test.js +0 -123
  120. package/__tests__/adapters/wework-pc.test.js +0 -124
  121. package/__tests__/adapters/win-recent.test.js +0 -192
  122. package/__tests__/analysis-skills.test.js +0 -754
  123. package/__tests__/analysis.test.js +0 -1845
  124. package/__tests__/audio-ximalaya-snapshot.test.js +0 -279
  125. package/__tests__/batch.test.js +0 -133
  126. package/__tests__/bridges-cc-kg.test.js +0 -231
  127. package/__tests__/bridges-cc-llm.test.js +0 -191
  128. package/__tests__/bridges-cc-rag.test.js +0 -162
  129. package/__tests__/categories.test.js +0 -92
  130. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +0 -213
  131. package/__tests__/e2e/full-user-journey.test.js +0 -188
  132. package/__tests__/e2e/local-data-adapters-cli.e2e.test.js +0 -146
  133. package/__tests__/entity-resolver-ingest-hook.test.js +0 -177
  134. package/__tests__/entity-resolver-stages.test.js +0 -411
  135. package/__tests__/entity-resolver-vault.test.js +0 -249
  136. package/__tests__/entity-resolver.test.js +0 -526
  137. package/__tests__/fitness-keep-snapshot.test.js +0 -224
  138. package/__tests__/fixtures/entity-resolver-200-mock.json +0 -96
  139. package/__tests__/ids.test.js +0 -45
  140. package/__tests__/integration/ai-chat-history-registry.test.js +0 -228
  141. package/__tests__/integration/aichat-wizard-end-to-end.test.js +0 -282
  142. package/__tests__/integration/cross-adapter-pipelines.test.js +0 -396
  143. package/__tests__/integration/local-data-adapters-pipeline.test.js +0 -373
  144. package/__tests__/integration/social-bilibili-pipeline.test.js +0 -261
  145. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +0 -390
  146. package/__tests__/key-providers.test.js +0 -126
  147. package/__tests__/kg-derive.test.js +0 -219
  148. package/__tests__/llm-client.test.js +0 -122
  149. package/__tests__/longtail-adapters.test.js +0 -281
  150. package/__tests__/messaging-qq-snapshot.test.js +0 -294
  151. package/__tests__/mobile-extractor-encrypted.test.js +0 -460
  152. package/__tests__/mobile-extractor.test.js +0 -288
  153. package/__tests__/mock-adapter.test.js +0 -93
  154. package/__tests__/prompt-builder.test.js +0 -249
  155. package/__tests__/query-parser.test.js +0 -365
  156. package/__tests__/rag-derive.test.js +0 -169
  157. package/__tests__/registry-readiness.test.js +0 -292
  158. package/__tests__/registry.test.js +0 -420
  159. package/__tests__/salvage-ingest.test.js +0 -97
  160. package/__tests__/schemas.test.js +0 -331
  161. package/__tests__/shopping-adapters.test.js +0 -392
  162. package/__tests__/shopping-eleme-snapshot.test.js +0 -454
  163. package/__tests__/shopping-pinduoduo-snapshot.test.js +0 -484
  164. package/__tests__/shopping-snapshot.test.js +0 -438
  165. package/__tests__/shopping-vipshop-snapshot.test.js +0 -425
  166. package/__tests__/shopping-xianyu-snapshot.test.js +0 -451
  167. package/__tests__/sidecar-contacts-cross-validate.test.js +0 -186
  168. package/__tests__/sidecar-supervisor.test.js +0 -128
  169. package/__tests__/sign-providers.test.js +0 -62
  170. package/__tests__/social-adapters.test.js +0 -280
  171. package/__tests__/social-bilibili-snapshot.test.js +0 -278
  172. package/__tests__/social-douban-snapshot.test.js +0 -351
  173. package/__tests__/social-douyin-im-direct-read.test.js +0 -377
  174. package/__tests__/social-douyin-salvage-collector.test.js +0 -98
  175. package/__tests__/social-douyin-salvage-mapper.test.js +0 -90
  176. package/__tests__/social-douyin-snapshot.test.js +0 -256
  177. package/__tests__/social-kuaishou-snapshot.test.js +0 -362
  178. package/__tests__/social-toutiao-snapshot.test.js +0 -366
  179. package/__tests__/social-weibo-snapshot.test.js +0 -234
  180. package/__tests__/social-weibo-sqlite-device.test.js +0 -174
  181. package/__tests__/social-xiaohongshu-snapshot.test.js +0 -232
  182. package/__tests__/sqlite-leaf-salvage.test.js +0 -97
  183. package/__tests__/travel-adapters.test.js +0 -483
  184. package/__tests__/travel-maps-snapshot.test.js +0 -426
  185. package/__tests__/vault-driver-error.test.js +0 -74
  186. package/__tests__/vault-search-helpers.test.js +0 -104
  187. package/__tests__/vault-search.test.js +0 -423
  188. package/__tests__/vault.test.js +0 -767
  189. package/__tests__/wechat-adapter.test.js +0 -594
  190. package/__tests__/whatsapp-adapter.test.js +0 -138
  191. package/scripts/_make-fixture-all.js +0 -126
  192. package/scripts/_make-fixture-contacts.js +0 -84
  193. package/scripts/evaluate-entity-resolver.js +0 -213
  194. package/scripts/run-native-tests-sandbox.sh +0 -55
  195. package/scripts/smoke-phase-5-5.js +0 -196
  196. package/scripts/smoke-phase-5-7.js +0 -181
  197. package/scripts/smoke-system-data-contacts.js +0 -309
  198. package/scripts/smoke-system-data.js +0 -312
  199. 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
- });