@modusensus/dsh-mneme 0.7.28 → 0.7.29
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.en.md +4 -0
- package/README.md +10 -6
- package/lib/api.js +10 -0
- package/lib/client.js +103 -3
- package/lib/config.js +27 -0
- package/lib/entities/extractor.js +12 -5
- package/lib/index.js +72 -30
- package/lib/service.js +171 -58
- package/lib/settings.js +13 -1
- package/package.json +1 -1
- package/scripts/benchmark-recall.js +71 -5
- package/src/api.js +10 -0
- package/src/config.js +27 -0
- package/src/entities/extractor.js +12 -5
- package/src/index.js +72 -30
- package/src/service.js +171 -58
- package/src/settings.js +13 -1
- package/test/api.test.js +30 -5
- package/test/benchmark.test.js +60 -1
- package/test/client.test.js +39 -0
- package/test/entities.test.js +25 -0
- package/test/reasoning-effort.test.js +77 -0
package/src/service.js
CHANGED
|
@@ -416,6 +416,171 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
416
416
|
}
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
/**
|
|
420
|
+
* Recall fusion (plan #1). Turns the three ranked signal lists (keyword,
|
|
421
|
+
* vector, BM25) into a single merged list. Three recipes, selected by
|
|
422
|
+
* config.recallFusion:
|
|
423
|
+
* - blend (default): legacy behavior — weighted sum for vector/hybrid, union
|
|
424
|
+
* backfill for auto. Byte-identical to pre-fusion code, so enabling the
|
|
425
|
+
* config never regresses anybody.
|
|
426
|
+
* - rrf: Reciprocal Rank Fusion — Σ 1/(k + rank + 1) over each list a row
|
|
427
|
+
* appears in. Rank-based, so the unit mismatch (raw cosine vs keyword
|
|
428
|
+
* score vs normalized IDF) is irrelevant.
|
|
429
|
+
* - minmax: min-max normalize each source list's scores to [0,1] then take
|
|
430
|
+
* the weighted sum — a scale-aware version of `blend`.
|
|
431
|
+
* Returns { merged, signals }, where signals is Map<id, {keyword, vector,
|
|
432
|
+
* bm25}> so searchMemories can decorate rows when signalTransparency is on.
|
|
433
|
+
*/
|
|
434
|
+
function fuseRecall({ keyword, vector, bm25, lim, mode, wv, wk, wb }) {
|
|
435
|
+
const recipe = config?.recallFusion ?? "blend";
|
|
436
|
+
|
|
437
|
+
// Per-source scores are recorded for every recipe so signalTransparency
|
|
438
|
+
// works regardless of how the ranking was produced.
|
|
439
|
+
const signals = new Map();
|
|
440
|
+
const addSig = (id, field, sc) => {
|
|
441
|
+
const cur = signals.get(id) ?? {};
|
|
442
|
+
cur[field] = sc;
|
|
443
|
+
signals.set(id, cur);
|
|
444
|
+
};
|
|
445
|
+
for (const m of keyword) addSig(m.id, "keyword", m.score ?? 0);
|
|
446
|
+
for (const m of vector) addSig(m.id, "vector", m.score ?? 0);
|
|
447
|
+
for (const m of bm25) addSig(m.id, "bm25", m.score ?? 0);
|
|
448
|
+
|
|
449
|
+
const vectorIds = new Set(vector.map((m) => m.id));
|
|
450
|
+
const keywordIds = new Set(keyword.map((m) => m.id));
|
|
451
|
+
|
|
452
|
+
// Mode contract (aligns rrf/minmax with blend): keyword-only searches must
|
|
453
|
+
// stay keyword-only regardless of recipe, so enabling an opt-in recipe can
|
|
454
|
+
// never pull vector/BM25 rows into a mode="keyword" request. This mirrors
|
|
455
|
+
// the blend branch's `mode === "keyword"` short-circuit (byte-for-byte).
|
|
456
|
+
if (mode === "keyword") {
|
|
457
|
+
return { merged: keyword.slice(0, lim), signals };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
let merged;
|
|
461
|
+
if (recipe === "rrf") {
|
|
462
|
+
// Rank-based: only the position of a row inside each surviving source
|
|
463
|
+
// list matters, so no cross-signal scale calibration is needed.
|
|
464
|
+
const k = 60; // standard RRF constant (plan #1 documents k=60)
|
|
465
|
+
const rows = new Map();
|
|
466
|
+
const addList = (list) => list.forEach((m, idx) => {
|
|
467
|
+
const s = 1 / (k + idx + 1);
|
|
468
|
+
const cur = rows.get(m.id);
|
|
469
|
+
if (cur) cur.score += s;
|
|
470
|
+
else rows.set(m.id, { ...m, score: s });
|
|
471
|
+
});
|
|
472
|
+
addList(keyword);
|
|
473
|
+
addList(vector);
|
|
474
|
+
addList(bm25);
|
|
475
|
+
merged = [...rows.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, lim);
|
|
476
|
+
} else if (recipe === "minmax") {
|
|
477
|
+
// Scale-aware weighted sum: each source list is min-max normalized to
|
|
478
|
+
// [0,1] before blending, so raw cosine and keyword score live on the
|
|
479
|
+
// same footing.
|
|
480
|
+
const norm = (list) => {
|
|
481
|
+
if (!list.length) return new Map();
|
|
482
|
+
let min = Infinity, max = -Infinity;
|
|
483
|
+
for (const m of list) { const s = m.score ?? 0; if (s < min) min = s; if (s > max) max = s; }
|
|
484
|
+
const range = max - min;
|
|
485
|
+
const out = new Map();
|
|
486
|
+
for (const m of list) out.set(m.id, range > 0 ? ((m.score ?? 0) - min) / range : 0.5);
|
|
487
|
+
return out;
|
|
488
|
+
};
|
|
489
|
+
const kw = norm(keyword), ve = norm(vector), bm = norm(bm25);
|
|
490
|
+
const rows = new Map();
|
|
491
|
+
const seed = (m) => { if (!rows.has(m.id)) rows.set(m.id, { ...m, score: 0 }); };
|
|
492
|
+
for (const m of keyword) seed(m);
|
|
493
|
+
for (const m of vector) seed(m);
|
|
494
|
+
for (const m of bm25) seed(m);
|
|
495
|
+
for (const [id, row] of rows) {
|
|
496
|
+
const k = kw.get(id) ?? 0;
|
|
497
|
+
const v = ve.get(id) ?? 0;
|
|
498
|
+
const b = bm.get(id) ?? 0;
|
|
499
|
+
row.score = v * wv + k * wk + b * wb;
|
|
500
|
+
}
|
|
501
|
+
merged = [...rows.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, lim);
|
|
502
|
+
} else {
|
|
503
|
+
// blend — the pre-existing per-mode behavior, extracted verbatim.
|
|
504
|
+
if (mode === "keyword") {
|
|
505
|
+
merged = keyword;
|
|
506
|
+
} else if (mode === "vector" || mode === "hybrid") {
|
|
507
|
+
const byId = new Map();
|
|
508
|
+
for (const m of vector) {
|
|
509
|
+
const rec = byId.get(m.id);
|
|
510
|
+
byId.set(m.id, rec ? { ...rec, score: Math.max(rec.score ?? 0, m.score ?? 0) } : m);
|
|
511
|
+
}
|
|
512
|
+
for (const m of keyword) {
|
|
513
|
+
const rec = byId.get(m.id);
|
|
514
|
+
if (rec) {
|
|
515
|
+
byId.set(m.id, { ...rec, score: (rec.score ?? 0) * wv + (m.score ?? 0) * wk });
|
|
516
|
+
} else {
|
|
517
|
+
byId.set(m.id, m);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
for (const m of bm25) {
|
|
521
|
+
const rec = byId.get(m.id);
|
|
522
|
+
if (rec) {
|
|
523
|
+
if (keywordIds.has(m.id)) continue;
|
|
524
|
+
byId.set(m.id, { ...rec, score: (rec.score ?? 0) + wb * (m.score ?? 0) });
|
|
525
|
+
} else {
|
|
526
|
+
byId.set(m.id, { ...m, score: wb * (m.score ?? 0) });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const ranked = [...byId.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
530
|
+
merged = ranked.slice(0, lim);
|
|
531
|
+
if (merged.length < lim && !merged.length) {
|
|
532
|
+
merged = keyword.slice(0, lim);
|
|
533
|
+
}
|
|
534
|
+
} else {
|
|
535
|
+
// auto: keyword leads, vector + BM25 fill remaining slots.
|
|
536
|
+
merged = keyword.slice(0, lim);
|
|
537
|
+
const seen = new Set(merged.map((m) => m.id));
|
|
538
|
+
for (const m of vector) {
|
|
539
|
+
if (merged.length >= lim) break;
|
|
540
|
+
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
|
541
|
+
}
|
|
542
|
+
for (const m of bm25) {
|
|
543
|
+
if (merged.length >= lim) break;
|
|
544
|
+
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Mode contract, auto (aligns rrf/minmax with blend): keyword leads, the
|
|
550
|
+
// recipe fills the remaining slots. blend.auto already front-loads keyword;
|
|
551
|
+
// rrf/minmax rank across sources, so re-apply the same "keyword first"
|
|
552
|
+
// ordering here to preserve the pre-fusion auto contract — the keyword
|
|
553
|
+
// hit list keeps its power, and only slots it couldn't fill go to the
|
|
554
|
+
// recipe's ranking.
|
|
555
|
+
if (recipe !== "blend" && mode === "auto" && keyword.length) {
|
|
556
|
+
const head = keyword.slice(0, lim);
|
|
557
|
+
const seen = new Set(head.map((m) => m.id));
|
|
558
|
+
const tail = merged.filter((m) => !seen.has(m.id)).slice(0, Math.max(0, lim - head.length));
|
|
559
|
+
merged = head.concat(tail);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return { merged, signals };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Search memories for a query. Merges up to three recall sources (keyword,
|
|
567
|
+
* vector, BM25) according to config.recallFusion (blend/rrf/minmax — see
|
|
568
|
+
* fuseRecall), then optionally decorates rows with per-source signals
|
|
569
|
+
* (config.signalTransparency), applies semantic dedup (non-keyword modes),
|
|
570
|
+
* reranking, and epistemic trust re-weighting, and finally hands the merged
|
|
571
|
+
* list to the recall-layer recorder.
|
|
572
|
+
*
|
|
573
|
+
* options:
|
|
574
|
+
* mode — 'auto' (default) | 'keyword' | 'vector' | 'hybrid'
|
|
575
|
+
* topK — max rows (default 20)
|
|
576
|
+
* threshold — explicit vector score floor (overrides adaptive)
|
|
577
|
+
* useRerank — apply the reranker if available (default true)
|
|
578
|
+
* recordRecall — write a recall_runs audit row (default from config)
|
|
579
|
+
*
|
|
580
|
+
* Returns an array of memory rows { id, title, content, score, source, ... },
|
|
581
|
+
* with `signals` added when config.signalTransparency is on. Never throws:
|
|
582
|
+
* a vector/rerank failure degrades to keyword results.
|
|
583
|
+
*/
|
|
419
584
|
async function searchMemories(query, options = {}) {
|
|
420
585
|
const { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = options.recordRecall ?? (config?.recallRecordDefault ?? true) } = options;
|
|
421
586
|
const q = String(query ?? "").trim();
|
|
@@ -477,69 +642,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
477
642
|
// Loose blend weight: BM25 confirms and backfills, never dominates the
|
|
478
643
|
// semantic signal. Same-memory overlap boosts, unseen ids backfill.
|
|
479
644
|
const wb = 0.3;
|
|
480
|
-
// Path bookkeeping for the boost rule below: which ids each semantic
|
|
481
|
-
// recall path surfaced.
|
|
482
|
-
const vectorIds = new Set(vector.map((m) => m.id));
|
|
483
|
-
const keywordIds = new Set(keyword.map((m) => m.id));
|
|
484
645
|
|
|
485
646
|
// Hybrid blending weights from config when provided.
|
|
486
647
|
const wv = config?.hybridSearchVectorWeight ?? DEFAULT_HYBRID_WEIGHTS.vector;
|
|
487
648
|
const wk = config?.hybridSearchKeywordWeight ?? DEFAULT_HYBRID_WEIGHTS.keyword;
|
|
488
649
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
// vector order leads (it is the semantic signal), lexical paths
|
|
496
|
-
// backfill.
|
|
497
|
-
const byId = new Map();
|
|
498
|
-
for (const m of vector) {
|
|
499
|
-
const rec = byId.get(m.id);
|
|
500
|
-
byId.set(m.id, rec ? { ...rec, score: Math.max(rec.score ?? 0, m.score ?? 0) } : m);
|
|
501
|
-
}
|
|
502
|
-
for (const m of keyword) {
|
|
503
|
-
const rec = byId.get(m.id);
|
|
504
|
-
if (rec) {
|
|
505
|
-
// Same memory from both sides: blend the scores.
|
|
506
|
-
byId.set(m.id, { ...rec, score: (rec.score ?? 0) * wv + (m.score ?? 0) * wk });
|
|
507
|
-
} else {
|
|
508
|
-
byId.set(m.id, m);
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
for (const m of bm25) {
|
|
512
|
-
const rec = byId.get(m.id);
|
|
513
|
-
if (rec) {
|
|
514
|
-
// Boost rule: a row the LIKE keyword path already hit carries the
|
|
515
|
-
// query as a substring, so BM25 tokens are trivially present —
|
|
516
|
-
// boosting it double-counts lexical evidence. Only vector-recalled
|
|
517
|
-
// rows (lexical hit is genuinely new information) get the boost.
|
|
518
|
-
if (keywordIds.has(m.id)) continue;
|
|
519
|
-
byId.set(m.id, { ...rec, score: (rec.score ?? 0) + wb * (m.score ?? 0) });
|
|
520
|
-
} else {
|
|
521
|
-
byId.set(m.id, { ...m, score: wb * (m.score ?? 0) });
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
const ranked = [...byId.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
525
|
-
merged = ranked.slice(0, lim);
|
|
526
|
-
if (merged.length < lim && !merged.length) {
|
|
527
|
-
// Vector unavailable entirely: fall back to plain keyword.
|
|
528
|
-
merged = keyword.slice(0, lim);
|
|
529
|
-
}
|
|
530
|
-
} else {
|
|
531
|
-
// auto: keyword leads, vector + BM25 fill remaining slots (legacy
|
|
532
|
-
// behavior, extended with the third path)
|
|
533
|
-
merged = keyword.slice(0, lim);
|
|
534
|
-
const seen = new Set(merged.map((m) => m.id));
|
|
535
|
-
for (const m of vector) {
|
|
536
|
-
if (merged.length >= lim) break;
|
|
537
|
-
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
|
538
|
-
}
|
|
539
|
-
for (const m of bm25) {
|
|
540
|
-
if (merged.length >= lim) break;
|
|
541
|
-
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
|
542
|
-
}
|
|
650
|
+
const { merged: fusedMerged, signals } = fuseRecall({ keyword, vector, bm25, lim, mode, wv, wk, wb });
|
|
651
|
+
let merged = fusedMerged;
|
|
652
|
+
// Signal transparency (#2): decorate each returned row with its per-source
|
|
653
|
+
// scores and the final fused score. Purely additive — never changes rank.
|
|
654
|
+
if (config?.signalTransparency === true) {
|
|
655
|
+
merged = merged.map((m) => ({ ...m, signals: { ...(signals.get(m.id) ?? {}), final: m.score ?? 0 } }));
|
|
543
656
|
}
|
|
544
657
|
|
|
545
658
|
// Search-time semantic dedup (v0.5.0 2.3): near-duplicate rows are
|
package/src/settings.js
CHANGED
|
@@ -58,6 +58,9 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
58
58
|
"bm25SearchEnabled",
|
|
59
59
|
"conflictFreezeEnabled",
|
|
60
60
|
"trustEpistemicWeighting",
|
|
61
|
+
// Plan #2: attach per-source {keyword, vector, bm25, final} signals to each
|
|
62
|
+
// search result for transparency/debugging. Default off, purely decorative.
|
|
63
|
+
"signalTransparency",
|
|
61
64
|
// Issue #89:宽容校验回归(默认开)+ 跨类型合并显式放宽(默认关)。
|
|
62
65
|
"dreamSkipInvalid",
|
|
63
66
|
"allowCrossTypeMerge",
|
|
@@ -86,6 +89,10 @@ const FEATURE_FLAG_STRINGS = [
|
|
|
86
89
|
// /llm-providers 端点一起提供,留空 = 用巩固模型或当前模型。
|
|
87
90
|
"sleepProvider",
|
|
88
91
|
"sleepModel",
|
|
92
|
+
// 实体抽取侧专用路由(issue #109):provider/model 显式指定,
|
|
93
|
+
// 留空 = 用当前默认模型。
|
|
94
|
+
"entityExtractionProvider",
|
|
95
|
+
"entityExtractionModel",
|
|
89
96
|
"localEmbedModel",
|
|
90
97
|
"ollamaModel"
|
|
91
98
|
];
|
|
@@ -94,7 +101,12 @@ const FEATURE_FLAG_STRINGS = [
|
|
|
94
101
|
const FEATURE_FLAG_URLS = ["ollamaBaseUrl"];
|
|
95
102
|
// 枚举开关(与 config.js 的 z.union(z.const(...)) 对齐):仅允许列出的值。
|
|
96
103
|
const FEATURE_FLAG_ENUMS = {
|
|
97
|
-
embedProvider: ["openai", "local", "ollama"]
|
|
104
|
+
embedProvider: ["openai", "local", "ollama"],
|
|
105
|
+
// Plan #1: recall fusion recipe. blend = legacy (default); rrf / minmax are
|
|
106
|
+
// rank/scale-aware alternatives selected by the panel.
|
|
107
|
+
recallFusion: ["blend", "rrf", "minmax"],
|
|
108
|
+
// 实体抽取思考强度(issue #109):与 dreamReasoningEffort 枚举对齐。
|
|
109
|
+
entityExtractionReasoning: ["low", "medium", "high", "none"]
|
|
98
110
|
};
|
|
99
111
|
const FEATURE_FLAG_STRING_MAX = 200;
|
|
100
112
|
|
package/test/api.test.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { EventEmitter } from "node:events";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
4
5
|
import { createStore } from "../src/store.js";
|
|
5
6
|
import { createService } from "../src/service.js";
|
|
6
7
|
import { createApi } from "../src/api.js";
|
|
@@ -517,6 +518,20 @@ test("Bug10: vector-reindex with an embed-only OpenAI-compatible embedder return
|
|
|
517
518
|
assert.equal(vectorIndex.getEmbedding(service.all()[0].id).length, 3, "embedding persisted");
|
|
518
519
|
});
|
|
519
520
|
|
|
521
|
+
// --- /info(反馈预填的插件版本)-----------------------------------------------
|
|
522
|
+
|
|
523
|
+
test("GET /api/dsh-mneme/info returns the package version for feedback prefills", async () => {
|
|
524
|
+
const { routes } = setup(undefined);
|
|
525
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/info");
|
|
526
|
+
const res = new FakeRes();
|
|
527
|
+
await route.handler(req("/api/dsh-mneme/info"), res);
|
|
528
|
+
assert.equal(res.statusCode, 200);
|
|
529
|
+
const data = JSON.parse(res.body);
|
|
530
|
+
// 与插件根 package.json 的版本一致(反馈 issue/邮件的预填环境信息依赖它)。
|
|
531
|
+
const expected = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
532
|
+
assert.equal(data.version, expected);
|
|
533
|
+
});
|
|
534
|
+
|
|
520
535
|
// --- feature flags(/features:overrides + effective)------------------------
|
|
521
536
|
|
|
522
537
|
test("GET /api/dsh-mneme/features returns empty overrides and effective config defaults", async () => {
|
|
@@ -527,12 +542,15 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
527
542
|
assert.equal(res.statusCode, 200);
|
|
528
543
|
const data = JSON.parse(res.body);
|
|
529
544
|
assert.deepEqual(data.overrides, {});
|
|
530
|
-
// effective 覆盖全部
|
|
545
|
+
// effective 覆盖全部 42 个白名单键(含 v0.7.20 heatEnabled、Issue #89 新增
|
|
531
546
|
// dreamSkipInvalid/allowCrossTypeMerge/dreamMinIntervalMinutes、面板可调的
|
|
532
|
-
// dreamMaxTokens
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
|
|
547
|
+
// dreamMaxTokens、睡眠路由 sleepProvider/sleepModel、PR1 新增的
|
|
548
|
+
// recallFusion/signalTransparency,以及 issue #109 新增的实体抽取路由
|
|
549
|
+
// entityExtractionProvider/entityExtractionModel/entityExtractionReasoning),
|
|
550
|
+
// 未覆盖时取 bundle 配置的解析默认值;
|
|
551
|
+
// dreamProvider/dreamModel 无 schema 默认值(Config({}) 解析为 undefined),
|
|
552
|
+
// 不编造给前端 → 42 - 2 = 40
|
|
553
|
+
assert.equal(Object.keys(data.effective).length, 40);
|
|
536
554
|
assert.equal(data.effective.dreamSkipInvalid, true);
|
|
537
555
|
assert.equal(data.effective.allowCrossTypeMerge, false);
|
|
538
556
|
assert.equal(data.effective.dreamMinIntervalMinutes, 0);
|
|
@@ -555,6 +573,13 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
555
573
|
assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
|
|
556
574
|
assert.equal(data.effective.ollamaModel, "nomic-embed-text");
|
|
557
575
|
assert.equal(data.effective.embedProvider, "openai");
|
|
576
|
+
// PR1:融合配方枚举 + 信号透明布尔(均有默认值,故计入 effective 计数)
|
|
577
|
+
assert.equal(data.effective.recallFusion, "blend");
|
|
578
|
+
assert.equal(data.effective.signalTransparency, false);
|
|
579
|
+
// issue #109:实体抽取路由三键(均有默认值,故计入 effective 计数)
|
|
580
|
+
assert.equal(data.effective.entityExtractionProvider, "");
|
|
581
|
+
assert.equal(data.effective.entityExtractionModel, "");
|
|
582
|
+
assert.equal(data.effective.entityExtractionReasoning, "none");
|
|
558
583
|
});
|
|
559
584
|
|
|
560
585
|
test("PUT /api/dsh-mneme/features round-trips, overrides effective and persists", async () => {
|
package/test/benchmark.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { runBenchmark, TEST_CASES } from "../scripts/benchmark-recall.js";
|
|
3
|
+
import { runBenchmark, runFusionBenchmark, seedService, TEST_CASES } from "../scripts/benchmark-recall.js";
|
|
4
4
|
|
|
5
5
|
// The benchmark harness must stay a working evaluation: it runs the real
|
|
6
6
|
// searchMemories pipeline over the seeded store and the fused configuration
|
|
@@ -33,3 +33,62 @@ test("test cases cover the scattered-term BM25 territory", () => {
|
|
|
33
33
|
assert.ok(tc.query && tc.expected.length > 0);
|
|
34
34
|
}
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
// --- PR1: recall fusion recipes + signal transparency ---------------------
|
|
38
|
+
|
|
39
|
+
test("fusion-recipe A/B runs blend/rrf/minmax with valid recall on the seed corpus", async () => {
|
|
40
|
+
const report = await runFusionBenchmark({ topK: 5 });
|
|
41
|
+
assert.deepEqual(report.runs.map((r) => r.config), ["blend", "rrf", "minmax"]);
|
|
42
|
+
for (const run of report.runs) {
|
|
43
|
+
assert.equal(run.rows.length, TEST_CASES.length, `${run.config} covers every query`);
|
|
44
|
+
assert.ok(run.recallAtK >= 0 && run.recallAtK <= 1, `${run.config} recallAtK in [0,1]`);
|
|
45
|
+
assert.ok(run.avgMrr >= 0 && run.avgMrr <= 1, `${run.config} avgMrr in [0,1]`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("signalTransparency decorates rows with per-source signals", async () => {
|
|
50
|
+
const svc = seedService({ recallFusion: "blend", signalTransparency: true });
|
|
51
|
+
const rows = await svc.searchMemories("rust 异步", { mode: "auto", topK: 5, useRerank: false });
|
|
52
|
+
assert.ok(rows.length > 0, "the seed corpus returns hits for a scattered-term query");
|
|
53
|
+
for (const r of rows) {
|
|
54
|
+
assert.equal(typeof r.signals, "object", "each row carries a signals object");
|
|
55
|
+
assert.equal(typeof r.signals.final, "number", "signals carries the fused final score");
|
|
56
|
+
assert.ok([
|
|
57
|
+
"keyword" in r.signals,
|
|
58
|
+
"vector" in r.signals,
|
|
59
|
+
"bm25" in r.signals
|
|
60
|
+
].some(Boolean), "at least one source signal is present");
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("recallFusion recipes produce distinct fused scores for the same memory", async () => {
|
|
65
|
+
const scores = {};
|
|
66
|
+
for (const recipe of ["blend", "rrf", "minmax"]) {
|
|
67
|
+
const svc = seedService({ recallFusion: recipe });
|
|
68
|
+
const rows = await svc.searchMemories("rust 异步", { mode: "auto", topK: 5, useRerank: false });
|
|
69
|
+
const hit = rows.find((r) => r.id === "mem_async_pattern") ?? rows[0];
|
|
70
|
+
assert.ok(hit, `${recipe} surfaces a hit`);
|
|
71
|
+
scores[recipe] = hit.score ?? 0;
|
|
72
|
+
}
|
|
73
|
+
// RRF is rank-based (Σ 1/(k+rank+1)) and minmax normalizes before blending,
|
|
74
|
+
// so they must not all collapse onto the same numeric score.
|
|
75
|
+
assert.ok(new Set(Object.values(scores)).size > 1, `recipes score differently: ${JSON.stringify(scores)}`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// PR1 + CodeRabbit: an opt-in recipe must never change keyword-only behavior.
|
|
79
|
+
// mode="keyword" is the documented text-only path; regardless of recipe, the
|
|
80
|
+
// result must contain only keyword-sourced rows — no vector/BM25 bleed-in.
|
|
81
|
+
// (Note: on a scattered-CJK query the keyword source can itself be empty, in
|
|
82
|
+
// which case auto correctly falls back to BM25 — that is the pre-fusion blend
|
|
83
|
+
// behavior and is NOT a regression. Only mode="keyword" is a hard text path.)
|
|
84
|
+
test("opt-in recipes keep mode=keyword keyword-only (no vector/BM25 bleed)", async () => {
|
|
85
|
+
for (const recipe of ["blend", "rrf", "minmax"]) {
|
|
86
|
+
const svc = seedService({ recallFusion: recipe });
|
|
87
|
+
const rows = await svc.searchMemories("rust 异步", { mode: "keyword", topK: 5, useRerank: false });
|
|
88
|
+
const keywordOnly = rows.every((r) => r.source === "keyword");
|
|
89
|
+
assert.ok(
|
|
90
|
+
keywordOnly,
|
|
91
|
+
`${recipe} under mode=keyword must keep only keyword rows; got sources: ${[...new Set(rows.map((r) => r.source))].join(",")}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
});
|
package/test/client.test.js
CHANGED
|
@@ -554,3 +554,42 @@ test("consolidation/sleep model routing: provider dropdowns from /llm-providers,
|
|
|
554
554
|
"the route selects must share the string-input width budget"
|
|
555
555
|
);
|
|
556
556
|
});
|
|
557
|
+
|
|
558
|
+
// 帮助与反馈入口(v0.8):设置页底部三个反馈链接——GitHub 新建 issue 预填
|
|
559
|
+
// (环境信息)、邮件反馈、浏览已知问题。纯前端链接零后端成本;插件版本从
|
|
560
|
+
// /info 拉取(version 只读,不铺任何 token/凭据)。公开链接不得带个人邮箱。
|
|
561
|
+
test("settings feedback card: prefilled issue + mailto + browse, version from /info", () => {
|
|
562
|
+
// 1. 版本预填端点
|
|
563
|
+
assert.ok(
|
|
564
|
+
clientSource.includes('apiFetch("/api/dsh-mneme/info")'),
|
|
565
|
+
"the feedback card must fetch the plugin version from /info"
|
|
566
|
+
);
|
|
567
|
+
assert.ok(
|
|
568
|
+
clientSource.includes("setPkgVersion"),
|
|
569
|
+
"the fetched version must land in component state"
|
|
570
|
+
);
|
|
571
|
+
// 2. GitHub 新建 issue:issues/new?title=&body= 预填环境信息(当前仓库无模板)
|
|
572
|
+
assert.ok(
|
|
573
|
+
clientSource.includes("https://github.com/modusensus/dsh-mneme/issues/new?title="),
|
|
574
|
+
"the issue link must prefill title+body on issues/new"
|
|
575
|
+
);
|
|
576
|
+
assert.ok(
|
|
577
|
+
clientSource.includes("**插件版本**") && clientSource.includes("**平台**"),
|
|
578
|
+
"the prefill body must carry plugin version and platform"
|
|
579
|
+
);
|
|
580
|
+
// 3. 邮件反馈:官方邮箱(对外不写个人邮箱),mailto 预填 subject+body
|
|
581
|
+
assert.ok(
|
|
582
|
+
clientSource.includes("mailto:work@modusensus.space?subject="),
|
|
583
|
+
"the mailto link must point at the public support address"
|
|
584
|
+
);
|
|
585
|
+
// 4. 浏览已知问题:跳仓库 issues 列表页(去重前置步骤)
|
|
586
|
+
assert.ok(
|
|
587
|
+
clientSource.includes('href: "https://github.com/modusensus/dsh-mneme/issues"'),
|
|
588
|
+
"the browse link must open the repo issues list"
|
|
589
|
+
);
|
|
590
|
+
// 5. 双语 i18n
|
|
591
|
+
for (const key of ["feedback.title", "feedback.newIssue", "feedback.email", "feedback.browse", "feedback.hint"]) {
|
|
592
|
+
const occurrences = clientSource.split(`"memory.settings.${key}"`).length - 1;
|
|
593
|
+
assert.ok(occurrences >= 2, `i18n key memory.settings.${key} must exist in both zh and en (got ${occurrences})`);
|
|
594
|
+
}
|
|
595
|
+
});
|
package/test/entities.test.js
CHANGED
|
@@ -520,3 +520,28 @@ test("extractor fails safe when callLLM rejects → {ok:false}", async () => {
|
|
|
520
520
|
assert.ok(result.error);
|
|
521
521
|
store.close();
|
|
522
522
|
});
|
|
523
|
+
|
|
524
|
+
// --- issue #109: provider/model override + reasoning effort pass-through -----
|
|
525
|
+
|
|
526
|
+
test("extractor passes provider/model/reasoningEffort through to callLLM options", async () => {
|
|
527
|
+
const store = openStore();
|
|
528
|
+
let captured = null;
|
|
529
|
+
const callLLM = async (_messages, options) => {
|
|
530
|
+
captured = options;
|
|
531
|
+
return JSON.stringify({ entities: [{ name: "Vite", type: "technology", attrs: [] }], relations: [] });
|
|
532
|
+
};
|
|
533
|
+
const config = { entityExtractionProvider: "openai", entityExtractionModel: "gpt-x", entityExtractionReasoning: "high" };
|
|
534
|
+
const result = await extractEntities({ id: "m1", content: "Vite 是前端构建工具" }, { store, config, callLLM });
|
|
535
|
+
assert.equal(result.ok, true);
|
|
536
|
+
assert.deepEqual(captured, { provider: "openai", model: "gpt-x", reasoningEffort: "high" });
|
|
537
|
+
store.close();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("extractor omits empty provider/model and 'none' reasoning from options", async () => {
|
|
541
|
+
const store = openStore();
|
|
542
|
+
let captured = "unset";
|
|
543
|
+
const callLLM = async (_m, options) => { captured = options; return JSON.stringify({ entities: [], relations: [] }); };
|
|
544
|
+
await extractEntities({ id: "m2", content: "空文本" }, { store, config: {}, callLLM });
|
|
545
|
+
assert.deepEqual(captured, {}, "no provider/model/reasoning keys when unset");
|
|
546
|
+
store.close();
|
|
547
|
+
});
|
|
@@ -13,6 +13,7 @@ import { runSleep } from "../src/dream/sleep.js";
|
|
|
13
13
|
import { createStore } from "../src/store.js";
|
|
14
14
|
import { createService } from "../src/service.js";
|
|
15
15
|
import { createVectorIndex } from "../src/vector-index.js";
|
|
16
|
+
import { createEntityStreamAdapter } from "../src/index.js";
|
|
16
17
|
|
|
17
18
|
const embedder = {
|
|
18
19
|
embedSingle: async () => [1, 0, 0],
|
|
@@ -576,3 +577,79 @@ test("defaultEffort trap: sleep conflict pass remaps a poison effort too", async
|
|
|
576
577
|
}
|
|
577
578
|
store.close();
|
|
578
579
|
});
|
|
580
|
+
|
|
581
|
+
// ------------------------------------------------- entity extraction adapter (issue #108/#109)
|
|
582
|
+
// The streamEntityText adapter lives in index.js; it maps the extractor's
|
|
583
|
+
// options onto a dsh-llm stream route and retries once without the effort
|
|
584
|
+
// when the first attempt is rejected. These tests drive the real exported
|
|
585
|
+
// factory, not a mock of it.
|
|
586
|
+
|
|
587
|
+
test("issue#109: entity extraction effort rejection retries once without the effort", async () => {
|
|
588
|
+
const calls = [];
|
|
589
|
+
const warnings = [];
|
|
590
|
+
const streamEntityText = createEntityStreamAdapter({
|
|
591
|
+
llm: {
|
|
592
|
+
async *stream(options) {
|
|
593
|
+
calls.push(options);
|
|
594
|
+
// First attempt carries the effort: the provider rejects it via an
|
|
595
|
+
// error finish chunk (the realistic stream-level rejection).
|
|
596
|
+
if (options.reasoningEffort) {
|
|
597
|
+
yield { type: "finish", reason: { kind: "error", message: "UNSUPPORTED_REASONING_EFFORT" } };
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
yield { type: "text-delta", index: 0, text: "{\"entities\":[{\"name\":\"张三\",\"type\":\"person\",\"attrs\":[{\"key\":\"职业\",\"value\":\"工程师\",\"confidence\":0.9}]}],\"relations\":[]}" };
|
|
601
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
|
|
605
|
+
logger: { warn: (m) => warnings.push(String(m)) }
|
|
606
|
+
});
|
|
607
|
+
const text = await streamEntityText(
|
|
608
|
+
[{ role: "user", content: [{ type: "text", text: "记忆内容" }] }],
|
|
609
|
+
{ reasoningEffort: "low" }
|
|
610
|
+
);
|
|
611
|
+
assert.equal(calls.length, 2, "attempt (rejected) + retry without effort");
|
|
612
|
+
assert.equal(calls[0].reasoningEffort, "low", "first attempt forwards the effort");
|
|
613
|
+
assert.equal("reasoningEffort" in calls[1], false, "retry omits the rejected effort field");
|
|
614
|
+
assert.equal(calls[0].provider, "mock", "route resolved from agentDefaultModel");
|
|
615
|
+
assert.equal(calls[0].model, "mock-model", "route model from agentDefaultModel");
|
|
616
|
+
assert.equal(calls[0].maxTokens, 4096, "extraction caps its output");
|
|
617
|
+
assert.ok(text.includes("张三"), "retry stream text is returned");
|
|
618
|
+
assert.ok(warnings.some((w) => w.includes("rejected, retrying without it")), "rejection is logged");
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("issue#109: entity extraction never retries blindly without an effort configured", async () => {
|
|
622
|
+
const calls = [];
|
|
623
|
+
const streamEntityText = createEntityStreamAdapter({
|
|
624
|
+
llm: {
|
|
625
|
+
async *stream(options) {
|
|
626
|
+
calls.push(options);
|
|
627
|
+
yield { type: "finish", reason: { kind: "error", message: "overloaded" } };
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
|
|
631
|
+
logger: { warn: () => {} }
|
|
632
|
+
});
|
|
633
|
+
const text = await streamEntityText([{ role: "user", content: [] }], {});
|
|
634
|
+
assert.equal(text, undefined, "failure yields no text");
|
|
635
|
+
assert.equal(calls.length, 1, "no blind retry when no effort was requested");
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("issue#109: entity extraction explicit provider/model win over the default route", async () => {
|
|
639
|
+
const calls = [];
|
|
640
|
+
const streamEntityText = createEntityStreamAdapter({
|
|
641
|
+
llm: {
|
|
642
|
+
async *stream(options) {
|
|
643
|
+
calls.push(options);
|
|
644
|
+
yield { type: "text-delta", index: 0, text: "{}" };
|
|
645
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "default", model: "default-model" }) },
|
|
649
|
+
logger: { warn: () => {} }
|
|
650
|
+
});
|
|
651
|
+
await streamEntityText([{ role: "user", content: [] }], { provider: "volcano", model: "deepseek-v3" });
|
|
652
|
+
assert.equal(calls.length, 1);
|
|
653
|
+
assert.equal(calls[0].provider, "volcano", "explicit provider beats the default");
|
|
654
|
+
assert.equal(calls[0].model, "deepseek-v3", "explicit model beats the default");
|
|
655
|
+
});
|