diff --git a/README.md b/README.md index 5f1c9d85..5c26a64f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ license CI node - tests + tests coverage Awesome

@@ -207,7 +207,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790 ```bash cd dsh-mneme && npm install -npm test # 1613 个测试 +npm test # 1615 个测试 npm run stress # 三轴线压测 npm run sync # src → lib 同步 ``` @@ -414,7 +414,7 @@ Community projects that wire mneme into more platforms — not part of this repo ```bash cd dsh-mneme && npm install -npm test # 1613 tests +npm test # 1615 tests npm run stress # three-axis stress test npm run sync # src → lib sync ``` diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index c32a783b..e48aa1ff 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -5,7 +5,7 @@ [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) -[![tests](https://img.shields.io/badge/tests-1613%20passed-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-1615%20passed-success)](https://github.com/slow-stack/mneme) [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions) [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org) [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme) @@ -613,7 +613,7 @@ src/ ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列) └── index.js # 插件接线 lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖) -test/ # 1613 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) +test/ # 1615 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单 ``` @@ -622,7 +622,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压 ```bash cd dsh-mneme npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试) -npm test # 运行 1613 个测试 +npm test # 运行 1615 个测试 npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM) npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行) ``` diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js index a25c5c0b..4efe2855 100644 --- a/dsh-mneme/lib/summarize.js +++ b/dsh-mneme/lib/summarize.js @@ -574,23 +574,28 @@ export function createSummarizer(ctx, service, config, deps = {}) { // 判定纯规则(字符数),无模型参与;被挡下的窗口照常消费游标,否则每个 // turn/end 都会重新评估同一段短文本。skip 原因进审计,作为后续按真实负载 // 决定下一级规则的依据。 + // + // 字符数改为无条件计算并写进审计 metadata:它是「下一级预判该不该开、阈值 + // 该给多少」的唯一依据,而原先开关一关就一个数都不留(维护者本机 1240 条 + // summarize_compress 成功行的 metadata 里窗口字符数为 0 条),验收 4「默认配置 + // 下调用量可量化下降」因此无法对账。本改动只加计量,不动任何跳过决策—— + // summarizeMinWindowChars 仍为 0 时行为逐字节不变。 + const distillChars = messages.reduce( + (total, message) => total + (Array.isArray(message.content) ? message.content : []) + .reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0), + 0 + ); const minWindowChars = config.summarizeMinWindowChars ?? 0; - if (minWindowChars > 0) { - const distillChars = messages.reduce( - (total, message) => total + (Array.isArray(message.content) ? message.content : []) - .reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0), - 0 - ); - if (distillChars < minWindowChars) { - commitCursor(session.id, nextSeq); - writeAudit({ - timestamp: new Date().toISOString(), - model_id: route ? `${route.provider}:${route.model}` : "unknown", - status: "skipped", - error_message: "window-too-small" - }); - return; - } + if (minWindowChars > 0 && distillChars < minWindowChars) { + commitCursor(session.id, nextSeq); + writeAudit({ + timestamp: new Date().toISOString(), + model_id: route ? `${route.provider}:${route.model}` : "unknown", + status: "skipped", + error_message: "window-too-small", + metadata: { distillChars, minWindowChars } + }); + return; } // 只有真正准备发起一次 LLM 蒸馏时才占用最小间隔与 run 预算;失败路径仍按 // 原语义占用(调用确实发生过),aborted 则由 finally 回滚。 @@ -809,8 +814,12 @@ export function createSummarizer(ctx, service, config, deps = {}) { // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。 persistCursor(session.id, nextSeq); } - if (audit && (capped > 0 || deduped > 0 || parsedResult.salvaged)) { + // Issue #239 定标:窗口字符数无条件随成功行落进 metadata。原先只有 + // capped / deduped / salvaged 其中一项发生时才建 metadata,默认档位下 + // 一条都不留——下一级预判规则的阈值就无从定标。 + if (audit) { audit.metadata = { + distillChars, parsed: parsed.length, ...(parsedResult.salvaged ? { json_salvaged: true } : {}), ...(capped > 0 ? { capped } : {}), diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js index a25c5c0b..4efe2855 100644 --- a/dsh-mneme/src/summarize.js +++ b/dsh-mneme/src/summarize.js @@ -574,23 +574,28 @@ export function createSummarizer(ctx, service, config, deps = {}) { // 判定纯规则(字符数),无模型参与;被挡下的窗口照常消费游标,否则每个 // turn/end 都会重新评估同一段短文本。skip 原因进审计,作为后续按真实负载 // 决定下一级规则的依据。 + // + // 字符数改为无条件计算并写进审计 metadata:它是「下一级预判该不该开、阈值 + // 该给多少」的唯一依据,而原先开关一关就一个数都不留(维护者本机 1240 条 + // summarize_compress 成功行的 metadata 里窗口字符数为 0 条),验收 4「默认配置 + // 下调用量可量化下降」因此无法对账。本改动只加计量,不动任何跳过决策—— + // summarizeMinWindowChars 仍为 0 时行为逐字节不变。 + const distillChars = messages.reduce( + (total, message) => total + (Array.isArray(message.content) ? message.content : []) + .reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0), + 0 + ); const minWindowChars = config.summarizeMinWindowChars ?? 0; - if (minWindowChars > 0) { - const distillChars = messages.reduce( - (total, message) => total + (Array.isArray(message.content) ? message.content : []) - .reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0), - 0 - ); - if (distillChars < minWindowChars) { - commitCursor(session.id, nextSeq); - writeAudit({ - timestamp: new Date().toISOString(), - model_id: route ? `${route.provider}:${route.model}` : "unknown", - status: "skipped", - error_message: "window-too-small" - }); - return; - } + if (minWindowChars > 0 && distillChars < minWindowChars) { + commitCursor(session.id, nextSeq); + writeAudit({ + timestamp: new Date().toISOString(), + model_id: route ? `${route.provider}:${route.model}` : "unknown", + status: "skipped", + error_message: "window-too-small", + metadata: { distillChars, minWindowChars } + }); + return; } // 只有真正准备发起一次 LLM 蒸馏时才占用最小间隔与 run 预算;失败路径仍按 // 原语义占用(调用确实发生过),aborted 则由 finally 回滚。 @@ -809,8 +814,12 @@ export function createSummarizer(ctx, service, config, deps = {}) { // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。 persistCursor(session.id, nextSeq); } - if (audit && (capped > 0 || deduped > 0 || parsedResult.salvaged)) { + // Issue #239 定标:窗口字符数无条件随成功行落进 metadata。原先只有 + // capped / deduped / salvaged 其中一项发生时才建 metadata,默认档位下 + // 一条都不留——下一级预判规则的阈值就无从定标。 + if (audit) { audit.metadata = { + distillChars, parsed: parsed.length, ...(parsedResult.salvaged ? { json_salvaged: true } : {}), ...(capped > 0 ? { capped } : {}), diff --git a/dsh-mneme/test/summarize.test.js b/dsh-mneme/test/summarize.test.js index 1e8b533f..f9aa3baa 100644 --- a/dsh-mneme/test/summarize.test.js +++ b/dsh-mneme/test/summarize.test.js @@ -274,6 +274,43 @@ test("#239 窗口过短:零 LLM 预判拦下调用,留 skip 审计并消费 assert.equal(service.listLlmAudits().length, 1, "游标已消费:同一窗口不重复评估、不重复审计"); }); +// #239 定标埋点:窗口字符数必须落进审计,否则下一级预判规则的阈值无从选起。 +// 第二条才是本改动的目的——预判开关关着(默认档位)时行为逐字节不变,但从这一刻 +// 起成功行开始留数,验收 4「默认配置下调用量可量化下降」才有账可对。 +test("#239 计量:window-too-small 的 skip 行带 distillChars 与阈值", async () => { + const { events, service } = setup({ summarizeMinWindowChars: 1000 }); + const handler = events.find((e) => e.name === "session/event").fn; + const session = { + id: "s239-metric-skip", + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events: [userMessage("好", 1), { seq: 2, type: "turn/end" }] + }; + await handler(session, { seq: 2, type: "turn/end" }); + const row = service.listLlmAudits()[0]; + assert.equal(row.error_message, "window-too-small"); + // 计的是**真正发给模型的转录文本**长度(含角色前缀:一条 1 字的「好」实测记 4), + // 与闸门比较的口径同一个数,定标才对得上。这里不锁字面量,锁语义:短于阈值才该 skip。 + assert.ok(Number.isInteger(row.metadata?.distillChars) && row.metadata.distillChars > 0, + "skip 行必须带窗口字符数"); + assert.equal(row.metadata.minWindowChars, 1000, "阈值随行落档,事后能复核是哪条闸门拦的"); + assert.ok(row.metadata.distillChars < row.metadata.minWindowChars, "skip 的前提:窗口短于阈值"); +}); + +test("#239 计量:预判开关关闭时成功行仍记录 distillChars", async () => { + const { events, service } = setup(); + const handler = events.find((e) => e.name === "session/event").fn; + const session = { + id: "s239-metric-success", + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events: [userMessage("今天决定把窗口字符数记进审计", 1), { seq: 2, type: "turn/end" }] + }; + await handler(session, { seq: 2, type: "turn/end" }); + const row = service.listLlmAudits()[0]; + assert.equal(row.status, "success"); + assert.ok(Number.isInteger(row.metadata?.distillChars) && row.metadata.distillChars > 0, + "默认档位下也要留数,否则验收 4 无从对账"); +}); + test("#239 窗口达到阈值时预判放行(不误杀)", async () => { const { events, calls } = setup({ summarizeMinWindowChars: 5 }); const handler = events.find((e) => e.name === "session/event").fn;