@modusensus/dsh-mneme 0.7.28 → 0.7.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -6
- package/lib/api.js +17 -1
- package/lib/client.js +142 -11
- package/lib/config.js +27 -0
- package/lib/embedding.js +3 -0
- package/lib/entities/extractor.js +12 -5
- package/lib/index.js +104 -37
- package/lib/local-embedder.js +4 -0
- package/lib/service.js +171 -58
- package/lib/settings.js +13 -1
- package/lib/store.js +4 -1
- package/package.json +1 -1
- package/scripts/benchmark-recall.js +71 -5
- package/scripts/e2e-dsh.js +6 -4
- package/src/api.js +17 -1
- package/src/config.js +27 -0
- package/src/embedding.js +3 -0
- package/src/entities/extractor.js +12 -5
- package/src/index.js +104 -37
- package/src/local-embedder.js +4 -0
- package/src/service.js +171 -58
- package/src/settings.js +13 -1
- package/src/store.js +4 -1
- package/test/api.test.js +65 -5
- package/test/benchmark.test.js +60 -1
- package/test/client.test.js +39 -0
- package/test/entities.test.js +25 -0
- package/test/local-embedder.test.js +2 -0
- package/test/reasoning-effort.test.js +77 -0
- package/test/store.test.js +9 -0
- package/README.en.md +0 -488
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";
|
|
@@ -434,6 +435,41 @@ test("no apiToken configured keeps all endpoints open", async () => {
|
|
|
434
435
|
assert.equal(res.statusCode, 200, "open when apiToken is unset");
|
|
435
436
|
});
|
|
436
437
|
|
|
438
|
+
// --- #118: /semantic exposes embedder readiness for the status card ----------
|
|
439
|
+
|
|
440
|
+
test("GET /api/dsh-mneme/semantic reports ready state per embedder", async () => {
|
|
441
|
+
const fetchSem = async (embedder) => {
|
|
442
|
+
const { routes } = setup(embedder);
|
|
443
|
+
const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
|
|
444
|
+
const res = new FakeRes();
|
|
445
|
+
await sem.handler(req("/api/dsh-mneme/semantic"), res);
|
|
446
|
+
assert.equal(res.statusCode, 200);
|
|
447
|
+
return JSON.parse(res.body);
|
|
448
|
+
};
|
|
449
|
+
// no embedder → everything null
|
|
450
|
+
assert.equal((await fetchSem(undefined)).embedProvider, null);
|
|
451
|
+
assert.equal((await fetchSem(undefined)).ready, null, "no embedder → ready null");
|
|
452
|
+
|
|
453
|
+
// Ollama-style embedder mid-init (ready:false) → ready false
|
|
454
|
+
class OllamaEmbedder { constructor() { this.ready = false; } }
|
|
455
|
+
const mid = await fetchSem(new OllamaEmbedder());
|
|
456
|
+
assert.equal(mid.embedProvider, "OllamaEmbedder");
|
|
457
|
+
assert.equal(mid.ready, false, "not-yet-ready embedder → ready false");
|
|
458
|
+
|
|
459
|
+
// ready after init
|
|
460
|
+
const ready = new OllamaEmbedder(); ready.ready = true;
|
|
461
|
+
assert.equal((await fetchSem(ready)).ready, true);
|
|
462
|
+
|
|
463
|
+
// legacy OpenAI embedder has no `ready` prop → treated as ready
|
|
464
|
+
assert.equal((await fetchSem({ embed() {} })).ready, true, "no ready prop → assumed ready");
|
|
465
|
+
|
|
466
|
+
// legacy embedder carries an explicit display name (constructor.name of a
|
|
467
|
+
// literal is "Object", which the status card must not render verbatim)
|
|
468
|
+
const named = await fetchSem({ name: "OpenAI", embed() {} });
|
|
469
|
+
assert.equal(named.embedProvider, "OpenAI", "explicit name beats constructor.name");
|
|
470
|
+
assert.equal(named.ready, true);
|
|
471
|
+
});
|
|
472
|
+
|
|
437
473
|
// --- Bug8: llm-audit API (pagination + stats) --------------------------------
|
|
438
474
|
|
|
439
475
|
test("GET /api/dsh-mneme/semantic/llm-audit returns paginated rows", async () => {
|
|
@@ -517,6 +553,20 @@ test("Bug10: vector-reindex with an embed-only OpenAI-compatible embedder return
|
|
|
517
553
|
assert.equal(vectorIndex.getEmbedding(service.all()[0].id).length, 3, "embedding persisted");
|
|
518
554
|
});
|
|
519
555
|
|
|
556
|
+
// --- /info(反馈预填的插件版本)-----------------------------------------------
|
|
557
|
+
|
|
558
|
+
test("GET /api/dsh-mneme/info returns the package version for feedback prefills", async () => {
|
|
559
|
+
const { routes } = setup(undefined);
|
|
560
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/info");
|
|
561
|
+
const res = new FakeRes();
|
|
562
|
+
await route.handler(req("/api/dsh-mneme/info"), res);
|
|
563
|
+
assert.equal(res.statusCode, 200);
|
|
564
|
+
const data = JSON.parse(res.body);
|
|
565
|
+
// 与插件根 package.json 的版本一致(反馈 issue/邮件的预填环境信息依赖它)。
|
|
566
|
+
const expected = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
567
|
+
assert.equal(data.version, expected);
|
|
568
|
+
});
|
|
569
|
+
|
|
520
570
|
// --- feature flags(/features:overrides + effective)------------------------
|
|
521
571
|
|
|
522
572
|
test("GET /api/dsh-mneme/features returns empty overrides and effective config defaults", async () => {
|
|
@@ -527,12 +577,15 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
527
577
|
assert.equal(res.statusCode, 200);
|
|
528
578
|
const data = JSON.parse(res.body);
|
|
529
579
|
assert.deepEqual(data.overrides, {});
|
|
530
|
-
// effective 覆盖全部
|
|
580
|
+
// effective 覆盖全部 42 个白名单键(含 v0.7.20 heatEnabled、Issue #89 新增
|
|
531
581
|
// dreamSkipInvalid/allowCrossTypeMerge/dreamMinIntervalMinutes、面板可调的
|
|
532
|
-
// dreamMaxTokens
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
|
|
582
|
+
// dreamMaxTokens、睡眠路由 sleepProvider/sleepModel、PR1 新增的
|
|
583
|
+
// recallFusion/signalTransparency,以及 issue #109 新增的实体抽取路由
|
|
584
|
+
// entityExtractionProvider/entityExtractionModel/entityExtractionReasoning),
|
|
585
|
+
// 未覆盖时取 bundle 配置的解析默认值;
|
|
586
|
+
// dreamProvider/dreamModel 无 schema 默认值(Config({}) 解析为 undefined),
|
|
587
|
+
// 不编造给前端 → 42 - 2 = 40
|
|
588
|
+
assert.equal(Object.keys(data.effective).length, 40);
|
|
536
589
|
assert.equal(data.effective.dreamSkipInvalid, true);
|
|
537
590
|
assert.equal(data.effective.allowCrossTypeMerge, false);
|
|
538
591
|
assert.equal(data.effective.dreamMinIntervalMinutes, 0);
|
|
@@ -555,6 +608,13 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
555
608
|
assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
|
|
556
609
|
assert.equal(data.effective.ollamaModel, "nomic-embed-text");
|
|
557
610
|
assert.equal(data.effective.embedProvider, "openai");
|
|
611
|
+
// PR1:融合配方枚举 + 信号透明布尔(均有默认值,故计入 effective 计数)
|
|
612
|
+
assert.equal(data.effective.recallFusion, "blend");
|
|
613
|
+
assert.equal(data.effective.signalTransparency, false);
|
|
614
|
+
// issue #109:实体抽取路由三键(均有默认值,故计入 effective 计数)
|
|
615
|
+
assert.equal(data.effective.entityExtractionProvider, "");
|
|
616
|
+
assert.equal(data.effective.entityExtractionModel, "");
|
|
617
|
+
assert.equal(data.effective.entityExtractionReasoning, "none");
|
|
558
618
|
});
|
|
559
619
|
|
|
560
620
|
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
|
+
});
|
|
@@ -116,7 +116,9 @@ test("OllamaEmbedder init probes server and infers dimension", async () => {
|
|
|
116
116
|
});
|
|
117
117
|
try {
|
|
118
118
|
const e = new OllamaEmbedder({ baseUrl: "http://localhost:11434", model: "nomic-embed-text" });
|
|
119
|
+
assert.equal(e.ready, false, "not ready before init");
|
|
119
120
|
await e.init();
|
|
121
|
+
assert.equal(e.ready, true, "ready after init");
|
|
120
122
|
assert.equal(e.dimension, 768);
|
|
121
123
|
} finally {
|
|
122
124
|
restore();
|
|
@@ -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
|
+
});
|
package/test/store.test.js
CHANGED
|
@@ -242,6 +242,15 @@ test("setEmbedding, embeddedCount, needsEmbedding and threshold filtering", () =
|
|
|
242
242
|
assert.equal(store.embeddedCount(), 0);
|
|
243
243
|
store.setEmbedding(m2.id, [0, 1]);
|
|
244
244
|
assert.equal(store.embeddedCount(), 1);
|
|
245
|
+
// Status-card denominator alignment: count() defaults exclude archived and
|
|
246
|
+
// forgotten rows, so embeddedCount must drop them too.
|
|
247
|
+
store.setArchived(m2.id, true);
|
|
248
|
+
assert.equal(store.embeddedCount(), 0, "archived rows leave the embedded count");
|
|
249
|
+
store.setArchived(m2.id, false);
|
|
250
|
+
store.setForget(m2.id, true);
|
|
251
|
+
assert.equal(store.embeddedCount(), 0, "forgotten rows leave the embedded count");
|
|
252
|
+
store.setForget(m2.id, false);
|
|
253
|
+
assert.equal(store.embeddedCount(), 1);
|
|
245
254
|
const missing = store.needsEmbedding(10);
|
|
246
255
|
assert.equal(missing.length, 1);
|
|
247
256
|
assert.equal(missing[0].id, t1.id, "only the non-embedded row is listed");
|