diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index ed78825..0c607ba 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -10,6 +10,7 @@ - **图谱锚定层 Activation Anchoring(issue #24 块1)**:在 `entityRecallEnabled` 的「查询命中实体 → 挂联记忆进融合池」之上再加一层级联——命中实体作锚定种子(`graphSeedCap` 封顶,默认 12),沿 `entity_relations` 按 `graphCascadeDepth`(默认 2)向邻居扩散,邻居挂联的记忆以跳数配额权重(1:2:3-hop = 0.5/0.3/0.2)补位进池,恒低于种子直达分(attr 1.0 / relation 0.9)。级联 BFS 逐层懒取邻居(>1 跳真正可达),邻接查询双向绑定(方向写反的关系行不丢)。默认关=检索行为与 #219 单跳轴逐字节一致(关档连种子裁剪都不生效)。新增三键 `graphAnchoringEnabled`/`graphSeedCap`/`graphCascadeDepth` 注册进 feature_flags 白名单,面板可启停;lightMode 强制关。 - **边权重演化 Weight Evolution(issue #24 块2)**:`entity_relations` 加 `weight`/`source` 两列(幂等迁移,存量行按 manual 1.0 认账)——建边来源决定初值(manual/confirmed 1.0、llm 0.4、tag 0.3),LLM 抽取自动建的边标 `source='llm'`;演化走 `bumpRelationWeight`(只加不减、封顶 1.0,单条 `UPDATE ... MIN(1.0, weight + ?)` 原子抬升,负/非有限增量在存储边界拒绝)。新增两键 `graphWeightEnabled`/`graphWeightDelta` 注册进 feature_flags 白名单,面板可启停;lightMode 强制关。触达侧接线见块4(被动确认)。 +- **关联提示防幻觉 Injection Budget(issue #24 块3)**:图召回候选进注入时默认只参与排序、不改变注入块构成(`graphInjectHint` 关,保守档);开启后以固定句式「[检索线索]」标注、并在首个线索行前加引导语(链路信息非事实断言),条数由独立预算 `graphInjectBudget` 约束——既不受 document 预算约束、也不挤占 `maxItems` 槽位。线索行不进 pin 池(pin 会在保守档下把它放回块内,绕过开关)。新增两键注册进 feature_flags 白名单;lightMode 强制关。 ## 🧹 工程 diff --git a/dsh-mneme/lib/config.js b/dsh-mneme/lib/config.js index 997d71d..8002106 100644 --- a/dsh-mneme/lib/config.js +++ b/dsh-mneme/lib/config.js @@ -480,6 +480,15 @@ export const Config = z.object({ graphSeedCap: z.natural().min(1).max(30).default(12), graphCascadeDepth: z.natural().min(1).max(3).default(2), + // 关联提示防幻觉(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) // sleep fires when the store has been quiet for sleepIdleMinutes and deep- @@ -732,6 +741,8 @@ const LIGHT_MODE_OFF = [ "graphAnchoringEnabled", // 轻量模式不开边权重演化(#24 块2,依赖实体抽取产出;抽取关则无 v08 边可抬升)。 "graphWeightEnabled", + // 轻量模式不开关联提示标注(#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 ce297d3..43e5414 100644 --- a/dsh-mneme/lib/inject.js +++ b/dsh-mneme/lib/inject.js @@ -274,9 +274,14 @@ 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)——引导语插在第一个线索行之前(保持块头 + // 语义:先声明「这些不是事实」)。线索行自身按 graphHintLine 渲染(线索/ 前缀 + // 区别于普通记忆行)。 + 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 +289,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 d3b8d8b..9893222 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 c7c74b9..cb338b4 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -1595,6 +1595,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 @@ -1602,7 +1622,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); @@ -1623,9 +1646,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/lib/settings.js b/dsh-mneme/lib/settings.js index 80c122e..bbf1c6a 100644 --- a/dsh-mneme/lib/settings.js +++ b/dsh-mneme/lib/settings.js @@ -64,6 +64,9 @@ const FEATURE_FLAG_BOOLEANS = [ // Issue #24 块2:边权重演化——touch 门控把被寻回路径的边权重抬升 // (默认关;依赖实体抽取产出,lightMode 强制关闭)。 "graphWeightEnabled", + // Issue #24 块3:关联提示防幻觉——图召回线索行以「[检索线索]」标注进注入 + // (默认关=线索只参与排序不进块;依赖实体抽取产出,lightMode 强制关闭)。 + "graphInjectHint", // Issue #164:叙述条——dream 期间按 tag 主题簇合成叙述落库(source= // narrative,evidence 回链簇内记忆;按需检索不常驻注入;默认关)。 "dreamNarrativeEnabled", @@ -152,7 +155,9 @@ const FEATURE_FLAG_INT_RANGES = { dreamSummaryMaxInputs: [0, 100000], // Issue #24 块1:锚定种子上限与级联深度(闭区间与 config.js 的 z.natural() 对齐)。 graphSeedCap: [1, 30], - graphCascadeDepth: [1, 3] + graphCascadeDepth: [1, 3], + // Issue #24 块3:图线索行的独立注入预算(0 = 线索不进块)。 + graphInjectBudget: [0, 5] }; // 浮点开关的闭区间(与 config.js 的 z.number().min().max() 对齐)。与整数开关 // 分开:面板的整数控件要求 Number.isInteger,而余弦相似度阈值必须允许小数。 diff --git a/dsh-mneme/src/config.js b/dsh-mneme/src/config.js index 997d71d..8002106 100644 --- a/dsh-mneme/src/config.js +++ b/dsh-mneme/src/config.js @@ -480,6 +480,15 @@ export const Config = z.object({ graphSeedCap: z.natural().min(1).max(30).default(12), graphCascadeDepth: z.natural().min(1).max(3).default(2), + // 关联提示防幻觉(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) // sleep fires when the store has been quiet for sleepIdleMinutes and deep- @@ -732,6 +741,8 @@ const LIGHT_MODE_OFF = [ "graphAnchoringEnabled", // 轻量模式不开边权重演化(#24 块2,依赖实体抽取产出;抽取关则无 v08 边可抬升)。 "graphWeightEnabled", + // 轻量模式不开关联提示标注(#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 ce297d3..43e5414 100644 --- a/dsh-mneme/src/inject.js +++ b/dsh-mneme/src/inject.js @@ -274,9 +274,14 @@ 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)——引导语插在第一个线索行之前(保持块头 + // 语义:先声明「这些不是事实」)。线索行自身按 graphHintLine 渲染(线索/ 前缀 + // 区别于普通记忆行)。 + 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 +289,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 d3b8d8b..9893222 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 c7c74b9..cb338b4 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -1595,6 +1595,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 @@ -1602,7 +1622,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); @@ -1623,9 +1646,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/settings.js b/dsh-mneme/src/settings.js index 80c122e..bbf1c6a 100644 --- a/dsh-mneme/src/settings.js +++ b/dsh-mneme/src/settings.js @@ -64,6 +64,9 @@ const FEATURE_FLAG_BOOLEANS = [ // Issue #24 块2:边权重演化——touch 门控把被寻回路径的边权重抬升 // (默认关;依赖实体抽取产出,lightMode 强制关闭)。 "graphWeightEnabled", + // Issue #24 块3:关联提示防幻觉——图召回线索行以「[检索线索]」标注进注入 + // (默认关=线索只参与排序不进块;依赖实体抽取产出,lightMode 强制关闭)。 + "graphInjectHint", // Issue #164:叙述条——dream 期间按 tag 主题簇合成叙述落库(source= // narrative,evidence 回链簇内记忆;按需检索不常驻注入;默认关)。 "dreamNarrativeEnabled", @@ -152,7 +155,9 @@ const FEATURE_FLAG_INT_RANGES = { dreamSummaryMaxInputs: [0, 100000], // Issue #24 块1:锚定种子上限与级联深度(闭区间与 config.js 的 z.natural() 对齐)。 graphSeedCap: [1, 30], - graphCascadeDepth: [1, 3] + graphCascadeDepth: [1, 3], + // 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 f65f278..e445512 100644 --- a/dsh-mneme/test/api.test.js +++ b/dsh-mneme/test/api.test.js @@ -671,8 +671,8 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d // issue #254 新增 writeAdmission.enabled/writeAdmission.enforce, // issue #34 恢复(#333)新增 injectTimePrefix、 // issue #24 块1 新增 graphAnchoringEnabled/graphSeedCap/graphCascadeDepth、 - // 块2 新增 graphWeightEnabled/graphWeightDelta) - assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2); + // 块2 新增 graphWeightEnabled/graphWeightDelta、块3 新增 graphInjectHint/graphInjectBudget) + assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 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 new file mode 100644 index 0000000..439c8c3 --- /dev/null +++ b/dsh-mneme/test/graph-inject-budget.test.js @@ -0,0 +1,104 @@ +// 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]")); +}); + +// ---- 复审回归 --------------------------------------------------------------- + +// 保守档(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