@chainlesschain/personal-data-hub 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/adapters/browser-history-chrome.test.js +377 -0
- package/__tests__/adapters/browser-history-edge.test.js +159 -0
- package/__tests__/adapters/git-activity.test.js +216 -0
- package/__tests__/adapters/local-files.test.js +264 -0
- package/__tests__/adapters/shell-history.test.js +180 -0
- package/__tests__/adapters/system-data-android.test.js +104 -3
- package/__tests__/adapters/vscode.test.js +299 -0
- package/__tests__/adapters/win-recent.test.js +192 -0
- package/__tests__/analysis.test.js +840 -1
- package/__tests__/categories.test.js +92 -0
- package/__tests__/e2e/local-data-adapters-cli.e2e.test.js +146 -0
- package/__tests__/entity-resolver-vault.test.js +5 -2
- package/__tests__/integration/local-data-adapters-pipeline.test.js +373 -0
- package/__tests__/query-parser.test.js +66 -0
- package/__tests__/registry.test.js +114 -0
- package/__tests__/sidecar-contacts-cross-validate.test.js +24 -1
- package/__tests__/sidecar-supervisor.test.js +9 -1
- package/__tests__/social-kuaishou-snapshot.test.js +55 -2
- package/__tests__/social-toutiao-snapshot.test.js +54 -2
- package/__tests__/vault-search-helpers.test.js +104 -0
- package/__tests__/vault-search.test.js +423 -0
- package/__tests__/vault.test.js +77 -3
- package/lib/adapters/browser-history-chrome/adapter.js +247 -0
- package/lib/adapters/browser-history-chrome/bookmarks-reader.js +79 -0
- package/lib/adapters/browser-history-chrome/chrome-db-reader.js +223 -0
- package/lib/adapters/browser-history-chrome/index.js +23 -0
- package/lib/adapters/browser-history-edge/adapter.js +34 -0
- package/lib/adapters/browser-history-edge/index.js +13 -0
- package/lib/adapters/git-activity/adapter.js +155 -0
- package/lib/adapters/git-activity/git-reader.js +125 -0
- package/lib/adapters/git-activity/index.js +17 -0
- package/lib/adapters/local-files/adapter.js +149 -0
- package/lib/adapters/local-files/file-walker.js +125 -0
- package/lib/adapters/local-files/index.js +18 -0
- package/lib/adapters/shell-history/adapter.js +137 -0
- package/lib/adapters/shell-history/index.js +17 -0
- package/lib/adapters/shell-history/shell-reader.js +100 -0
- package/lib/adapters/social-kuaishou/index.js +57 -1
- package/lib/adapters/social-toutiao/index.js +59 -1
- package/lib/adapters/system-data-android/adapter.js +220 -3
- package/lib/adapters/vscode/adapter.js +285 -0
- package/lib/adapters/vscode/index.js +18 -0
- package/lib/adapters/vscode/vscode-reader.js +191 -0
- package/lib/adapters/win-recent/adapter.js +150 -0
- package/lib/adapters/win-recent/index.js +16 -0
- package/lib/adapters/win-recent/win-recent-reader.js +72 -0
- package/lib/analysis.js +227 -9
- package/lib/categories.js +101 -0
- package/lib/index.js +61 -0
- package/lib/migrations.js +146 -0
- package/lib/query-parser.js +74 -0
- package/lib/registry.js +162 -0
- package/lib/vault.js +363 -2
- package/package.json +2 -1
- package/scripts/run-native-tests-sandbox.sh +53 -0
|
@@ -561,7 +561,9 @@ describe("AnalysisEngine.retrieveContext", () => {
|
|
|
561
561
|
expect(r.facts.length).toBe(3);
|
|
562
562
|
expect(r.factIds).toEqual(expect.arrayContaining([e1.id, e2.id, e3.id]));
|
|
563
563
|
expect(r.factCount).toBe(3);
|
|
564
|
-
|
|
564
|
+
// `truncated` is the count of dropped facts (Number), not a boolean.
|
|
565
|
+
// 3 gathered, all kept (no maxFacts cap) → 0 dropped.
|
|
566
|
+
expect(r.truncated).toBe(0);
|
|
565
567
|
expect(Array.isArray(r.messages)).toBe(true);
|
|
566
568
|
expect(r.messages.length).toBeGreaterThan(0);
|
|
567
569
|
expect(r.messages[0]).toHaveProperty("role");
|
|
@@ -628,3 +630,840 @@ describe("AnalysisEngine.retrieveContext", () => {
|
|
|
628
630
|
await expect(engine.retrieveContext(null)).rejects.toThrow();
|
|
629
631
|
});
|
|
630
632
|
});
|
|
633
|
+
|
|
634
|
+
// ─── Per-call budget overrides (small-model callers) ─────────────────────
|
|
635
|
+
//
|
|
636
|
+
// On-device Qwen2.5-1.5B has an effective instruction-following window of
|
|
637
|
+
// 2-4K tokens, much tighter than the 80-fact / 200-row default sized for
|
|
638
|
+
// desktop 7B+ models. Android passes `maxFacts=20 maxQueryLimit=50` per
|
|
639
|
+
// call to keep the prompt ~1.5K tokens. Construction stays untouched so
|
|
640
|
+
// the desktop default path is unaffected.
|
|
641
|
+
describe("AnalysisEngine per-call budget overrides", () => {
|
|
642
|
+
it("ask() honors options.maxFacts and options.maxQueryLimit", async () => {
|
|
643
|
+
const queryEventsCalls = [];
|
|
644
|
+
const fakeVault = {
|
|
645
|
+
queryEvents: (q) => {
|
|
646
|
+
queryEventsCalls.push(q);
|
|
647
|
+
// Return exactly q.limit rows so we can detect the cap.
|
|
648
|
+
return Array.from({ length: q.limit }, (_, i) => ({
|
|
649
|
+
id: "e" + i, type: "event", subtype: "order",
|
|
650
|
+
occurredAt: Date.now(), actor: "self", ingestedAt: Date.now(),
|
|
651
|
+
source: { adapter: "taobao", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
652
|
+
}));
|
|
653
|
+
},
|
|
654
|
+
queryPersons: () => [],
|
|
655
|
+
queryItems: () => [],
|
|
656
|
+
getEvent: () => null,
|
|
657
|
+
audit: () => {},
|
|
658
|
+
stats: () => ({ events: 30, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
659
|
+
};
|
|
660
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
661
|
+
// Default constructor (maxFacts=80, maxQueryLimit=200) — overridden per call.
|
|
662
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
663
|
+
await engine.ask("hi", { maxFacts: 10, maxQueryLimit: 50 });
|
|
664
|
+
// queryEvents.limit must reflect the per-call override, not the default 200.
|
|
665
|
+
expect(queryEventsCalls).toHaveLength(1);
|
|
666
|
+
expect(queryEventsCalls[0].limit).toBe(50);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("ask() retrieveContext-level maxFacts bounds factCount via buildPrompt", async () => {
|
|
670
|
+
const fakeVault = {
|
|
671
|
+
queryEvents: (q) => Array.from({ length: q.limit }, (_, i) => ({
|
|
672
|
+
id: "e" + i, type: "event", subtype: "order",
|
|
673
|
+
occurredAt: Date.now(), actor: "self", ingestedAt: Date.now(),
|
|
674
|
+
source: { adapter: "taobao", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
675
|
+
})),
|
|
676
|
+
queryPersons: () => [],
|
|
677
|
+
queryItems: () => [],
|
|
678
|
+
getEvent: () => null,
|
|
679
|
+
audit: () => {},
|
|
680
|
+
stats: () => ({ events: 200, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
681
|
+
};
|
|
682
|
+
const llm = { isLocal: true, chat: () => { throw new Error("nope"); } };
|
|
683
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
684
|
+
const r = await engine.retrieveContext("hi", { maxFacts: 10, maxQueryLimit: 50 });
|
|
685
|
+
// _gatherFacts returns 50 events, but buildPrompt caps factCount to maxFacts=10.
|
|
686
|
+
// `truncated` is a count of dropped facts, not a boolean.
|
|
687
|
+
expect(r.factCount).toBe(10);
|
|
688
|
+
expect(r.truncated).toBe(40); // 50 gathered - 10 kept = 40 truncated
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("retrieveContext() honors options.maxFacts and options.maxQueryLimit", async () => {
|
|
692
|
+
const queryEventsCalls = [];
|
|
693
|
+
const fakeVault = {
|
|
694
|
+
queryEvents: (q) => {
|
|
695
|
+
queryEventsCalls.push(q);
|
|
696
|
+
return [];
|
|
697
|
+
},
|
|
698
|
+
queryPersons: () => [],
|
|
699
|
+
queryItems: () => [],
|
|
700
|
+
getEvent: () => null,
|
|
701
|
+
audit: () => {},
|
|
702
|
+
stats: () => ({ events: 0, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
703
|
+
};
|
|
704
|
+
const llm = { isLocal: true, chat: () => { throw new Error("nope"); } };
|
|
705
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
706
|
+
await engine.retrieveContext("hi", { maxFacts: 15, maxQueryLimit: 40 });
|
|
707
|
+
expect(queryEventsCalls).toHaveLength(1);
|
|
708
|
+
expect(queryEventsCalls[0].limit).toBe(40);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("ignores non-positive / non-integer overrides → falls back to constructor defaults", async () => {
|
|
712
|
+
const queryEventsCalls = [];
|
|
713
|
+
const fakeVault = {
|
|
714
|
+
queryEvents: (q) => { queryEventsCalls.push(q); return []; },
|
|
715
|
+
queryPersons: () => [],
|
|
716
|
+
queryItems: () => [],
|
|
717
|
+
getEvent: () => null,
|
|
718
|
+
audit: () => {},
|
|
719
|
+
stats: () => ({ events: 0, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
720
|
+
};
|
|
721
|
+
const llm = { isLocal: true, chat: () => { throw new Error("nope"); } };
|
|
722
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
723
|
+
await engine.retrieveContext("hi", { maxFacts: 0, maxQueryLimit: -5 });
|
|
724
|
+
// Both bogus → fall back to ctor defaults (maxQueryLimit=200)
|
|
725
|
+
expect(queryEventsCalls[0].limit).toBe(200);
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
// ─── intent=latest routing — newest-few path ─────────────────────────────
|
|
730
|
+
//
|
|
731
|
+
// 2026-05-24 follow-up — _gatherFacts now routes intent=latest WITHOUT a
|
|
732
|
+
// time window to a hard-capped queryEvents({ limit: 3 }) and skips
|
|
733
|
+
// persons/items entirely. Frees prompt budget for the LLM to actually read
|
|
734
|
+
// row content instead of skimming 200 rows. Memory:
|
|
735
|
+
// pdh_analysis_engine_intent_routing.md.
|
|
736
|
+
//
|
|
737
|
+
// Guards covered:
|
|
738
|
+
// (a) intent=latest + no timeWindow → ≤3 events, persons/items NOT touched
|
|
739
|
+
// (b) intent=latest + timeWindow ("最近 30 天") → fall through (list semantics)
|
|
740
|
+
// (c) intent=latest + 0 results → fall back to default (persons+items pulled)
|
|
741
|
+
// (d) intent=latest + adapter filter → respects filter on the narrow path
|
|
742
|
+
// (e) parseQuery sanity: "最近的订单" → intent=latest, timeWindow=null
|
|
743
|
+
|
|
744
|
+
describe("AnalysisEngine._gatherFacts intent=latest routing", () => {
|
|
745
|
+
it("(a) latest without timeWindow → ≤3 events, persons/items NOT queried", async () => {
|
|
746
|
+
const queryEventsCalls = [];
|
|
747
|
+
const fakeVault = {
|
|
748
|
+
queryEvents: (q) => {
|
|
749
|
+
queryEventsCalls.push(q);
|
|
750
|
+
return Array.from({ length: 10 }, (_, i) => ({
|
|
751
|
+
id: "e-" + i, type: "event", subtype: "order",
|
|
752
|
+
occurredAt: Date.now() - i * 1000, actor: "self",
|
|
753
|
+
ingestedAt: Date.now(),
|
|
754
|
+
source: { adapter: "taobao", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
755
|
+
})).slice(0, q.limit);
|
|
756
|
+
},
|
|
757
|
+
queryPersons: vi.fn(() => []),
|
|
758
|
+
queryItems: vi.fn(() => []),
|
|
759
|
+
getEvent: () => null,
|
|
760
|
+
audit: () => {},
|
|
761
|
+
stats: () => ({ events: 10, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
762
|
+
};
|
|
763
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
764
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
765
|
+
const r = await engine.ask("最近的订单");
|
|
766
|
+
|
|
767
|
+
expect(r.parsed.intent).toBe("latest");
|
|
768
|
+
expect(r.parsed.timeWindow).toBeNull();
|
|
769
|
+
expect(queryEventsCalls).toHaveLength(1);
|
|
770
|
+
expect(queryEventsCalls[0].limit).toBe(3);
|
|
771
|
+
expect(r.facts).toHaveLength(3);
|
|
772
|
+
expect(r.facts.every((f) => f.type === "event")).toBe(true);
|
|
773
|
+
expect(fakeVault.queryPersons).not.toHaveBeenCalled();
|
|
774
|
+
expect(fakeVault.queryItems).not.toHaveBeenCalled();
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it("(b) latest WITH timeWindow ('最近 30 天') → falls through to default broader path", async () => {
|
|
778
|
+
const queryEventsCalls = [];
|
|
779
|
+
const fakeVault = {
|
|
780
|
+
queryEvents: (q) => {
|
|
781
|
+
queryEventsCalls.push(q);
|
|
782
|
+
return [];
|
|
783
|
+
},
|
|
784
|
+
queryPersons: vi.fn(() => []),
|
|
785
|
+
queryItems: vi.fn(() => []),
|
|
786
|
+
getEvent: () => null,
|
|
787
|
+
audit: () => {},
|
|
788
|
+
stats: () => ({ events: 0, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
789
|
+
};
|
|
790
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
791
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
792
|
+
const r = await engine.ask("最近 30 天的消费", { now: NOW });
|
|
793
|
+
|
|
794
|
+
expect(r.parsed.intent).toBe("latest");
|
|
795
|
+
expect(r.parsed.timeWindow).not.toBeNull();
|
|
796
|
+
// Default path: limit=200 (DEFAULT_MAX_QUERY_LIMIT), NOT 3.
|
|
797
|
+
expect(queryEventsCalls).toHaveLength(1);
|
|
798
|
+
expect(queryEventsCalls[0].limit).toBe(200);
|
|
799
|
+
// Default path also tries persons + items (budget remaining after 0 events).
|
|
800
|
+
expect(fakeVault.queryPersons).toHaveBeenCalled();
|
|
801
|
+
expect(fakeVault.queryItems).toHaveBeenCalled();
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("(c) latest with 0 results → fallback pulls persons + items via default path", async () => {
|
|
805
|
+
const queryEventsCalls = [];
|
|
806
|
+
const fakeVault = {
|
|
807
|
+
queryEvents: (q) => {
|
|
808
|
+
queryEventsCalls.push(q);
|
|
809
|
+
return []; // both narrow + default calls return 0 events
|
|
810
|
+
},
|
|
811
|
+
queryPersons: ({ limit }) => Array.from({ length: Math.min(limit, 2) }, (_, i) => ({
|
|
812
|
+
id: "p-" + i, type: "person", subtype: "contact", names: ["P" + i],
|
|
813
|
+
ingestedAt: Date.now(),
|
|
814
|
+
source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
815
|
+
})),
|
|
816
|
+
queryItems: ({ limit }) => Array.from({ length: Math.min(limit, 2) }, (_, i) => ({
|
|
817
|
+
id: "i-" + i, type: "item", subtype: "other", name: "I" + i,
|
|
818
|
+
ingestedAt: Date.now(),
|
|
819
|
+
source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
820
|
+
})),
|
|
821
|
+
getEvent: () => null,
|
|
822
|
+
audit: () => {},
|
|
823
|
+
stats: () => ({ events: 0, persons: 2, places: 0, items: 2, topics: 0 }),
|
|
824
|
+
};
|
|
825
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
826
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
827
|
+
const r = await engine.ask("最近的订单");
|
|
828
|
+
|
|
829
|
+
// Narrow path (limit=3) called first, returned 0 → fall through to default
|
|
830
|
+
// (limit=200) — so we expect 2 queryEvents calls total.
|
|
831
|
+
expect(queryEventsCalls).toHaveLength(2);
|
|
832
|
+
expect(queryEventsCalls[0].limit).toBe(3);
|
|
833
|
+
expect(queryEventsCalls[1].limit).toBe(200);
|
|
834
|
+
// Default path pulled persons + items; user gets a useful answer instead of "no-facts".
|
|
835
|
+
expect(r.facts.filter((f) => f.type === "person").length).toBe(2);
|
|
836
|
+
expect(r.facts.filter((f) => f.type === "item").length).toBe(2);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("(d) latest passes adapter filter to the narrow queryEvents call", async () => {
|
|
840
|
+
const queryEventsCalls = [];
|
|
841
|
+
const fakeVault = {
|
|
842
|
+
queryEvents: (q) => {
|
|
843
|
+
queryEventsCalls.push(q);
|
|
844
|
+
return Array.from({ length: 3 }, (_, i) => ({
|
|
845
|
+
id: "e-" + i, type: "event", subtype: "order",
|
|
846
|
+
occurredAt: Date.now() - i * 1000, actor: "self",
|
|
847
|
+
ingestedAt: Date.now(),
|
|
848
|
+
source: { adapter: "taobao", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
849
|
+
}));
|
|
850
|
+
},
|
|
851
|
+
queryPersons: () => [],
|
|
852
|
+
queryItems: () => [],
|
|
853
|
+
getEvent: () => null,
|
|
854
|
+
audit: () => {},
|
|
855
|
+
stats: () => ({ events: 3, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
856
|
+
};
|
|
857
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
858
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
859
|
+
await engine.ask("最近在淘宝买的");
|
|
860
|
+
|
|
861
|
+
expect(queryEventsCalls).toHaveLength(1);
|
|
862
|
+
expect(queryEventsCalls[0].adapter).toBe("taobao");
|
|
863
|
+
expect(queryEventsCalls[0].limit).toBe(3);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it("(e) parseQuery sanity: '最近的订单' → intent=latest, timeWindow=null", () => {
|
|
867
|
+
const { parseQuery } = require("../lib/query-parser");
|
|
868
|
+
const q = parseQuery("最近的订单");
|
|
869
|
+
expect(q.intent).toBe("latest");
|
|
870
|
+
expect(q.timeWindow).toBeNull();
|
|
871
|
+
// Sanity: 最近 N 天 still produces both (list-with-window semantics on
|
|
872
|
+
// the engine side, but parser still tags intent=latest because "最近"
|
|
873
|
+
// matches. Engine's heuristic handles the disambiguation.)
|
|
874
|
+
const q2 = parseQuery("最近 30 天");
|
|
875
|
+
expect(q2.intent).toBe("latest");
|
|
876
|
+
expect(q2.timeWindow).not.toBeNull();
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it("(f) latest narrow path respects per-call maxFacts cap (Android small-model 20 budget)", async () => {
|
|
880
|
+
// If caller passes maxFacts=2 (tighter than LATEST_INTENT_FACT_LIMIT=3),
|
|
881
|
+
// honor the tighter cap — small-model callers know their budget best.
|
|
882
|
+
const queryEventsCalls = [];
|
|
883
|
+
const fakeVault = {
|
|
884
|
+
queryEvents: (q) => {
|
|
885
|
+
queryEventsCalls.push(q);
|
|
886
|
+
return Array.from({ length: q.limit }, (_, i) => ({
|
|
887
|
+
id: "e-" + i, type: "event", subtype: "order",
|
|
888
|
+
occurredAt: Date.now() - i * 1000, actor: "self",
|
|
889
|
+
ingestedAt: Date.now(),
|
|
890
|
+
source: { adapter: "taobao", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
891
|
+
}));
|
|
892
|
+
},
|
|
893
|
+
queryPersons: () => [],
|
|
894
|
+
queryItems: () => [],
|
|
895
|
+
getEvent: () => null,
|
|
896
|
+
audit: () => {},
|
|
897
|
+
stats: () => ({ events: 2, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
898
|
+
};
|
|
899
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
900
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
901
|
+
await engine.ask("最近消息", { maxFacts: 2 });
|
|
902
|
+
expect(queryEventsCalls[0].limit).toBe(2);
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
// ─── intent=list + entity-name FTS5 augmentation ────────────────────────
|
|
907
|
+
//
|
|
908
|
+
// 2026-05-24 follow-up — when the parser pulls a probable entity name out
|
|
909
|
+
// of the question (extractEntityTerm), _gatherFacts appends FTS5 hits to
|
|
910
|
+
// the FACTS pool via vault.searchEvents. Strictly additive: wrong term →
|
|
911
|
+
// 0 rows wasted, never lost events. FTS unavailable / errors → main path
|
|
912
|
+
// (queryEvents + persons + items) unaffected. Memory:
|
|
913
|
+
// pdh_analysis_engine_intent_routing.md.
|
|
914
|
+
|
|
915
|
+
describe("AnalysisEngine._gatherFacts intent=list + entity-name FTS augmentation", () => {
|
|
916
|
+
// Shared event row factory.
|
|
917
|
+
const mkEvent = (id, adapter = "wechat") => ({
|
|
918
|
+
id, type: "event", subtype: "message",
|
|
919
|
+
occurredAt: Date.now(), actor: "self",
|
|
920
|
+
ingestedAt: Date.now(),
|
|
921
|
+
source: { adapter, adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it("(a) entity extracted → searchEvents called with q + adapter + timeWindow passthrough", async () => {
|
|
925
|
+
const queryEventsCalls = [];
|
|
926
|
+
const searchEventsCalls = [];
|
|
927
|
+
const fakeVault = {
|
|
928
|
+
queryEvents: (qq) => {
|
|
929
|
+
queryEventsCalls.push(qq);
|
|
930
|
+
return [mkEvent("e-1", "wechat"), mkEvent("e-2", "wechat")];
|
|
931
|
+
},
|
|
932
|
+
searchEvents: (qq) => {
|
|
933
|
+
searchEventsCalls.push(qq);
|
|
934
|
+
return { rows: [mkEvent("fts-1", "wechat"), mkEvent("fts-2", "wechat")], nextCursor: null, mode: "fts5", shortQuery: false };
|
|
935
|
+
},
|
|
936
|
+
queryPersons: () => [],
|
|
937
|
+
queryItems: () => [],
|
|
938
|
+
getEvent: () => null,
|
|
939
|
+
audit: () => {},
|
|
940
|
+
stats: () => ({ events: 4, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
941
|
+
};
|
|
942
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
943
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
944
|
+
const r = await engine.ask("提到王老板的微信消息");
|
|
945
|
+
|
|
946
|
+
expect(r.parsed.intent).toBe("list");
|
|
947
|
+
expect(searchEventsCalls).toHaveLength(1);
|
|
948
|
+
expect(searchEventsCalls[0].q).toBe("王老板");
|
|
949
|
+
expect(searchEventsCalls[0].adapter).toBe("wechat"); // parsed.filters.adapter passthrough
|
|
950
|
+
expect(searchEventsCalls[0].limit).toBe(10); // LIST_INTENT_FTS_LIMIT cap
|
|
951
|
+
// facts: 2 events + 2 FTS hits = 4 unique
|
|
952
|
+
expect(r.facts.filter((f) => f.type === "event")).toHaveLength(4);
|
|
953
|
+
expect(r.facts.map((f) => f.id)).toEqual(expect.arrayContaining(["e-1", "e-2", "fts-1", "fts-2"]));
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it("(b) no extractable entity → searchEvents NOT called", async () => {
|
|
957
|
+
const searchEventsCalls = [];
|
|
958
|
+
const fakeVault = {
|
|
959
|
+
queryEvents: () => [mkEvent("e-1")],
|
|
960
|
+
searchEvents: (qq) => { searchEventsCalls.push(qq); return { rows: [], mode: "fts5", shortQuery: false }; },
|
|
961
|
+
queryPersons: () => [],
|
|
962
|
+
queryItems: () => [],
|
|
963
|
+
getEvent: () => null,
|
|
964
|
+
audit: () => {},
|
|
965
|
+
stats: () => ({ events: 1, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
966
|
+
};
|
|
967
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
968
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
969
|
+
// "在淘宝买了什么" — extractEntityTerm strips everything → null
|
|
970
|
+
await engine.ask("在淘宝买了什么");
|
|
971
|
+
expect(searchEventsCalls).toHaveLength(0);
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
it("(c) vault without searchEvents method → graceful skip, main path runs", async () => {
|
|
975
|
+
const fakeVault = {
|
|
976
|
+
queryEvents: () => [mkEvent("e-1")],
|
|
977
|
+
// no searchEvents — legacy vault fork
|
|
978
|
+
queryPersons: () => [],
|
|
979
|
+
queryItems: () => [],
|
|
980
|
+
getEvent: () => null,
|
|
981
|
+
audit: () => {},
|
|
982
|
+
stats: () => ({ events: 1, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
983
|
+
};
|
|
984
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
985
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
986
|
+
const r = await engine.ask("提到王老板的消息");
|
|
987
|
+
// Engine doesn't blow up; main path returns the 1 event.
|
|
988
|
+
expect(r.facts).toHaveLength(1);
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
it("(d) FTS hits with overlapping ids are deduped (no double-count)", async () => {
|
|
992
|
+
const fakeVault = {
|
|
993
|
+
queryEvents: () => [mkEvent("e-1"), mkEvent("e-2")],
|
|
994
|
+
searchEvents: () => ({
|
|
995
|
+
rows: [mkEvent("e-1"), mkEvent("fts-3")], // e-1 overlaps with main query
|
|
996
|
+
nextCursor: null, mode: "fts5", shortQuery: false,
|
|
997
|
+
}),
|
|
998
|
+
queryPersons: () => [],
|
|
999
|
+
queryItems: () => [],
|
|
1000
|
+
getEvent: () => null,
|
|
1001
|
+
audit: () => {},
|
|
1002
|
+
stats: () => ({ events: 3, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
1003
|
+
};
|
|
1004
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
1005
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
1006
|
+
const r = await engine.ask("提到王老板的消息");
|
|
1007
|
+
// e-1, e-2, fts-3 — NOT 4 entries (e-1 dedup'd)
|
|
1008
|
+
expect(r.facts.filter((f) => f.type === "event")).toHaveLength(3);
|
|
1009
|
+
const ids = r.facts.map((f) => f.id);
|
|
1010
|
+
expect(new Set(ids).size).toBe(ids.length); // no duplicates
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
it("(e) intent=count / latest / sum-amount do NOT trigger FTS augmentation", async () => {
|
|
1014
|
+
const searchEventsCalls = [];
|
|
1015
|
+
const fakeVault = {
|
|
1016
|
+
queryEvents: () => [mkEvent("e-1")],
|
|
1017
|
+
searchEvents: (qq) => { searchEventsCalls.push(qq); return { rows: [], mode: "fts5", shortQuery: false }; },
|
|
1018
|
+
queryPersons: () => [],
|
|
1019
|
+
queryItems: () => [],
|
|
1020
|
+
getEvent: () => null,
|
|
1021
|
+
audit: () => {},
|
|
1022
|
+
stats: () => ({ events: 1, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
1023
|
+
};
|
|
1024
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
1025
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
1026
|
+
// intent=count
|
|
1027
|
+
await engine.ask("提到王老板的几个消息");
|
|
1028
|
+
// intent=latest is short-circuited in narrow path; with extracted entity
|
|
1029
|
+
// "王老板" wouldn't be hit because narrow returns 1 event and bails. Still
|
|
1030
|
+
// verify the augmentation branch doesn't fire post-narrow.
|
|
1031
|
+
await engine.ask("最近提到王老板的消息");
|
|
1032
|
+
// intent=sum-amount
|
|
1033
|
+
await engine.ask("总共花了多少在王老板这?");
|
|
1034
|
+
expect(searchEventsCalls).toHaveLength(0);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
it("(f) searchEvents throwing does not block — main events still returned", async () => {
|
|
1038
|
+
const fakeVault = {
|
|
1039
|
+
queryEvents: () => [mkEvent("e-1"), mkEvent("e-2")],
|
|
1040
|
+
searchEvents: () => { throw new Error("FTS5 module missing"); },
|
|
1041
|
+
queryPersons: () => [],
|
|
1042
|
+
queryItems: () => [],
|
|
1043
|
+
getEvent: () => null,
|
|
1044
|
+
audit: () => {},
|
|
1045
|
+
stats: () => ({ events: 2, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
1046
|
+
};
|
|
1047
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
1048
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
1049
|
+
const r = await engine.ask("提到王老板的消息");
|
|
1050
|
+
expect(r.facts).toHaveLength(2); // main path's events survive
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it("(g) FTS limit respects headroom — small maxFacts shrinks the FTS slice", async () => {
|
|
1054
|
+
const searchEventsCalls = [];
|
|
1055
|
+
const fakeVault = {
|
|
1056
|
+
queryEvents: () => [mkEvent("e-1"), mkEvent("e-2"), mkEvent("e-3")],
|
|
1057
|
+
searchEvents: (qq) => {
|
|
1058
|
+
searchEventsCalls.push(qq);
|
|
1059
|
+
return { rows: [mkEvent("fts-" + qq.limit)], mode: "fts5", shortQuery: false };
|
|
1060
|
+
},
|
|
1061
|
+
queryPersons: () => [],
|
|
1062
|
+
queryItems: () => [],
|
|
1063
|
+
getEvent: () => null,
|
|
1064
|
+
audit: () => {},
|
|
1065
|
+
stats: () => ({ events: 4, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
1066
|
+
};
|
|
1067
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
1068
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 5 });
|
|
1069
|
+
await engine.ask("提到王老板的消息");
|
|
1070
|
+
// maxFacts=5, events=3 → headroom=2, FTS limit = min(2, 10) = 2
|
|
1071
|
+
expect(searchEventsCalls[0].limit).toBe(2);
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
it("(h) when events already fill maxFacts, FTS skipped entirely", async () => {
|
|
1075
|
+
const searchEventsCalls = [];
|
|
1076
|
+
const fakeVault = {
|
|
1077
|
+
queryEvents: () => Array.from({ length: 10 }, (_, i) => mkEvent("e-" + i)),
|
|
1078
|
+
searchEvents: (qq) => { searchEventsCalls.push(qq); return { rows: [], mode: "fts5", shortQuery: false }; },
|
|
1079
|
+
queryPersons: () => [],
|
|
1080
|
+
queryItems: () => [],
|
|
1081
|
+
getEvent: () => null,
|
|
1082
|
+
audit: () => {},
|
|
1083
|
+
stats: () => ({ events: 10, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
1084
|
+
};
|
|
1085
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
1086
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 10 });
|
|
1087
|
+
await engine.ask("提到王老板的消息");
|
|
1088
|
+
expect(searchEventsCalls).toHaveLength(0); // headroom = 0
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it("(i) FTS hit budget consumes persons/items remainder — not additive on top", async () => {
|
|
1092
|
+
// FTS hits push events.length up → remaining budget for persons/items shrinks.
|
|
1093
|
+
// Validates the FTS augment happens BEFORE persons/items calc.
|
|
1094
|
+
const queryPersonsCalls = [];
|
|
1095
|
+
const queryItemsCalls = [];
|
|
1096
|
+
const fakeVault = {
|
|
1097
|
+
queryEvents: () => [mkEvent("e-1"), mkEvent("e-2")], // 2 events
|
|
1098
|
+
searchEvents: () => ({ rows: [mkEvent("fts-1"), mkEvent("fts-2")], mode: "fts5", shortQuery: false }),
|
|
1099
|
+
queryPersons: ({ limit }) => { queryPersonsCalls.push(limit); return []; },
|
|
1100
|
+
queryItems: ({ limit }) => { queryItemsCalls.push(limit); return []; },
|
|
1101
|
+
getEvent: () => null,
|
|
1102
|
+
audit: () => {},
|
|
1103
|
+
stats: () => ({ events: 4, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
1104
|
+
};
|
|
1105
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
1106
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 10 });
|
|
1107
|
+
await engine.ask("提到王老板的消息");
|
|
1108
|
+
// 2 events + 2 FTS = 4 in events array → remaining = 10-4 = 6
|
|
1109
|
+
// sideBudget = 3 → personBudget=3, itemBudget=3
|
|
1110
|
+
expect(queryPersonsCalls[0]).toBe(3);
|
|
1111
|
+
expect(queryItemsCalls[0]).toBe(3);
|
|
1112
|
+
});
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
// ─── intent=sum-amount routing — subtype-narrowed amount slice ──────────
|
|
1116
|
+
//
|
|
1117
|
+
// 2026-05-24 follow-up — "总共花了多少" / "在淘宝花了多少钱" only needs
|
|
1118
|
+
// events from amount-bearing subtypes (order/payment/transfer/income).
|
|
1119
|
+
// Pulling messages / visits / browses wastes prompt budget on rows the
|
|
1120
|
+
// LLM can't sum. We split the budget across the 4 subtypes (min 20 each),
|
|
1121
|
+
// union+dedup+sort by occurredAt DESC, skip persons/items entirely.
|
|
1122
|
+
// 0 hits → fall through to default (defensive: empty-vault graceful).
|
|
1123
|
+
// Memory: pdh_analysis_engine_intent_routing.md.
|
|
1124
|
+
|
|
1125
|
+
describe("AnalysisEngine._gatherFacts intent=sum-amount routing", () => {
|
|
1126
|
+
const mkEvent = (id, subtype, adapter = "taobao", occurredAt = Date.now()) => ({
|
|
1127
|
+
id, type: "event", subtype, occurredAt, actor: "self",
|
|
1128
|
+
content: { amount: { value: 100, currency: "CNY", direction: "out" } },
|
|
1129
|
+
ingestedAt: Date.now(),
|
|
1130
|
+
source: { adapter, adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
it("(a) hits 4 subtype queries: order/payment/transfer/income → merged + deduped + sorted DESC", async () => {
|
|
1134
|
+
const queryEventsCalls = [];
|
|
1135
|
+
const fakeVault = {
|
|
1136
|
+
queryEvents: (q) => {
|
|
1137
|
+
queryEventsCalls.push(q);
|
|
1138
|
+
// Return one event per subtype, occurredAt staggered so we can verify sort.
|
|
1139
|
+
if (q.subtype === "order") return [mkEvent("o-1", "order", "taobao", 5000)];
|
|
1140
|
+
if (q.subtype === "payment") return [mkEvent("p-1", "payment", "alipay-bill", 4000)];
|
|
1141
|
+
if (q.subtype === "transfer") return [mkEvent("t-1", "transfer", "wechat", 3000)];
|
|
1142
|
+
if (q.subtype === "income") return [mkEvent("i-1", "income", "email-imap", 2000)];
|
|
1143
|
+
return [];
|
|
1144
|
+
},
|
|
1145
|
+
queryPersons: vi.fn(() => []),
|
|
1146
|
+
queryItems: vi.fn(() => []),
|
|
1147
|
+
getEvent: () => null,
|
|
1148
|
+
audit: () => {},
|
|
1149
|
+
stats: () => ({ events: 4, 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
|
+
const r = await engine.ask("总共花了多少钱");
|
|
1154
|
+
|
|
1155
|
+
expect(r.parsed.intent).toBe("sum-amount");
|
|
1156
|
+
// 4 queryEvents calls, one per subtype.
|
|
1157
|
+
expect(queryEventsCalls).toHaveLength(4);
|
|
1158
|
+
expect(queryEventsCalls.map((c) => c.subtype).sort()).toEqual(
|
|
1159
|
+
["income", "order", "payment", "transfer"]
|
|
1160
|
+
);
|
|
1161
|
+
// facts: 4 unique events, sorted DESC by occurredAt → o-1 first.
|
|
1162
|
+
expect(r.facts.map((f) => f.id)).toEqual(["o-1", "p-1", "t-1", "i-1"]);
|
|
1163
|
+
// persons + items skipped — sum-amount doesn't need them.
|
|
1164
|
+
expect(fakeVault.queryPersons).not.toHaveBeenCalled();
|
|
1165
|
+
expect(fakeVault.queryItems).not.toHaveBeenCalled();
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
it("(b) 0 amount events → return EMPTY (NOT fall through, prevents LLM summing unrelated rows)", async () => {
|
|
1169
|
+
// Design change 2026-05-24: sum-amount narrow returning 0 used to fall
|
|
1170
|
+
// through to the default broader path, which pulled persons/items.
|
|
1171
|
+
// Bug: default path would also pull messages/visits/browsing — events
|
|
1172
|
+
// the LLM might wrongly try to "sum" when asked total spending.
|
|
1173
|
+
// Fix: return empty → warning="no-facts" → LLM uses TOTALS preamble to
|
|
1174
|
+
// say "找不到相关花费记录" cleanly. Diverges from latest's fallback
|
|
1175
|
+
// (which surfaces context); for sum-amount fallback actively misleads.
|
|
1176
|
+
const queryEventsCalls = [];
|
|
1177
|
+
const queryPersonsCalls = [];
|
|
1178
|
+
const queryItemsCalls = [];
|
|
1179
|
+
const fakeVault = {
|
|
1180
|
+
queryEvents: (q) => { queryEventsCalls.push(q); return []; },
|
|
1181
|
+
queryPersons: vi.fn(() => { queryPersonsCalls.push(true); return []; }),
|
|
1182
|
+
queryItems: vi.fn(() => { queryItemsCalls.push(true); return []; }),
|
|
1183
|
+
getEvent: () => null,
|
|
1184
|
+
audit: () => {},
|
|
1185
|
+
stats: () => ({ events: 0, persons: 5, places: 0, items: 2, topics: 0 }),
|
|
1186
|
+
};
|
|
1187
|
+
const llm = new MockLLMClient({ reply: "找不到相关花费记录" });
|
|
1188
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
1189
|
+
const r = await engine.ask("总共花了多少");
|
|
1190
|
+
|
|
1191
|
+
// Only the 4 narrow (subtype-keyed) calls — NO default path call.
|
|
1192
|
+
expect(queryEventsCalls).toHaveLength(4);
|
|
1193
|
+
expect(queryEventsCalls.map((c) => c.subtype).sort()).toEqual(
|
|
1194
|
+
["income", "order", "payment", "transfer"]
|
|
1195
|
+
);
|
|
1196
|
+
// persons/items NOT pulled (sum-amount skips them; no fallback to default).
|
|
1197
|
+
expect(fakeVault.queryPersons).not.toHaveBeenCalled();
|
|
1198
|
+
expect(fakeVault.queryItems).not.toHaveBeenCalled();
|
|
1199
|
+
// Empty facts + warning fired.
|
|
1200
|
+
expect(r.facts).toHaveLength(0);
|
|
1201
|
+
expect(r.warning).toBe("no-facts");
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
it("(c) adapter filter passes through to all 4 subtype queries", async () => {
|
|
1205
|
+
const queryEventsCalls = [];
|
|
1206
|
+
const fakeVault = {
|
|
1207
|
+
queryEvents: (q) => {
|
|
1208
|
+
queryEventsCalls.push(q);
|
|
1209
|
+
return q.subtype === "order" ? [mkEvent("o-1", "order", "taobao")] : [];
|
|
1210
|
+
},
|
|
1211
|
+
queryPersons: () => [],
|
|
1212
|
+
queryItems: () => [],
|
|
1213
|
+
getEvent: () => null,
|
|
1214
|
+
audit: () => {},
|
|
1215
|
+
stats: () => ({ events: 1, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
1216
|
+
};
|
|
1217
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
1218
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
1219
|
+
await engine.ask("在淘宝总共花了多少钱");
|
|
1220
|
+
|
|
1221
|
+
expect(queryEventsCalls).toHaveLength(4);
|
|
1222
|
+
for (const c of queryEventsCalls) {
|
|
1223
|
+
expect(c.adapter).toBe("taobao");
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
it("(d) timeWindow passes through to all 4 subtype queries", async () => {
|
|
1228
|
+
const queryEventsCalls = [];
|
|
1229
|
+
const fakeVault = {
|
|
1230
|
+
queryEvents: (q) => {
|
|
1231
|
+
queryEventsCalls.push(q);
|
|
1232
|
+
return [];
|
|
1233
|
+
},
|
|
1234
|
+
queryPersons: () => [],
|
|
1235
|
+
queryItems: () => [],
|
|
1236
|
+
getEvent: () => null,
|
|
1237
|
+
audit: () => {},
|
|
1238
|
+
stats: () => ({ events: 0, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
1239
|
+
};
|
|
1240
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
1241
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
1242
|
+
await engine.ask("上个月总共花了多少", { now: NOW });
|
|
1243
|
+
|
|
1244
|
+
// Narrow path's 4 subtype calls — NO default fallback since 2026-05-24
|
|
1245
|
+
// sum-amount bug fix (empty narrow no longer falls through).
|
|
1246
|
+
expect(queryEventsCalls).toHaveLength(4);
|
|
1247
|
+
for (const c of queryEventsCalls) {
|
|
1248
|
+
expect(c.since).toBeDefined();
|
|
1249
|
+
expect(c.until).toBeDefined();
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
it("(e) sum-amount does NOT trigger FTS augmentation (list-only branch)", async () => {
|
|
1254
|
+
const searchEventsCalls = [];
|
|
1255
|
+
const fakeVault = {
|
|
1256
|
+
queryEvents: () => [mkEvent("o-1", "order")],
|
|
1257
|
+
searchEvents: (q) => { searchEventsCalls.push(q); return { rows: [], mode: "fts5", shortQuery: false }; },
|
|
1258
|
+
queryPersons: () => [],
|
|
1259
|
+
queryItems: () => [],
|
|
1260
|
+
getEvent: () => null,
|
|
1261
|
+
audit: () => {},
|
|
1262
|
+
stats: () => ({ events: 1, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
1263
|
+
};
|
|
1264
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
1265
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
1266
|
+
// Question carries a potential entity name "王老板", but intent=sum-amount
|
|
1267
|
+
// must NOT call searchEvents (FTS is list-only).
|
|
1268
|
+
await engine.ask("总共付给王老板多少钱");
|
|
1269
|
+
expect(searchEventsCalls).toHaveLength(0);
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
it("(f) per-subtype budget respects effMaxQueryLimit/4 with floor of 20", async () => {
|
|
1273
|
+
const queryEventsCalls = [];
|
|
1274
|
+
const fakeVault = {
|
|
1275
|
+
queryEvents: (q) => { queryEventsCalls.push(q); return []; },
|
|
1276
|
+
queryPersons: () => [],
|
|
1277
|
+
queryItems: () => [],
|
|
1278
|
+
getEvent: () => null,
|
|
1279
|
+
audit: () => {},
|
|
1280
|
+
stats: () => ({ events: 0, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
1281
|
+
};
|
|
1282
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
1283
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
1284
|
+
// effMaxQueryLimit = 200 (constructor default) → 200/4 = 50 per subtype
|
|
1285
|
+
await engine.ask("总共花了多少");
|
|
1286
|
+
// First 4 calls are narrow path (subtype-keyed).
|
|
1287
|
+
expect(queryEventsCalls[0].limit).toBe(50);
|
|
1288
|
+
|
|
1289
|
+
// Small-model budget: effMaxQueryLimit=50 → 50/4 = 12 → max(20, 12) = 20
|
|
1290
|
+
queryEventsCalls.length = 0;
|
|
1291
|
+
await engine.ask("总共花了多少", { maxQueryLimit: 50 });
|
|
1292
|
+
expect(queryEventsCalls[0].limit).toBe(20);
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
it("(g) dedup: same event id surfaced under multiple subtypes appears once", async () => {
|
|
1296
|
+
// Defensive — events have unique subtype, but verify dedup if vault
|
|
1297
|
+
// ever returns the same event from multiple subtype queries.
|
|
1298
|
+
const fakeVault = {
|
|
1299
|
+
queryEvents: (q) => {
|
|
1300
|
+
// Both "order" and "payment" return e-shared (impossible in real
|
|
1301
|
+
// vault but proves dedup logic).
|
|
1302
|
+
if (q.subtype === "order" || q.subtype === "payment") {
|
|
1303
|
+
return [mkEvent("e-shared", q.subtype)];
|
|
1304
|
+
}
|
|
1305
|
+
return [];
|
|
1306
|
+
},
|
|
1307
|
+
queryPersons: () => [],
|
|
1308
|
+
queryItems: () => [],
|
|
1309
|
+
getEvent: () => null,
|
|
1310
|
+
audit: () => {},
|
|
1311
|
+
stats: () => ({ events: 1, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
1312
|
+
};
|
|
1313
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
1314
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
1315
|
+
const r = await engine.ask("总共花了多少");
|
|
1316
|
+
|
|
1317
|
+
expect(r.facts).toHaveLength(1);
|
|
1318
|
+
expect(r.facts[0].id).toBe("e-shared");
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
it("(h) result truncated to effMaxFacts (small-model 20 budget)", async () => {
|
|
1322
|
+
const fakeVault = {
|
|
1323
|
+
queryEvents: (q) => {
|
|
1324
|
+
// Each subtype returns 50 events → 4*50 = 200 total before cap
|
|
1325
|
+
return Array.from({ length: 50 }, (_, i) => mkEvent(
|
|
1326
|
+
q.subtype + "-" + i, q.subtype, "taobao", Date.now() - i
|
|
1327
|
+
));
|
|
1328
|
+
},
|
|
1329
|
+
queryPersons: () => [],
|
|
1330
|
+
queryItems: () => [],
|
|
1331
|
+
getEvent: () => null,
|
|
1332
|
+
audit: () => {},
|
|
1333
|
+
stats: () => ({ events: 200, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
1334
|
+
};
|
|
1335
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
1336
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
1337
|
+
const r = await engine.ask("总共花了多少", { maxFacts: 20 });
|
|
1338
|
+
expect(r.facts).toHaveLength(20);
|
|
1339
|
+
});
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
// ─── intent=count routing — isolated coverage ───────────────────────────
|
|
1343
|
+
//
|
|
1344
|
+
// 2026-05-24 — `intent=count` ("几个 X" / "多少个 Y") is handled by the
|
|
1345
|
+
// TOTALS preamble (commit 19c11920e): vault.stats() is rendered before
|
|
1346
|
+
// FACTS so the LLM quotes the real number instead of FACTS array length.
|
|
1347
|
+
// FACTS itself still goes through the default broader path (no narrow
|
|
1348
|
+
// routing). This block isolates the count-specific behavior into its
|
|
1349
|
+
// own describe so the audit gap is closed.
|
|
1350
|
+
|
|
1351
|
+
describe("AnalysisEngine._gatherFacts intent=count routing", () => {
|
|
1352
|
+
const mkEvent = (id, subtype = "order", adapter = "taobao") => ({
|
|
1353
|
+
id, type: "event", subtype, occurredAt: Date.now(), actor: "self",
|
|
1354
|
+
ingestedAt: Date.now(),
|
|
1355
|
+
source: { adapter, adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
it("(a) intent=count goes through default broader path (no narrow query)", async () => {
|
|
1359
|
+
const queryEventsCalls = [];
|
|
1360
|
+
const fakeVault = {
|
|
1361
|
+
queryEvents: (q) => {
|
|
1362
|
+
queryEventsCalls.push(q);
|
|
1363
|
+
return [mkEvent("e-1"), mkEvent("e-2")];
|
|
1364
|
+
},
|
|
1365
|
+
queryPersons: () => [],
|
|
1366
|
+
queryItems: () => [],
|
|
1367
|
+
getEvent: () => null,
|
|
1368
|
+
audit: () => {},
|
|
1369
|
+
stats: () => ({ events: 2, persons: 500, places: 0, items: 0, topics: 0 }),
|
|
1370
|
+
};
|
|
1371
|
+
const llm = new MockLLMClient({ reply: "你有 500 个联系人" });
|
|
1372
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
1373
|
+
const r = await engine.ask("我有几个联系人");
|
|
1374
|
+
|
|
1375
|
+
expect(r.parsed.intent).toBe("count");
|
|
1376
|
+
// Single default queryEvents call (limit=200, no subtype filter, no narrow).
|
|
1377
|
+
expect(queryEventsCalls).toHaveLength(1);
|
|
1378
|
+
expect(queryEventsCalls[0].limit).toBe(200);
|
|
1379
|
+
expect(queryEventsCalls[0].subtype).toBeUndefined();
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
it("(b) intent=count emits TOTALS block in prompt (authoritative ground truth)", async () => {
|
|
1383
|
+
const chatCalls = [];
|
|
1384
|
+
const fakeVault = {
|
|
1385
|
+
queryEvents: () => [],
|
|
1386
|
+
queryPersons: () => [],
|
|
1387
|
+
queryItems: () => [],
|
|
1388
|
+
getEvent: () => null,
|
|
1389
|
+
audit: () => {},
|
|
1390
|
+
stats: () => ({ events: 12, persons: 512, places: 3, items: 89, topics: 0 }),
|
|
1391
|
+
};
|
|
1392
|
+
const llm = {
|
|
1393
|
+
isLocal: true,
|
|
1394
|
+
chat: async (msgs) => { chatCalls.push(msgs); return { text: "你有 512 个联系人", usage: {} }; },
|
|
1395
|
+
};
|
|
1396
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
1397
|
+
await engine.ask("我有多少个联系人");
|
|
1398
|
+
|
|
1399
|
+
const userMsg = chatCalls[0][1].content;
|
|
1400
|
+
expect(userMsg).toContain("TOTALS");
|
|
1401
|
+
expect(userMsg).toContain('"persons": 512');
|
|
1402
|
+
expect(userMsg).toContain('"items": 89');
|
|
1403
|
+
// System prompt instructs LLM to trust TOTALS over FACTS length.
|
|
1404
|
+
expect(chatCalls[0][0].content).toMatch(/TOTALS.*authoritative/i);
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
it("(c) intent=count does NOT trigger FTS augmentation (even with entity name)", async () => {
|
|
1408
|
+
const searchEventsCalls = [];
|
|
1409
|
+
const fakeVault = {
|
|
1410
|
+
queryEvents: () => [],
|
|
1411
|
+
searchEvents: (q) => { searchEventsCalls.push(q); return { rows: [], mode: "fts5", shortQuery: false }; },
|
|
1412
|
+
queryPersons: () => [],
|
|
1413
|
+
queryItems: () => [],
|
|
1414
|
+
getEvent: () => null,
|
|
1415
|
+
audit: () => {},
|
|
1416
|
+
stats: () => ({ events: 0, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
1417
|
+
};
|
|
1418
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
1419
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
1420
|
+
// Question carries an entity name "王老板" but intent=count must not call FTS
|
|
1421
|
+
// (FTS is list-only; count uses TOTALS path).
|
|
1422
|
+
await engine.ask("提到王老板的几个消息");
|
|
1423
|
+
expect(searchEventsCalls).toHaveLength(0);
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
it("(d) intent=count does NOT trigger sum-amount narrow (separate routing)", async () => {
|
|
1427
|
+
const queryEventsCalls = [];
|
|
1428
|
+
const fakeVault = {
|
|
1429
|
+
queryEvents: (q) => { queryEventsCalls.push(q); return []; },
|
|
1430
|
+
queryPersons: () => [],
|
|
1431
|
+
queryItems: () => [],
|
|
1432
|
+
getEvent: () => null,
|
|
1433
|
+
audit: () => {},
|
|
1434
|
+
stats: () => ({ events: 0, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
1435
|
+
};
|
|
1436
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
1437
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
1438
|
+
await engine.ask("几个订单");
|
|
1439
|
+
// Single default call — NOT 4 subtype calls (those are sum-amount only).
|
|
1440
|
+
expect(queryEventsCalls).toHaveLength(1);
|
|
1441
|
+
expect(queryEventsCalls[0].subtype).toBeUndefined();
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
it("(e) intent=count pulls persons + items in FACTS (default path behavior)", async () => {
|
|
1445
|
+
const fakeVault = {
|
|
1446
|
+
queryEvents: () => [],
|
|
1447
|
+
queryPersons: ({ limit }) => Array.from({ length: Math.min(limit, 5) }, (_, i) => ({
|
|
1448
|
+
id: "p-" + i, type: "person", subtype: "contact", names: ["P" + i],
|
|
1449
|
+
ingestedAt: Date.now(),
|
|
1450
|
+
source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
1451
|
+
})),
|
|
1452
|
+
queryItems: ({ limit }) => Array.from({ length: Math.min(limit, 3) }, (_, i) => ({
|
|
1453
|
+
id: "i-" + i, type: "item", subtype: "other", name: "App" + i,
|
|
1454
|
+
ingestedAt: Date.now(),
|
|
1455
|
+
source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
|
|
1456
|
+
})),
|
|
1457
|
+
getEvent: () => null,
|
|
1458
|
+
audit: () => {},
|
|
1459
|
+
stats: () => ({ events: 0, persons: 5, places: 0, items: 3, topics: 0 }),
|
|
1460
|
+
};
|
|
1461
|
+
const llm = new MockLLMClient({ reply: "ok" });
|
|
1462
|
+
const engine = new AnalysisEngine({ vault: fakeVault, llm });
|
|
1463
|
+
const r = await engine.ask("我有几个 app");
|
|
1464
|
+
|
|
1465
|
+
expect(r.parsed.intent).toBe("count");
|
|
1466
|
+
expect(r.facts.filter((f) => f.type === "person").length).toBe(5);
|
|
1467
|
+
expect(r.facts.filter((f) => f.type === "item").length).toBe(3);
|
|
1468
|
+
});
|
|
1469
|
+
});
|