From c44833e8973270d5fb2330bf06a5646a52328f52 Mon Sep 17 00:00:00 2001 From: modusensus Date: Wed, 30 Sep 2026 18:58:34 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(graph):=20=E5=85=B3=E8=81=94=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E9=98=B2=E5=B9=BB=E8=A7=89=20Injection=20Budget?= =?UTF-8?q?=EF=BC=88issue=20#24=20=E5=9D=973=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图召回候选进注入时默认只参与排序、不改变注入块构成(graphInjectHint=false 保守档,heptaspirit「宁可搜不到不搜坏结果」建议);开启后以固定句式 [检索线索] 前缀标出,引导语声明「链路信息非事实断言」,防 LLM 单次消费 把桥接召回当有依据的事实回头扩散(幻觉诱导)。线索行走独立预算 graphInjectBudget(默认 1 条,不挤占 maxInjectedItems 槽位)。 - injectCandidates:entityRecallEnabled 开启时把图召回命中者挞 graphHint 标 (候选池打标 + 追加补位都带标),保守档跳过进块、开档受独立预算放行 - inject.js render:线索行用 graphHintLine 渲染(线索/ 前缀)+ 块头插 graphHintHeader 引导语(第一个线索行之前,先声明非事实) - lang.js:graphHintHeader / graphHintLine 双语 - config:graphInjectHint(默认 false)+ graphInjectBudget(默认 1),全走 feature_flags 面板可启停、lightMode 强制关闭 - 与检索侧共用 entityRecallEnabled 总闸:块1 #341 把它升级成级联后注入侧 自动吃到同样的扩散信号,无需再改 测试:5 组 graph-inject-budget 新增全过;inject/entity/recall 回归绿 --- dsh-mneme/lib/config.js | 10 +++ dsh-mneme/lib/inject.js | 14 +++- dsh-mneme/lib/lang.js | 10 +++ dsh-mneme/lib/service.js | 36 ++++++++- dsh-mneme/src/config.js | 10 +++ dsh-mneme/src/inject.js | 14 +++- dsh-mneme/src/lang.js | 10 +++ dsh-mneme/src/service.js | 36 ++++++++- dsh-mneme/test/graph-inject-budget.test.js | 85 ++++++++++++++++++++++ 9 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 dsh-mneme/test/graph-inject-budget.test.js diff --git a/dsh-mneme/lib/config.js b/dsh-mneme/lib/config.js index e187f67e..51286578 100644 --- a/dsh-mneme/lib/config.js +++ b/dsh-mneme/lib/config.js @@ -463,6 +463,14 @@ export const Config = z.object({ // 检索融合池(与 BM25 同级的确认/回填信号)。默认关=检索行为与 #219 前 // 逐字节一致;走 feature_flags 面板可启停,lightMode 强制关闭。 entityRecallEnabled: z.boolean().default(false), + // 关联提示防幻觉(issue #24 块3):图召回的候选(查询命中实体的挂联记忆) + // 进注入时默认只参与排序、不改变注入块构成(graphInjectHint=false 保守档); + // 开启后注入文本里以固定句式「[检索线索] …」标出,引导语说明这是链路信息 + // 非事实断言,防 LLM 单次消费把桥接召回当有依据的事实回头扩散。 + graphInjectHint: z.boolean().default(false), + // 图召回候选在注入块里的独立预算(条数上限)。与 pin/document 预算同构: + // 不占 maxInjectedItems 名额、只约束「桥接线索」这类低信任信息的常驻占比。 + graphInjectBudget: z.natural().min(0).max(5).default(1), // --- sleep mode: idle-triggered deep maintenance (v0.4.0) --------------- // Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight) @@ -712,6 +720,8 @@ const LIGHT_MODE_OFF = [ "bm25SearchEnabled", // 轻量模式不开图召回轴(#219,依赖实体抽取产出;抽取本身已被关掉)。 "entityRecallEnabled", + // 轻量模式不开关联提示标注(#24 块3,常驻注入文本多一段提示成本)。 + "graphInjectHint", // 轻量模式不开叙述条(额外 LLM 调用;#164 对齐,opt-in)。 "dreamNarrativeEnabled", // 轻量模式不开 document 指针行(#230,opt-in:注册/注入/检索增强全随闸)。 diff --git a/dsh-mneme/lib/inject.js b/dsh-mneme/lib/inject.js index ce297d30..25ff75f3 100644 --- a/dsh-mneme/lib/inject.js +++ b/dsh-mneme/lib/inject.js @@ -274,9 +274,15 @@ export function createInjector(ctx, service, settings, config) { // 日志抢同一份预算后被降级」正是本议题要修的结构缺陷。单条仍有硬顶 // (PINNED_CONTENT_MAX),超出照旧带截断提示,不静默。 const pinnedCount = Math.max(0, Math.min(candidates.length, pinnedStats?.shown ?? 0)); + // #24 块3:图召回线索行(graphHint)——渲染前数出数量,若 >0 在块头插入 + // 一段固定引导语(链路信息非事实断言,防幻觉扩散)。线索行自身按 + // graphHintLine 渲染(线索/ 前缀区别于普通记忆行)。 + const graphCount = candidates.filter((m) => m.graphHint === true).length; + let graphSeen = 0; for (let i = 0; i < candidates.length; i++) { const m = candidates[i]; const pinned = i < pinnedCount; + if (m.graphHint === true) graphSeen++; // Epistemic trust (v0.4.5): when enabled, measured observations are // flagged so the agent can weigh them above guesses/opinions. const verified = config.trustEpistemicWeighting === true && m.epistemic_status === "observation" @@ -284,7 +290,13 @@ export function createInjector(ctx, service, settings, config) { : ""; const title = STR.entryTitle[language](m.title, m.importance); const content = injectMemory(m, pinned ? PINNED_CONTENT_MAX : maxContent); - const full = STR.entryLine[language](m.type, verified, title, content); + const full = m.graphHint === true + ? STR.graphHintLine[language](m.type, title, content) + : STR.entryLine[language](m.type, verified, title, content); + // 线索行引导语插在第一个线索行之前(保持块头语义:先声明「这些不是事实」)。 + if (m.graphHint === true && graphSeen === 1) { + lines.push(STR.graphHintHeader[language]); + } if (pinned) { // pin 不扣块预算(上面那条设计注释的落地):pin 一条就够击穿 MAX_BLOCK // (PINNED_CONTENT_MAX 2000 > MAX_BLOCK 1500),照扣会把 budget 压成负数, diff --git a/dsh-mneme/lib/lang.js b/dsh-mneme/lib/lang.js index d3b8d8be..98932224 100644 --- a/dsh-mneme/lib/lang.js +++ b/dsh-mneme/lib/lang.js @@ -81,6 +81,16 @@ export const STR = { zh: (n) => `〔约束/偏好类另有 ${n} 条未展示〕`, en: (n) => `[${n} more constraint/preference entries not shown]` }, + // Issue #24 块3:图召回线索行的引导语——正向声明「链路信息非事实断言」, + // 防 LLM 单次消费把桥接召回当有依据的事实回头扩散(幻觉诱导)。 + graphHintHeader: { + zh: "[检索线索] 以下为实体图搜索的链路信息,仅供定位参考,非事实断言:", + en: "[Retrieval hints] Entity-graph link trails below are navigation aids, not factual assertions:" + }, + graphHintLine: { + zh: (type, title, content) => `- [线索/${type}] ${title}:${content}`, + en: (type, title, content) => `- [hint/${type}] ${title}: ${content}` + }, userSettingsHeader: { zh: "[用户设置] 来自 dsh-mneme 的用户画像与规则:", en: "[User settings] Profile and rules from dsh-mneme:" diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index 4324ad45..fb48f52a 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -1532,6 +1532,26 @@ export function createService({ store, mirror, config, onWrite, logger, document if (config?.strictScope === true && scope) { candidates = candidates.filter((m) => isVisibleInScope(m, scope)); } + // #24 块3:图召回候选打标。entityRecall 只在 entityRecallEnabled 开启时 + // 有产出,这里为命中者贴 graphHint 标签——注入侧据此(a)graphInjectHint + // 开=标成 [检索线索] 前缀的线索行(独立预算),(b)关=线索行只参与排序 + // 不改变注入块(保守档)。与检索侧共用同一图闸:块1 #341 把 entityRecall + // 升级成级联后,注入侧自动吃到同样的扩散信号,无需再改。只在语义/规则候选 + // 为空时兜底检索,避免注入路径双倍候选成本。 + if (config?.entityRecallEnabled === true && q) { + try { + const graphHits = entityRecall(q, maxItems * 2); + if (graphHits.length) { + const graphIds = new Set(graphHits.map((m) => m.id)); + candidates = [ + ...candidates.map((m) => graphIds.has(m.id) ? { ...m, graphHint: true } : m), + ...graphHits + .filter((m) => !candidates.some((c) => c.id === m.id)) + .map((m) => ({ ...m, graphHint: true })) + ]; + } + } catch { /* graph hint is best-effort */ } + } // #249 第一批:B1 pin 池。取在相关性排序之后、轮换之前——取谁按此刻的候选 // 次序(即相关性次序),取到后从候选中摘除,于是下面的轮换重排碰不到它们 // (验收:pin 不参与跨轮轮换)。独立预算的两层意义:pin 既不占 maxItems @@ -1560,9 +1580,23 @@ export function createService({ store, mirror, config, onWrite, logger, document // 注入块变成文档目录(#164 失败判据:批量把历史塞进上下文)。 const documentBudget = config?.documentInjectBudget ?? 2; let documentSeen = 0; + // #24 块3:图线索行(graphHint)按独立预算 graphInjectBudget 进块;所谓 + // 独立=既不受 document 预算约束、也不挤占 maxItems 槽位。graphInjectHint + // 关(默认保守档)时线索行**不进注入块**——它们只参与候选池排序(对融合无 + // 影响,因为候选池生成的唯一用途就是注入),桥接信号不污染常驻文本;开时 + // 才作为 [检索线索] 前缀行进入。与 document/pin 同构:预算只约束注入。 + const hintOpen = config?.graphInjectHint === true; + let graphSeen = 0; + const graphBudget = hintOpen ? Math.max(0, Math.floor(config?.graphInjectBudget ?? 1)) : 0; const general = []; for (const m of candidates) { - if (general.length >= maxItems) break; + if (m.graphHint === true) { + if (!hintOpen) continue; // 保守档:线索只排序不进块 + if (graphSeen >= graphBudget) continue; // 独立预算内放行 + graphSeen++; + } else if (general.length >= maxItems) { + break; // 普通候选受 maxItems 槽位上界 + } if (m.type === "document") { if (documentSeen >= documentBudget) continue; documentSeen++; diff --git a/dsh-mneme/src/config.js b/dsh-mneme/src/config.js index e187f67e..51286578 100644 --- a/dsh-mneme/src/config.js +++ b/dsh-mneme/src/config.js @@ -463,6 +463,14 @@ export const Config = z.object({ // 检索融合池(与 BM25 同级的确认/回填信号)。默认关=检索行为与 #219 前 // 逐字节一致;走 feature_flags 面板可启停,lightMode 强制关闭。 entityRecallEnabled: z.boolean().default(false), + // 关联提示防幻觉(issue #24 块3):图召回的候选(查询命中实体的挂联记忆) + // 进注入时默认只参与排序、不改变注入块构成(graphInjectHint=false 保守档); + // 开启后注入文本里以固定句式「[检索线索] …」标出,引导语说明这是链路信息 + // 非事实断言,防 LLM 单次消费把桥接召回当有依据的事实回头扩散。 + graphInjectHint: z.boolean().default(false), + // 图召回候选在注入块里的独立预算(条数上限)。与 pin/document 预算同构: + // 不占 maxInjectedItems 名额、只约束「桥接线索」这类低信任信息的常驻占比。 + graphInjectBudget: z.natural().min(0).max(5).default(1), // --- sleep mode: idle-triggered deep maintenance (v0.4.0) --------------- // Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight) @@ -712,6 +720,8 @@ const LIGHT_MODE_OFF = [ "bm25SearchEnabled", // 轻量模式不开图召回轴(#219,依赖实体抽取产出;抽取本身已被关掉)。 "entityRecallEnabled", + // 轻量模式不开关联提示标注(#24 块3,常驻注入文本多一段提示成本)。 + "graphInjectHint", // 轻量模式不开叙述条(额外 LLM 调用;#164 对齐,opt-in)。 "dreamNarrativeEnabled", // 轻量模式不开 document 指针行(#230,opt-in:注册/注入/检索增强全随闸)。 diff --git a/dsh-mneme/src/inject.js b/dsh-mneme/src/inject.js index ce297d30..25ff75f3 100644 --- a/dsh-mneme/src/inject.js +++ b/dsh-mneme/src/inject.js @@ -274,9 +274,15 @@ export function createInjector(ctx, service, settings, config) { // 日志抢同一份预算后被降级」正是本议题要修的结构缺陷。单条仍有硬顶 // (PINNED_CONTENT_MAX),超出照旧带截断提示,不静默。 const pinnedCount = Math.max(0, Math.min(candidates.length, pinnedStats?.shown ?? 0)); + // #24 块3:图召回线索行(graphHint)——渲染前数出数量,若 >0 在块头插入 + // 一段固定引导语(链路信息非事实断言,防幻觉扩散)。线索行自身按 + // graphHintLine 渲染(线索/ 前缀区别于普通记忆行)。 + const graphCount = candidates.filter((m) => m.graphHint === true).length; + let graphSeen = 0; for (let i = 0; i < candidates.length; i++) { const m = candidates[i]; const pinned = i < pinnedCount; + if (m.graphHint === true) graphSeen++; // Epistemic trust (v0.4.5): when enabled, measured observations are // flagged so the agent can weigh them above guesses/opinions. const verified = config.trustEpistemicWeighting === true && m.epistemic_status === "observation" @@ -284,7 +290,13 @@ export function createInjector(ctx, service, settings, config) { : ""; const title = STR.entryTitle[language](m.title, m.importance); const content = injectMemory(m, pinned ? PINNED_CONTENT_MAX : maxContent); - const full = STR.entryLine[language](m.type, verified, title, content); + const full = m.graphHint === true + ? STR.graphHintLine[language](m.type, title, content) + : STR.entryLine[language](m.type, verified, title, content); + // 线索行引导语插在第一个线索行之前(保持块头语义:先声明「这些不是事实」)。 + if (m.graphHint === true && graphSeen === 1) { + lines.push(STR.graphHintHeader[language]); + } if (pinned) { // pin 不扣块预算(上面那条设计注释的落地):pin 一条就够击穿 MAX_BLOCK // (PINNED_CONTENT_MAX 2000 > MAX_BLOCK 1500),照扣会把 budget 压成负数, diff --git a/dsh-mneme/src/lang.js b/dsh-mneme/src/lang.js index d3b8d8be..98932224 100644 --- a/dsh-mneme/src/lang.js +++ b/dsh-mneme/src/lang.js @@ -81,6 +81,16 @@ export const STR = { zh: (n) => `〔约束/偏好类另有 ${n} 条未展示〕`, en: (n) => `[${n} more constraint/preference entries not shown]` }, + // Issue #24 块3:图召回线索行的引导语——正向声明「链路信息非事实断言」, + // 防 LLM 单次消费把桥接召回当有依据的事实回头扩散(幻觉诱导)。 + graphHintHeader: { + zh: "[检索线索] 以下为实体图搜索的链路信息,仅供定位参考,非事实断言:", + en: "[Retrieval hints] Entity-graph link trails below are navigation aids, not factual assertions:" + }, + graphHintLine: { + zh: (type, title, content) => `- [线索/${type}] ${title}:${content}`, + en: (type, title, content) => `- [hint/${type}] ${title}: ${content}` + }, userSettingsHeader: { zh: "[用户设置] 来自 dsh-mneme 的用户画像与规则:", en: "[User settings] Profile and rules from dsh-mneme:" diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index 4324ad45..fb48f52a 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -1532,6 +1532,26 @@ export function createService({ store, mirror, config, onWrite, logger, document if (config?.strictScope === true && scope) { candidates = candidates.filter((m) => isVisibleInScope(m, scope)); } + // #24 块3:图召回候选打标。entityRecall 只在 entityRecallEnabled 开启时 + // 有产出,这里为命中者贴 graphHint 标签——注入侧据此(a)graphInjectHint + // 开=标成 [检索线索] 前缀的线索行(独立预算),(b)关=线索行只参与排序 + // 不改变注入块(保守档)。与检索侧共用同一图闸:块1 #341 把 entityRecall + // 升级成级联后,注入侧自动吃到同样的扩散信号,无需再改。只在语义/规则候选 + // 为空时兜底检索,避免注入路径双倍候选成本。 + if (config?.entityRecallEnabled === true && q) { + try { + const graphHits = entityRecall(q, maxItems * 2); + if (graphHits.length) { + const graphIds = new Set(graphHits.map((m) => m.id)); + candidates = [ + ...candidates.map((m) => graphIds.has(m.id) ? { ...m, graphHint: true } : m), + ...graphHits + .filter((m) => !candidates.some((c) => c.id === m.id)) + .map((m) => ({ ...m, graphHint: true })) + ]; + } + } catch { /* graph hint is best-effort */ } + } // #249 第一批:B1 pin 池。取在相关性排序之后、轮换之前——取谁按此刻的候选 // 次序(即相关性次序),取到后从候选中摘除,于是下面的轮换重排碰不到它们 // (验收:pin 不参与跨轮轮换)。独立预算的两层意义:pin 既不占 maxItems @@ -1560,9 +1580,23 @@ export function createService({ store, mirror, config, onWrite, logger, document // 注入块变成文档目录(#164 失败判据:批量把历史塞进上下文)。 const documentBudget = config?.documentInjectBudget ?? 2; let documentSeen = 0; + // #24 块3:图线索行(graphHint)按独立预算 graphInjectBudget 进块;所谓 + // 独立=既不受 document 预算约束、也不挤占 maxItems 槽位。graphInjectHint + // 关(默认保守档)时线索行**不进注入块**——它们只参与候选池排序(对融合无 + // 影响,因为候选池生成的唯一用途就是注入),桥接信号不污染常驻文本;开时 + // 才作为 [检索线索] 前缀行进入。与 document/pin 同构:预算只约束注入。 + const hintOpen = config?.graphInjectHint === true; + let graphSeen = 0; + const graphBudget = hintOpen ? Math.max(0, Math.floor(config?.graphInjectBudget ?? 1)) : 0; const general = []; for (const m of candidates) { - if (general.length >= maxItems) break; + if (m.graphHint === true) { + if (!hintOpen) continue; // 保守档:线索只排序不进块 + if (graphSeen >= graphBudget) continue; // 独立预算内放行 + graphSeen++; + } else if (general.length >= maxItems) { + break; // 普通候选受 maxItems 槽位上界 + } if (m.type === "document") { if (documentSeen >= documentBudget) continue; documentSeen++; diff --git a/dsh-mneme/test/graph-inject-budget.test.js b/dsh-mneme/test/graph-inject-budget.test.js new file mode 100644 index 00000000..d11d8cf5 --- /dev/null +++ b/dsh-mneme/test/graph-inject-budget.test.js @@ -0,0 +1,85 @@ +// issue #24 块3 关联提示防幻觉(graphInjectHint / graphInjectBudget)测试。 +// 覆盖:图候选打标 graphHint / 保守档(默认关)线索行不进注入块 / 开档受独立 +// 预算 graphInjectBudget 约束且不挤占 maxItems / [检索线索] 引导语与线索行文本。 +import test from "node:test"; +import assert from "node:assert/strict"; +import { createStore } from "../src/store.js"; +import { createService } from "../src/service.js"; +import { STR, langOf } from "../src/lang.js"; + +function setup(config = {}) { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config }); + return { store, service }; +} + +function linkEntityMem(store, service, { name, title, content, key = "k", value = "v", type = "technology" }) { + const mem = service.saveWithDedupe({ type: "project", title, content, importance: 3 }).memory; + let ent = store.findEntityByName(name); + if (!ent) ent = store.createEntity({ name, type }); + store.saveAttr({ entity_id: ent.id, attr_key: key, attr_value: value, memory_id: mem.id }); + return mem; +} + +test("graphHint candidates are tagged when entityRecallEnabled, and do NOT enter the block by default (conservative)", () => { + const { store, service } = setup({ entityRecallEnabled: true, maxInjectedItems: 5 }); + // 一条与查询无关的普通记忆(避免规则路占用),一条实体挂联记忆。 + linkEntityMem(store, service, { + name: "PostgreSQL", title: "数据库迁移", content: "主库切换完成,复制延迟归零" + }); + service.saveWithDedupe({ type: "decision", title: "无关决策", content: "今天天气不错", importance: 4 }); + const selected = service.injectCandidates({ maxItems: 5, query: "PostgreSQL" }); + // 保守档默认 graphInjectHint=false:图线索行被 general 选取跳过—— + // 候选池里能查到 graphHint 标记,但注入终集会排除它。 + assert.ok(selected.every((m) => m.graphHint !== true), "conservative: no graph hint rows in the injected set"); +}); + +test("graphInjectHint=true admits graph hint rows under independent budget", () => { + const { store, service } = setup({ + entityRecallEnabled: true, maxInjectedItems: 5, graphInjectHint: true, graphInjectBudget: 2 + }); + // 两条实体挂联记忆(都命中查询实体),一条普通规则记忆。 + linkEntityMem(store, service, { name: "PostgreSQL", title: "数据库迁移", content: "主库切换完成", key: "k1" }); + linkEntityMem(store, service, { name: "PostgreSQL", title: "连接池调优", content: "PgBouncer 配置", key: "k2" }); + service.saveWithDedupe({ type: "decision", title: "无关决策", content: "其他内容", importance: 5 }); + const selected = service.injectCandidates({ maxItems: 5, query: "PostgreSQL" }); + const hints = selected.filter((m) => m.graphHint === true); + assert.equal(hints.length, 2, "both graph hint rows fit within budget 2"); + // 普通规则记忆仍在(graph 行不挤占 maxItems 槽)。 + assert.ok(selected.some((m) => !m.graphHint), "non-graph rows still present"); + store.close(); +}); + +test("graphInjectBudget caps graph hint rows even when more match the query", () => { + const { store, service } = setup({ + entityRecallEnabled: true, maxInjectedItems: 10, graphInjectHint: true, graphInjectBudget: 1 + }); + // 三条候选实体挂联记忆,预算只有 1。 + for (const i of [1, 2, 3]) { + linkEntityMem(store, service, { name: "PostgreSQL", title: `PG 记录 ${i}`, content: `内容 ${i}`, key: `k${i}` }); + } + const selected = service.injectCandidates({ maxItems: 10 }); + const hints = selected.filter((m) => m.graphHint === true); + assert.ok(hints.length <= 1, `budget 1 caps at 1, got ${hints.length}`); + store.close(); +}); + +test("graphHint=false (default) + entityRecallEnabled returns zero candidate set change (conservative = sorting only)", () => { + const { store, service } = setup({ entityRecallEnabled: true, maxInjectedItems: 5 }); + // 仅实体挂联记忆,保守档下不该有任何候选进块。 + linkEntityMem(store, service, { name: "PostgreSQL", title: "数据库迁移", content: "主库切换完成" }); + const selected = service.injectCandidates({ maxItems: 5, query: "PostgreSQL 现状" }); + assert.equal(selected.filter((m) => m.graphHint).length, 0); + store.close(); +}); + +test("[检索线索] 引导语与线索行文本渲染正确(zh/en 双语)", () => { + assert.ok(STR.graphHintHeader.zh.includes("检索线索")); + assert.ok(STR.graphHintHeader.zh.includes("非事实断言")); + assert.ok(STR.graphHintHeader.en.includes("Retrieval hints")); + assert.ok(STR.graphHintHeader.en.toLowerCase().includes("not factual")); + const lineZh = STR.graphHintLine.zh("entity", "某某", "内容"); + assert.ok(lineZh.startsWith("- [线索/entity]")); + const lineEn = STR.graphHintLine.en("technology", "Foo", "bar"); + assert.ok(lineEn.startsWith("- [hint/technology]")); +}); \ No newline at end of file From c114c563472ad157551a022279b9f41e1c640d2c Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 1 Oct 2026 03:01:35 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(graph):=20#24=20=E5=9D=973=20=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=E4=BF=AE=E6=AD=A3=E2=80=94=E2=80=94=E7=BA=BF=E7=B4=A2?= =?UTF-8?q?=E8=A1=8C=E4=B8=8D=E8=BF=9B=20pin=20=E6=B1=A0=EF=BC=88=E4=BF=9D?= =?UTF-8?q?=E5=AE=88=E6=A1=A3=E6=B3=84=E6=BC=8F=EF=BC=89=E3=80=81=E6=8B=86?= =?UTF-8?q?=E6=8E=89=E6=AD=BB=E5=8F=98=E9=87=8F=E3=80=81=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E6=8E=A5=E7=BA=BF(+2=20=E9=94=AE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh-mneme/CHANGELOG.md | 6 ++++++ dsh-mneme/lib/inject.js | 7 +++---- dsh-mneme/lib/service.js | 5 ++++- dsh-mneme/lib/settings.js | 7 ++++++- dsh-mneme/src/inject.js | 7 +++---- dsh-mneme/src/service.js | 5 ++++- dsh-mneme/src/settings.js | 7 ++++++- dsh-mneme/test/api.test.js | 5 +++-- dsh-mneme/test/graph-inject-budget.test.js | 19 +++++++++++++++++++ 9 files changed, 54 insertions(+), 14 deletions(-) diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index dc1b24b1..1dacebea 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +## 🆕 新增 + +- **关联提示防幻觉 Injection Budget(issue #24 块3)**:图召回候选进注入时默认只参与排序、不改变注入块构成(`graphInjectHint` 关,保守档);开启后以固定句式「[检索线索]」标注、并在首个线索行前加引导语(链路信息非事实断言),条数由独立预算 `graphInjectBudget` 约束——既不受 document 预算约束、也不挤占 `maxItems` 槽位。线索行不进 pin 池(pin 会在保守档下把它放回块内,绕过开关)。新增两键注册进 feature_flags 白名单;lightMode 强制关。 + ## [0.8.11] - 2026-09-30 ## 🐛 修复 diff --git a/dsh-mneme/lib/inject.js b/dsh-mneme/lib/inject.js index 25ff75f3..43e5414b 100644 --- a/dsh-mneme/lib/inject.js +++ b/dsh-mneme/lib/inject.js @@ -274,10 +274,9 @@ export function createInjector(ctx, service, settings, config) { // 日志抢同一份预算后被降级」正是本议题要修的结构缺陷。单条仍有硬顶 // (PINNED_CONTENT_MAX),超出照旧带截断提示,不静默。 const pinnedCount = Math.max(0, Math.min(candidates.length, pinnedStats?.shown ?? 0)); - // #24 块3:图召回线索行(graphHint)——渲染前数出数量,若 >0 在块头插入 - // 一段固定引导语(链路信息非事实断言,防幻觉扩散)。线索行自身按 - // graphHintLine 渲染(线索/ 前缀区别于普通记忆行)。 - const graphCount = candidates.filter((m) => m.graphHint === true).length; + // #24 块3:图召回线索行(graphHint)——引导语插在第一个线索行之前(保持块头 + // 语义:先声明「这些不是事实」)。线索行自身按 graphHintLine 渲染(线索/ 前缀 + // 区别于普通记忆行)。 let graphSeen = 0; for (let i = 0; i < candidates.length; i++) { const m = candidates[i]; diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index fb48f52a..df22db20 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -1559,7 +1559,10 @@ export function createService({ store, mirror, config, onWrite, logger, document // 超预算的条数回报给调用方,在块内如实标注(绝不静默省略)。 const pinnedBudget = Math.max(0, Math.min(5, Math.floor(config?.pinnedInjectBudget ?? 0))); // eligible 留到块外:未展示条数要等 general 槽选完才算得准(见 selected 之后)。 - const eligible = pinnedBudget > 0 ? candidates.filter((m) => PINNED_MEMORY_TYPES.has(m.type)) : []; + // graphHint 行不进 pin 池:pin 是「每轮必进的约束/偏好」,而线索行按定义是 + // 「链路信息、非事实断言」,且 pin 长在候选池遍历之前——不排除的话,保守档 + // (graphInjectHint 关)也能从 pin 侧把它放回注入块,线索开关就被绕过去了。 + const eligible = pinnedBudget > 0 ? candidates.filter((m) => PINNED_MEMORY_TYPES.has(m.type) && m.graphHint !== true) : []; let pinned = []; if (pinnedBudget > 0 && eligible.length > 0) { pinned = eligible.slice(0, pinnedBudget); diff --git a/dsh-mneme/lib/settings.js b/dsh-mneme/lib/settings.js index 1d9591be..a8c16d6a 100644 --- a/dsh-mneme/lib/settings.js +++ b/dsh-mneme/lib/settings.js @@ -58,6 +58,9 @@ const FEATURE_FLAG_BOOLEANS = [ // Issue #219:图召回轴——查询命中实体名时把挂联记忆并入检索融合池 // (默认关;依赖实体抽取产出,lightMode 强制关闭)。 "entityRecallEnabled", + // Issue #24 块3:关联提示防幻觉——图召回线索行以「[检索线索]」标注进注入 + // (默认关=线索只参与排序不进块;依赖实体抽取产出,lightMode 强制关闭)。 + "graphInjectHint", // Issue #164:叙述条——dream 期间按 tag 主题簇合成叙述落库(source= // narrative,evidence 回链簇内记忆;按需检索不常驻注入;默认关)。 "dreamNarrativeEnabled", @@ -143,7 +146,9 @@ const FEATURE_FLAG_INT_RANGES = { // Issue #257:sleep 冲突/模式阶段的 LLM 输出预算(原硬编码 2048,实测不足)。 sleepMaxTokens: [256, 131072], // Issue #258:总览(dream_summarize)输入条数硬上限(0 = 不设上限)。 - dreamSummaryMaxInputs: [0, 100000] + dreamSummaryMaxInputs: [0, 100000], + // Issue #24 块3:图线索行的独立注入预算(0 = 线索不进块)。 + graphInjectBudget: [0, 5] }; // 浮点开关的闭区间(与 config.js 的 z.number().min().max() 对齐)。与整数开关 // 分开:面板的整数控件要求 Number.isInteger,而余弦相似度阈值必须允许小数。 diff --git a/dsh-mneme/src/inject.js b/dsh-mneme/src/inject.js index 25ff75f3..43e5414b 100644 --- a/dsh-mneme/src/inject.js +++ b/dsh-mneme/src/inject.js @@ -274,10 +274,9 @@ export function createInjector(ctx, service, settings, config) { // 日志抢同一份预算后被降级」正是本议题要修的结构缺陷。单条仍有硬顶 // (PINNED_CONTENT_MAX),超出照旧带截断提示,不静默。 const pinnedCount = Math.max(0, Math.min(candidates.length, pinnedStats?.shown ?? 0)); - // #24 块3:图召回线索行(graphHint)——渲染前数出数量,若 >0 在块头插入 - // 一段固定引导语(链路信息非事实断言,防幻觉扩散)。线索行自身按 - // graphHintLine 渲染(线索/ 前缀区别于普通记忆行)。 - const graphCount = candidates.filter((m) => m.graphHint === true).length; + // #24 块3:图召回线索行(graphHint)——引导语插在第一个线索行之前(保持块头 + // 语义:先声明「这些不是事实」)。线索行自身按 graphHintLine 渲染(线索/ 前缀 + // 区别于普通记忆行)。 let graphSeen = 0; for (let i = 0; i < candidates.length; i++) { const m = candidates[i]; diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index fb48f52a..df22db20 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -1559,7 +1559,10 @@ export function createService({ store, mirror, config, onWrite, logger, document // 超预算的条数回报给调用方,在块内如实标注(绝不静默省略)。 const pinnedBudget = Math.max(0, Math.min(5, Math.floor(config?.pinnedInjectBudget ?? 0))); // eligible 留到块外:未展示条数要等 general 槽选完才算得准(见 selected 之后)。 - const eligible = pinnedBudget > 0 ? candidates.filter((m) => PINNED_MEMORY_TYPES.has(m.type)) : []; + // graphHint 行不进 pin 池:pin 是「每轮必进的约束/偏好」,而线索行按定义是 + // 「链路信息、非事实断言」,且 pin 长在候选池遍历之前——不排除的话,保守档 + // (graphInjectHint 关)也能从 pin 侧把它放回注入块,线索开关就被绕过去了。 + const eligible = pinnedBudget > 0 ? candidates.filter((m) => PINNED_MEMORY_TYPES.has(m.type) && m.graphHint !== true) : []; let pinned = []; if (pinnedBudget > 0 && eligible.length > 0) { pinned = eligible.slice(0, pinnedBudget); diff --git a/dsh-mneme/src/settings.js b/dsh-mneme/src/settings.js index 1d9591be..a8c16d6a 100644 --- a/dsh-mneme/src/settings.js +++ b/dsh-mneme/src/settings.js @@ -58,6 +58,9 @@ const FEATURE_FLAG_BOOLEANS = [ // Issue #219:图召回轴——查询命中实体名时把挂联记忆并入检索融合池 // (默认关;依赖实体抽取产出,lightMode 强制关闭)。 "entityRecallEnabled", + // Issue #24 块3:关联提示防幻觉——图召回线索行以「[检索线索]」标注进注入 + // (默认关=线索只参与排序不进块;依赖实体抽取产出,lightMode 强制关闭)。 + "graphInjectHint", // Issue #164:叙述条——dream 期间按 tag 主题簇合成叙述落库(source= // narrative,evidence 回链簇内记忆;按需检索不常驻注入;默认关)。 "dreamNarrativeEnabled", @@ -143,7 +146,9 @@ const FEATURE_FLAG_INT_RANGES = { // Issue #257:sleep 冲突/模式阶段的 LLM 输出预算(原硬编码 2048,实测不足)。 sleepMaxTokens: [256, 131072], // Issue #258:总览(dream_summarize)输入条数硬上限(0 = 不设上限)。 - dreamSummaryMaxInputs: [0, 100000] + dreamSummaryMaxInputs: [0, 100000], + // Issue #24 块3:图线索行的独立注入预算(0 = 线索不进块)。 + graphInjectBudget: [0, 5] }; // 浮点开关的闭区间(与 config.js 的 z.number().min().max() 对齐)。与整数开关 // 分开:面板的整数控件要求 Number.isInteger,而余弦相似度阈值必须允许小数。 diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js index 4f969825..669b4c08 100644 --- a/dsh-mneme/test/api.test.js +++ b/dsh-mneme/test/api.test.js @@ -669,8 +669,9 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d // issue #239 第 4 项镜像到巩固新增 dreamPeakHours/dreamPeakMaxDeferMinutes, // issue #292 新增 autoDreamFailureBackoff、 // issue #254 新增 writeAdmission.enabled/writeAdmission.enforce, - // issue #34 恢复(#333)新增 injectTimePrefix) - assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1); + // issue #34 恢复(#333)新增 injectTimePrefix、 + // issue #24 块3 新增 graphInjectHint/graphInjectBudget) + assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 2); assert.equal(data.effective.dreamSkipInvalid, true); assert.equal(data.effective.allowCrossTypeMerge, false); assert.equal(data.effective.dreamMinIntervalMinutes, 0); diff --git a/dsh-mneme/test/graph-inject-budget.test.js b/dsh-mneme/test/graph-inject-budget.test.js index d11d8cf5..439c8c39 100644 --- a/dsh-mneme/test/graph-inject-budget.test.js +++ b/dsh-mneme/test/graph-inject-budget.test.js @@ -82,4 +82,23 @@ test("[检索线索] 引导语与线索行文本渲染正确(zh/en 双语)", assert.ok(lineZh.startsWith("- [线索/entity]")); const lineEn = STR.graphHintLine.en("technology", "Foo", "bar"); assert.ok(lineEn.startsWith("- [hint/technology]")); +}); + +// ---- 复审回归 --------------------------------------------------------------- + +// 保守档(graphInjectHint 关)下线索行不许从 pin 池侧漏进注入块:pin 池取自打标 +// 之后、又不占 maxItems 名额,pin 若不排除 graphHint 行,「图召回 + 约束型记忆」 +// 就会在默认关档下照常进常驻注入文本,把线索开关整个绕过去。 +test("conservative mode: a graph hint row cannot leak in via the pin pool", () => { + const { store, service } = setup({ entityRecallEnabled: true, maxInjectedItems: 5, pinnedInjectBudget: 1 }); + // 约束型记忆 + 命中查询实体:同时是 pin 候选与图线索候选(最坏交叉)。 + const mem = service.saveWithDedupe({ + type: "constraint", title: "部署约束", content: "生产环境不许直接改库", importance: 4 + }).memory; + const ent = store.createEntity({ name: "PostgreSQL", type: "technology" }); + store.saveAttr({ entity_id: ent.id, attr_key: "k", attr_value: "v", memory_id: mem.id }); + + const selected = service.injectCandidates({ maxItems: 5, query: "PostgreSQL" }); + assert.equal(selected.filter((m) => m.graphHint === true).length, 0, "hint rows stay out while graphInjectHint is off"); + store.close(); }); \ No newline at end of file