From d45bcbfc39b77e812d4bb0f2c26dfa96bb95be32 Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 12 Nov 2026 10:30:54 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(memory):=20=E5=8D=95=E6=AC=A1=E5=9C=BA?= =?UTF-8?q?=E5=90=88=E6=83=85=E7=BB=AA=E8=BF=9B=E6=B3=A8=E5=85=A5=EF=BC=8C?= =?UTF-8?q?=E5=9B=BE=E5=83=8F=E6=8F=8F=E8=BF=B0=E6=A1=A3=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=95=B4=E6=A1=A3=E9=9D=99=E9=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 情感线:occasion 档情绪此前只写进 entity_relations、面板可看而模型看不见, 因为边不是 memory,进不了 INJECT_TYPES 白名单。现在由 store 批量按候选记忆 取 affective_context 边,service 在 injectCandidates 里给命中记忆挂 affectiveNote,inject.js 渲染时接在记忆正文后(沿用 #641 conflictNote 的 通道,不新开块、不动 schema)。trait 档跳过——它已以 pattern 身份进注入。 注释吃注入块预算,限 160 字,超长整条放弃。开关 affectiveInjectEnabled, 默认关,父开关 sleepAffectiveEnabled,进轻量档关闭清单。 图像线:describeImages 的 catch 吞掉所有异常、prompt 槽留空整档不跑也不报, 审计里只有一个 imageCount,于是「开了但一次没跑成」与「这窗没有图」无法区分。 现在 describeImages 接可选 stats 出参,记 attempted/described/failed 与首条 错误;summarize 的 describeAuditPatch 是唯一出口,三处审计分支共用;开着没 路由、模型看不懂图也各记一条 skipped。默认关时一个 imageDescribe* 键都不写。 测试:新增 test/affective-inject.test.js 8 条,image-description 增 5 条; 变异自证八轮各自变红。全量 3414 tests / 3413 pass / 0 fail / 1 skipped。 --- dsh-mneme/CHANGELOG.md | 11 + dsh-mneme/docs/CONFIGURATION.md | 3 +- dsh-mneme/docs/SLEEP.md | 1 + dsh-mneme/lib/client.js | 9 +- dsh-mneme/lib/config.js | 17 +- dsh-mneme/lib/image-describe.js | 36 +- dsh-mneme/lib/inject.js | 5 + dsh-mneme/lib/lang.js | 20 + dsh-mneme/lib/service.js | 42 +- dsh-mneme/lib/settings.js | 3 + dsh-mneme/lib/store.js | 46 ++ dsh-mneme/lib/summarize.js | 44 +- dsh-mneme/src/config.js | 17 +- dsh-mneme/src/image-describe.js | 36 +- dsh-mneme/src/inject.js | 5 + dsh-mneme/src/lang.js | 20 + dsh-mneme/src/service.js | 42 +- dsh-mneme/src/settings.js | 3 + dsh-mneme/src/store.js | 46 ++ dsh-mneme/src/summarize.js | 44 +- dsh-mneme/test/affective-inject.test.js | 184 ++++++++ dsh-mneme/test/api.test.js | 6 +- dsh-mneme/test/image-description.test.js | 567 +++++++++++++---------- 23 files changed, 934 insertions(+), 273 deletions(-) create mode 100644 dsh-mneme/test/affective-inject.test.js diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index f60e00e1..f59a5561 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +- **情感记忆此前只写在库里、模型看不见:单次场合的情绪(occasion 档)补上注入出口,`affectiveInjectEnabled` 默认关**。`sleep` 的 affective 相位从 0.13 就在跑——它扫情绪归因、往 `entity_relations` 写 `affective_context` 边(`emotion` / `attribution` / `sessionCount` / `evidence`),面板的「情感」页也能按「谁·情绪·对象·场合/倾向」聚合展示。但**边不是记忆**:`INJECT_TYPES` 是 memory 类型白名单,边不在里面,所以 occasion 档一百条也进不了模型视野。跨够 `sleepAffectiveTraitMinSessions`(默认 3)个独立会话升上来的 trait 会铸成 `type: "pattern"`,那是真进注入的——可模型读到的是一条普通 pattern,不知道它是情绪归因。于是最典型的「她那天很焦虑」写进了库、面板看得见,问模型的时候它答不出来。 + - **复用 #641 的注释通道,不新开块**:`src/inject.js` 渲染每行时已经会拼 `m.conflictNote`(矛盾对标注),情绪线索挂在同一位置、另起字段 `affectiveNote`,顺序是「正文 → 情绪 → 冲突」——情绪说明背景,矛盾标注是决策指令必须贴最后。这样不新增 `ctx.systemPrompt.context`(那会改变整个注入块的顺序与预算分配),也不动 schema。 + - **挂哪条边、挂不挂由 service 决定**:`src/store.js` 新增 `affectiveRelationsByMemories(memoryIds, perMemory = 1)`,一次 SQL 按候选记忆 id 批量取边(`WHERE relation_type = 'affective_context' AND memory_id IN (…)`,两端 LEFT JOIN entities 取人名与对象名),按 `created_at` 倒序、每条记忆最多一条。`src/service.js` 在 `injectCandidates` 的 superseded 过滤之后、pinnedStats 之前挂注释,**trait 档跳过**(`attribution === "trait"` 或 `promotedToTrait === true`)——它已经以 pattern 身份进来了,再挂一遍等于同一件事说两次。名字为空的边(实体被删)直接跳过,不渲染「unknown 对 unknown」。 + - **注释吃注入块预算,所以限长**:`MAX_BLOCK = maxContent + 600`,一条超长注释会把同批候选挤成降级标题行。`AFFECTIVE_NOTE_MAX = 160` 字,超了整条不挂(不是截断——截一半的情绪说明比没有更误导),`why` 单独截 60 字。 + - **开关是子项,不占新座位**:`affectiveInjectEnabled` 默认 false,父开关是 `sleepAffectiveEnabled`(后端 `INJECT_CHILD_FLAGS`,面板 `FEATURE_CHILDREN`,两表同源由 `test/inject-parent-gate.test.js` 锁)。父关时子项一并不挂但持久值保留,父开关缺省(未写过)时只按子项自己判。进 `LIGHT_MODE_OFF`:低资源档本来就连 `sleepAffectiveEnabled` 一起关,情绪边都不会产出,留着这个注入开关没有意义。 + - **测试**:`test/affective-inject.test.js` 8 条(默认关不改注入、开着带全四项事实且单次场合不写次数、trait 档不重复挂、父关一并不挂与父缺省只按子项判、实体被删不挂、超长整条放弃但记忆仍在块里、同一记忆多条边只挂最新一条、配置默认值与轻量档)。变异自证五轮各自变红:删掉 `inject.js` 的拼接、删掉 trait 跳过、把父闸换成裸读子项、把「任一侧名字为空」写成「两侧都空」、删掉长度闸。 + +- **图像描述档此前整档静默:开了开关、配了模型、一次都没跑成,审计里与「这个窗口没有图」长得一样——现在四种结局都留痕**。`describeImages` 的 `catch {}` 吞掉所有异常,provider/model 留空整档不跑也不报,宿主 `textOnly` 降级同样无声;审计 metadata 里只有一个 `imageCount`(图块数)。于是「我明明开了」和「库里从来没进过图」在运维上无法区分(实测本库 273MB 里含 `[图` 的记忆 6 条,全是关于这个功能本身的说明文字,没有一条来自真实图片)。 + - **计数走出参,不认开关态**:`describeImages(..., options.stats)` 多一个可选出参(口径同 `injectCandidates` 的 `pinnedStats`:不传时行为逐字节等于改前),逐张记 `attempted`、成功记 `described`、空描述与抛异常各记 `failed` 并留首条错误原文(截 120 字)。`src/summarize.js` 加的 `describeAuditPatch(stats)` 是唯一出口,三处写审计的分支(窗口过小 / 解析失败 / 成功)共用,避免抄三遍漂移。 + - **四条结局**:跑成了写 `imageDescribed` + `imageDescribeFailed: 0`(失败数显式给 0,缺键会被读成「没跑」,那正是要避免的模糊);试了失败写失败数与 `imageDescribeError`;开着没配路由写 `imageDescribeSkipped: "no route"`;模型明确不能看图写 `"model-cannot-see-images"`。**默认关着时一个 `imageDescribe*` 键都不写**,老档位审计行逐字节不变。 + - **测试**:`test/image-description.test.js` 增 5 条(跑成 / 失败带原文 / 没路由 / 看不懂图 / 关闭态不写任何键)。变异自证三轮:去掉成功分支的 patch(4 红)、去掉 no route 的记账(1 红)、去掉 `attempted` 计数(2 红)。 ## [0.14.0-beta.3] - 2026-11-12 - **情绪页白屏(beta.1 起的回归,beta.2 仍在):点开「情感」页整块面板变空白,且此后每次打开都空白**。根因是 `lib/client.js` 的 `AffectivePanel` 里一行 `formatRelativeTime(it.lastSeen)` 漏传第二个参数 `t`——该函数内部要调 `t("memory.time.now")`,缺参就抛 `t is not a function`,而这行跑在 render 里,React 整棵树卸载,日志里什么都不留。引入提交是 #619(`244da66d`),`v0.14.0-beta.1` 与 `v0.14.0-beta.2` 都带它;只有库里存在带 `lastSeen` 的情绪边时才触发,所以不是每个用户都会遇到。修法是补上 `, t`,并加一条静态断言:扫出所有 `formatRelativeTime(` 调用点,逐个做括号配对、数顶层逗号,实参不足 2 个就红。全库只有这一处漏传(`formatDate`/`formatDateShort` 本来只收一个参数,扫描器报的几处是误报)。变异自证:撤掉修复后断言变红并直接指出 `:8168 (argc=1)`。 diff --git a/dsh-mneme/docs/CONFIGURATION.md b/dsh-mneme/docs/CONFIGURATION.md index fa015428..a105099a 100644 --- a/dsh-mneme/docs/CONFIGURATION.md +++ b/dsh-mneme/docs/CONFIGURATION.md @@ -250,6 +250,7 @@ | `sleepAffectiveEnabled` **light** | `false` | 情感归因相位(#619):情境情绪写成 `affective_context` 关系,跨多次独立会话的稳定倾向才铸成 pattern | opt-in;`sleepModeEnabled` 的子相,父关着不跑;要调模型且依赖实体抽取 | | `sleepAffectiveTraitMinSessions` | `3` | 判「稳定倾向」所需的**独立会话**数(2–10) | 下界 2 是故意的:N=1 就是单次场合,正是必须禁止升特质的那个情形 | | `sleepAffectiveMaxRelationsPerRun` | `10` | 单轮最多写多少条 `affective_context` 关系(0–50) | 同 `sleepMaxPatternPerRun` 的角色;0 = 一条也不写 | +| `affectiveInjectEnabled` **light** | `false` | 注入侧情绪注释(#619 补链):把 `attribution=occasion` 的情绪边作为一行注释挂到对应记忆下,让模型看见「她那天为什么那样」 | 默认关;`sleepAffectiveEnabled` 的子项——父关则无情绪边可挂;只挂 occasion,trait 已铸成 pattern 自己进注入;注释限 160 字(吃注入块预算) | | `sleepProvider` / `sleepModel` | 空 | sleep 专用路由 | 空 = 用巩固路由 / 当前模型 | | `sleepReasoningEffort` | 未配置 | 同 `dreamReasoningEffort` | — | | `sleepMaxTokens` | `8192` | 冲突/模式阶段输出预算(#257) | 原硬编码 2048 对 `full` 档必然截断 | @@ -390,7 +391,7 @@ `lightMode: true`(或面板 `panel_mode=light`)时,以下键被预设强制为 `false`——都是 重型路径;核心循环(autoInject、autoSummarize、hotMemory*、质量过滤、关键词检索)不动: -`entityExtractionEnabled` · `autoDream` · `sleepModeEnabled` · `sleepAffectiveEnabled`(#619) · `rerankEnabled` · +`entityExtractionEnabled` · `autoDream` · `sleepModeEnabled` · `sleepAffectiveEnabled`(#619) · `affectiveInjectEnabled` · `rerankEnabled` · `autoReindexOnBoot` · `hybridInject` · `injectGuidanceEnabled` · `searchSemanticDedup` · `selectiveInjectEnabled` · `bm25SearchEnabled` · `entityRecallEnabled` · `graphAnchoringEnabled` · `graphWeightEnabled` · `graphInjectHint` · `graphPassiveConfirm` · diff --git a/dsh-mneme/docs/SLEEP.md b/dsh-mneme/docs/SLEEP.md index 1e80564c..d94b55e6 100644 --- a/dsh-mneme/docs/SLEEP.md +++ b/dsh-mneme/docs/SLEEP.md @@ -53,6 +53,7 @@ flowchart TD | `sleepPatternLookbackDays` | `30` | 模式发现回溯的时间窗口(天) | | `sleepMaxPatternPerRun` | `3` | 单次运行最多创建的模式数量上限 | | `sleepAffectiveEnabled` | `false` | 情感归因相位(#619,opt-in):情境情绪写成 `affective_context` 关系,跨多次独立会话的稳定倾向才铸成 pattern。是 `sleepModeEnabled` 的子相,父开关关着整个 sleep 不跑 | +| `affectiveInjectEnabled` | `false` | 注入侧情绪注释(#619 补链):单次场合的情绪边挂成记忆行下的一行注释。`sleepAffectiveEnabled` 的子项,父关则无从挂起 | | `sleepAffectiveTraitMinSessions` | `3` | 判「稳定倾向」所需的**独立会话**数(2–10)。下界 2 是故意的:N=1 就是单次场合,正是必须禁止升成特质的那个情形 | | `sleepAffectiveMaxRelationsPerRun` | `10` | 单轮最多写多少条 `affective_context` 关系(0–50,0 = 一条不写) | | `sleepProvider` | `''` | 睡眠模式专用 LLM Provider(留空则复用默认) | diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index 5fdf7535..e68d6a0c 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -1003,6 +1003,8 @@ window.__ModuleLoader__.load({ // #619:情感归因是 sleep 的子相——父开关关着它就一定不跑,所以紧挨着摆。 "memory.features.sleepAffectiveEnabled": "情感归因", "memory.features.sleepAffectiveEnabled.hint": "整理时记下「谁在哪件事上什么心情」,反复出现才认。", + "memory.features.affectiveInjectEnabled": "情绪带进对话", + "memory.features.affectiveInjectEnabled.hint": "把记下的单次情绪附在相关记忆后面,模型才知道「那天为什么那样」。", // #427 建议 5:hybridInject / selectiveInjectEnabled / adaptiveThresholdEnabled // 是同一件事「带什么回来」的三档,相邻摆放并在 hint 里点明,不再摊成 // 三个不相干的并列开关。 @@ -2142,6 +2144,8 @@ window.__ModuleLoader__.load({ // the parent is off, so it sits next to it. "memory.features.sleepAffectiveEnabled": "Affect attribution", "memory.features.sleepAffectiveEnabled.hint": "Records who feels what about which matter, across repeated occasions.", + "memory.features.affectiveInjectEnabled": "Bring affect into chat", + "memory.features.affectiveInjectEnabled.hint": "Attaches a recorded one-off emotion to the related memory, so the model knows why that day felt like that.", // #427 suggestion 5: hybridInject / selectiveInjectEnabled / // adaptiveThresholdEnabled are three dials of one question — what comes // back. Adjacent placement plus a hint that names the other two. @@ -4644,7 +4648,10 @@ window.__ModuleLoader__.load({ heatEnabled: ["injectHeatEnabled"], // 地志的两个子开关贴着总闸(#607 后续):都是「地志开着才有意义」的 // 口径开关——读路径自动重跑 T0、面板按钮能否发起 T1。 - chorographyEnabled: ["chorographyAutoRefresh", "chorographyT1FromPanel"] + chorographyEnabled: ["chorographyAutoRefresh", "chorographyT1FromPanel"], + // #619 补链:情绪注释是情感归因的下游——写下的情绪要能进模型视野,否则 + // 面板上看得见、对话里看不见。贴着父开关摆,父关时这一行标注不生效。 + sleepAffectiveEnabled: ["affectiveInjectEnabled"] }; // Issue #427 建议 1:折叠线挪到「新手之外」。group.basic 是首屏层,只列新 // 用户敢动的总开关;group.scope 也在首屏——它回答新用户的第二个必答题 diff --git a/dsh-mneme/lib/config.js b/dsh-mneme/lib/config.js index a8501be0..a3dd64d8 100644 --- a/dsh-mneme/lib/config.js +++ b/dsh-mneme/lib/config.js @@ -764,6 +764,15 @@ export const Config = z.object({ // sleepMaxPatternPerRun for the relation channel: a runaway model that // "finds" an emotion in every memory must be capped, not trusted. sleepAffectiveMaxRelationsPerRun: z.natural().min(0).max(50).default(10), + // #619 补链(注入侧):把**单次场合**(attribution=occasion)的情绪边作为一行 + // 注释挂到它所属的那条记忆下,让模型看得见「她那天为什么那样」,而不是只在 + // 面板里留着。trait 档不走这条——它已被铸成 type=pattern 的记忆,本来就会 + // 自己进注入,再挂一遍是同一件事说两次。 + // + // 默认关:它与 sleepAffectiveEnabled 是父子关系(父关则无情绪边可挂),父开 + // 子留关时行为与加这个开关之前逐字节一致。违反「不加开关就默认生效」的直觉 + // 是有意的——这条注释每轮都会重复出现,未经用户同意不该占注入预算。 + affectiveInjectEnabled: z.boolean().default(false), // Optional LLM route override for sleep's bulk passes (empty = use dream // route / agent default model). sleepProvider: z.string().default("").description("sleep 深维护专用模型服务商,留空用巩固模型或当前模型。"), @@ -1238,6 +1247,8 @@ const LIGHT_MODE_OFF = [ // #619:情感归因相位要调模型、且依赖实体抽取产出(抽取关掉就没有实体可挂边), // 与低资源档双重互斥。默认本就是关,这里只是轻量预设再确认一次。 "sleepAffectiveEnabled", + // 注入侧的情绪注释同理:轻量档不开情感线,注释自然无从挂起。 + "affectiveInjectEnabled", "rerankEnabled", "autoReindexOnBoot", "hybridInject", @@ -1349,7 +1360,11 @@ export function applyLightModePreset(cfg) { * 是扁平键,不受这条限制。 */ export const INJECT_CHILD_FLAGS = Object.freeze({ - autoInject: Object.freeze(["injectGuidanceEnabled", "continuityRescueEnabled"]) + autoInject: Object.freeze(["injectGuidanceEnabled", "continuityRescueEnabled"]), + // #619 补链(注入侧):情绪注释是「情感归因」的下游——父关掉(不再产生新情绪 + // 边)之后,库里遗留的边不该继续往注入里说话。父关 = 注释一并不挂,子项持久 + // 值原样保留,与 autoInject 那组同一条纪律。 + sleepAffectiveEnabled: Object.freeze(["affectiveInjectEnabled"]) }); /** 子开关的运行时生效值:父开关显式关(false)时恒不生效。 */ diff --git a/dsh-mneme/lib/image-describe.js b/dsh-mneme/lib/image-describe.js index 047eb4a2..c1747e61 100644 --- a/dsh-mneme/lib/image-describe.js +++ b/dsh-mneme/lib/image-describe.js @@ -80,16 +80,29 @@ function trimDescription(text) { * @param {{provider: string, model: string}} route 描述专用路由 * @param {object} config 由 langOf 取提示词语言 * @param {Array} blocks image 块数组(通常是同一个 part 的) - * @param {{signal?: AbortSignal}} [options] + * @param {{signal?: AbortSignal, stats?: object}} [options] + * `stats` 是可选的**出参**(同 injectCandidates 的 pinnedStats 口径):调用方 + * 传一个对象进来,本函数往里累计 attempted/described/failed 与第一条失败原因。 + * 它存在的原因是「整档静默」——描述失败原先被 catch 吞掉,审计里只有一个 + * 图块数 imageCount,用户开了开关、选了模型、一次没跑成,界面上零痕迹。 + * 不传就逐字节等于改前(只返回 Map)。 */ export async function describeImages(ctx, route, config, blocks, options = {}) { const out = new Map(); + const stats = options.stats && typeof options.stats === "object" ? options.stats : null; + const bump = (key) => { if (stats) stats[key] = (stats[key] ?? 0) + 1; }; if (!Array.isArray(blocks) || blocks.length === 0) return out; - if (typeof ctx?.llm?.stream !== "function") return out; + if (typeof ctx?.llm?.stream !== "function") { + // 宿主没给 llm:这不是「这张图失败」,是整档根本没条件跑。记一条原因, + // 别让它看起来像「库里就是没图」。 + if (stats) stats.unavailable = "no llm stream"; + return out; + } const prompt = STR.prompts.imageDescription[langOf(config)]; for (const [index, block] of blocks.entries()) { const key = attachmentKey(block, index); if (out.has(key)) continue; + bump("attempted"); try { let text = ""; const stream = ctx.llm.stream({ @@ -117,9 +130,22 @@ export async function describeImages(ctx, route, config, blocks, options = {}) { } } const description = trimDescription(text); - if (description) out.set(key, description); - } catch { - // 单张图失败不影响其他张,也不影响蒸馏(见文件头 ③)。 + if (description) { + out.set(key, description); + bump("described"); + } else { + // 流正常结束但一个字都没有:模型拒答、被内容策略拦、或适配器没把图 + // 编进去。这也是失败,只是没有异常可抓——最容易被误读成「成功」的一种。 + bump("failed"); + if (stats && !stats.firstError) stats.firstError = "empty description"; + } + } catch (error) { + // 单张图失败不影响其他张,也不影响蒸馏(见文件头 ③)——但现在要留痕: + // 失败计数与第一条原因由调用方写进审计,否则用户永远看不见「一次都没成」。 + bump("failed"); + if (stats && !stats.firstError) { + stats.firstError = String(error?.message ?? error ?? "unknown").slice(0, 120); + } } } return out; diff --git a/dsh-mneme/lib/inject.js b/dsh-mneme/lib/inject.js index 8e313c2f..4060ee75 100644 --- a/dsh-mneme/lib/inject.js +++ b/dsh-mneme/lib/inject.js @@ -307,7 +307,12 @@ export function createInjector(ctx, service, settings, config, gate = null, sess // Issue #641(T4 注入标注):conflictNote 由 injectCandidates 在 enforce // 档下挂上(矛盾对同批时的机械排位说明)。#537 的教训:标注必须进注入 // 文本本体,不能只留在账本里——消费方看不见的标注等于没标。 + // #619 补链(注入侧):affectiveNote 由 injectCandidates 挂上(单次场合的 + // 情绪记录)。顺序刻意是「内容 → 情绪记录 → 矛盾标注」:情绪说明这条记忆 + // 是在什么心情下发生的,矛盾标注说的是它跟同批另一条的取信关系——后者是 + // 决策指令,必须贴在最后,别被一大段情绪描述顶到视线之外。 const content = injectMemory(m, pinned ? PINNED_CONTENT_MAX : maxContent) + + (m.affectiveNote ? "\n" + m.affectiveNote : "") + (m.conflictNote ? "\n" + m.conflictNote : ""); const full = m.graphHint === true ? STR.graphHintLine[language](m.type, title, content, date) diff --git a/dsh-mneme/lib/lang.js b/dsh-mneme/lib/lang.js index f2a97d10..7a01cb33 100644 --- a/dsh-mneme/lib/lang.js +++ b/dsh-mneme/lib/lang.js @@ -182,6 +182,26 @@ export const STR = { zh: (type, title, content, date) => `- [线索/${type}] ${date ? `${date} ` : ""}${title}:${content}`, en: (type, title, content, date) => `- [hint/${type}] ${date ? `${date} ` : ""}${title}: ${content}` }, + // #619 补链(注入侧):单次场合的情绪注释,挂在它所属的记忆行下面。 + // 三个信息缺一不可——谁、什么心情、哪件事:只写「她不高兴」是没用的注入, + // 模型会把一次场合的情绪当成稳定评价用;场合数说明了「这是一次,不是一贯」。 + // `why` 是 sleep 侧给的理由(可缺省),有就带上,没有不补空话。 + affectiveNote: { + zh: (person, emotion, target, occasions, why) => + `〔情绪记录·单次场合〕${person} 在「${target}」这件事上${emotion}` + + (occasions > 1 ? `(记了 ${occasions} 次,仍属同一场合)` : "") + + (why ? `;当时的原因:${why}` : "") + + "。这是场合性的,不要当作一贯表现。", + en: (person, emotion, target, occasions, why) => + `[affect record · single occasion] ${person} was ${emotion} about "${target}"` + + (occasions > 1 ? ` (recorded ${occasions}× in the same occasion)` : "") + + (why ? `; reason at the time: ${why}` : "") + + ". Occasion-bound — do not read it as a standing disposition." + }, + affectiveTraitNote: { + zh: (target, occasions) => `〔情绪记录·多次独立场合〕这一态度在 ${occasions} 个独立场合都出现过:「${target}」。`, + en: (target, occasions) => `[affect record · repeated occasions] This attitude recurred across ${occasions} independent occasions: "${target}".` + }, 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 13ab0d09..bf23263d 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -7,7 +7,7 @@ import { STR, langOf } from "./lang.js"; import { computeHeat } from "./heat.js"; import { recallStats } from "./recall-stats.js"; import { evaluateMemoryQuality } from "./quality-filter.js"; -import { CAPACITY_WARNING_DEFAULTS } from "./config.js"; +import { CAPACITY_WARNING_DEFAULTS, injectChildEnabled } from "./config.js"; // 外来行(source='imported',导入路径落的来源)的身份判定只许有一个定义处——本文件的 // 两处汇流口与巩固/降级那些读侧行池都调这同一个谓词,避免各处自己写字符串比较然后各自漂移。 import { isImportedRow } from "./memory-source.js"; @@ -39,6 +39,12 @@ const INJECT_TYPES = new Set(["preference", "project", "decision", "summary", "r // 是运行时状态,两者不能搅在一起。 const INJECT_TYPES_WITH_DOCUMENT = new Set([...INJECT_TYPES, "document"]); +// #619 补链(注入侧):情绪注释的长度上限。注释拼在记忆行内容之后、按整行扣注入 +// 块预算(src/inject.js 的 MAX_BLOCK),所以它必须有一个硬上限——否则一条啰嗦的 +// 情绪记录就能把同批后面的候选全挤成标题行。160 字够写完「谁·什么心情·哪件事· +// 几次·为什么」,超过的多半是模型在复述理由,边际价值低于它挤掉的记忆。 +const AFFECTIVE_NOTE_MAX = 160; + // 编码记忆类型(codingRetrospect):rejected_solution / pitfall / constraint // 只在编码任务时注入(防噪声污染其他业务),且编码场景下按 codingBoostFactor // 加权排序提前。 @@ -2158,6 +2164,40 @@ export function createService({ store, mirror, config, onWrite, logger, document } } } + // #619 补链(注入侧):单次场合的情绪边挂成注释行。到这里为止,#641 已把 + // 矛盾标注写进注入文本本体;情绪走同一条通道(m.affectiveNote,由 inject.js + // 拼在该行内容之后),不新开注入块、不动 schema。 + // + // 只挂 occasion:trait 档已被 sleep 铸成 type=pattern 的记忆,本来就自己进 + // 注入(见 INJECT_TYPES 里 pattern 的收编说明),再挂一遍是同一件事说两次。 + // 开关默认关;关着时这一段整段不执行,注入结果与加它之前逐字节一致。 + if (injectChildEnabled(config, "affectiveInjectEnabled") && selected.length) { + const language = langOf(config); + const byMemory = store.affectiveRelationsByMemories(selected.map((m) => m.id), 1); + if (byMemory.size) { + for (const m of selected) { + const edge = byMemory.get(m.id)?.[0]; + if (!edge?.metadata || typeof edge.metadata !== "object") continue; + if (edge.metadata.attribution === "trait" || edge.metadata.promotedToTrait === true) continue; + const emotion = String(edge.metadata.emotion ?? "").trim(); + if (!emotion) continue; + // 谁、对什么:端点名字取自这次 SQL 的 LEFT JOIN;实体被删了(名字为 + // null)就宁可不挂——「某个已删实体不高兴」是噪声不是信息。 + const person = String(edge.from_name ?? "").trim(); + const target = String(edge.to_name ?? "").trim(); + if (!person || !target) continue; + const occasions = Number.isFinite(edge.metadata.sessionCount) + ? Math.max(1, Math.trunc(edge.metadata.sessionCount)) + : 1; + const why = typeof edge.metadata.why === "string" ? edge.metadata.why.trim().slice(0, 60) : ""; + // 长度闸:注释会吃注入块预算(MAX_BLOCK − 块头),一条长注释能把后面 + // 的候选挤成标题行。情绪记录的边际价值不值这个价,超了就不挂。 + const note = STR.affectiveNote[language](person, emotion, target, occasions, why); + if (note.length > AFFECTIVE_NOTE_MAX) continue; + m.affectiveNote = note; + } + } + } if (pinnedStats) { // 「未展示」只数**真的没进块**的 pin 类条目:被 pin 预算挤下来的条目会回到 // 候选池,仍可能被 general 槽选中——那就是展示了。按 eligible - pinned 直接 diff --git a/dsh-mneme/lib/settings.js b/dsh-mneme/lib/settings.js index ba5d8dce..c0cde295 100644 --- a/dsh-mneme/lib/settings.js +++ b/dsh-mneme/lib/settings.js @@ -180,6 +180,9 @@ const FEATURE_FLAG_BOOLEANS = [ // 才允许升成稳定特质。要调模型且依赖实体抽取,所以必须显式打开、可随时关掉; // 它是 sleepModeEnabled 的子相:父开关关着时整个 sleep 不跑。 "sleepAffectiveEnabled", + // #619 补链(注入侧):把单次场合的情绪记录作为一行注释挂在对应记忆下(默认关)。 + // 与 sleepAffectiveEnabled 是父子:父关则库里的情绪边不再增长,子关则一条都不挂。 + "affectiveInjectEnabled", "heatEnabled", // issue #218 / E5:注入侧 heat 独立开关(默认关,老用户 injectHeatEnabled=true 回滚)。 "injectHeatEnabled", diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index 29a7e978..c2be1864 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -4848,6 +4848,51 @@ export function createStore(path) { ).all(memoryId).map(toRelation); } + /** + * 按一批记忆 id 一次取回它们的情绪边(叠加在注入行上的「情绪记录」注释用)。 + * + * 为什么不复用 getRelationsByMemory 逐条查:注入每轮都有 3-5 个候选,逐条就是一个 + * N+1;而且注释要显示「谁对什么」,那边只回实体 id,还得再查两次名字。这里 + * 一次 SQL 带 LEFT JOIN 把两端名字取回,按 memory_id 分组成 Map。 + * + * 只读、只取 affective_context,limit 由调用方给(默认按候选数)。排序与 + * relationsByType 一致:created_at DESC,最近记的优先——同一记忆上的多条情绪 + * 边里,注入只挂最新那条(多挂会把注入块撑爆,也会把历史情绪误读成现状)。 + */ + function affectiveRelationsByMemories(memoryIds, perMemory = 1) { + const ids = Array.isArray(memoryIds) + ? memoryIds.filter((id) => typeof id === "string" && id.length > 0) + : []; + if (ids.length === 0) return new Map(); + const cap = Number.isFinite(perMemory) ? Math.max(1, Math.trunc(perMemory)) : 1; + const placeholders = ids.map(() => "?").join(", "); + const rows = db.prepare( + `SELECT r.*, f.name AS from_name, f.type AS from_type, + t.name AS to_name, t.type AS to_type + FROM entity_relations r + LEFT JOIN entities f ON f.id = r.from_entity + LEFT JOIN entities t ON t.id = r.to_entity + WHERE r.relation_type = 'affective_context' AND r.memory_id IN (${placeholders}) + ORDER BY r.created_at DESC` + ).all(...ids); + const out = new Map(); + for (const row of rows) { + const key = row.memory_id; + if (!key) continue; + const bucket = out.get(key) ?? []; + if (bucket.length >= cap) continue; + bucket.push({ + ...toRelation(row), + from_name: row.from_name ?? null, + from_type: row.from_type ?? null, + to_name: row.to_name ?? null, + to_type: row.to_type ?? null + }); + out.set(key, bucket); + } + return out; + } + /** * 按关系类型取全部边(#619 可见化 C)。情感边散在 23k 个实体之间,`ego` 端点 * 是「从某个实体往外看」,天然不适合回答「这库里一共记了哪些情绪」——那要的 @@ -5837,6 +5882,7 @@ export function createStore(path) { relationsByType, getEntityNeighbors, getRelationsByMemory, + affectiveRelationsByMemories, setMirrorState, getMirrorState, markMirrorDirty, diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js index 5d1de83b..bc61d57c 100644 --- a/dsh-mneme/lib/summarize.js +++ b/dsh-mneme/lib/summarize.js @@ -400,6 +400,30 @@ function snapshotSessionEvents(session, afterSeq, throughSeq) { }); } +/** + * 图像描述档的审计片段(#619 补链,图像侧)。 + * + * 为什么单独一个函数:三处写审计 metadata 的分支(窗口过小 / 解析失败 / 成功) + * 都必须带同一份口径,抄三遍就一定会漂移。规则是「有数才写」——全零时返回空 + * 对象,于是关闭态(默认)的审计行与加这段之前逐字节一致。 + * + * attempted 是分母、described 是分子:两者相减才是「试了没成」。只看 described + * 会把「一张都没试」和「试了全失败」混成同一行,而那正是这次要修的静默。 + */ +function describeAuditPatch(stats) { + if (!stats || typeof stats !== "object") return {}; + const attempted = stats.attempted ?? 0; + const described = stats.described ?? 0; + const failed = stats.failed ?? 0; + const out = {}; + if (attempted > 0) out.imageDescribed = described; + if (attempted > 0) out.imageDescribeFailed = failed; + if (stats.firstError) out.imageDescribeError = String(stats.firstError).slice(0, 120); + if (stats.unavailable) out.imageDescribeUnavailable = String(stats.unavailable).slice(0, 120); + if (stats.skipped) out.imageDescribeSkipped = String(stats.skipped); + return out; +} + async function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, throughSeq, describePart) { const events = snapshotSessionEvents(session, afterSeq, throughSeq); const lines = []; @@ -849,6 +873,11 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = // 判定放在这里而不是 image-describe.js 里:路由来自 config,准入是一次可能 // 很贵的 resolveModelInfo 调用,两者都该只问一次,而不是每张图问一次。 let describePart; + // 描述档的逐次计数(#619 补链,图像侧):attempted/described/failed 与第一条 + // 失败原因。原先这条链路上只有一个 imageCount(图块数),「开了开关但一次 + // 都没跑成」在审计里与「这个窗口没有图」长得完全一样——静默失效。整档不跑 + // 的原因也记在这里,审计 metadata 会带上它。 + const describeStats = { attempted: 0, described: 0, failed: 0 }; if (config.imageDescribeEnabled === true) { const describeProvider = typeof config.imageDescribeProvider === "string" ? config.imageDescribeProvider.trim() : ""; const describeModel = typeof config.imageDescribeModel === "string" ? config.imageDescribeModel.trim() : ""; @@ -856,7 +885,9 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = const sight = await canSeeImages(ctx, describeProvider, describeModel); // "unknown"(老适配器不声明能力)照发:宿主那侧对未知是不投影,照原样 // 交给适配器,它若真看不到再降级成占位——这里替它决定就是猜。 - if (sight !== false) { + if (sight === false) { + describeStats.skipped = "model-cannot-see-images"; + } else { const describeRoute = { provider: describeProvider, model: describeModel }; // 同一张图在一次蒸馏里只描述一次:同一附件常被重复贴(追问、引用), // 按 attachmentId 缓存,省掉重复的视觉调用。 @@ -869,12 +900,17 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = return !described.has(key); }); if (fresh.length > 0) { - const got = await describeImages(ctx, describeRoute, config, fresh, { signal: controller.signal }); + const got = await describeImages(ctx, describeRoute, config, fresh, { signal: controller.signal, stats: describeStats }); for (const [key, value] of got) described.set(key, value); } return appendDescriptions(part.text, blocks, described); }; } + } else { + // 开着却没配路由:用户以为自己开了,实际一次都没跑——这正是要能看见的 + // 那一种静默,所以写进审计。反过来,开关关着(默认)不写任何东西,审计行 + // 必须与加这段之前逐字节一致。 + describeStats.skipped = "no route"; } } const collected = await collectMessages( @@ -927,7 +963,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = model_id: route ? `${route.provider}:${route.model}` : "unknown", status: "skipped", error_message: "window-too-small", - metadata: { distillChars, minWindowChars, ...(imageCount > 0 ? { imageCount } : {}) } + metadata: { distillChars, minWindowChars, ...(imageCount > 0 ? { imageCount } : {}), ...describeAuditPatch(describeStats) } }); return; } @@ -1135,6 +1171,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = audit.metadata = { distillChars, ...(imageCount > 0 ? { imageCount } : {}), + ...describeAuditPatch(describeStats), ...failureAudit.metadata, streak, ...(maxParseRetries > 0 ? { maxRetries: maxParseRetries } : {}), @@ -1275,6 +1312,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = audit.metadata = { distillChars, ...(imageCount > 0 ? { imageCount } : {}), + ...describeAuditPatch(describeStats), parsed: parsed.length, ...(parsedResult.salvaged ? { json_salvaged: true } : {}), ...(capped > 0 ? { capped } : {}), diff --git a/dsh-mneme/src/config.js b/dsh-mneme/src/config.js index a8501be0..a3dd64d8 100644 --- a/dsh-mneme/src/config.js +++ b/dsh-mneme/src/config.js @@ -764,6 +764,15 @@ export const Config = z.object({ // sleepMaxPatternPerRun for the relation channel: a runaway model that // "finds" an emotion in every memory must be capped, not trusted. sleepAffectiveMaxRelationsPerRun: z.natural().min(0).max(50).default(10), + // #619 补链(注入侧):把**单次场合**(attribution=occasion)的情绪边作为一行 + // 注释挂到它所属的那条记忆下,让模型看得见「她那天为什么那样」,而不是只在 + // 面板里留着。trait 档不走这条——它已被铸成 type=pattern 的记忆,本来就会 + // 自己进注入,再挂一遍是同一件事说两次。 + // + // 默认关:它与 sleepAffectiveEnabled 是父子关系(父关则无情绪边可挂),父开 + // 子留关时行为与加这个开关之前逐字节一致。违反「不加开关就默认生效」的直觉 + // 是有意的——这条注释每轮都会重复出现,未经用户同意不该占注入预算。 + affectiveInjectEnabled: z.boolean().default(false), // Optional LLM route override for sleep's bulk passes (empty = use dream // route / agent default model). sleepProvider: z.string().default("").description("sleep 深维护专用模型服务商,留空用巩固模型或当前模型。"), @@ -1238,6 +1247,8 @@ const LIGHT_MODE_OFF = [ // #619:情感归因相位要调模型、且依赖实体抽取产出(抽取关掉就没有实体可挂边), // 与低资源档双重互斥。默认本就是关,这里只是轻量预设再确认一次。 "sleepAffectiveEnabled", + // 注入侧的情绪注释同理:轻量档不开情感线,注释自然无从挂起。 + "affectiveInjectEnabled", "rerankEnabled", "autoReindexOnBoot", "hybridInject", @@ -1349,7 +1360,11 @@ export function applyLightModePreset(cfg) { * 是扁平键,不受这条限制。 */ export const INJECT_CHILD_FLAGS = Object.freeze({ - autoInject: Object.freeze(["injectGuidanceEnabled", "continuityRescueEnabled"]) + autoInject: Object.freeze(["injectGuidanceEnabled", "continuityRescueEnabled"]), + // #619 补链(注入侧):情绪注释是「情感归因」的下游——父关掉(不再产生新情绪 + // 边)之后,库里遗留的边不该继续往注入里说话。父关 = 注释一并不挂,子项持久 + // 值原样保留,与 autoInject 那组同一条纪律。 + sleepAffectiveEnabled: Object.freeze(["affectiveInjectEnabled"]) }); /** 子开关的运行时生效值:父开关显式关(false)时恒不生效。 */ diff --git a/dsh-mneme/src/image-describe.js b/dsh-mneme/src/image-describe.js index 047eb4a2..c1747e61 100644 --- a/dsh-mneme/src/image-describe.js +++ b/dsh-mneme/src/image-describe.js @@ -80,16 +80,29 @@ function trimDescription(text) { * @param {{provider: string, model: string}} route 描述专用路由 * @param {object} config 由 langOf 取提示词语言 * @param {Array} blocks image 块数组(通常是同一个 part 的) - * @param {{signal?: AbortSignal}} [options] + * @param {{signal?: AbortSignal, stats?: object}} [options] + * `stats` 是可选的**出参**(同 injectCandidates 的 pinnedStats 口径):调用方 + * 传一个对象进来,本函数往里累计 attempted/described/failed 与第一条失败原因。 + * 它存在的原因是「整档静默」——描述失败原先被 catch 吞掉,审计里只有一个 + * 图块数 imageCount,用户开了开关、选了模型、一次没跑成,界面上零痕迹。 + * 不传就逐字节等于改前(只返回 Map)。 */ export async function describeImages(ctx, route, config, blocks, options = {}) { const out = new Map(); + const stats = options.stats && typeof options.stats === "object" ? options.stats : null; + const bump = (key) => { if (stats) stats[key] = (stats[key] ?? 0) + 1; }; if (!Array.isArray(blocks) || blocks.length === 0) return out; - if (typeof ctx?.llm?.stream !== "function") return out; + if (typeof ctx?.llm?.stream !== "function") { + // 宿主没给 llm:这不是「这张图失败」,是整档根本没条件跑。记一条原因, + // 别让它看起来像「库里就是没图」。 + if (stats) stats.unavailable = "no llm stream"; + return out; + } const prompt = STR.prompts.imageDescription[langOf(config)]; for (const [index, block] of blocks.entries()) { const key = attachmentKey(block, index); if (out.has(key)) continue; + bump("attempted"); try { let text = ""; const stream = ctx.llm.stream({ @@ -117,9 +130,22 @@ export async function describeImages(ctx, route, config, blocks, options = {}) { } } const description = trimDescription(text); - if (description) out.set(key, description); - } catch { - // 单张图失败不影响其他张,也不影响蒸馏(见文件头 ③)。 + if (description) { + out.set(key, description); + bump("described"); + } else { + // 流正常结束但一个字都没有:模型拒答、被内容策略拦、或适配器没把图 + // 编进去。这也是失败,只是没有异常可抓——最容易被误读成「成功」的一种。 + bump("failed"); + if (stats && !stats.firstError) stats.firstError = "empty description"; + } + } catch (error) { + // 单张图失败不影响其他张,也不影响蒸馏(见文件头 ③)——但现在要留痕: + // 失败计数与第一条原因由调用方写进审计,否则用户永远看不见「一次都没成」。 + bump("failed"); + if (stats && !stats.firstError) { + stats.firstError = String(error?.message ?? error ?? "unknown").slice(0, 120); + } } } return out; diff --git a/dsh-mneme/src/inject.js b/dsh-mneme/src/inject.js index 8e313c2f..4060ee75 100644 --- a/dsh-mneme/src/inject.js +++ b/dsh-mneme/src/inject.js @@ -307,7 +307,12 @@ export function createInjector(ctx, service, settings, config, gate = null, sess // Issue #641(T4 注入标注):conflictNote 由 injectCandidates 在 enforce // 档下挂上(矛盾对同批时的机械排位说明)。#537 的教训:标注必须进注入 // 文本本体,不能只留在账本里——消费方看不见的标注等于没标。 + // #619 补链(注入侧):affectiveNote 由 injectCandidates 挂上(单次场合的 + // 情绪记录)。顺序刻意是「内容 → 情绪记录 → 矛盾标注」:情绪说明这条记忆 + // 是在什么心情下发生的,矛盾标注说的是它跟同批另一条的取信关系——后者是 + // 决策指令,必须贴在最后,别被一大段情绪描述顶到视线之外。 const content = injectMemory(m, pinned ? PINNED_CONTENT_MAX : maxContent) + + (m.affectiveNote ? "\n" + m.affectiveNote : "") + (m.conflictNote ? "\n" + m.conflictNote : ""); const full = m.graphHint === true ? STR.graphHintLine[language](m.type, title, content, date) diff --git a/dsh-mneme/src/lang.js b/dsh-mneme/src/lang.js index f2a97d10..7a01cb33 100644 --- a/dsh-mneme/src/lang.js +++ b/dsh-mneme/src/lang.js @@ -182,6 +182,26 @@ export const STR = { zh: (type, title, content, date) => `- [线索/${type}] ${date ? `${date} ` : ""}${title}:${content}`, en: (type, title, content, date) => `- [hint/${type}] ${date ? `${date} ` : ""}${title}: ${content}` }, + // #619 补链(注入侧):单次场合的情绪注释,挂在它所属的记忆行下面。 + // 三个信息缺一不可——谁、什么心情、哪件事:只写「她不高兴」是没用的注入, + // 模型会把一次场合的情绪当成稳定评价用;场合数说明了「这是一次,不是一贯」。 + // `why` 是 sleep 侧给的理由(可缺省),有就带上,没有不补空话。 + affectiveNote: { + zh: (person, emotion, target, occasions, why) => + `〔情绪记录·单次场合〕${person} 在「${target}」这件事上${emotion}` + + (occasions > 1 ? `(记了 ${occasions} 次,仍属同一场合)` : "") + + (why ? `;当时的原因:${why}` : "") + + "。这是场合性的,不要当作一贯表现。", + en: (person, emotion, target, occasions, why) => + `[affect record · single occasion] ${person} was ${emotion} about "${target}"` + + (occasions > 1 ? ` (recorded ${occasions}× in the same occasion)` : "") + + (why ? `; reason at the time: ${why}` : "") + + ". Occasion-bound — do not read it as a standing disposition." + }, + affectiveTraitNote: { + zh: (target, occasions) => `〔情绪记录·多次独立场合〕这一态度在 ${occasions} 个独立场合都出现过:「${target}」。`, + en: (target, occasions) => `[affect record · repeated occasions] This attitude recurred across ${occasions} independent occasions: "${target}".` + }, 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 13ab0d09..bf23263d 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -7,7 +7,7 @@ import { STR, langOf } from "./lang.js"; import { computeHeat } from "./heat.js"; import { recallStats } from "./recall-stats.js"; import { evaluateMemoryQuality } from "./quality-filter.js"; -import { CAPACITY_WARNING_DEFAULTS } from "./config.js"; +import { CAPACITY_WARNING_DEFAULTS, injectChildEnabled } from "./config.js"; // 外来行(source='imported',导入路径落的来源)的身份判定只许有一个定义处——本文件的 // 两处汇流口与巩固/降级那些读侧行池都调这同一个谓词,避免各处自己写字符串比较然后各自漂移。 import { isImportedRow } from "./memory-source.js"; @@ -39,6 +39,12 @@ const INJECT_TYPES = new Set(["preference", "project", "decision", "summary", "r // 是运行时状态,两者不能搅在一起。 const INJECT_TYPES_WITH_DOCUMENT = new Set([...INJECT_TYPES, "document"]); +// #619 补链(注入侧):情绪注释的长度上限。注释拼在记忆行内容之后、按整行扣注入 +// 块预算(src/inject.js 的 MAX_BLOCK),所以它必须有一个硬上限——否则一条啰嗦的 +// 情绪记录就能把同批后面的候选全挤成标题行。160 字够写完「谁·什么心情·哪件事· +// 几次·为什么」,超过的多半是模型在复述理由,边际价值低于它挤掉的记忆。 +const AFFECTIVE_NOTE_MAX = 160; + // 编码记忆类型(codingRetrospect):rejected_solution / pitfall / constraint // 只在编码任务时注入(防噪声污染其他业务),且编码场景下按 codingBoostFactor // 加权排序提前。 @@ -2158,6 +2164,40 @@ export function createService({ store, mirror, config, onWrite, logger, document } } } + // #619 补链(注入侧):单次场合的情绪边挂成注释行。到这里为止,#641 已把 + // 矛盾标注写进注入文本本体;情绪走同一条通道(m.affectiveNote,由 inject.js + // 拼在该行内容之后),不新开注入块、不动 schema。 + // + // 只挂 occasion:trait 档已被 sleep 铸成 type=pattern 的记忆,本来就自己进 + // 注入(见 INJECT_TYPES 里 pattern 的收编说明),再挂一遍是同一件事说两次。 + // 开关默认关;关着时这一段整段不执行,注入结果与加它之前逐字节一致。 + if (injectChildEnabled(config, "affectiveInjectEnabled") && selected.length) { + const language = langOf(config); + const byMemory = store.affectiveRelationsByMemories(selected.map((m) => m.id), 1); + if (byMemory.size) { + for (const m of selected) { + const edge = byMemory.get(m.id)?.[0]; + if (!edge?.metadata || typeof edge.metadata !== "object") continue; + if (edge.metadata.attribution === "trait" || edge.metadata.promotedToTrait === true) continue; + const emotion = String(edge.metadata.emotion ?? "").trim(); + if (!emotion) continue; + // 谁、对什么:端点名字取自这次 SQL 的 LEFT JOIN;实体被删了(名字为 + // null)就宁可不挂——「某个已删实体不高兴」是噪声不是信息。 + const person = String(edge.from_name ?? "").trim(); + const target = String(edge.to_name ?? "").trim(); + if (!person || !target) continue; + const occasions = Number.isFinite(edge.metadata.sessionCount) + ? Math.max(1, Math.trunc(edge.metadata.sessionCount)) + : 1; + const why = typeof edge.metadata.why === "string" ? edge.metadata.why.trim().slice(0, 60) : ""; + // 长度闸:注释会吃注入块预算(MAX_BLOCK − 块头),一条长注释能把后面 + // 的候选挤成标题行。情绪记录的边际价值不值这个价,超了就不挂。 + const note = STR.affectiveNote[language](person, emotion, target, occasions, why); + if (note.length > AFFECTIVE_NOTE_MAX) continue; + m.affectiveNote = note; + } + } + } if (pinnedStats) { // 「未展示」只数**真的没进块**的 pin 类条目:被 pin 预算挤下来的条目会回到 // 候选池,仍可能被 general 槽选中——那就是展示了。按 eligible - pinned 直接 diff --git a/dsh-mneme/src/settings.js b/dsh-mneme/src/settings.js index ba5d8dce..c0cde295 100644 --- a/dsh-mneme/src/settings.js +++ b/dsh-mneme/src/settings.js @@ -180,6 +180,9 @@ const FEATURE_FLAG_BOOLEANS = [ // 才允许升成稳定特质。要调模型且依赖实体抽取,所以必须显式打开、可随时关掉; // 它是 sleepModeEnabled 的子相:父开关关着时整个 sleep 不跑。 "sleepAffectiveEnabled", + // #619 补链(注入侧):把单次场合的情绪记录作为一行注释挂在对应记忆下(默认关)。 + // 与 sleepAffectiveEnabled 是父子:父关则库里的情绪边不再增长,子关则一条都不挂。 + "affectiveInjectEnabled", "heatEnabled", // issue #218 / E5:注入侧 heat 独立开关(默认关,老用户 injectHeatEnabled=true 回滚)。 "injectHeatEnabled", diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index 29a7e978..c2be1864 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -4848,6 +4848,51 @@ export function createStore(path) { ).all(memoryId).map(toRelation); } + /** + * 按一批记忆 id 一次取回它们的情绪边(叠加在注入行上的「情绪记录」注释用)。 + * + * 为什么不复用 getRelationsByMemory 逐条查:注入每轮都有 3-5 个候选,逐条就是一个 + * N+1;而且注释要显示「谁对什么」,那边只回实体 id,还得再查两次名字。这里 + * 一次 SQL 带 LEFT JOIN 把两端名字取回,按 memory_id 分组成 Map。 + * + * 只读、只取 affective_context,limit 由调用方给(默认按候选数)。排序与 + * relationsByType 一致:created_at DESC,最近记的优先——同一记忆上的多条情绪 + * 边里,注入只挂最新那条(多挂会把注入块撑爆,也会把历史情绪误读成现状)。 + */ + function affectiveRelationsByMemories(memoryIds, perMemory = 1) { + const ids = Array.isArray(memoryIds) + ? memoryIds.filter((id) => typeof id === "string" && id.length > 0) + : []; + if (ids.length === 0) return new Map(); + const cap = Number.isFinite(perMemory) ? Math.max(1, Math.trunc(perMemory)) : 1; + const placeholders = ids.map(() => "?").join(", "); + const rows = db.prepare( + `SELECT r.*, f.name AS from_name, f.type AS from_type, + t.name AS to_name, t.type AS to_type + FROM entity_relations r + LEFT JOIN entities f ON f.id = r.from_entity + LEFT JOIN entities t ON t.id = r.to_entity + WHERE r.relation_type = 'affective_context' AND r.memory_id IN (${placeholders}) + ORDER BY r.created_at DESC` + ).all(...ids); + const out = new Map(); + for (const row of rows) { + const key = row.memory_id; + if (!key) continue; + const bucket = out.get(key) ?? []; + if (bucket.length >= cap) continue; + bucket.push({ + ...toRelation(row), + from_name: row.from_name ?? null, + from_type: row.from_type ?? null, + to_name: row.to_name ?? null, + to_type: row.to_type ?? null + }); + out.set(key, bucket); + } + return out; + } + /** * 按关系类型取全部边(#619 可见化 C)。情感边散在 23k 个实体之间,`ego` 端点 * 是「从某个实体往外看」,天然不适合回答「这库里一共记了哪些情绪」——那要的 @@ -5837,6 +5882,7 @@ export function createStore(path) { relationsByType, getEntityNeighbors, getRelationsByMemory, + affectiveRelationsByMemories, setMirrorState, getMirrorState, markMirrorDirty, diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js index 5d1de83b..bc61d57c 100644 --- a/dsh-mneme/src/summarize.js +++ b/dsh-mneme/src/summarize.js @@ -400,6 +400,30 @@ function snapshotSessionEvents(session, afterSeq, throughSeq) { }); } +/** + * 图像描述档的审计片段(#619 补链,图像侧)。 + * + * 为什么单独一个函数:三处写审计 metadata 的分支(窗口过小 / 解析失败 / 成功) + * 都必须带同一份口径,抄三遍就一定会漂移。规则是「有数才写」——全零时返回空 + * 对象,于是关闭态(默认)的审计行与加这段之前逐字节一致。 + * + * attempted 是分母、described 是分子:两者相减才是「试了没成」。只看 described + * 会把「一张都没试」和「试了全失败」混成同一行,而那正是这次要修的静默。 + */ +function describeAuditPatch(stats) { + if (!stats || typeof stats !== "object") return {}; + const attempted = stats.attempted ?? 0; + const described = stats.described ?? 0; + const failed = stats.failed ?? 0; + const out = {}; + if (attempted > 0) out.imageDescribed = described; + if (attempted > 0) out.imageDescribeFailed = failed; + if (stats.firstError) out.imageDescribeError = String(stats.firstError).slice(0, 120); + if (stats.unavailable) out.imageDescribeUnavailable = String(stats.unavailable).slice(0, 120); + if (stats.skipped) out.imageDescribeSkipped = String(stats.skipped); + return out; +} + async function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, throughSeq, describePart) { const events = snapshotSessionEvents(session, afterSeq, throughSeq); const lines = []; @@ -849,6 +873,11 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = // 判定放在这里而不是 image-describe.js 里:路由来自 config,准入是一次可能 // 很贵的 resolveModelInfo 调用,两者都该只问一次,而不是每张图问一次。 let describePart; + // 描述档的逐次计数(#619 补链,图像侧):attempted/described/failed 与第一条 + // 失败原因。原先这条链路上只有一个 imageCount(图块数),「开了开关但一次 + // 都没跑成」在审计里与「这个窗口没有图」长得完全一样——静默失效。整档不跑 + // 的原因也记在这里,审计 metadata 会带上它。 + const describeStats = { attempted: 0, described: 0, failed: 0 }; if (config.imageDescribeEnabled === true) { const describeProvider = typeof config.imageDescribeProvider === "string" ? config.imageDescribeProvider.trim() : ""; const describeModel = typeof config.imageDescribeModel === "string" ? config.imageDescribeModel.trim() : ""; @@ -856,7 +885,9 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = const sight = await canSeeImages(ctx, describeProvider, describeModel); // "unknown"(老适配器不声明能力)照发:宿主那侧对未知是不投影,照原样 // 交给适配器,它若真看不到再降级成占位——这里替它决定就是猜。 - if (sight !== false) { + if (sight === false) { + describeStats.skipped = "model-cannot-see-images"; + } else { const describeRoute = { provider: describeProvider, model: describeModel }; // 同一张图在一次蒸馏里只描述一次:同一附件常被重复贴(追问、引用), // 按 attachmentId 缓存,省掉重复的视觉调用。 @@ -869,12 +900,17 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = return !described.has(key); }); if (fresh.length > 0) { - const got = await describeImages(ctx, describeRoute, config, fresh, { signal: controller.signal }); + const got = await describeImages(ctx, describeRoute, config, fresh, { signal: controller.signal, stats: describeStats }); for (const [key, value] of got) described.set(key, value); } return appendDescriptions(part.text, blocks, described); }; } + } else { + // 开着却没配路由:用户以为自己开了,实际一次都没跑——这正是要能看见的 + // 那一种静默,所以写进审计。反过来,开关关着(默认)不写任何东西,审计行 + // 必须与加这段之前逐字节一致。 + describeStats.skipped = "no route"; } } const collected = await collectMessages( @@ -927,7 +963,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = model_id: route ? `${route.provider}:${route.model}` : "unknown", status: "skipped", error_message: "window-too-small", - metadata: { distillChars, minWindowChars, ...(imageCount > 0 ? { imageCount } : {}) } + metadata: { distillChars, minWindowChars, ...(imageCount > 0 ? { imageCount } : {}), ...describeAuditPatch(describeStats) } }); return; } @@ -1135,6 +1171,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = audit.metadata = { distillChars, ...(imageCount > 0 ? { imageCount } : {}), + ...describeAuditPatch(describeStats), ...failureAudit.metadata, streak, ...(maxParseRetries > 0 ? { maxRetries: maxParseRetries } : {}), @@ -1275,6 +1312,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = audit.metadata = { distillChars, ...(imageCount > 0 ? { imageCount } : {}), + ...describeAuditPatch(describeStats), parsed: parsed.length, ...(parsedResult.salvaged ? { json_salvaged: true } : {}), ...(capped > 0 ? { capped } : {}), diff --git a/dsh-mneme/test/affective-inject.test.js b/dsh-mneme/test/affective-inject.test.js new file mode 100644 index 00000000..5b06f197 --- /dev/null +++ b/dsh-mneme/test/affective-inject.test.js @@ -0,0 +1,184 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createStore } from "../src/store.js"; +import { createService } from "../src/service.js"; +import { createSettings } from "../src/settings.js"; +import { createInjector } from "../src/inject.js"; +import { Config, injectChildEnabled } from "../src/config.js"; + +// Issue #619 补链(注入侧):情感记忆要能被模型看见。 +// +// 这套用例防的是同一类回归:情绪写进库了、面板上也看得见,但注入里没有—— +// 那就是「情感记忆」只对用户可见、对模型不可见,对外一宣称就会被 +// 「那我问它,它为什么不知道我那天焦虑」直接打穿。 +// +// 锁六件事: +// ① 默认关 → 注入块与不加这条之前**逐字节相同**(不做未授权的额外注入); +// ② 开 → 单次场合(occasion)的情绪边真的挂进注入正文,且带「谁·什么心情· +// 哪件事·次数」四项; +// ③ trait 档不挂——它已被铸成 type=pattern 的记忆自己进注入,再挂一遍是 +// 同一件事说两次; +// ④ 父关(sleepAffectiveEnabled=false)→ 子项一并不挂,但子项持久值保留; +// ⑤ 端点实体缺名字(被删)→ 不挂,宁可不说话也不说「某人」; +// ⑥ 注释限长——它吃注入块预算,一条长注释能把同批后面的候选挤成标题行。 + +const embedder = { + embedSingle: async () => [1, 0, 0], + embed: async () => [1, 0, 0], + schedule: () => {}, + modelHash: "mock#1", + dimension: 3 +}; + +function setup(configOverrides = {}) { + const config = { maxInjectedItems: 5, importanceThreshold: 3, ...configOverrides }; + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config }); + service.setEmbedder(embedder); + return { store, service, config }; +} + +function makeMemory(service, title, content = "内容") { + return service.saveWithDedupe({ type: "project", title, content, importance: 3 }).memory; +} + +/** 造一条情绪边:person --affective_context--> target,挂到 memory 上。 */ +function makeAffect(store, service, memoryId, meta = {}) { + const person = store.createEntity({ name: meta.person ?? "桉桉", type: "person" }); + const target = store.createEntity({ name: meta.target ?? "答辩", type: "project" }); + const edge = service.saveRelation({ + from_entity: person.id, + to_entity: target.id, + relation_type: "affective_context", + memory_id: memoryId, + source: "llm", + metadata: { + emotion: meta.emotion ?? "焦虑", + attribution: meta.attribution ?? "occasion", + why: meta.why ?? "首场答辩前一晚", + sessionCount: meta.sessionCount ?? 1, + ...(meta.promotedToTrait ? { promotedToTrait: true } : {}) + } + }); + return { person, target, edge }; +} + +function injectText(config) { + const { store, service } = setup(config); + const contexts = []; + const ctx = { + systemPrompt: { + context(def) { contexts.push(def); return () => {}; } + } + }; + createInjector(ctx, service, createSettings(store.db), config); + return { store, service, contexts }; +} + +function bodyOf(contexts) { + return contexts[0].text({}); +} + +test("#619 补链:默认关时注入块与不加注释逐字节相同", () => { + const config = { maxInjectedItems: 5, importanceThreshold: 3 }; + const off = injectText(config); + makeMemory(off.service, "答辩安排", "6 月 3 日首场"); + const offText = bodyOf(off.contexts); + + // 同样的库、同样的边,但开关打开 → 必须多出情绪行。 + const on = injectText({ ...config, affectiveInjectEnabled: true }); + const mem = makeMemory(on.service, "答辩安排", "6 月 3 日首场"); + makeAffect(on.store, on.service, mem.id); + const onText = bodyOf(on.contexts); + + assert.ok(!offText.includes("情绪记录"), "默认关:不许出现情绪注释"); + assert.ok(onText.includes("情绪记录"), "打开后:情绪注释必须进注入正文"); + assert.notEqual(offText, onText, "开关必须真的改变注入结果(否则是假开关)"); +}); + +test("#619 补链:注释带全四项事实,并标清单次场合", () => { + const { store, service, contexts } = injectText({ affectiveInjectEnabled: true }); + const mem = makeMemory(service, "答辩安排", "6 月 3 日首场"); + makeAffect(store, service, mem.id, { + person: "桉桉", + target: "答辩", + emotion: "焦虑", + why: "首场前一晚", + sessionCount: 1 + }); + const text = bodyOf(contexts); + assert.ok(text.includes("桉桉"), "要说清楚是谁"); + assert.ok(text.includes("焦虑"), "要说清楚什么心情"); + assert.ok(text.includes("答辩"), "要说清楚对哪件事"); + assert.ok(text.includes("单次场合"), "必须标清单次场合——否则模型会把一次情绪当一贯表现"); + assert.ok(!/记了 \d+ 次/.test(text), "sessionCount=1 不写次数(写「记了 1 次」是废话)"); +}); + +test("#619 补链:trait 档不重复注入(它已铸成 pattern 自己进)", () => { + const cases = [ + { attribution: "trait", label: "attribution=trait" }, + { attribution: "occasion", promotedToTrait: true, label: "promotedToTrait=true" } + ]; + for (const c of cases) { + const { store, service, contexts } = injectText({ affectiveInjectEnabled: true }); + const mem = makeMemory(service, "答辩安排", "6 月 3 日首场"); + makeAffect(store, service, mem.id, c); + const text = bodyOf(contexts); + assert.ok(!text.includes("情绪记录"), `${c.label}:稳定倾向不该再挂一次注释`); + } +}); + +test("#619 补链:父开关关着一并不挂,但子项持久值保留", () => { + const { store, service, contexts } = injectText({ + affectiveInjectEnabled: true, + sleepAffectiveEnabled: false + }); + const mem = makeMemory(service, "答辩安排", "6 月 3 日首场"); + makeAffect(store, service, mem.id); + assert.ok(!bodyOf(contexts).includes("情绪记录"), "父关 → 遗留的情绪边不许继续往注入里说话"); + + // 闸门只判「当下生效」,不改写值:重开父开关,子项选择还在。 + assert.equal(injectChildEnabled({ sleepAffectiveEnabled: false, affectiveInjectEnabled: true }, "affectiveInjectEnabled"), false); + assert.equal(injectChildEnabled({ sleepAffectiveEnabled: true, affectiveInjectEnabled: true }, "affectiveInjectEnabled"), true); + assert.equal(injectChildEnabled({ affectiveInjectEnabled: true }, "affectiveInjectEnabled"), true, "父未出现 → 只按子项判"); +}); + +test("#619 补链:端点实体缺名字时不挂(宁可不说话)", () => { + const { store, service, contexts } = injectText({ affectiveInjectEnabled: true }); + const mem = makeMemory(service, "答辩安排", "6 月 3 日首场"); + // 造一条边后把目标实体删掉:LEFT JOIN 取不到名字,注入侧必须跳过。 + const { target } = makeAffect(store, service, mem.id); + store.db.prepare("DELETE FROM entities WHERE id = ?").run(target.id); + assert.ok(!bodyOf(contexts).includes("情绪记录"), "实体缺名字的空壳注释是噪声,不许进注入"); +}); + +test("#619 补链:注释限长,不吃掉注入块预算", () => { + const { store, service, contexts } = injectText({ affectiveInjectEnabled: true }); + const mem = makeMemory(service, "答辩安排", "6 月 3 日首场"); + // why 在 service 侧已截到 60 字,撑不破上限;真正能撑爆的是端点名字—— + // 实体名来自抽取,长名字(整段标题被当成实体名)是现实里会发生的。 + makeAffect(store, service, mem.id, { target: "很长的目标名".repeat(20) }); + const text = bodyOf(contexts); + // 触发限长 → 整条不挂;但记忆本身必须还在块里(注释不该把记忆也带走)。 + assert.ok(!text.includes("情绪记录"), "超长注释应整条放弃"); + assert.ok(text.includes("答辩安排"), "放弃注释不能连带丢掉那条记忆"); +}); + +test("#619 补链:同一记忆多条情绪边只挂最新一条", () => { + const { store, service, contexts } = injectText({ affectiveInjectEnabled: true }); + const mem = makeMemory(service, "答辩安排", "6 月 3 日首场"); + makeAffect(store, service, mem.id, { emotion: "焦虑", target: "答辩" }); + makeAffect(store, service, mem.id, { emotion: "松了口气", target: "答辩" }); + const text = bodyOf(contexts); + const hits = (text.match(/情绪记录/g) || []).length; + assert.equal(hits, 1, "一条记忆只挂一条情绪注释(多挂会把注入块撑爆,也会把历史情绪误读成现状)"); +}); + +test("#619 补链:新配置键登记进面板白名单与轻量档", () => { + assert.equal(Config({}).affectiveInjectEnabled, false, "默认关:未经用户同意不该占注入预算"); + const client = createStore(":memory:"); + const settings = createSettings(client.db); + settings.setFeatureFlags({ affectiveInjectEnabled: true }); + assert.equal(settings.getFeatureFlags().affectiveInjectEnabled, true, "面板能写进 kv(否则点了只会拿到 400)"); + assert.equal(Config({ lightMode: true }).affectiveInjectEnabled, false, "轻量档必须关掉"); +}); diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js index 2d4f3574..aa9404dc 100644 --- a/dsh-mneme/test/api.test.js +++ b/dsh-mneme/test/api.test.js @@ -870,13 +870,17 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d // 图像描述档(docs/DISTILL.md:346)新增三个键:imageDescribeEnabled(顶层布尔键, // 走 BOOLEANS 表)+ imageDescribeProvider / imageDescribeModel(两条路由串,走 // STRINGS 表)= +3,故 131 + 3 = 134。 - assert.equal(Object.keys(data.effective).length, 134); + // #619 补链(注入侧)新增 affectiveInjectEnabled(顶层布尔键,走 BOOLEANS 表) + // = +1,故 134 + 1 = 135。 + assert.equal(Object.keys(data.effective).length, 135); // 描述档三条的默认值单独钉:开关默认关(不配路由就一个调用都不发), // 路由默认空串——空串在这里的语义是「整档不跑」,不是「跟随当前模型」, // 所以它不能像 summarizeProvider 那样被回填成会话模型。 assert.equal(data.effective.imageDescribeEnabled, false); assert.equal(data.effective.imageDescribeProvider, ""); assert.equal(data.effective.imageDescribeModel, ""); + // #619 补链:情绪注释默认关——未获用户同意前不进注入块、不占预算。 + assert.equal(data.effective.affectiveInjectEnabled, false); // issue #641:分诊账本档位默认 off(默认路径零行为变化)、T1 阈值默认 0.9。 // 值单独钉:计数锁钉不住枚举默认被翻转(shadow 意外变默认会让每轮巩固多写账本)。 assert.equal(data.effective.conflictLedgerMode, "off"); diff --git a/dsh-mneme/test/image-description.test.js b/dsh-mneme/test/image-description.test.js index 82128362..18f734c9 100644 --- a/dsh-mneme/test/image-description.test.js +++ b/dsh-mneme/test/image-description.test.js @@ -1,271 +1,338 @@ -// 图像描述档(docs/DISTILL.md:346)的验收:这一份要证的不是「函数能跑」, -// 而是三件容易做错的事—— -// ① 默认关着时,蒸馏发给模型的那条消息**逐字节不变**(#611 的行为不能被 -// 一个 opt-in 功能顺手改掉); -// ② 开着时,描述真的并进了转录,而不是只发生在某个局部变量里; -// ③ 描述失败绝不连累蒸馏(描述是增强,不是前置条件)。 -// 另外两条属于防回归:300 字天花板(否则四张图能把正文挤出窗口)、 -// 以及「模型明确不能看图时不发这个调用」(省一次必然换回占位白花的钱)。 -import test from "node:test"; -import assert from "node:assert/strict"; -import { createStore } from "../src/store.js"; -import { createService } from "../src/service.js"; -import { createSummarizer } from "../src/summarize.js"; -import { imageLabel } from "../src/attachment-text.js"; -import { - MAX_DESCRIPTION_CHARS, - appendDescriptions, - canSeeImages, - describeImages, - imageBlocks -} from "../src/image-describe.js"; +// 图像描述档(docs/DISTILL.md:346)的验收:这一份要证的不是「函数能跑」, +// 而是三件容易做错的事—— +// ① 默认关着时,蒸馏发给模型的那条消息**逐字节不变**(#611 的行为不能被 +// 一个 opt-in 功能顺手改掉); +// ② 开着时,描述真的并进了转录,而不是只发生在某个局部变量里; +// ③ 描述失败绝不连累蒸馏(描述是增强,不是前置条件)。 +// 另外两条属于防回归:300 字天花板(否则四张图能把正文挤出窗口)、 +// 以及「模型明确不能看图时不发这个调用」(省一次必然换回占位白花的钱)。 +import test from "node:test"; +import assert from "node:assert/strict"; +import { createStore } from "../src/store.js"; +import { createService } from "../src/service.js"; +import { createSummarizer } from "../src/summarize.js"; +import { imageLabel } from "../src/attachment-text.js"; +import { + MAX_DESCRIPTION_CHARS, + appendDescriptions, + canSeeImages, + describeImages, + imageBlocks +} from "../src/image-describe.js"; + +const IMAGE = { + type: "image", + attachment: { + attachmentId: "sha256:abc123", + mediaType: "image/png", + width: 1543, + height: 983, + name: "屏幕截图 2026-10-19 072000.png" + } +}; + +function imageMessage(seq, text) { + const content = []; + if (text) content.push({ type: "text", text }); + content.push({ ...IMAGE }); + return { seq, type: "user/message", data: { source: { kind: "user" }, content } }; +} + +// 一个能同时扮演「描述调用」与「蒸馏调用」的假 llm:按 purpose 分流, +// 并且把每次调用都记下来,让用例能断言「到底发了几次、发给谁」。 +function fakeLlm({ description = "一张表格截图,两列数据对比", modalities = ["text", "image"], throwOnDescribe = false } = {}) { + const calls = []; + return { + calls, + resolveModelInfo: async () => ({ inputModalities: modalities }), + stream(options) { + calls.push(options); + if (options.purpose === "image-description") { + if (throwOnDescribe) throw new Error("vision route down"); + return (async function* () { + yield { type: "text-delta", delta: description }; + yield { type: "finish", kind: "ok" }; + })(); + } + const json = JSON.stringify([ + { type: "history", title: "看过一张截图", content: "用户发了一张截图问数据对比", importance: 3 } + ]); + return (async function* () { + yield { type: "block-start", block: { type: "text" } }; + yield { type: "text-delta", delta: json }; + yield { type: "block-end", block: { type: "text" } }; + yield { type: "finish", kind: "ok" }; + })(); + } + }; +} + +function setup(over = {}, llm = fakeLlm()) { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config: {} }); + const events = []; + const ctx = { + on(name, fn) { + events.push({ name, fn }); + return () => {}; + }, + llm + }; + const config = { autoSummarize: true, distillRateLimitIntervalMs: 0, ...over }; + const summarizer = createSummarizer(ctx, service, config, {}); + return { store, service, events, ctx, llm, summarizer }; +} + +async function distill(setupResult, session) { + const handler = setupResult.events.find((e) => e.name === "session/event").fn; + await handler(session, { seq: session.events.at(-1).seq, type: "turn/end" }); +} + +function sessionOf(id, events) { + return { + id, + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events + }; +} + +function transcriptOf(call) { + return call.messages.find((m) => m.role === "user").content[0].text; +} + +// ── 准入判定 ───────────────────────────────────────────────────────────── + +test("准入:inputModalities 缺席是「未知」,不是「不支持」", async () => { + const unknown = { llm: { resolveModelInfo: async () => ({}) } }; + assert.equal(await canSeeImages(unknown, "p", "m"), "unknown", + "老适配器不声明能力时必须按未知处理,交给宿主决定"); + const notArray = { llm: { resolveModelInfo: async () => ({ inputModalities: "image" }) } }; + assert.equal(await canSeeImages(notArray, "p", "m"), "unknown", "非数组同属未知"); + const noApi = { llm: {} }; + assert.equal(await canSeeImages(noApi, "p", "m"), "unknown", "连查询接口都没有也是未知"); +}); + +test("准入:只有明确声明了且不含 image 才是确凿的看不到", async () => { + const textOnly = { llm: { resolveModelInfo: async () => ({ inputModalities: ["text"] }) } }; + assert.equal(await canSeeImages(textOnly, "p", "m"), false); + const vision = { llm: { resolveModelInfo: async () => ({ inputModalities: ["text", "image"] }) } }; + assert.equal(await canSeeImages(vision, "p", "m"), true); +}); + +test("准入:能力查询抛错按未知处理(不因为一次 RPC 失败就整档不跑)", async () => { + const boom = { llm: { resolveModelInfo: async () => { throw new Error("nope"); } } }; + assert.equal(await canSeeImages(boom, "p", "m"), "unknown"); +}); + +// ── 默认关:逐字节不变 ─────────────────────────────────────────────────── + +test("默认关:即使配了描述模型,也不发描述调用、转录逐字节不变", async () => { + const base = setup({}); + await distill(base, sessionOf("img-off", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + assert.equal(base.llm.calls.length, 1, "只该有一次蒸馏调用"); + assert.equal(base.llm.calls[0].purpose, "summarization"); + const baseline = transcriptOf(base.llm.calls[0]); + + // 配了路由但开关没开:不该有任何变化。 + const off = setup({ imageDescribeProvider: "vision", imageDescribeModel: "v1" }); + await distill(off, sessionOf("img-off2", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + assert.equal(off.llm.calls.length, 1, "开关关着就不该有描述调用"); + assert.equal(transcriptOf(off.llm.calls[0]), baseline, "关着时转录必须与 #611 的行为逐字节一致"); +}); + +test("开关开着但路由留空 = 整档不跑(不是跟随主模型)", async () => { + const r = setup({ imageDescribeEnabled: true }); + await distill(r, sessionOf("img-noroute", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + assert.equal(r.llm.calls.length, 1, "路由留空只该有一次蒸馏调用"); + assert.equal(r.llm.calls[0].purpose, "summarization"); +}); + +test("开关开着但模型明确不能看图:不发描述调用", async () => { + const r = setup( + { imageDescribeEnabled: true, imageDescribeProvider: "text", imageDescribeModel: "t1" }, + fakeLlm({ modalities: ["text"] }) + ); + await distill(r, sessionOf("img-blind", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + assert.equal(r.llm.calls.length, 1, "已知看不到还发调用,只会换回一行占位"); + assert.equal(r.llm.calls[0].purpose, "summarization"); +}); + +test("能力未知(老适配器不声明 inputModalities)时照发,不替宿主决定", async () => { + // 这条与上一条是同一个判据的两侧:false 才跳过,"unknown" 必须照发。 + // 抹掉这条,实现里把判据写成 `sight === true` 也照样全绿——那等于把所有 + // 不声明能力的老适配器一律当成瞎子,功能在真机上永远不生效。 + const r = setup( + { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, + // null 而不是 undefined:解构默认值只在 undefined 时生效,传 undefined 等于 + // 又拿回默认的 ["text","image"],这条用例会变成「能看图也能过」的假绿。 + fakeLlm({ modalities: null, description: "一张截图" }) + ); + await distill(r, sessionOf("img-unknown", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + assert.equal( + r.llm.calls.filter((c) => c.purpose === "image-description").length, + 1, + "能力未知时必须照发——宿主那侧对未知是不投影,照原样交给适配器" + ); +}); + +// ── 开着:描述真的进了转录 ─────────────────────────────────────────────── + +test("开着:描述并进转录,占位与文件名都还在", async () => { + const r = setup( + { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, + fakeLlm({ description: "一张两列数据的表格截图" }) + ); + await distill(r, sessionOf("img-on", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + + const describeCalls = r.llm.calls.filter((c) => c.purpose === "image-description"); + assert.equal(describeCalls.length, 1, "一张图一次描述调用"); + // 描述调用里必须带的是**真 image 块**:字节由宿主按 inputModalities 决定编不编。 + const content = describeCalls[0].messages[0].content; + assert.equal(content[0].type, "image"); + assert.equal(content[0].attachment.attachmentId, "sha256:abc123", "图块原样转发,不改写"); + assert.equal(content[1].type, "text", "提示词与图块同一条消息,图在图前"); + + const distillCall = r.llm.calls.find((c) => c.purpose === "summarization"); + const transcript = transcriptOf(distillCall); + assert.ok(transcript.includes("一张两列数据的表格截图"), "描述必须真的进转录"); + assert.ok(transcript.includes("[图:"), "占位不能被描述替换掉"); + assert.ok(transcript.includes("屏幕截图 2026-10-19 072000.png"), "文件名溯源要留"); + assert.ok(transcript.includes("1543x983"), "尺寸要留"); + assert.ok(transcript.includes("描述:"), "描述行有前缀,才能与正文区分"); +}); + +test("同一张图在一次蒸馏里只描述一次(重复贴图不重复付费)", async () => { + const r = setup( + { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, + fakeLlm({ description: "同一张图" }) + ); + const dup = imageMessage(2, "再看一眼"); + await distill(r, sessionOf("img-dup", [imageMessage(1, "看下这个"), dup, { seq: 3, type: "turn/end" }])); + assert.equal( + r.llm.calls.filter((c) => c.purpose === "image-description").length, + 1, + "相同 attachmentId 只该描述一次" + ); +}); + +test("描述调用失败:蒸馏照跑,转录退回 #611 的占位形态", async () => { + const r = setup( + { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, + fakeLlm({ throwOnDescribe: true }) + ); + await distill(r, sessionOf("img-fail", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + + const distillCall = r.llm.calls.find((c) => c.purpose === "summarization"); + assert.ok(distillCall, "描述挂了也必须继续蒸馏"); + const transcript = transcriptOf(distillCall); + assert.ok(transcript.includes("[图:"), "占位照旧"); + assert.ok(!transcript.includes("描述:"), "没有描述就别留空壳"); +}); + +// ── 单元:长度与拼接 ───────────────────────────────────────────────────── + +test("描述超过 300 字被截断,不把正文挤出窗口", async () => { + const long = "长".repeat(MAX_DESCRIPTION_CHARS + 200); + const ctx = { llm: { stream: () => (async function* () { yield { type: "text-delta", delta: long }; })() } }; + const got = await describeImages(ctx, { provider: "v", model: "v1" }, {}, [IMAGE]); + const text = [...got.values()][0]; + assert.ok(text, "应当产出描述"); + assert.ok(text.length <= MAX_DESCRIPTION_CHARS, `截断后不该超上限,实得 ${text.length}`); + assert.ok(text.endsWith("…"), "截断要留省略号,别让人以为原文就这么短"); +}); + +test("描述里的换行被压成空格(一行一条,不破坏转录结构)", async () => { + const ctx = { llm: { stream: () => (async function* () { yield { type: "text-delta", delta: "第一行\n\n第二行" }; })() } }; + const got = await describeImages(ctx, { provider: "v", model: "v1" }, {}, [IMAGE]); + const text = [...got.values()][0]; + assert.equal(text, "第一行 第二行", "换行会让转录多出假的一行「用户:…」"); +}); + +test("appendDescriptions 不动没有描述的块,且只替换第一处", () => { + // 占位文本按 attachment-text 的真实输出写(标签是「文件名 mediaType 宽x高」), + // 别自己编一个更短的形状——那会让这条用例在没有真实占位时也变绿。 + const placeholder = `[图: ${imageLabel(IMAGE)}]`; + const text = `${placeholder}\n${placeholder}`; + const one = new Map([["sha256:abc123", "描述内容"]]); + const out = appendDescriptions(text, [IMAGE, IMAGE], one); + assert.equal(out, `${placeholder}\n 描述:描述内容\n${placeholder}`); + assert.equal(appendDescriptions(text, [IMAGE], new Map()), text, "没有描述时原样返回"); +}); + +test("imageBlocks 只挑带 attachment 的 image 块", () => { + const content = [ + { type: "text", text: "hi" }, + { type: "image", attachment: IMAGE.attachment }, + { type: "image" }, + { type: "reasoning", text: "私有推理" } + ]; + assert.equal(imageBlocks(content).length, 1, "没有 attachment 的 image 块无从描述"); + assert.deepEqual(imageBlocks(null), [], "非数组返回空,不抛"); +}); -const IMAGE = { - type: "image", - attachment: { - attachmentId: "sha256:abc123", - mediaType: "image/png", - width: 1543, - height: 983, - name: "屏幕截图 2026-10-19 072000.png" - } -}; +// ── 审计:整档静默失效必须在 metadata 里留下痕迹 ───────────────────────── +// 这一组的理由:这条链路上原先只有 imageCount(图块数),于是「开了开关但一次 +// 都没跑成」与「这个窗口本来没有图」在审计里长得一模一样。四条用例分别钉住 +// 「跑成了」「试了失败」「开着没路由」「默认关着不写」四种结局,少任何一条, +// 下次有人把计数删了都不会有测试变红。 -function imageMessage(seq, text) { - const content = []; - if (text) content.push({ type: "text", text }); - content.push({ ...IMAGE }); - return { seq, type: "user/message", data: { source: { kind: "user" }, content } }; +function auditRow(service) { + return service.listLlmAudits().find((r) => r.status === "success"); } -// 一个能同时扮演「描述调用」与「蒸馏调用」的假 llm:按 purpose 分流, -// 并且把每次调用都记下来,让用例能断言「到底发了几次、发给谁」。 -function fakeLlm({ description = "一张表格截图,两列数据对比", modalities = ["text", "image"], throwOnDescribe = false } = {}) { - const calls = []; - return { - calls, - resolveModelInfo: async () => ({ inputModalities: modalities }), - stream(options) { - calls.push(options); - if (options.purpose === "image-description") { - if (throwOnDescribe) throw new Error("vision route down"); - return (async function* () { - yield { type: "text-delta", delta: description }; - yield { type: "finish", kind: "ok" }; - })(); - } - const json = JSON.stringify([ - { type: "history", title: "看过一张截图", content: "用户发了一张截图问数据对比", importance: 3 } - ]); - return (async function* () { - yield { type: "block-start", block: { type: "text" } }; - yield { type: "text-delta", delta: json }; - yield { type: "block-end", block: { type: "text" } }; - yield { type: "finish", kind: "ok" }; - })(); - } - }; -} - -function setup(over = {}, llm = fakeLlm()) { - const store = createStore(":memory:"); - const service = createService({ store, mirror: null, config: {} }); - const events = []; - const ctx = { - on(name, fn) { - events.push({ name, fn }); - return () => {}; - }, - llm - }; - const config = { autoSummarize: true, distillRateLimitIntervalMs: 0, ...over }; - const summarizer = createSummarizer(ctx, service, config, {}); - return { store, service, events, ctx, llm, summarizer }; -} - -async function distill(setupResult, session) { - const handler = setupResult.events.find((e) => e.name === "session/event").fn; - await handler(session, { seq: session.events.at(-1).seq, type: "turn/end" }); -} - -function sessionOf(id, events) { - return { - id, - requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), - events - }; -} - -function transcriptOf(call) { - return call.messages.find((m) => m.role === "user").content[0].text; -} - -// ── 准入判定 ───────────────────────────────────────────────────────────── - -test("准入:inputModalities 缺席是「未知」,不是「不支持」", async () => { - const unknown = { llm: { resolveModelInfo: async () => ({}) } }; - assert.equal(await canSeeImages(unknown, "p", "m"), "unknown", - "老适配器不声明能力时必须按未知处理,交给宿主决定"); - const notArray = { llm: { resolveModelInfo: async () => ({ inputModalities: "image" }) } }; - assert.equal(await canSeeImages(notArray, "p", "m"), "unknown", "非数组同属未知"); - const noApi = { llm: {} }; - assert.equal(await canSeeImages(noApi, "p", "m"), "unknown", "连查询接口都没有也是未知"); -}); - -test("准入:只有明确声明了且不含 image 才是确凿的看不到", async () => { - const textOnly = { llm: { resolveModelInfo: async () => ({ inputModalities: ["text"] }) } }; - assert.equal(await canSeeImages(textOnly, "p", "m"), false); - const vision = { llm: { resolveModelInfo: async () => ({ inputModalities: ["text", "image"] }) } }; - assert.equal(await canSeeImages(vision, "p", "m"), true); -}); - -test("准入:能力查询抛错按未知处理(不因为一次 RPC 失败就整档不跑)", async () => { - const boom = { llm: { resolveModelInfo: async () => { throw new Error("nope"); } } }; - assert.equal(await canSeeImages(boom, "p", "m"), "unknown"); -}); - -// ── 默认关:逐字节不变 ─────────────────────────────────────────────────── - -test("默认关:即使配了描述模型,也不发描述调用、转录逐字节不变", async () => { - const base = setup({}); - await distill(base, sessionOf("img-off", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); - assert.equal(base.llm.calls.length, 1, "只该有一次蒸馏调用"); - assert.equal(base.llm.calls[0].purpose, "summarization"); - const baseline = transcriptOf(base.llm.calls[0]); - - // 配了路由但开关没开:不该有任何变化。 - const off = setup({ imageDescribeProvider: "vision", imageDescribeModel: "v1" }); - await distill(off, sessionOf("img-off2", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); - assert.equal(off.llm.calls.length, 1, "开关关着就不该有描述调用"); - assert.equal(transcriptOf(off.llm.calls[0]), baseline, "关着时转录必须与 #611 的行为逐字节一致"); -}); - -test("开关开着但路由留空 = 整档不跑(不是跟随主模型)", async () => { - const r = setup({ imageDescribeEnabled: true }); - await distill(r, sessionOf("img-noroute", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); - assert.equal(r.llm.calls.length, 1, "路由留空只该有一次蒸馏调用"); - assert.equal(r.llm.calls[0].purpose, "summarization"); -}); - -test("开关开着但模型明确不能看图:不发描述调用", async () => { - const r = setup( - { imageDescribeEnabled: true, imageDescribeProvider: "text", imageDescribeModel: "t1" }, - fakeLlm({ modalities: ["text"] }) - ); - await distill(r, sessionOf("img-blind", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); - assert.equal(r.llm.calls.length, 1, "已知看不到还发调用,只会换回一行占位"); - assert.equal(r.llm.calls[0].purpose, "summarization"); -}); - -test("能力未知(老适配器不声明 inputModalities)时照发,不替宿主决定", async () => { - // 这条与上一条是同一个判据的两侧:false 才跳过,"unknown" 必须照发。 - // 抹掉这条,实现里把判据写成 `sight === true` 也照样全绿——那等于把所有 - // 不声明能力的老适配器一律当成瞎子,功能在真机上永远不生效。 +test("审计:开着且跑成,写 imageDescribed 与 imageDescribeFailed=0", async () => { const r = setup( { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, - // null 而不是 undefined:解构默认值只在 undefined 时生效,传 undefined 等于 - // 又拿回默认的 ["text","image"],这条用例会变成「能看图也能过」的假绿。 - fakeLlm({ modalities: null, description: "一张截图" }) - ); - await distill(r, sessionOf("img-unknown", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); - assert.equal( - r.llm.calls.filter((c) => c.purpose === "image-description").length, - 1, - "能力未知时必须照发——宿主那侧对未知是不投影,照原样交给适配器" + fakeLlm({ description: "一张截图" }) ); + await distill(r, sessionOf("img-audit-ok", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + const row = auditRow(r.service); + assert.ok(row, "成功行要存在"); + assert.equal(row.metadata?.imageDescribed, 1, "跑成一张就记 1"); + assert.equal(row.metadata?.imageDescribeFailed, 0, "失败数必须是显式的 0,不能缺键"); + assert.equal(row.metadata?.imageDescribeError, undefined, "没失败就不写原因"); + assert.equal(row.metadata?.imageCount, 1, "图块数照旧"); }); -// ── 开着:描述真的进了转录 ─────────────────────────────────────────────── - -test("开着:描述并进转录,占位与文件名都还在", async () => { +test("审计:描述调用失败,写 imageDescribeFailed 与首条错误原文", async () => { const r = setup( { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, - fakeLlm({ description: "一张两列数据的表格截图" }) + fakeLlm({ throwOnDescribe: true }) + ); + await distill(r, sessionOf("img-audit-fail", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + const row = auditRow(r.service); + assert.ok(row, "描述挂了,蒸馏本身仍是成功行"); + assert.equal(row.metadata?.imageDescribed, 0, "一张都没成"); + assert.equal(row.metadata?.imageDescribeFailed, 1, "试了一张、失败一张"); + assert.ok( + String(row.metadata?.imageDescribeError ?? "").includes("vision route down"), + "首条错误要留原文,否则只知道失败了不知道为什么" ); - await distill(r, sessionOf("img-on", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); - - const describeCalls = r.llm.calls.filter((c) => c.purpose === "image-description"); - assert.equal(describeCalls.length, 1, "一张图一次描述调用"); - // 描述调用里必须带的是**真 image 块**:字节由宿主按 inputModalities 决定编不编。 - const content = describeCalls[0].messages[0].content; - assert.equal(content[0].type, "image"); - assert.equal(content[0].attachment.attachmentId, "sha256:abc123", "图块原样转发,不改写"); - assert.equal(content[1].type, "text", "提示词与图块同一条消息,图在图前"); - - const distillCall = r.llm.calls.find((c) => c.purpose === "summarization"); - const transcript = transcriptOf(distillCall); - assert.ok(transcript.includes("一张两列数据的表格截图"), "描述必须真的进转录"); - assert.ok(transcript.includes("[图:"), "占位不能被描述替换掉"); - assert.ok(transcript.includes("屏幕截图 2026-10-19 072000.png"), "文件名溯源要留"); - assert.ok(transcript.includes("1543x983"), "尺寸要留"); - assert.ok(transcript.includes("描述:"), "描述行有前缀,才能与正文区分"); }); -test("同一张图在一次蒸馏里只描述一次(重复贴图不重复付费)", async () => { - const r = setup( - { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, - fakeLlm({ description: "同一张图" }) - ); - const dup = imageMessage(2, "再看一眼"); - await distill(r, sessionOf("img-dup", [imageMessage(1, "看下这个"), dup, { seq: 3, type: "turn/end" }])); - assert.equal( - r.llm.calls.filter((c) => c.purpose === "image-description").length, - 1, - "相同 attachmentId 只该描述一次" - ); +test("审计:开着但路由留空,写 imageDescribeSkipped=no route", async () => { + const r = setup({ imageDescribeEnabled: true, imageDescribeProvider: "", imageDescribeModel: "" }); + await distill(r, sessionOf("img-audit-noroute", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + const row = auditRow(r.service); + assert.equal(row.metadata?.imageDescribeSkipped, "no route", "「我开了但没配」必须能看见"); + assert.equal(row.metadata?.imageDescribed, undefined, "没试过就不该有尝试数"); }); -test("描述调用失败:蒸馏照跑,转录退回 #611 的占位形态", async () => { +test("审计:模型明确不能看图,写 imageDescribeSkipped=model-cannot-see-images", async () => { const r = setup( - { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, - fakeLlm({ throwOnDescribe: true }) + { imageDescribeEnabled: true, imageDescribeProvider: "text", imageDescribeModel: "t1" }, + fakeLlm({ modalities: ["text"] }) ); - await distill(r, sessionOf("img-fail", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); - - const distillCall = r.llm.calls.find((c) => c.purpose === "summarization"); - assert.ok(distillCall, "描述挂了也必须继续蒸馏"); - const transcript = transcriptOf(distillCall); - assert.ok(transcript.includes("[图:"), "占位照旧"); - assert.ok(!transcript.includes("描述:"), "没有描述就别留空壳"); -}); - -// ── 单元:长度与拼接 ───────────────────────────────────────────────────── - -test("描述超过 300 字被截断,不把正文挤出窗口", async () => { - const long = "长".repeat(MAX_DESCRIPTION_CHARS + 200); - const ctx = { llm: { stream: () => (async function* () { yield { type: "text-delta", delta: long }; })() } }; - const got = await describeImages(ctx, { provider: "v", model: "v1" }, {}, [IMAGE]); - const text = [...got.values()][0]; - assert.ok(text, "应当产出描述"); - assert.ok(text.length <= MAX_DESCRIPTION_CHARS, `截断后不该超上限,实得 ${text.length}`); - assert.ok(text.endsWith("…"), "截断要留省略号,别让人以为原文就这么短"); -}); - -test("描述里的换行被压成空格(一行一条,不破坏转录结构)", async () => { - const ctx = { llm: { stream: () => (async function* () { yield { type: "text-delta", delta: "第一行\n\n第二行" }; })() } }; - const got = await describeImages(ctx, { provider: "v", model: "v1" }, {}, [IMAGE]); - const text = [...got.values()][0]; - assert.equal(text, "第一行 第二行", "换行会让转录多出假的一行「用户:…」"); -}); - -test("appendDescriptions 不动没有描述的块,且只替换第一处", () => { - // 占位文本按 attachment-text 的真实输出写(标签是「文件名 mediaType 宽x高」), - // 别自己编一个更短的形状——那会让这条用例在没有真实占位时也变绿。 - const placeholder = `[图: ${imageLabel(IMAGE)}]`; - const text = `${placeholder}\n${placeholder}`; - const one = new Map([["sha256:abc123", "描述内容"]]); - const out = appendDescriptions(text, [IMAGE, IMAGE], one); - assert.equal(out, `${placeholder}\n 描述:描述内容\n${placeholder}`); - assert.equal(appendDescriptions(text, [IMAGE], new Map()), text, "没有描述时原样返回"); + await distill(r, sessionOf("img-audit-blind", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + const row = auditRow(r.service); + assert.equal(row.metadata?.imageDescribeSkipped, "model-cannot-see-images"); }); -test("imageBlocks 只挑带 attachment 的 image 块", () => { - const content = [ - { type: "text", text: "hi" }, - { type: "image", attachment: IMAGE.attachment }, - { type: "image" }, - { type: "reasoning", text: "私有推理" } - ]; - assert.equal(imageBlocks(content).length, 1, "没有 attachment 的 image 块无从描述"); - assert.deepEqual(imageBlocks(null), [], "非数组返回空,不抛"); +test("审计:默认关着时不写任何 imageDescribe* 键(老档位逐字节不变)", async () => { + const r = setup({ imageDescribeProvider: "vision", imageDescribeModel: "v1" }); + await distill(r, sessionOf("img-audit-off", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + const row = auditRow(r.service); + assert.equal(row.metadata?.imageCount, 1, "图块数照旧要数"); + const extra = Object.keys(row.metadata ?? {}).filter((k) => k.startsWith("imageDescribe")); + assert.deepEqual(extra, [], `关闭态不该多出任何键,实得多出 ${extra.join(",")}`); }); From 39e150113f6822e4ce78b747c41cad9afb8c80ed Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 12 Nov 2026 10:36:37 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(memory):=20=E6=8F=8F=E8=BF=B0=E8=BF=9B?= =?UTF-8?q?=E8=BD=AC=E5=BD=95=E8=BF=98=E4=B8=8D=E5=A4=9F=EF=BC=8C=E8=92=B8?= =?UTF-8?q?=E9=A6=8F=E6=8F=90=E7=A4=BA=E8=AF=8D=E8=A1=A5=E4=B8=80=E5=8F=A5?= =?UTF-8?q?=E8=AE=A9=E5=9B=BE=E9=87=8C=E5=86=85=E5=AE=B9=E7=9C=9F=E7=9A=84?= =?UTF-8?q?=E8=90=BD=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh-mneme/CHANGELOG.md | 6 + dsh-mneme/lib/lang.js | 9 + dsh-mneme/lib/summarize.js | 15 +- dsh-mneme/src/lang.js | 9 + dsh-mneme/src/summarize.js | 15 +- dsh-mneme/test/image-description.test.js | 582 ++++++++++++----------- 6 files changed, 359 insertions(+), 277 deletions(-) diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index f59a5561..a06f66df 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -9,6 +9,12 @@ - **开关是子项,不占新座位**:`affectiveInjectEnabled` 默认 false,父开关是 `sleepAffectiveEnabled`(后端 `INJECT_CHILD_FLAGS`,面板 `FEATURE_CHILDREN`,两表同源由 `test/inject-parent-gate.test.js` 锁)。父关时子项一并不挂但持久值保留,父开关缺省(未写过)时只按子项自己判。进 `LIGHT_MODE_OFF`:低资源档本来就连 `sleepAffectiveEnabled` 一起关,情绪边都不会产出,留着这个注入开关没有意义。 - **测试**:`test/affective-inject.test.js` 8 条(默认关不改注入、开着带全四项事实且单次场合不写次数、trait 档不重复挂、父关一并不挂与父缺省只按子项判、实体被删不挂、超长整条放弃但记忆仍在块里、同一记忆多条边只挂最新一条、配置默认值与轻量档)。变异自证五轮各自变红:删掉 `inject.js` 的拼接、删掉 trait 跳过、把父闸换成裸读子项、把「任一侧名字为空」写成「两侧都空」、删掉长度闸。 +- **描述进得了转录、留不进记忆:蒸馏提示词补一句「描述行是事实来源」,让图里写了什么真的落库、真的能被检索到**。上一版的描述档只把 `描述:…` 并进转录,然后把转录交给蒸馏——但蒸馏提示词从没提过「描述」这回事,模型不知道那一行值不值得记。实测最坏的情况是把描述当排版噪音丢掉,蒸馏出来只剩「用户发了张截图」,而那张图里唯一能复原的信息(报错原文、界面上的数字)在落库前就没了,等于描述档白跑一次视觉调用。 + - **只在真有描述时加**:`describeStats.described > 0` 才把这句拼进 system prompt。没有描述却说「描述行是事实来源」,等于让模型去找一行不存在的文本;默认关着、或描述全失败时,提示词与 #611 逐字节一致。 + - **前缀字面量从生成端导入**,不在提示词里手抄一遍 `[图: ` / `描述:`。这两处一旦漂移,症状是「提示词让模型去找『描述:』,而转录里写的是别的」,不报错、不崩、只是安静地不生效——最难查的一类。 + - **不扩大自由度**:这句话只陈述「描述里的内容属于事实来源,与用户原话同等对待」,仍然受既有的立场纪律约束(不记对用户本人的评价);它让模型可以抄图里的关键文字进 content,而不是把「发了张图」当成记忆的全部。 + - **测试**:`test/image-description.test.js` 再增 3 条(有描述才加且前缀同源、描述失败不加、默认关逐字节不变)。变异自证两轮:把提示恒置空(1 红)、把 `> 0` 放松成 `>= 0` 让失败态也加(2 红)。 + - **图像描述档此前整档静默:开了开关、配了模型、一次都没跑成,审计里与「这个窗口没有图」长得一样——现在四种结局都留痕**。`describeImages` 的 `catch {}` 吞掉所有异常,provider/model 留空整档不跑也不报,宿主 `textOnly` 降级同样无声;审计 metadata 里只有一个 `imageCount`(图块数)。于是「我明明开了」和「库里从来没进过图」在运维上无法区分(实测本库 273MB 里含 `[图` 的记忆 6 条,全是关于这个功能本身的说明文字,没有一条来自真实图片)。 - **计数走出参,不认开关态**:`describeImages(..., options.stats)` 多一个可选出参(口径同 `injectCandidates` 的 `pinnedStats`:不传时行为逐字节等于改前),逐张记 `attempted`、成功记 `described`、空描述与抛异常各记 `failed` 并留首条错误原文(截 120 字)。`src/summarize.js` 加的 `describeAuditPatch(stats)` 是唯一出口,三处写审计的分支(窗口过小 / 解析失败 / 成功)共用,避免抄三遍漂移。 - **四条结局**:跑成了写 `imageDescribed` + `imageDescribeFailed: 0`(失败数显式给 0,缺键会被读成「没跑」,那正是要避免的模糊);试了失败写失败数与 `imageDescribeError`;开着没配路由写 `imageDescribeSkipped: "no route"`;模型明确不能看图写 `"model-cannot-see-images"`。**默认关着时一个 `imageDescribe*` 键都不写**,老档位审计行逐字节不变。 diff --git a/dsh-mneme/lib/lang.js b/dsh-mneme/lib/lang.js index 7a01cb33..a978a868 100644 --- a/dsh-mneme/lib/lang.js +++ b/dsh-mneme/lib/lang.js @@ -127,6 +127,15 @@ export const STR = { zh: (iso) => `\n\n当前时间:${iso}(本会话发生的时间)。据此换算 occurred_at。`, en: (iso) => `\n\nCurrent time: ${iso} (when this conversation happened). Use it to convert occurred_at.` }, + // 图像描述档(imageDescribeEnabled)开着且这一窗真的产出了描述时,把这句加进 + // 蒸馏提示词。为什么必须加:描述只并进了转录,蒸馏模型并不天然知道「描述:」 + // 那一行值不值得记——实测最容易被当成排版噪音丢掉,于是「图里写了什么」这个 + // 唯一可复原的信息在落库前就没了,描述档等于白跑。这句话只陈述转录口径,不 + // 扩大模型的自由度:它仍然只记事实,只是把图里的内容也算作事实来源。 + imageDistillHint: { + zh: (imgPrefix, descPrefix) => `\n\n关于本段转录中的图片:以「${imgPrefix}」开头的行是图片占位,紧随其后的「${descPrefix}」行是指定视觉模型看图后写的。描述里的主体、界面文字、报错、图表数据都是**真实存在的内容**,可以当作事实来源,与用户原话同等对待;需要记住图里写了什么时,把关键文字抄进 content,不要只写「用户发了张图」。`, + en: (imgPrefix, descPrefix) => `\n\nAbout images in this transcript: lines starting with "${imgPrefix}" are placeholders, and the "${descPrefix}" line right after each one was written by a vision model that actually saw the image. What the description states — subjects, on-screen text, errors, chart values — is real content and a valid source of facts, on equal footing with the user's own words. When the image's content matters, copy the key text into content; do not merely write "the user sent an image".` + }, // --- inject.js:注入块标题 / 条目行 --------------------------------------- hotHeader: { zh: (rounds, body) => `[短期上下文] 最近对话(共 ${rounds} 轮):\n${body}`, diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js index bc61d57c..2bb61822 100644 --- a/dsh-mneme/lib/summarize.js +++ b/dsh-mneme/lib/summarize.js @@ -10,10 +10,10 @@ import { createScopeResolver } from "./scope.js"; // 原先能提前闭合围栏——那是把用户消息升格成指令最短的一条路。 import { breakFenceMarkers } from "./fence.js"; // Issue #611:图片块的文本口径(占位,不是空串)。三个 textOf 副本共用这一份。 -import { contentToText, countImages } from "./attachment-text.js"; +import { contentToText, countImages, IMAGE_PLACEHOLDER_PREFIX } from "./attachment-text.js"; // 图像描述档(docs/DISTILL.md:346):占位之外再补一段「图里是什么」。本文件只 // 负责把描述并进转录行;出描述的调用、准入判断都在 image-describe.js。 -import { appendDescriptions, describeImages, imageBlocks, canSeeImages } from "./image-describe.js"; +import { appendDescriptions, describeImages, imageBlocks, canSeeImages, DESCRIPTION_PREFIX } from "./image-describe.js"; // #512:每轮蒸馏一个 run id(归因主体)。store 侧也能铸造,但这里必须知道它—— // 写记忆时要把它一路带到 saveMemoryProvenance / 落 run 行,拿回来再写就是两次往返。 import { randomUUID } from "node:crypto"; @@ -1007,7 +1007,16 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = // (可注入 now(),测试用假时钟);锚点行同时是 prompt 里「按提示末尾给出的 // 当前时间换算」那句的落点,改写/移动随两处一起动。 const distillPrompt = config.codingRetrospect ? STR.prompts.codingSummary[langOf(config)] : STR.prompts.summary[langOf(config)]; - const anchoredPrompt = `${distillPrompt}${STR.distillTimeAnchor[langOf(config)](new Date(now()).toISOString())}`; + // 图像描述档真的产出了描述时,才补一句「描述行是事实来源」。这一句必须在 + // 拿到 stats 之后拼(describeStats.described 是本次窗口实际写进转录的条数): + // 没有描述却告诉模型「描述行可信」是在描述一个不存在的行,只会让它去找。 + // 前缀字面量从两个模块导入,不在这里抄一遍——文案与生成端漂移过一次就会 + // 变成「提示词说要找『描述:』、转录里写的却是别的」这种最难查的静默失效。 + const describedCount = describeStats.described ?? 0; + const imageHint = describedCount > 0 + ? STR.imageDistillHint[langOf(config)](IMAGE_PLACEHOLDER_PREFIX, DESCRIPTION_PREFIX) + : ""; + const anchoredPrompt = `${distillPrompt}${STR.distillTimeAnchor[langOf(config)](new Date(now()).toISOString())}${imageHint}`; const options = { provider: route.provider, diff --git a/dsh-mneme/src/lang.js b/dsh-mneme/src/lang.js index 7a01cb33..a978a868 100644 --- a/dsh-mneme/src/lang.js +++ b/dsh-mneme/src/lang.js @@ -127,6 +127,15 @@ export const STR = { zh: (iso) => `\n\n当前时间:${iso}(本会话发生的时间)。据此换算 occurred_at。`, en: (iso) => `\n\nCurrent time: ${iso} (when this conversation happened). Use it to convert occurred_at.` }, + // 图像描述档(imageDescribeEnabled)开着且这一窗真的产出了描述时,把这句加进 + // 蒸馏提示词。为什么必须加:描述只并进了转录,蒸馏模型并不天然知道「描述:」 + // 那一行值不值得记——实测最容易被当成排版噪音丢掉,于是「图里写了什么」这个 + // 唯一可复原的信息在落库前就没了,描述档等于白跑。这句话只陈述转录口径,不 + // 扩大模型的自由度:它仍然只记事实,只是把图里的内容也算作事实来源。 + imageDistillHint: { + zh: (imgPrefix, descPrefix) => `\n\n关于本段转录中的图片:以「${imgPrefix}」开头的行是图片占位,紧随其后的「${descPrefix}」行是指定视觉模型看图后写的。描述里的主体、界面文字、报错、图表数据都是**真实存在的内容**,可以当作事实来源,与用户原话同等对待;需要记住图里写了什么时,把关键文字抄进 content,不要只写「用户发了张图」。`, + en: (imgPrefix, descPrefix) => `\n\nAbout images in this transcript: lines starting with "${imgPrefix}" are placeholders, and the "${descPrefix}" line right after each one was written by a vision model that actually saw the image. What the description states — subjects, on-screen text, errors, chart values — is real content and a valid source of facts, on equal footing with the user's own words. When the image's content matters, copy the key text into content; do not merely write "the user sent an image".` + }, // --- inject.js:注入块标题 / 条目行 --------------------------------------- hotHeader: { zh: (rounds, body) => `[短期上下文] 最近对话(共 ${rounds} 轮):\n${body}`, diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js index bc61d57c..2bb61822 100644 --- a/dsh-mneme/src/summarize.js +++ b/dsh-mneme/src/summarize.js @@ -10,10 +10,10 @@ import { createScopeResolver } from "./scope.js"; // 原先能提前闭合围栏——那是把用户消息升格成指令最短的一条路。 import { breakFenceMarkers } from "./fence.js"; // Issue #611:图片块的文本口径(占位,不是空串)。三个 textOf 副本共用这一份。 -import { contentToText, countImages } from "./attachment-text.js"; +import { contentToText, countImages, IMAGE_PLACEHOLDER_PREFIX } from "./attachment-text.js"; // 图像描述档(docs/DISTILL.md:346):占位之外再补一段「图里是什么」。本文件只 // 负责把描述并进转录行;出描述的调用、准入判断都在 image-describe.js。 -import { appendDescriptions, describeImages, imageBlocks, canSeeImages } from "./image-describe.js"; +import { appendDescriptions, describeImages, imageBlocks, canSeeImages, DESCRIPTION_PREFIX } from "./image-describe.js"; // #512:每轮蒸馏一个 run id(归因主体)。store 侧也能铸造,但这里必须知道它—— // 写记忆时要把它一路带到 saveMemoryProvenance / 落 run 行,拿回来再写就是两次往返。 import { randomUUID } from "node:crypto"; @@ -1007,7 +1007,16 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = // (可注入 now(),测试用假时钟);锚点行同时是 prompt 里「按提示末尾给出的 // 当前时间换算」那句的落点,改写/移动随两处一起动。 const distillPrompt = config.codingRetrospect ? STR.prompts.codingSummary[langOf(config)] : STR.prompts.summary[langOf(config)]; - const anchoredPrompt = `${distillPrompt}${STR.distillTimeAnchor[langOf(config)](new Date(now()).toISOString())}`; + // 图像描述档真的产出了描述时,才补一句「描述行是事实来源」。这一句必须在 + // 拿到 stats 之后拼(describeStats.described 是本次窗口实际写进转录的条数): + // 没有描述却告诉模型「描述行可信」是在描述一个不存在的行,只会让它去找。 + // 前缀字面量从两个模块导入,不在这里抄一遍——文案与生成端漂移过一次就会 + // 变成「提示词说要找『描述:』、转录里写的却是别的」这种最难查的静默失效。 + const describedCount = describeStats.described ?? 0; + const imageHint = describedCount > 0 + ? STR.imageDistillHint[langOf(config)](IMAGE_PLACEHOLDER_PREFIX, DESCRIPTION_PREFIX) + : ""; + const anchoredPrompt = `${distillPrompt}${STR.distillTimeAnchor[langOf(config)](new Date(now()).toISOString())}${imageHint}`; const options = { provider: route.provider, diff --git a/dsh-mneme/test/image-description.test.js b/dsh-mneme/test/image-description.test.js index 18f734c9..892d7832 100644 --- a/dsh-mneme/test/image-description.test.js +++ b/dsh-mneme/test/image-description.test.js @@ -1,274 +1,275 @@ -// 图像描述档(docs/DISTILL.md:346)的验收:这一份要证的不是「函数能跑」, -// 而是三件容易做错的事—— -// ① 默认关着时,蒸馏发给模型的那条消息**逐字节不变**(#611 的行为不能被 -// 一个 opt-in 功能顺手改掉); -// ② 开着时,描述真的并进了转录,而不是只发生在某个局部变量里; -// ③ 描述失败绝不连累蒸馏(描述是增强,不是前置条件)。 -// 另外两条属于防回归:300 字天花板(否则四张图能把正文挤出窗口)、 -// 以及「模型明确不能看图时不发这个调用」(省一次必然换回占位白花的钱)。 -import test from "node:test"; -import assert from "node:assert/strict"; -import { createStore } from "../src/store.js"; -import { createService } from "../src/service.js"; -import { createSummarizer } from "../src/summarize.js"; -import { imageLabel } from "../src/attachment-text.js"; -import { - MAX_DESCRIPTION_CHARS, - appendDescriptions, - canSeeImages, - describeImages, - imageBlocks -} from "../src/image-describe.js"; - -const IMAGE = { - type: "image", - attachment: { - attachmentId: "sha256:abc123", - mediaType: "image/png", - width: 1543, - height: 983, - name: "屏幕截图 2026-10-19 072000.png" - } -}; - -function imageMessage(seq, text) { - const content = []; - if (text) content.push({ type: "text", text }); - content.push({ ...IMAGE }); - return { seq, type: "user/message", data: { source: { kind: "user" }, content } }; -} - -// 一个能同时扮演「描述调用」与「蒸馏调用」的假 llm:按 purpose 分流, -// 并且把每次调用都记下来,让用例能断言「到底发了几次、发给谁」。 -function fakeLlm({ description = "一张表格截图,两列数据对比", modalities = ["text", "image"], throwOnDescribe = false } = {}) { - const calls = []; - return { - calls, - resolveModelInfo: async () => ({ inputModalities: modalities }), - stream(options) { - calls.push(options); - if (options.purpose === "image-description") { - if (throwOnDescribe) throw new Error("vision route down"); - return (async function* () { - yield { type: "text-delta", delta: description }; - yield { type: "finish", kind: "ok" }; - })(); - } - const json = JSON.stringify([ - { type: "history", title: "看过一张截图", content: "用户发了一张截图问数据对比", importance: 3 } - ]); - return (async function* () { - yield { type: "block-start", block: { type: "text" } }; - yield { type: "text-delta", delta: json }; - yield { type: "block-end", block: { type: "text" } }; - yield { type: "finish", kind: "ok" }; - })(); - } - }; -} - -function setup(over = {}, llm = fakeLlm()) { - const store = createStore(":memory:"); - const service = createService({ store, mirror: null, config: {} }); - const events = []; - const ctx = { - on(name, fn) { - events.push({ name, fn }); - return () => {}; - }, - llm - }; - const config = { autoSummarize: true, distillRateLimitIntervalMs: 0, ...over }; - const summarizer = createSummarizer(ctx, service, config, {}); - return { store, service, events, ctx, llm, summarizer }; -} - -async function distill(setupResult, session) { - const handler = setupResult.events.find((e) => e.name === "session/event").fn; - await handler(session, { seq: session.events.at(-1).seq, type: "turn/end" }); -} - -function sessionOf(id, events) { - return { - id, - requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), - events - }; -} - -function transcriptOf(call) { - return call.messages.find((m) => m.role === "user").content[0].text; -} - -// ── 准入判定 ───────────────────────────────────────────────────────────── - -test("准入:inputModalities 缺席是「未知」,不是「不支持」", async () => { - const unknown = { llm: { resolveModelInfo: async () => ({}) } }; - assert.equal(await canSeeImages(unknown, "p", "m"), "unknown", - "老适配器不声明能力时必须按未知处理,交给宿主决定"); - const notArray = { llm: { resolveModelInfo: async () => ({ inputModalities: "image" }) } }; - assert.equal(await canSeeImages(notArray, "p", "m"), "unknown", "非数组同属未知"); - const noApi = { llm: {} }; - assert.equal(await canSeeImages(noApi, "p", "m"), "unknown", "连查询接口都没有也是未知"); -}); - -test("准入:只有明确声明了且不含 image 才是确凿的看不到", async () => { - const textOnly = { llm: { resolveModelInfo: async () => ({ inputModalities: ["text"] }) } }; - assert.equal(await canSeeImages(textOnly, "p", "m"), false); - const vision = { llm: { resolveModelInfo: async () => ({ inputModalities: ["text", "image"] }) } }; - assert.equal(await canSeeImages(vision, "p", "m"), true); -}); - -test("准入:能力查询抛错按未知处理(不因为一次 RPC 失败就整档不跑)", async () => { - const boom = { llm: { resolveModelInfo: async () => { throw new Error("nope"); } } }; - assert.equal(await canSeeImages(boom, "p", "m"), "unknown"); -}); - -// ── 默认关:逐字节不变 ─────────────────────────────────────────────────── - -test("默认关:即使配了描述模型,也不发描述调用、转录逐字节不变", async () => { - const base = setup({}); - await distill(base, sessionOf("img-off", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); - assert.equal(base.llm.calls.length, 1, "只该有一次蒸馏调用"); - assert.equal(base.llm.calls[0].purpose, "summarization"); - const baseline = transcriptOf(base.llm.calls[0]); - - // 配了路由但开关没开:不该有任何变化。 - const off = setup({ imageDescribeProvider: "vision", imageDescribeModel: "v1" }); - await distill(off, sessionOf("img-off2", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); - assert.equal(off.llm.calls.length, 1, "开关关着就不该有描述调用"); - assert.equal(transcriptOf(off.llm.calls[0]), baseline, "关着时转录必须与 #611 的行为逐字节一致"); -}); - -test("开关开着但路由留空 = 整档不跑(不是跟随主模型)", async () => { - const r = setup({ imageDescribeEnabled: true }); - await distill(r, sessionOf("img-noroute", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); - assert.equal(r.llm.calls.length, 1, "路由留空只该有一次蒸馏调用"); - assert.equal(r.llm.calls[0].purpose, "summarization"); -}); - -test("开关开着但模型明确不能看图:不发描述调用", async () => { - const r = setup( - { imageDescribeEnabled: true, imageDescribeProvider: "text", imageDescribeModel: "t1" }, - fakeLlm({ modalities: ["text"] }) - ); - await distill(r, sessionOf("img-blind", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); - assert.equal(r.llm.calls.length, 1, "已知看不到还发调用,只会换回一行占位"); - assert.equal(r.llm.calls[0].purpose, "summarization"); -}); - -test("能力未知(老适配器不声明 inputModalities)时照发,不替宿主决定", async () => { - // 这条与上一条是同一个判据的两侧:false 才跳过,"unknown" 必须照发。 - // 抹掉这条,实现里把判据写成 `sight === true` 也照样全绿——那等于把所有 - // 不声明能力的老适配器一律当成瞎子,功能在真机上永远不生效。 - const r = setup( - { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, - // null 而不是 undefined:解构默认值只在 undefined 时生效,传 undefined 等于 - // 又拿回默认的 ["text","image"],这条用例会变成「能看图也能过」的假绿。 - fakeLlm({ modalities: null, description: "一张截图" }) - ); - await distill(r, sessionOf("img-unknown", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); - assert.equal( - r.llm.calls.filter((c) => c.purpose === "image-description").length, - 1, - "能力未知时必须照发——宿主那侧对未知是不投影,照原样交给适配器" - ); -}); - -// ── 开着:描述真的进了转录 ─────────────────────────────────────────────── - -test("开着:描述并进转录,占位与文件名都还在", async () => { - const r = setup( - { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, - fakeLlm({ description: "一张两列数据的表格截图" }) - ); - await distill(r, sessionOf("img-on", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); - - const describeCalls = r.llm.calls.filter((c) => c.purpose === "image-description"); - assert.equal(describeCalls.length, 1, "一张图一次描述调用"); - // 描述调用里必须带的是**真 image 块**:字节由宿主按 inputModalities 决定编不编。 - const content = describeCalls[0].messages[0].content; - assert.equal(content[0].type, "image"); - assert.equal(content[0].attachment.attachmentId, "sha256:abc123", "图块原样转发,不改写"); - assert.equal(content[1].type, "text", "提示词与图块同一条消息,图在图前"); - - const distillCall = r.llm.calls.find((c) => c.purpose === "summarization"); - const transcript = transcriptOf(distillCall); - assert.ok(transcript.includes("一张两列数据的表格截图"), "描述必须真的进转录"); - assert.ok(transcript.includes("[图:"), "占位不能被描述替换掉"); - assert.ok(transcript.includes("屏幕截图 2026-10-19 072000.png"), "文件名溯源要留"); - assert.ok(transcript.includes("1543x983"), "尺寸要留"); - assert.ok(transcript.includes("描述:"), "描述行有前缀,才能与正文区分"); -}); - -test("同一张图在一次蒸馏里只描述一次(重复贴图不重复付费)", async () => { - const r = setup( - { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, - fakeLlm({ description: "同一张图" }) - ); - const dup = imageMessage(2, "再看一眼"); - await distill(r, sessionOf("img-dup", [imageMessage(1, "看下这个"), dup, { seq: 3, type: "turn/end" }])); - assert.equal( - r.llm.calls.filter((c) => c.purpose === "image-description").length, - 1, - "相同 attachmentId 只该描述一次" - ); -}); - -test("描述调用失败:蒸馏照跑,转录退回 #611 的占位形态", async () => { - const r = setup( - { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, - fakeLlm({ throwOnDescribe: true }) - ); - await distill(r, sessionOf("img-fail", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); - - const distillCall = r.llm.calls.find((c) => c.purpose === "summarization"); - assert.ok(distillCall, "描述挂了也必须继续蒸馏"); - const transcript = transcriptOf(distillCall); - assert.ok(transcript.includes("[图:"), "占位照旧"); - assert.ok(!transcript.includes("描述:"), "没有描述就别留空壳"); -}); - -// ── 单元:长度与拼接 ───────────────────────────────────────────────────── - -test("描述超过 300 字被截断,不把正文挤出窗口", async () => { - const long = "长".repeat(MAX_DESCRIPTION_CHARS + 200); - const ctx = { llm: { stream: () => (async function* () { yield { type: "text-delta", delta: long }; })() } }; - const got = await describeImages(ctx, { provider: "v", model: "v1" }, {}, [IMAGE]); - const text = [...got.values()][0]; - assert.ok(text, "应当产出描述"); - assert.ok(text.length <= MAX_DESCRIPTION_CHARS, `截断后不该超上限,实得 ${text.length}`); - assert.ok(text.endsWith("…"), "截断要留省略号,别让人以为原文就这么短"); -}); - -test("描述里的换行被压成空格(一行一条,不破坏转录结构)", async () => { - const ctx = { llm: { stream: () => (async function* () { yield { type: "text-delta", delta: "第一行\n\n第二行" }; })() } }; - const got = await describeImages(ctx, { provider: "v", model: "v1" }, {}, [IMAGE]); - const text = [...got.values()][0]; - assert.equal(text, "第一行 第二行", "换行会让转录多出假的一行「用户:…」"); -}); - -test("appendDescriptions 不动没有描述的块,且只替换第一处", () => { - // 占位文本按 attachment-text 的真实输出写(标签是「文件名 mediaType 宽x高」), - // 别自己编一个更短的形状——那会让这条用例在没有真实占位时也变绿。 - const placeholder = `[图: ${imageLabel(IMAGE)}]`; - const text = `${placeholder}\n${placeholder}`; - const one = new Map([["sha256:abc123", "描述内容"]]); - const out = appendDescriptions(text, [IMAGE, IMAGE], one); - assert.equal(out, `${placeholder}\n 描述:描述内容\n${placeholder}`); - assert.equal(appendDescriptions(text, [IMAGE], new Map()), text, "没有描述时原样返回"); -}); - -test("imageBlocks 只挑带 attachment 的 image 块", () => { - const content = [ - { type: "text", text: "hi" }, - { type: "image", attachment: IMAGE.attachment }, - { type: "image" }, - { type: "reasoning", text: "私有推理" } - ]; - assert.equal(imageBlocks(content).length, 1, "没有 attachment 的 image 块无从描述"); - assert.deepEqual(imageBlocks(null), [], "非数组返回空,不抛"); -}); +// 图像描述档(docs/DISTILL.md:346)的验收:这一份要证的不是「函数能跑」, +// 而是三件容易做错的事—— +// ① 默认关着时,蒸馏发给模型的那条消息**逐字节不变**(#611 的行为不能被 +// 一个 opt-in 功能顺手改掉); +// ② 开着时,描述真的并进了转录,而不是只发生在某个局部变量里; +// ③ 描述失败绝不连累蒸馏(描述是增强,不是前置条件)。 +// 另外两条属于防回归:300 字天花板(否则四张图能把正文挤出窗口)、 +// 以及「模型明确不能看图时不发这个调用」(省一次必然换回占位白花的钱)。 +import test from "node:test"; +import assert from "node:assert/strict"; +import { createStore } from "../src/store.js"; +import { createService } from "../src/service.js"; +import { createSummarizer } from "../src/summarize.js"; +import { imageLabel, IMAGE_PLACEHOLDER_PREFIX } from "../src/attachment-text.js"; +import { + MAX_DESCRIPTION_CHARS, + appendDescriptions, + canSeeImages, + describeImages, + imageBlocks, + DESCRIPTION_PREFIX +} from "../src/image-describe.js"; + +const IMAGE = { + type: "image", + attachment: { + attachmentId: "sha256:abc123", + mediaType: "image/png", + width: 1543, + height: 983, + name: "屏幕截图 2026-10-19 072000.png" + } +}; + +function imageMessage(seq, text) { + const content = []; + if (text) content.push({ type: "text", text }); + content.push({ ...IMAGE }); + return { seq, type: "user/message", data: { source: { kind: "user" }, content } }; +} + +// 一个能同时扮演「描述调用」与「蒸馏调用」的假 llm:按 purpose 分流, +// 并且把每次调用都记下来,让用例能断言「到底发了几次、发给谁」。 +function fakeLlm({ description = "一张表格截图,两列数据对比", modalities = ["text", "image"], throwOnDescribe = false } = {}) { + const calls = []; + return { + calls, + resolveModelInfo: async () => ({ inputModalities: modalities }), + stream(options) { + calls.push(options); + if (options.purpose === "image-description") { + if (throwOnDescribe) throw new Error("vision route down"); + return (async function* () { + yield { type: "text-delta", delta: description }; + yield { type: "finish", kind: "ok" }; + })(); + } + const json = JSON.stringify([ + { type: "history", title: "看过一张截图", content: "用户发了一张截图问数据对比", importance: 3 } + ]); + return (async function* () { + yield { type: "block-start", block: { type: "text" } }; + yield { type: "text-delta", delta: json }; + yield { type: "block-end", block: { type: "text" } }; + yield { type: "finish", kind: "ok" }; + })(); + } + }; +} + +function setup(over = {}, llm = fakeLlm()) { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config: {} }); + const events = []; + const ctx = { + on(name, fn) { + events.push({ name, fn }); + return () => {}; + }, + llm + }; + const config = { autoSummarize: true, distillRateLimitIntervalMs: 0, ...over }; + const summarizer = createSummarizer(ctx, service, config, {}); + return { store, service, events, ctx, llm, summarizer }; +} + +async function distill(setupResult, session) { + const handler = setupResult.events.find((e) => e.name === "session/event").fn; + await handler(session, { seq: session.events.at(-1).seq, type: "turn/end" }); +} + +function sessionOf(id, events) { + return { + id, + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events + }; +} + +function transcriptOf(call) { + return call.messages.find((m) => m.role === "user").content[0].text; +} + +// ── 准入判定 ───────────────────────────────────────────────────────────── + +test("准入:inputModalities 缺席是「未知」,不是「不支持」", async () => { + const unknown = { llm: { resolveModelInfo: async () => ({}) } }; + assert.equal(await canSeeImages(unknown, "p", "m"), "unknown", + "老适配器不声明能力时必须按未知处理,交给宿主决定"); + const notArray = { llm: { resolveModelInfo: async () => ({ inputModalities: "image" }) } }; + assert.equal(await canSeeImages(notArray, "p", "m"), "unknown", "非数组同属未知"); + const noApi = { llm: {} }; + assert.equal(await canSeeImages(noApi, "p", "m"), "unknown", "连查询接口都没有也是未知"); +}); + +test("准入:只有明确声明了且不含 image 才是确凿的看不到", async () => { + const textOnly = { llm: { resolveModelInfo: async () => ({ inputModalities: ["text"] }) } }; + assert.equal(await canSeeImages(textOnly, "p", "m"), false); + const vision = { llm: { resolveModelInfo: async () => ({ inputModalities: ["text", "image"] }) } }; + assert.equal(await canSeeImages(vision, "p", "m"), true); +}); + +test("准入:能力查询抛错按未知处理(不因为一次 RPC 失败就整档不跑)", async () => { + const boom = { llm: { resolveModelInfo: async () => { throw new Error("nope"); } } }; + assert.equal(await canSeeImages(boom, "p", "m"), "unknown"); +}); + +// ── 默认关:逐字节不变 ─────────────────────────────────────────────────── + +test("默认关:即使配了描述模型,也不发描述调用、转录逐字节不变", async () => { + const base = setup({}); + await distill(base, sessionOf("img-off", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + assert.equal(base.llm.calls.length, 1, "只该有一次蒸馏调用"); + assert.equal(base.llm.calls[0].purpose, "summarization"); + const baseline = transcriptOf(base.llm.calls[0]); + + // 配了路由但开关没开:不该有任何变化。 + const off = setup({ imageDescribeProvider: "vision", imageDescribeModel: "v1" }); + await distill(off, sessionOf("img-off2", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + assert.equal(off.llm.calls.length, 1, "开关关着就不该有描述调用"); + assert.equal(transcriptOf(off.llm.calls[0]), baseline, "关着时转录必须与 #611 的行为逐字节一致"); +}); + +test("开关开着但路由留空 = 整档不跑(不是跟随主模型)", async () => { + const r = setup({ imageDescribeEnabled: true }); + await distill(r, sessionOf("img-noroute", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + assert.equal(r.llm.calls.length, 1, "路由留空只该有一次蒸馏调用"); + assert.equal(r.llm.calls[0].purpose, "summarization"); +}); + +test("开关开着但模型明确不能看图:不发描述调用", async () => { + const r = setup( + { imageDescribeEnabled: true, imageDescribeProvider: "text", imageDescribeModel: "t1" }, + fakeLlm({ modalities: ["text"] }) + ); + await distill(r, sessionOf("img-blind", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + assert.equal(r.llm.calls.length, 1, "已知看不到还发调用,只会换回一行占位"); + assert.equal(r.llm.calls[0].purpose, "summarization"); +}); + +test("能力未知(老适配器不声明 inputModalities)时照发,不替宿主决定", async () => { + // 这条与上一条是同一个判据的两侧:false 才跳过,"unknown" 必须照发。 + // 抹掉这条,实现里把判据写成 `sight === true` 也照样全绿——那等于把所有 + // 不声明能力的老适配器一律当成瞎子,功能在真机上永远不生效。 + const r = setup( + { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, + // null 而不是 undefined:解构默认值只在 undefined 时生效,传 undefined 等于 + // 又拿回默认的 ["text","image"],这条用例会变成「能看图也能过」的假绿。 + fakeLlm({ modalities: null, description: "一张截图" }) + ); + await distill(r, sessionOf("img-unknown", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + assert.equal( + r.llm.calls.filter((c) => c.purpose === "image-description").length, + 1, + "能力未知时必须照发——宿主那侧对未知是不投影,照原样交给适配器" + ); +}); + +// ── 开着:描述真的进了转录 ─────────────────────────────────────────────── + +test("开着:描述并进转录,占位与文件名都还在", async () => { + const r = setup( + { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, + fakeLlm({ description: "一张两列数据的表格截图" }) + ); + await distill(r, sessionOf("img-on", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + + const describeCalls = r.llm.calls.filter((c) => c.purpose === "image-description"); + assert.equal(describeCalls.length, 1, "一张图一次描述调用"); + // 描述调用里必须带的是**真 image 块**:字节由宿主按 inputModalities 决定编不编。 + const content = describeCalls[0].messages[0].content; + assert.equal(content[0].type, "image"); + assert.equal(content[0].attachment.attachmentId, "sha256:abc123", "图块原样转发,不改写"); + assert.equal(content[1].type, "text", "提示词与图块同一条消息,图在图前"); + + const distillCall = r.llm.calls.find((c) => c.purpose === "summarization"); + const transcript = transcriptOf(distillCall); + assert.ok(transcript.includes("一张两列数据的表格截图"), "描述必须真的进转录"); + assert.ok(transcript.includes("[图:"), "占位不能被描述替换掉"); + assert.ok(transcript.includes("屏幕截图 2026-10-19 072000.png"), "文件名溯源要留"); + assert.ok(transcript.includes("1543x983"), "尺寸要留"); + assert.ok(transcript.includes("描述:"), "描述行有前缀,才能与正文区分"); +}); + +test("同一张图在一次蒸馏里只描述一次(重复贴图不重复付费)", async () => { + const r = setup( + { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, + fakeLlm({ description: "同一张图" }) + ); + const dup = imageMessage(2, "再看一眼"); + await distill(r, sessionOf("img-dup", [imageMessage(1, "看下这个"), dup, { seq: 3, type: "turn/end" }])); + assert.equal( + r.llm.calls.filter((c) => c.purpose === "image-description").length, + 1, + "相同 attachmentId 只该描述一次" + ); +}); + +test("描述调用失败:蒸馏照跑,转录退回 #611 的占位形态", async () => { + const r = setup( + { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, + fakeLlm({ throwOnDescribe: true }) + ); + await distill(r, sessionOf("img-fail", [imageMessage(1, "看下这个"), { seq: 2, type: "turn/end" }])); + + const distillCall = r.llm.calls.find((c) => c.purpose === "summarization"); + assert.ok(distillCall, "描述挂了也必须继续蒸馏"); + const transcript = transcriptOf(distillCall); + assert.ok(transcript.includes("[图:"), "占位照旧"); + assert.ok(!transcript.includes("描述:"), "没有描述就别留空壳"); +}); + +// ── 单元:长度与拼接 ───────────────────────────────────────────────────── + +test("描述超过 300 字被截断,不把正文挤出窗口", async () => { + const long = "长".repeat(MAX_DESCRIPTION_CHARS + 200); + const ctx = { llm: { stream: () => (async function* () { yield { type: "text-delta", delta: long }; })() } }; + const got = await describeImages(ctx, { provider: "v", model: "v1" }, {}, [IMAGE]); + const text = [...got.values()][0]; + assert.ok(text, "应当产出描述"); + assert.ok(text.length <= MAX_DESCRIPTION_CHARS, `截断后不该超上限,实得 ${text.length}`); + assert.ok(text.endsWith("…"), "截断要留省略号,别让人以为原文就这么短"); +}); + +test("描述里的换行被压成空格(一行一条,不破坏转录结构)", async () => { + const ctx = { llm: { stream: () => (async function* () { yield { type: "text-delta", delta: "第一行\n\n第二行" }; })() } }; + const got = await describeImages(ctx, { provider: "v", model: "v1" }, {}, [IMAGE]); + const text = [...got.values()][0]; + assert.equal(text, "第一行 第二行", "换行会让转录多出假的一行「用户:…」"); +}); + +test("appendDescriptions 不动没有描述的块,且只替换第一处", () => { + // 占位文本按 attachment-text 的真实输出写(标签是「文件名 mediaType 宽x高」), + // 别自己编一个更短的形状——那会让这条用例在没有真实占位时也变绿。 + const placeholder = `[图: ${imageLabel(IMAGE)}]`; + const text = `${placeholder}\n${placeholder}`; + const one = new Map([["sha256:abc123", "描述内容"]]); + const out = appendDescriptions(text, [IMAGE, IMAGE], one); + assert.equal(out, `${placeholder}\n 描述:描述内容\n${placeholder}`); + assert.equal(appendDescriptions(text, [IMAGE], new Map()), text, "没有描述时原样返回"); +}); + +test("imageBlocks 只挑带 attachment 的 image 块", () => { + const content = [ + { type: "text", text: "hi" }, + { type: "image", attachment: IMAGE.attachment }, + { type: "image" }, + { type: "reasoning", text: "私有推理" } + ]; + assert.equal(imageBlocks(content).length, 1, "没有 attachment 的 image 块无从描述"); + assert.deepEqual(imageBlocks(null), [], "非数组返回空,不抛"); +}); // ── 审计:整档静默失效必须在 metadata 里留下痕迹 ───────────────────────── // 这一组的理由:这条链路上原先只有 imageCount(图块数),于是「开了开关但一次 @@ -336,3 +337,42 @@ test("审计:默认关着时不写任何 imageDescribe* 键(老档位逐字 const extra = Object.keys(row.metadata ?? {}).filter((k) => k.startsWith("imageDescribe")); assert.deepEqual(extra, [], `关闭态不该多出任何键,实得多出 ${extra.join(",")}`); }); + +// ── 蒸馏提示词:描述进转录还不够,得让蒸馏模型知道它是事实来源 ──────────── +// 「描述可检索」这件事真正的断点在这里:描述混进转录后,蒸馏模型并不天然知道 +// 那一行值得记,实测最容易被当排版噪音丢掉,于是图里写了什么在落库前就没了。 +// 这一组钉住三件事——有描述才加提示、提示里的前缀字面量与生成端同源、没描述时 +// 提示词逐字节不变(默认关仍与 #611 一致)。 + +function systemPromptOf(call) { + return call.messages.find((m) => m.role === "system").content[0].text; +} + +test("蒸馏提示词:产出了描述才加「描述行是事实来源」,且前缀与生成端同源", async () => { + const r = setup( + { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, + fakeLlm({ description: "一张报错截图,红字 ECONNREFUSED" }) + ); + await distill(r, sessionOf("img-hint-on", [imageMessage(1, "这个怎么修"), { seq: 2, type: "turn/end" }])); + const prompt = systemPromptOf(r.llm.calls.find((c) => c.purpose === "summarization")); + assert.ok(prompt.includes(IMAGE_PLACEHOLDER_PREFIX), "提示里引用的占位前缀必须是真的那个"); + assert.ok(prompt.includes(DESCRIPTION_PREFIX), "提示里引用的描述前缀必须是真的那个"); + assert.ok(prompt.includes("ECONNREFUSED") === false, "提示词里不该抄描述内容——那是转录的事"); +}); + +test("蒸馏提示词:描述调用失败时不加这句(不能指向不存在的行)", async () => { + const r = setup( + { imageDescribeEnabled: true, imageDescribeProvider: "vision", imageDescribeModel: "v1" }, + fakeLlm({ throwOnDescribe: true }) + ); + await distill(r, sessionOf("img-hint-fail", [imageMessage(1, "看下"), { seq: 2, type: "turn/end" }])); + const prompt = systemPromptOf(r.llm.calls.find((c) => c.purpose === "summarization")); + assert.ok(!prompt.includes(DESCRIPTION_PREFIX), "没有描述就不该告诉模型去找描述行"); +}); + +test("蒸馏提示词:默认关着时逐字节等于不带图像提示的形态", async () => { + const off = setup({ imageDescribeProvider: "vision", imageDescribeModel: "v1" }); + await distill(off, sessionOf("img-hint-off", [imageMessage(1, "看下"), { seq: 2, type: "turn/end" }])); + const prompt = systemPromptOf(off.llm.calls.find((c) => c.purpose === "summarization")); + assert.ok(!prompt.includes(DESCRIPTION_PREFIX), "关着时提示词不该多出这一段"); +});