@chainlesschain/personal-data-hub 0.3.6 → 0.3.8
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/__tests__/adapters/social-kuaishou-adb-api-client.test.js +432 -0
- package/__tests__/adapters/social-kuaishou-adb-collector.test.js +276 -0
- package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +141 -0
- package/__tests__/adapters/social-kuaishou-adb-snapshot-builder.test.js +178 -0
- package/__tests__/adapters/social-toutiao-adb-api-client.test.js +537 -0
- package/__tests__/adapters/social-toutiao-adb-collector.test.js +285 -0
- package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +163 -0
- package/__tests__/adapters/social-toutiao-adb-snapshot-builder.test.js +196 -0
- package/__tests__/adapters/social-xiaohongshu-adb-sign-provider-injection.test.js +351 -0
- package/__tests__/analysis.test.js +239 -14
- package/__tests__/query-parser.test.js +86 -0
- package/__tests__/vault.test.js +88 -0
- package/lib/adapters/ai-chat-history/health-checker.js +11 -0
- package/lib/adapters/social-kuaishou-adb/api-client.js +397 -0
- package/lib/adapters/social-kuaishou-adb/collector.js +196 -0
- package/lib/adapters/social-kuaishou-adb/cookies-extension.js +261 -0
- package/lib/adapters/social-kuaishou-adb/index.js +53 -0
- package/lib/adapters/social-kuaishou-adb/snapshot-builder.js +145 -0
- package/lib/adapters/social-toutiao-adb/api-client.js +377 -0
- package/lib/adapters/social-toutiao-adb/collector.js +200 -0
- package/lib/adapters/social-toutiao-adb/cookies-extension.js +266 -0
- package/lib/adapters/social-toutiao-adb/index.js +52 -0
- package/lib/adapters/social-toutiao-adb/snapshot-builder.js +148 -0
- package/lib/adapters/social-xiaohongshu-adb/api-client.js +36 -5
- package/lib/adapters/social-xiaohongshu-adb/collector.js +102 -51
- package/lib/analysis.js +154 -17
- package/lib/query-parser.js +93 -0
- package/lib/vault.js +64 -0
- package/package.json +5 -1
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase 6b — verify signProvider injection in XhsApiClient + collector.
|
|
5
|
+
*
|
|
6
|
+
* Tests both paths:
|
|
7
|
+
* - Bridge path (signedHeaders returns non-empty) → uses bridge headers
|
|
8
|
+
* - Fallback path (NullSignProvider OR bridge returns {}) → in-process md5
|
|
9
|
+
*
|
|
10
|
+
* Uses a fake fetch + fake signProvider to verify wiring without spawning
|
|
11
|
+
* Electron WebContentsView.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, vi } from "vitest";
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
XhsApiClient,
|
|
18
|
+
} = require("../../lib/adapters/social-xiaohongshu-adb/api-client");
|
|
19
|
+
const {
|
|
20
|
+
collect,
|
|
21
|
+
} = require("../../lib/adapters/social-xiaohongshu-adb/collector");
|
|
22
|
+
const { NULL_SIGN_PROVIDER } = require("../../lib/sign-providers");
|
|
23
|
+
|
|
24
|
+
function makeFakeFetch(responses) {
|
|
25
|
+
const calls = [];
|
|
26
|
+
const fakeFetch = async (urlStr, opts) => {
|
|
27
|
+
calls.push({ url: urlStr, opts });
|
|
28
|
+
for (const [pattern, payload] of responses) {
|
|
29
|
+
if (urlStr.includes(pattern)) {
|
|
30
|
+
const resolved =
|
|
31
|
+
typeof payload === "function" ? await payload(urlStr, opts) : payload;
|
|
32
|
+
return {
|
|
33
|
+
ok: resolved.status == null || resolved.status === 200,
|
|
34
|
+
status: resolved.status || 200,
|
|
35
|
+
text: async () => resolved.body,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
throw new Error("fake fetch: no response for " + urlStr);
|
|
40
|
+
};
|
|
41
|
+
return { fakeFetch, calls };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const HAPPY_RESPONSES = [
|
|
45
|
+
[
|
|
46
|
+
"/user/me",
|
|
47
|
+
{
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
code: 0,
|
|
50
|
+
data: { user_id: "5e8c8f7e", nickname: "Alice" },
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
"user_posted",
|
|
56
|
+
{
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
code: 0,
|
|
59
|
+
data: { notes: [{ note_id: "N1", title: "n", time: 1 }] },
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
[
|
|
64
|
+
"note/like/page",
|
|
65
|
+
{
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
code: 0,
|
|
68
|
+
data: { notes: [{ note_id: "L1", title: "l" }] },
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
"user/follow/list",
|
|
74
|
+
{
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
code: 0,
|
|
77
|
+
data: { users: [{ user_id: "U1", nickname: "x" }] },
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
// ─── XhsApiClient direct injection ──────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
describe("XhsApiClient — signProvider injection", () => {
|
|
86
|
+
it("defaults to NULL_SIGN_PROVIDER when no opts.signProvider", () => {
|
|
87
|
+
const client = new XhsApiClient({ fetch: () => {} });
|
|
88
|
+
expect(client.signProvider).toBe(NULL_SIGN_PROVIDER);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("uses opts.signProvider verbatim when provided", () => {
|
|
92
|
+
const fakeProvider = { signedHeaders: vi.fn(async () => ({})) };
|
|
93
|
+
const client = new XhsApiClient({
|
|
94
|
+
fetch: () => {},
|
|
95
|
+
signProvider: fakeProvider,
|
|
96
|
+
});
|
|
97
|
+
expect(client.signProvider).toBe(fakeProvider);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("falls back to in-process md5 when provider returns {}", async () => {
|
|
101
|
+
const { fakeFetch, calls } = makeFakeFetch(HAPPY_RESPONSES);
|
|
102
|
+
const fakeProvider = { signedHeaders: vi.fn(async () => ({})) };
|
|
103
|
+
const client = new XhsApiClient({
|
|
104
|
+
fetch: fakeFetch,
|
|
105
|
+
signProvider: fakeProvider,
|
|
106
|
+
});
|
|
107
|
+
await client.fetchNotes("a1=fp; web_session=s", "fp", "5e8c8f7e");
|
|
108
|
+
const notesCall = calls.find((c) => c.url.includes("user_posted"));
|
|
109
|
+
// Fallback used → in-process X-S/X-T headers should be set
|
|
110
|
+
expect(notesCall.opts.headers["X-S"]).toMatch(/^XYW_/);
|
|
111
|
+
expect(notesCall.opts.headers["X-T"]).toMatch(/^\d+$/);
|
|
112
|
+
expect(client._bridgeHits).toBe(0);
|
|
113
|
+
expect(client._fallbackHits).toBe(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("uses bridge headers when provider returns non-empty", async () => {
|
|
117
|
+
const { fakeFetch, calls } = makeFakeFetch(HAPPY_RESPONSES);
|
|
118
|
+
const fakeProvider = {
|
|
119
|
+
signedHeaders: vi.fn(async () => ({
|
|
120
|
+
"X-s": "XYW_bridge_value",
|
|
121
|
+
"X-t": "1716383021000",
|
|
122
|
+
"X-s-common": "common_value",
|
|
123
|
+
})),
|
|
124
|
+
};
|
|
125
|
+
const client = new XhsApiClient({
|
|
126
|
+
fetch: fakeFetch,
|
|
127
|
+
signProvider: fakeProvider,
|
|
128
|
+
});
|
|
129
|
+
await client.fetchNotes("a1=fp; web_session=s", "fp", "5e8c8f7e");
|
|
130
|
+
const notesCall = calls.find((c) => c.url.includes("user_posted"));
|
|
131
|
+
expect(notesCall.opts.headers["X-s"]).toBe("XYW_bridge_value");
|
|
132
|
+
expect(notesCall.opts.headers["X-t"]).toBe("1716383021000");
|
|
133
|
+
expect(notesCall.opts.headers["X-s-common"]).toBe("common_value");
|
|
134
|
+
// Bridge headers used — no fallback X-S/X-T injected
|
|
135
|
+
expect(notesCall.opts.headers["X-S"]).toBeUndefined();
|
|
136
|
+
expect(client._bridgeHits).toBe(1);
|
|
137
|
+
expect(client._fallbackHits).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("does NOT call signedHeaders for /user/me (no X-S required)", async () => {
|
|
141
|
+
const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
|
|
142
|
+
const fakeProvider = { signedHeaders: vi.fn(async () => ({})) };
|
|
143
|
+
const client = new XhsApiClient({
|
|
144
|
+
fetch: fakeFetch,
|
|
145
|
+
signProvider: fakeProvider,
|
|
146
|
+
});
|
|
147
|
+
await client.fetchMe("a1=fp; web_session=s");
|
|
148
|
+
expect(fakeProvider.signedHeaders).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("calls signedHeaders for fetchNotes / fetchLiked / fetchFollows", async () => {
|
|
152
|
+
const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
|
|
153
|
+
const fakeProvider = { signedHeaders: vi.fn(async () => ({})) };
|
|
154
|
+
const client = new XhsApiClient({
|
|
155
|
+
fetch: fakeFetch,
|
|
156
|
+
signProvider: fakeProvider,
|
|
157
|
+
});
|
|
158
|
+
await client.fetchNotes("a1=fp; web_session=s", "fp", "5e8c8f7e");
|
|
159
|
+
await client.fetchLiked("a1=fp; web_session=s", "fp");
|
|
160
|
+
await client.fetchFollows("a1=fp; web_session=s", "fp", "5e8c8f7e");
|
|
161
|
+
expect(fakeProvider.signedHeaders).toHaveBeenCalledTimes(3);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("forwards `<path>|` as purpose to signedHeaders", async () => {
|
|
165
|
+
const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
|
|
166
|
+
const fakeProvider = { signedHeaders: vi.fn(async () => ({})) };
|
|
167
|
+
const client = new XhsApiClient({
|
|
168
|
+
fetch: fakeFetch,
|
|
169
|
+
signProvider: fakeProvider,
|
|
170
|
+
});
|
|
171
|
+
await client.fetchNotes("a1=fp; web_session=s", "fp", "5e8c8f7e");
|
|
172
|
+
const [, purpose] = fakeProvider.signedHeaders.mock.calls[0];
|
|
173
|
+
expect(purpose).toMatch(/^\/api\/sns\/web\/v2\/user_posted.*\|$/);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ─── collector lifecycle ────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
describe("collector — signProvider lifecycle", () => {
|
|
180
|
+
it("warms up bridge before X-S endpoints", async () => {
|
|
181
|
+
const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
|
|
182
|
+
const apiClient = new XhsApiClient({ fetch: fakeFetch });
|
|
183
|
+
const warmedUp = [];
|
|
184
|
+
const fakeProvider = {
|
|
185
|
+
warmUp: vi.fn(async (cookie) => warmedUp.push(cookie)),
|
|
186
|
+
signedHeaders: vi.fn(async () => ({})),
|
|
187
|
+
shutdown: vi.fn(async () => {}),
|
|
188
|
+
};
|
|
189
|
+
const bridge = {
|
|
190
|
+
invoke: vi.fn(async () => ({
|
|
191
|
+
cookie: "a1=fp; web_session=s",
|
|
192
|
+
a1: "fp",
|
|
193
|
+
diagnostic: {},
|
|
194
|
+
})),
|
|
195
|
+
};
|
|
196
|
+
await collect(bridge, {
|
|
197
|
+
apiClient,
|
|
198
|
+
signProvider: fakeProvider,
|
|
199
|
+
stagingDir: require("node:os").tmpdir(),
|
|
200
|
+
});
|
|
201
|
+
expect(fakeProvider.warmUp).toHaveBeenCalledOnce();
|
|
202
|
+
expect(warmedUp[0]).toBe("a1=fp; web_session=s");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("shuts down bridge in finally — happy path", async () => {
|
|
206
|
+
const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
|
|
207
|
+
const apiClient = new XhsApiClient({ fetch: fakeFetch });
|
|
208
|
+
const fakeProvider = {
|
|
209
|
+
warmUp: vi.fn(async () => {}),
|
|
210
|
+
signedHeaders: vi.fn(async () => ({})),
|
|
211
|
+
shutdown: vi.fn(async () => {}),
|
|
212
|
+
};
|
|
213
|
+
const bridge = {
|
|
214
|
+
invoke: vi.fn(async () => ({
|
|
215
|
+
cookie: "a1=fp; web_session=s",
|
|
216
|
+
a1: "fp",
|
|
217
|
+
diagnostic: {},
|
|
218
|
+
})),
|
|
219
|
+
};
|
|
220
|
+
await collect(bridge, {
|
|
221
|
+
apiClient,
|
|
222
|
+
signProvider: fakeProvider,
|
|
223
|
+
stagingDir: require("node:os").tmpdir(),
|
|
224
|
+
});
|
|
225
|
+
expect(fakeProvider.shutdown).toHaveBeenCalledOnce();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("shuts down bridge in finally — even on fetchMe failure", async () => {
|
|
229
|
+
const { fakeFetch } = makeFakeFetch([
|
|
230
|
+
["/user/me", { body: JSON.stringify({ code: 0, data: {} }) }],
|
|
231
|
+
]);
|
|
232
|
+
const apiClient = new XhsApiClient({ fetch: fakeFetch });
|
|
233
|
+
const fakeProvider = {
|
|
234
|
+
warmUp: vi.fn(async () => {}),
|
|
235
|
+
signedHeaders: vi.fn(async () => ({})),
|
|
236
|
+
shutdown: vi.fn(async () => {}),
|
|
237
|
+
};
|
|
238
|
+
const bridge = {
|
|
239
|
+
invoke: vi.fn(async () => ({
|
|
240
|
+
cookie: "a1=fp; web_session=s",
|
|
241
|
+
a1: "fp",
|
|
242
|
+
diagnostic: {},
|
|
243
|
+
})),
|
|
244
|
+
};
|
|
245
|
+
const result = await collect(bridge, {
|
|
246
|
+
apiClient,
|
|
247
|
+
signProvider: fakeProvider,
|
|
248
|
+
stagingDir: require("node:os").tmpdir(),
|
|
249
|
+
});
|
|
250
|
+
expect(result.meFetchFailed).toBe(true);
|
|
251
|
+
expect(fakeProvider.shutdown).toHaveBeenCalledOnce();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("tolerates warmUp throw (falls back to in-process md5)", async () => {
|
|
255
|
+
const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
|
|
256
|
+
const fakeProvider = {
|
|
257
|
+
warmUp: vi.fn(async () => {
|
|
258
|
+
throw new Error("xhs.com 403 — anti-bot blocked");
|
|
259
|
+
}),
|
|
260
|
+
signedHeaders: vi.fn(async () => ({})),
|
|
261
|
+
shutdown: vi.fn(async () => {}),
|
|
262
|
+
};
|
|
263
|
+
// Inject same provider into the apiClient too — real wiring path
|
|
264
|
+
// creates them together inside collector.
|
|
265
|
+
const apiClient = new XhsApiClient({
|
|
266
|
+
fetch: fakeFetch,
|
|
267
|
+
signProvider: fakeProvider,
|
|
268
|
+
});
|
|
269
|
+
const bridge = {
|
|
270
|
+
invoke: vi.fn(async () => ({
|
|
271
|
+
cookie: "a1=fp; web_session=s",
|
|
272
|
+
a1: "fp",
|
|
273
|
+
diagnostic: {},
|
|
274
|
+
})),
|
|
275
|
+
};
|
|
276
|
+
const result = await collect(bridge, {
|
|
277
|
+
apiClient,
|
|
278
|
+
signProvider: fakeProvider,
|
|
279
|
+
stagingDir: require("node:os").tmpdir(),
|
|
280
|
+
});
|
|
281
|
+
// Sync proceeded — fallback md5 used instead of bridge for all 3
|
|
282
|
+
// X-S endpoints (bridge.signedHeaders returns {} so client falls
|
|
283
|
+
// back). Note lastErrorCode gets cleared by successful subsequent
|
|
284
|
+
// fetchMe so we check _fallbackHits instead.
|
|
285
|
+
expect(result.meFetchFailed).toBe(false);
|
|
286
|
+
expect(apiClient._fallbackHits).toBeGreaterThanOrEqual(3);
|
|
287
|
+
expect(apiClient._bridgeHits).toBe(0);
|
|
288
|
+
// shutdown still runs in finally
|
|
289
|
+
expect(fakeProvider.shutdown).toHaveBeenCalledOnce();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("reports signProvider diagnostic in collect result", async () => {
|
|
293
|
+
const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
|
|
294
|
+
// Subclass to control constructor.name (vitest mocks can't override it
|
|
295
|
+
// on plain object literals — constructor.name is read-only on Object).
|
|
296
|
+
class XhsSignBridge {
|
|
297
|
+
constructor() {
|
|
298
|
+
this.warmUp = vi.fn(async () => {});
|
|
299
|
+
this.signedHeaders = vi.fn(async () => ({
|
|
300
|
+
"X-s": "bridge",
|
|
301
|
+
"X-t": "1",
|
|
302
|
+
"X-s-common": "c",
|
|
303
|
+
}));
|
|
304
|
+
this.shutdown = vi.fn(async () => {});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const fakeProvider = new XhsSignBridge();
|
|
308
|
+
const apiClient = new XhsApiClient({
|
|
309
|
+
fetch: fakeFetch,
|
|
310
|
+
signProvider: fakeProvider,
|
|
311
|
+
});
|
|
312
|
+
const bridge = {
|
|
313
|
+
invoke: vi.fn(async () => ({
|
|
314
|
+
cookie: "a1=fp; web_session=s",
|
|
315
|
+
a1: "fp",
|
|
316
|
+
diagnostic: {},
|
|
317
|
+
})),
|
|
318
|
+
};
|
|
319
|
+
const result = await collect(bridge, {
|
|
320
|
+
apiClient,
|
|
321
|
+
signProvider: fakeProvider,
|
|
322
|
+
stagingDir: require("node:os").tmpdir(),
|
|
323
|
+
});
|
|
324
|
+
expect(result.signProviderUsed).toBe("XhsSignBridge");
|
|
325
|
+
expect(result.signProviderHits).toBe(3); // 3 X-S endpoints all hit bridge
|
|
326
|
+
expect(result.signProviderFallbacks).toBe(0);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("undefined signProvider → no warmUp/shutdown, fallback md5 used", async () => {
|
|
330
|
+
const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
|
|
331
|
+
// Pre-construct apiClient with fakeFetch but no signProvider — client
|
|
332
|
+
// defaults to NULL_SIGN_PROVIDER which returns {} from signedHeaders.
|
|
333
|
+
const apiClient = new XhsApiClient({ fetch: fakeFetch });
|
|
334
|
+
const bridge = {
|
|
335
|
+
invoke: vi.fn(async () => ({
|
|
336
|
+
cookie: "a1=fp; web_session=s",
|
|
337
|
+
a1: "fp",
|
|
338
|
+
diagnostic: {},
|
|
339
|
+
})),
|
|
340
|
+
};
|
|
341
|
+
const result = await collect(bridge, {
|
|
342
|
+
apiClient,
|
|
343
|
+
stagingDir: require("node:os").tmpdir(),
|
|
344
|
+
});
|
|
345
|
+
// No bridge → in-process fallback throughout
|
|
346
|
+
expect(result.signProviderUsed).toBe("none");
|
|
347
|
+
expect(result.signProviderHits).toBe(0);
|
|
348
|
+
// 3 X-S endpoints called fallback md5 each
|
|
349
|
+
expect(result.signProviderFallbacks).toBeGreaterThanOrEqual(3);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -403,11 +403,16 @@ describe("AnalysisEngine.ask cache bypass", () => {
|
|
|
403
403
|
// + items into facts within the maxFacts budget.
|
|
404
404
|
|
|
405
405
|
describe("AnalysisEngine._gatherFacts includes persons and items", () => {
|
|
406
|
-
it("
|
|
406
|
+
it("contact question routes via entityFocus=persons — persons only, no items competition", async () => {
|
|
407
407
|
freshVault();
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
408
|
+
// 2026-05-27 fix: "我有几个联系人" now matches parseEntityFocus → "persons",
|
|
409
|
+
// which intentionally skips the items table to give the full prompt
|
|
410
|
+
// budget to contacts. Pre-fix this test asserted 5 persons + 3 items
|
|
411
|
+
// (8 facts) because _gatherFacts always pulled both tables; post-fix
|
|
412
|
+
// items are deliberately excluded — the user asked about contacts, not
|
|
413
|
+
// apps. Items still surface for generic "what's in my vault" questions
|
|
414
|
+
// (entityFocus=null) and for explicit "我装了哪些 app" (entityFocus=
|
|
415
|
+
// "items"). Verified at __tests__:_gatherFacts entityFocus routing.
|
|
411
416
|
const fakeVault = {
|
|
412
417
|
queryEvents: () => [],
|
|
413
418
|
queryPersons: ({ limit }) => {
|
|
@@ -448,9 +453,8 @@ describe("AnalysisEngine._gatherFacts includes persons and items", () => {
|
|
|
448
453
|
const llm = new MockLLMClient({ reply: "你共有 5 个联系人" });
|
|
449
454
|
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
450
455
|
const r = await engine.ask("我有几个联系人");
|
|
451
|
-
expect(r.facts.length).toBe(8); // 0 events + 5 persons + 3 items
|
|
452
456
|
expect(r.facts.filter((f) => f.type === "person").length).toBe(5);
|
|
453
|
-
expect(r.facts.filter((f) => f.type === "item").length).toBe(
|
|
457
|
+
expect(r.facts.filter((f) => f.type === "item").length).toBe(0);
|
|
454
458
|
});
|
|
455
459
|
|
|
456
460
|
it("respects maxFacts budget — events get majority, persons + items split remainder", async () => {
|
|
@@ -498,7 +502,11 @@ describe("AnalysisEngine._gatherFacts includes persons and items", () => {
|
|
|
498
502
|
expect(r.warning).toBe("no-facts");
|
|
499
503
|
});
|
|
500
504
|
|
|
501
|
-
it("events
|
|
505
|
+
it("events overflow + empty side tables → events refill the reserved slots", async () => {
|
|
506
|
+
// 2026-05-27 fix: when events would monopolize effMaxFacts the engine
|
|
507
|
+
// reserves slots for persons + items; if BOTH side tables return 0 rows
|
|
508
|
+
// the reserve is refilled with events so a contact-less vault still
|
|
509
|
+
// sees the full event budget.
|
|
502
510
|
const fakeVault = {
|
|
503
511
|
queryEvents: () => Array.from({ length: 80 }, (_, i) => ({
|
|
504
512
|
id: "e" + i, type: "event", subtype: "order",
|
|
@@ -515,10 +523,225 @@ describe("AnalysisEngine._gatherFacts includes persons and items", () => {
|
|
|
515
523
|
const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 80 });
|
|
516
524
|
const r = await engine.ask("hi");
|
|
517
525
|
expect(r.facts.length).toBe(80);
|
|
518
|
-
|
|
519
|
-
//
|
|
520
|
-
expect(fakeVault.queryPersons).
|
|
521
|
-
expect(fakeVault.queryItems).
|
|
526
|
+
expect(r.facts.filter((f) => f.type === "event").length).toBe(80);
|
|
527
|
+
// Side queries WERE called (different from pre-fix); they just returned [].
|
|
528
|
+
expect(fakeVault.queryPersons).toHaveBeenCalledWith({ limit: 16 });
|
|
529
|
+
expect(fakeVault.queryItems).toHaveBeenCalledWith({ limit: 8 });
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("Android small-model budget — events overflow cap, persons survive", async () => {
|
|
533
|
+
// Regression: Android local path (effMaxFacts=20, effMaxQueryLimit=50).
|
|
534
|
+
// Vault returns 50 events; pre-fix _gatherFacts shipped 50 events,
|
|
535
|
+
// buildPrompt sliced to first 20 events, persons = 0 → "几个联系人"
|
|
536
|
+
// hallucinated zero. Now events cap at 14 (20*0.7), persons get 3,
|
|
537
|
+
// items get 3 → contact rows reach the LLM.
|
|
538
|
+
const fakeVault = {
|
|
539
|
+
queryEvents: () => Array.from({ length: 50 }, (_, i) => ({
|
|
540
|
+
id: "e" + i, type: "event", subtype: "message",
|
|
541
|
+
occurredAt: Date.now(), actor: "self",
|
|
542
|
+
ingestedAt: Date.now(),
|
|
543
|
+
source: { adapter: "wechat", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
544
|
+
})),
|
|
545
|
+
queryPersons: ({ limit }) => Array.from({ length: limit }, (_, i) => ({
|
|
546
|
+
id: "p" + i, type: "person", subtype: "contact",
|
|
547
|
+
names: ["联系人" + i], ingestedAt: Date.now(),
|
|
548
|
+
source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
549
|
+
})),
|
|
550
|
+
queryItems: ({ limit }) => Array.from({ length: limit }, (_, i) => ({
|
|
551
|
+
id: "i" + i, type: "item", subtype: "other", name: "App" + i,
|
|
552
|
+
ingestedAt: Date.now(),
|
|
553
|
+
source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
554
|
+
})),
|
|
555
|
+
getEvent: () => null,
|
|
556
|
+
audit: () => {},
|
|
557
|
+
};
|
|
558
|
+
const llm = new MockLLMClient({ reply: "" });
|
|
559
|
+
const engine = new AnalysisEngine({
|
|
560
|
+
vault: fakeVault, llm,
|
|
561
|
+
maxFacts: 20, maxQueryLimit: 50,
|
|
562
|
+
});
|
|
563
|
+
const r = await engine.ask("hi"); // generic question — default path
|
|
564
|
+
// 20 * 0.2 = 4 persons, 20 * 0.1 = 2 items, remainder 14 for events.
|
|
565
|
+
expect(r.facts.filter((f) => f.type === "event").length).toBe(14);
|
|
566
|
+
expect(r.facts.filter((f) => f.type === "person").length).toBe(4);
|
|
567
|
+
expect(r.facts.filter((f) => f.type === "item").length).toBe(2);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// ─── entityFocus routing — persons / items table priority ────────────────
|
|
572
|
+
//
|
|
573
|
+
// 2026-05-27 fix: when the question is explicitly about contacts ("我有
|
|
574
|
+
// 哪些联系人", "妈手机号"), _gatherFacts must NOT compete persons against
|
|
575
|
+
// the events pool. Pre-fix Android small-model budgets (20 facts / 50 row
|
|
576
|
+
// cap) had events drown out the contact slice → user saw "没数据" even
|
|
577
|
+
// when the vault held hundreds of contacts.
|
|
578
|
+
|
|
579
|
+
describe("AnalysisEngine._gatherFacts entityFocus routing", () => {
|
|
580
|
+
it("entityFocus=persons skips events broad scan, prioritizes persons", async () => {
|
|
581
|
+
const fakeVault = {
|
|
582
|
+
queryEvents: vi.fn(() => Array.from({ length: 50 }, (_, i) => ({
|
|
583
|
+
id: "e" + i, type: "event", subtype: "message",
|
|
584
|
+
occurredAt: Date.now(), actor: "self",
|
|
585
|
+
ingestedAt: Date.now(),
|
|
586
|
+
source: { adapter: "wechat", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
587
|
+
}))),
|
|
588
|
+
queryPersons: vi.fn(({ limit }) => Array.from({ length: limit }, (_, i) => ({
|
|
589
|
+
id: "p" + i, type: "person", subtype: "contact",
|
|
590
|
+
names: ["联系人" + i], ingestedAt: Date.now(),
|
|
591
|
+
source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
592
|
+
}))),
|
|
593
|
+
queryItems: vi.fn(() => []),
|
|
594
|
+
getEvent: () => null,
|
|
595
|
+
audit: () => {},
|
|
596
|
+
};
|
|
597
|
+
const llm = new MockLLMClient({ reply: "" });
|
|
598
|
+
const engine = new AnalysisEngine({
|
|
599
|
+
vault: fakeVault, llm,
|
|
600
|
+
maxFacts: 20, maxQueryLimit: 50,
|
|
601
|
+
});
|
|
602
|
+
const r = await engine.ask("我有哪些联系人");
|
|
603
|
+
// 95% goes to persons (19), 5% headroom = 1 event slot.
|
|
604
|
+
expect(r.facts.filter((f) => f.type === "person").length).toBe(19);
|
|
605
|
+
expect(r.facts.filter((f) => f.type === "event").length).toBeLessThanOrEqual(1);
|
|
606
|
+
expect(fakeVault.queryPersons).toHaveBeenCalledWith({ limit: 19 });
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("entityFocus=persons falls through to default path when persons table is empty", async () => {
|
|
610
|
+
const fakeVault = {
|
|
611
|
+
queryEvents: () => Array.from({ length: 5 }, (_, i) => ({
|
|
612
|
+
id: "e" + i, type: "event", subtype: "message",
|
|
613
|
+
occurredAt: Date.now(), actor: "self",
|
|
614
|
+
ingestedAt: Date.now(),
|
|
615
|
+
source: { adapter: "wechat", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
616
|
+
})),
|
|
617
|
+
queryPersons: () => [], // empty contacts table
|
|
618
|
+
queryItems: () => [],
|
|
619
|
+
getEvent: () => null,
|
|
620
|
+
audit: () => {},
|
|
621
|
+
};
|
|
622
|
+
const llm = new MockLLMClient({ reply: "" });
|
|
623
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
624
|
+
const r = await engine.ask("我有哪些联系人");
|
|
625
|
+
// Fell through to default → 5 events surfaced (no cap since 5 < 80).
|
|
626
|
+
expect(r.facts.filter((f) => f.type === "event").length).toBe(5);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("entityFocus=persons with name candidate → searchPersons short-circuit", async () => {
|
|
630
|
+
// 2026-05-27 S3 治本 — "妈手机号" must hit searchPersons LIKE search
|
|
631
|
+
// even when vault holds 500 contacts. Pre-S3 _gatherFacts dumped the
|
|
632
|
+
// first N by ingest_at; the target person rarely landed in the slice.
|
|
633
|
+
const fakeVault = {
|
|
634
|
+
queryEvents: () => [],
|
|
635
|
+
queryPersons: vi.fn(() => [
|
|
636
|
+
{ id: "p-other", type: "person", subtype: "contact", names: ["张三"], ingestedAt: 0,
|
|
637
|
+
source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: 0, capturedBy: "api" } },
|
|
638
|
+
]),
|
|
639
|
+
searchPersons: vi.fn(({ q, limit }) => {
|
|
640
|
+
if (q === "妈") {
|
|
641
|
+
return [{
|
|
642
|
+
id: "p-mom", type: "person", subtype: "contact", names: ["妈妈"],
|
|
643
|
+
identifiers: { phone: ["13800138000"] }, ingestedAt: 0,
|
|
644
|
+
source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: 0, capturedBy: "api" },
|
|
645
|
+
}];
|
|
646
|
+
}
|
|
647
|
+
return [];
|
|
648
|
+
}),
|
|
649
|
+
queryItems: () => [],
|
|
650
|
+
getEvent: () => null,
|
|
651
|
+
audit: () => {},
|
|
652
|
+
};
|
|
653
|
+
const llm = new MockLLMClient({ reply: "妈手机号是 13800138000" });
|
|
654
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 20 });
|
|
655
|
+
const r = await engine.ask("妈手机号是多少");
|
|
656
|
+
expect(fakeVault.searchPersons).toHaveBeenCalledWith({ q: "妈", limit: 19 });
|
|
657
|
+
expect(fakeVault.queryPersons).not.toHaveBeenCalled(); // search hit → skip fallback
|
|
658
|
+
expect(r.facts.filter((f) => f.type === "person").length).toBe(1);
|
|
659
|
+
expect(r.facts.find((f) => f.id === "p-mom")).toBeDefined();
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("entityFocus=persons with name candidate but 0 search hits → falls back to queryPersons", async () => {
|
|
663
|
+
const fakeVault = {
|
|
664
|
+
queryEvents: () => [],
|
|
665
|
+
queryPersons: vi.fn(({ limit }) => Array.from({ length: limit }, (_, i) => ({
|
|
666
|
+
id: "p" + i, type: "person", subtype: "contact", names: ["P" + i], ingestedAt: 0,
|
|
667
|
+
source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: 0, capturedBy: "api" },
|
|
668
|
+
}))),
|
|
669
|
+
searchPersons: vi.fn(() => []), // 0 hits
|
|
670
|
+
queryItems: () => [],
|
|
671
|
+
getEvent: () => null,
|
|
672
|
+
audit: () => {},
|
|
673
|
+
};
|
|
674
|
+
const llm = new MockLLMClient({ reply: "" });
|
|
675
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 20 });
|
|
676
|
+
await engine.ask("张三的电话号码");
|
|
677
|
+
expect(fakeVault.searchPersons).toHaveBeenCalled();
|
|
678
|
+
expect(fakeVault.queryPersons).toHaveBeenCalledWith({ limit: 19 });
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it("entityFocus=persons without name candidate (pure list) skips searchPersons", async () => {
|
|
682
|
+
const fakeVault = {
|
|
683
|
+
queryEvents: () => [],
|
|
684
|
+
queryPersons: vi.fn(({ limit }) => Array.from({ length: limit }, (_, i) => ({
|
|
685
|
+
id: "p" + i, type: "person", subtype: "contact", names: ["P" + i], ingestedAt: 0,
|
|
686
|
+
source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: 0, capturedBy: "api" },
|
|
687
|
+
}))),
|
|
688
|
+
searchPersons: vi.fn(() => []),
|
|
689
|
+
queryItems: () => [],
|
|
690
|
+
getEvent: () => null,
|
|
691
|
+
audit: () => {},
|
|
692
|
+
};
|
|
693
|
+
const llm = new MockLLMClient({ reply: "" });
|
|
694
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 20 });
|
|
695
|
+
await engine.ask("我有哪些联系人");
|
|
696
|
+
// Pure list — no name in question → skip searchPersons, go straight to queryPersons.
|
|
697
|
+
expect(fakeVault.searchPersons).not.toHaveBeenCalled();
|
|
698
|
+
expect(fakeVault.queryPersons).toHaveBeenCalledWith({ limit: 19 });
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it("entityFocus=persons tolerates vault without searchPersons (legacy)", async () => {
|
|
702
|
+
const fakeVault = {
|
|
703
|
+
queryEvents: () => [],
|
|
704
|
+
queryPersons: vi.fn(({ limit }) => Array.from({ length: Math.min(limit, 3) }, (_, i) => ({
|
|
705
|
+
id: "p" + i, type: "person", subtype: "contact", names: ["P" + i], ingestedAt: 0,
|
|
706
|
+
source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: 0, capturedBy: "api" },
|
|
707
|
+
}))),
|
|
708
|
+
// No searchPersons method
|
|
709
|
+
queryItems: () => [],
|
|
710
|
+
getEvent: () => null,
|
|
711
|
+
audit: () => {},
|
|
712
|
+
};
|
|
713
|
+
const llm = new MockLLMClient({ reply: "" });
|
|
714
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 20 });
|
|
715
|
+
const r = await engine.ask("妈手机号");
|
|
716
|
+
expect(fakeVault.queryPersons).toHaveBeenCalled();
|
|
717
|
+
expect(r.facts.filter((f) => f.type === "person").length).toBe(3);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("entityFocus=items prioritizes items table over events", async () => {
|
|
721
|
+
const fakeVault = {
|
|
722
|
+
queryEvents: () => Array.from({ length: 100 }, (_, i) => ({
|
|
723
|
+
id: "e" + i, type: "event", subtype: "browse",
|
|
724
|
+
occurredAt: Date.now(), actor: "self",
|
|
725
|
+
ingestedAt: Date.now(),
|
|
726
|
+
source: { adapter: "browser-history", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
727
|
+
})),
|
|
728
|
+
queryPersons: () => [],
|
|
729
|
+
queryItems: vi.fn(({ limit }) => Array.from({ length: limit }, (_, i) => ({
|
|
730
|
+
id: "i" + i, type: "item", subtype: "other", name: "App" + i,
|
|
731
|
+
ingestedAt: Date.now(),
|
|
732
|
+
source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
733
|
+
}))),
|
|
734
|
+
getEvent: () => null,
|
|
735
|
+
audit: () => {},
|
|
736
|
+
};
|
|
737
|
+
const llm = new MockLLMClient({ reply: "" });
|
|
738
|
+
const engine = new AnalysisEngine({
|
|
739
|
+
vault: fakeVault, llm,
|
|
740
|
+
maxFacts: 20, maxQueryLimit: 50,
|
|
741
|
+
});
|
|
742
|
+
const r = await engine.ask("我装了哪些 app");
|
|
743
|
+
expect(r.facts.filter((f) => f.type === "item").length).toBe(19);
|
|
744
|
+
expect(fakeVault.queryItems).toHaveBeenCalledWith({ limit: 19 });
|
|
522
745
|
});
|
|
523
746
|
});
|
|
524
747
|
|
|
@@ -682,10 +905,12 @@ describe("AnalysisEngine per-call budget overrides", () => {
|
|
|
682
905
|
const llm = { isLocal: true, chat: () => { throw new Error("nope"); } };
|
|
683
906
|
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
684
907
|
const r = await engine.retrieveContext("hi", { maxFacts: 10, maxQueryLimit: 50 });
|
|
685
|
-
//
|
|
686
|
-
//
|
|
908
|
+
// 2026-05-27 fix: _gatherFacts now respects effMaxFacts upstream
|
|
909
|
+
// (events would have overflowed → reservation branch; persons/items
|
|
910
|
+
// returned [] → refill back to events.slice(0,10)). buildPrompt sees
|
|
911
|
+
// exactly 10 facts, nothing to truncate.
|
|
687
912
|
expect(r.factCount).toBe(10);
|
|
688
|
-
expect(r.truncated).toBe(
|
|
913
|
+
expect(r.truncated).toBe(0);
|
|
689
914
|
});
|
|
690
915
|
|
|
691
916
|
it("retrieveContext() honors options.maxFacts and options.maxQueryLimit", async () => {
|