@chainlesschain/personal-data-hub 0.4.28 → 0.4.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/lib/adapters/social-douyin-adb/usage-profile-reader.js +253 -0
- package/lib/adapters/social-douyin-adb/watch-history-reader.js +104 -31
- package/lib/adapters/social-toutiao-adb/article-reader.js +202 -0
- package/lib/analysis-skills/overview.js +24 -4
- package/lib/analysis-skills/spending.js +63 -2
- package/lib/analysis-skills/timeline.js +11 -6
- package/lib/prompt-builder.js +15 -1
- package/lib/query-parser.js +38 -8
- package/package.json +4 -1
- package/__tests__/adapter-guide.test.js +0 -47
- package/__tests__/adapter-spec.test.js +0 -78
- package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +0 -211
- package/__tests__/adapters/ai-chat-health-checker.test.js +0 -262
- package/__tests__/adapters/ai-chat-history.test.js +0 -396
- package/__tests__/adapters/ai-chat-http-client.test.js +0 -242
- package/__tests__/adapters/ai-chat-vendors.test.js +0 -874
- package/__tests__/adapters/alipay-bill-adapter.test.js +0 -538
- package/__tests__/adapters/apple-health.test.js +0 -95
- package/__tests__/adapters/bank-family.test.js +0 -125
- package/__tests__/adapters/biz-tianyancha.test.js +0 -159
- package/__tests__/adapters/browser-history-chrome.test.js +0 -377
- package/__tests__/adapters/browser-history-edge.test.js +0 -159
- package/__tests__/adapters/car-mercedesme.test.js +0 -74
- package/__tests__/adapters/doc-baidu-netdisk.test.js +0 -102
- package/__tests__/adapters/doc-camscanner.test.js +0 -147
- package/__tests__/adapters/doc-platforms.test.js +0 -177
- package/__tests__/adapters/edu-huawei-learning-live.test.js +0 -198
- package/__tests__/adapters/edu-zuoyebang-live.test.js +0 -226
- package/__tests__/adapters/email-adapter-snapshot.test.js +0 -237
- package/__tests__/adapters/email-adapter.test.js +0 -742
- package/__tests__/adapters/email-classifier.test.js +0 -347
- package/__tests__/adapters/email-imap-session.test.js +0 -334
- package/__tests__/adapters/email-parser.test.js +0 -244
- package/__tests__/adapters/email-pdf-extractor.test.js +0 -529
- package/__tests__/adapters/email-providers.test.js +0 -84
- package/__tests__/adapters/email-retry-progress.test.js +0 -294
- package/__tests__/adapters/email-templates.test.js +0 -822
- package/__tests__/adapters/family-23-collectors-scaffold.test.js +0 -182
- package/__tests__/adapters/finance-alipay-live.test.js +0 -258
- package/__tests__/adapters/finance-dcep.test.js +0 -74
- package/__tests__/adapters/fitness-joyrun.test.js +0 -82
- package/__tests__/adapters/game-genshin-live.test.js +0 -238
- package/__tests__/adapters/game-genshin-scaffold.test.js +0 -108
- package/__tests__/adapters/game-honor-of-kings-live.test.js +0 -230
- package/__tests__/adapters/git-activity.test.js +0 -222
- package/__tests__/adapters/gov-12123.test.js +0 -103
- package/__tests__/adapters/gov-ixiamen.test.js +0 -150
- package/__tests__/adapters/gov-tax.test.js +0 -135
- package/__tests__/adapters/health-meiyou.test.js +0 -125
- package/__tests__/adapters/local-files.test.js +0 -264
- package/__tests__/adapters/local-im-pc.test.js +0 -154
- package/__tests__/adapters/messaging-whatsapp.test.js +0 -289
- package/__tests__/adapters/music-kugou.test.js +0 -187
- package/__tests__/adapters/music-qq.test.js +0 -112
- package/__tests__/adapters/netease-music-live.test.js +0 -244
- package/__tests__/adapters/netease-music.test.js +0 -74
- package/__tests__/adapters/pc-local-discovery.test.js +0 -141
- package/__tests__/adapters/qq-pc-direct-read.test.js +0 -227
- package/__tests__/adapters/reading-family.test.js +0 -108
- package/__tests__/adapters/recruit-boss.test.js +0 -180
- package/__tests__/adapters/shell-history.test.js +0 -180
- package/__tests__/adapters/shopping-base.test.js +0 -179
- package/__tests__/adapters/shopping-dianping.test.js +0 -239
- package/__tests__/adapters/social-bilibili-adb-api-client.test.js +0 -721
- package/__tests__/adapters/social-bilibili-adb-chromium-cookies-reader.test.js +0 -346
- package/__tests__/adapters/social-bilibili-adb-collector.test.js +0 -284
- package/__tests__/adapters/social-bilibili-adb-cookies-extension.test.js +0 -343
- package/__tests__/adapters/social-bilibili-adb-snapshot-builder.test.js +0 -296
- package/__tests__/adapters/social-csdn.test.js +0 -175
- package/__tests__/adapters/social-dongchedi.test.js +0 -165
- package/__tests__/adapters/social-douyin-adb-aweme-detail.test.js +0 -165
- package/__tests__/adapters/social-douyin-adb-collector.test.js +0 -254
- package/__tests__/adapters/social-douyin-adb-db-extension.test.js +0 -114
- package/__tests__/adapters/social-douyin-adb-im-db-parser.test.js +0 -304
- package/__tests__/adapters/social-douyin-adb-snapshot-builder.test.js +0 -216
- package/__tests__/adapters/social-douyin-adb-watch-history.test.js +0 -192
- package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +0 -496
- package/__tests__/adapters/social-kuaishou-adb-collector.test.js +0 -276
- package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +0 -152
- package/__tests__/adapters/social-kuaishou-adb-snapshot-builder.test.js +0 -178
- package/__tests__/adapters/social-toutiao-adb-account-reader.test.js +0 -135
- package/__tests__/adapters/social-toutiao-adb-api-client.test.js +0 -626
- package/__tests__/adapters/social-toutiao-adb-collector.test.js +0 -378
- package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +0 -193
- package/__tests__/adapters/social-toutiao-adb-snapshot-builder.test.js +0 -196
- package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +0 -311
- package/__tests__/adapters/social-weibo-adb-api-client.test.js +0 -362
- package/__tests__/adapters/social-weibo-adb-collector.test.js +0 -201
- package/__tests__/adapters/social-weibo-adb-cookies-extension.test.js +0 -167
- package/__tests__/adapters/social-weibo-adb-snapshot-builder.test.js +0 -189
- package/__tests__/adapters/social-xiaohongshu-adb-api-client.test.js +0 -431
- package/__tests__/adapters/social-xiaohongshu-adb-collector.test.js +0 -207
- package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
- package/__tests__/adapters/social-xiaohongshu-adb-sign-provider-injection.test.js +0 -351
- package/__tests__/adapters/social-xiaohongshu-adb-sign.test.js +0 -130
- package/__tests__/adapters/social-xiaohongshu-adb-snapshot-builder.test.js +0 -200
- package/__tests__/adapters/social-zhihu.test.js +0 -246
- package/__tests__/adapters/system-data-adapter.test.js +0 -443
- package/__tests__/adapters/system-data-android-ingest.test.js +0 -144
- package/__tests__/adapters/system-data-android.test.js +0 -519
- package/__tests__/adapters/system-data-disclosure.test.js +0 -153
- package/__tests__/adapters/travel-12306.test.js +0 -512
- package/__tests__/adapters/travel-amap.test.js +0 -219
- package/__tests__/adapters/travel-baidu-map.test.js +0 -305
- package/__tests__/adapters/travel-base.test.js +0 -205
- package/__tests__/adapters/travel-ctrip.test.js +0 -377
- package/__tests__/adapters/travel-didi-consumer.test.js +0 -66
- package/__tests__/adapters/travel-didi.test.js +0 -204
- package/__tests__/adapters/travel-tencent-map.test.js +0 -207
- package/__tests__/adapters/travel-tongcheng.test.js +0 -289
- package/__tests__/adapters/video-platforms.test.js +0 -152
- package/__tests__/adapters/video-xigua.test.js +0 -106
- package/__tests__/adapters/vscode.test.js +0 -299
- package/__tests__/adapters/wechat-bootstrap.test.js +0 -240
- package/__tests__/adapters/wechat-env-probe.test.js +0 -162
- package/__tests__/adapters/wechat-frida-agent.test.js +0 -322
- package/__tests__/adapters/wechat-frida-integration.test.js +0 -149
- package/__tests__/adapters/wechat-frida-key-provider.test.js +0 -188
- package/__tests__/adapters/wechat-md5-key-provider.test.js +0 -101
- package/__tests__/adapters/wechat-pc-direct-read.test.js +0 -365
- package/__tests__/adapters/wechat-pc-group-topic.test.js +0 -63
- package/__tests__/adapters/wechat-pc-v4-sidecar.test.js +0 -72
- package/__tests__/adapters/weread.test.js +0 -123
- package/__tests__/adapters/wework-pc.test.js +0 -124
- package/__tests__/adapters/win-recent.test.js +0 -192
- package/__tests__/analysis-skills.test.js +0 -679
- package/__tests__/analysis.test.js +0 -1845
- package/__tests__/audio-ximalaya-snapshot.test.js +0 -279
- package/__tests__/batch.test.js +0 -133
- package/__tests__/bridges-cc-kg.test.js +0 -231
- package/__tests__/bridges-cc-llm.test.js +0 -191
- package/__tests__/bridges-cc-rag.test.js +0 -162
- package/__tests__/categories.test.js +0 -92
- package/__tests__/e2e/ai-chat-cross-source-journey.test.js +0 -213
- package/__tests__/e2e/full-user-journey.test.js +0 -188
- package/__tests__/e2e/local-data-adapters-cli.e2e.test.js +0 -146
- package/__tests__/entity-resolver-ingest-hook.test.js +0 -177
- package/__tests__/entity-resolver-stages.test.js +0 -411
- package/__tests__/entity-resolver-vault.test.js +0 -249
- package/__tests__/entity-resolver.test.js +0 -526
- package/__tests__/fitness-keep-snapshot.test.js +0 -224
- package/__tests__/fixtures/entity-resolver-200-mock.json +0 -96
- package/__tests__/ids.test.js +0 -45
- package/__tests__/integration/ai-chat-history-registry.test.js +0 -228
- package/__tests__/integration/aichat-wizard-end-to-end.test.js +0 -282
- package/__tests__/integration/cross-adapter-pipelines.test.js +0 -396
- package/__tests__/integration/local-data-adapters-pipeline.test.js +0 -373
- package/__tests__/integration/social-bilibili-pipeline.test.js +0 -261
- package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +0 -390
- package/__tests__/key-providers.test.js +0 -126
- package/__tests__/kg-derive.test.js +0 -219
- package/__tests__/llm-client.test.js +0 -122
- package/__tests__/longtail-adapters.test.js +0 -281
- package/__tests__/messaging-qq-snapshot.test.js +0 -294
- package/__tests__/mobile-extractor-encrypted.test.js +0 -460
- package/__tests__/mobile-extractor.test.js +0 -288
- package/__tests__/mock-adapter.test.js +0 -93
- package/__tests__/prompt-builder.test.js +0 -249
- package/__tests__/query-parser.test.js +0 -302
- package/__tests__/rag-derive.test.js +0 -169
- package/__tests__/registry-readiness.test.js +0 -292
- package/__tests__/registry.test.js +0 -420
- package/__tests__/salvage-ingest.test.js +0 -97
- package/__tests__/schemas.test.js +0 -331
- package/__tests__/shopping-adapters.test.js +0 -392
- package/__tests__/shopping-eleme-snapshot.test.js +0 -454
- package/__tests__/shopping-pinduoduo-snapshot.test.js +0 -484
- package/__tests__/shopping-snapshot.test.js +0 -438
- package/__tests__/shopping-vipshop-snapshot.test.js +0 -425
- package/__tests__/shopping-xianyu-snapshot.test.js +0 -451
- package/__tests__/sidecar-contacts-cross-validate.test.js +0 -186
- package/__tests__/sidecar-supervisor.test.js +0 -128
- package/__tests__/sign-providers.test.js +0 -62
- package/__tests__/social-adapters.test.js +0 -280
- package/__tests__/social-bilibili-snapshot.test.js +0 -278
- package/__tests__/social-douban-snapshot.test.js +0 -351
- package/__tests__/social-douyin-im-direct-read.test.js +0 -377
- package/__tests__/social-douyin-salvage-collector.test.js +0 -98
- package/__tests__/social-douyin-salvage-mapper.test.js +0 -90
- package/__tests__/social-douyin-snapshot.test.js +0 -256
- package/__tests__/social-kuaishou-snapshot.test.js +0 -362
- package/__tests__/social-toutiao-snapshot.test.js +0 -366
- package/__tests__/social-weibo-snapshot.test.js +0 -234
- package/__tests__/social-weibo-sqlite-device.test.js +0 -174
- package/__tests__/social-xiaohongshu-snapshot.test.js +0 -232
- package/__tests__/sqlite-leaf-salvage.test.js +0 -97
- package/__tests__/travel-adapters.test.js +0 -483
- package/__tests__/travel-maps-snapshot.test.js +0 -426
- package/__tests__/vault-driver-error.test.js +0 -74
- package/__tests__/vault-search-helpers.test.js +0 -104
- package/__tests__/vault-search.test.js +0 -423
- package/__tests__/vault.test.js +0 -767
- package/__tests__/wechat-adapter.test.js +0 -594
- package/__tests__/whatsapp-adapter.test.js +0 -138
- package/scripts/_make-fixture-all.js +0 -126
- package/scripts/_make-fixture-contacts.js +0 -84
- package/scripts/evaluate-entity-resolver.js +0 -213
- package/scripts/run-native-tests-sandbox.sh +0 -55
- package/scripts/smoke-phase-5-5.js +0 -196
- package/scripts/smoke-phase-5-7.js +0 -181
- package/scripts/smoke-system-data-contacts.js +0 -309
- package/scripts/smoke-system-data.js +0 -312
- 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
|
-
});
|