From 659c3c6a965010d6c644f7f33563308f613d6f86 Mon Sep 17 00:00:00 2001 From: Hao19911125 <99091644+Hao19911125@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:29:08 +0800 Subject: [PATCH 1/5] Add mobile config entry for message trace --- panel.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/panel.js b/panel.js index 5ea8f37..8f9eef8 100644 --- a/panel.js +++ b/panel.js @@ -794,6 +794,32 @@ function _switchConfigSection(sectionId) { _syncConfigSectionState(); } +function _ensureMobileTraceConfigNavButton() { + if (!panelEl) return; + + const mobileNav = panelEl.querySelector(".bme-config-nav-mobile"); + if (!mobileNav) return; + if (mobileNav.querySelector('[data-config-section="trace"]')) return; + + const appearanceButton = mobileNav.querySelector( + '[data-config-section="appearance"]', + ); + const traceButton = document.createElement("button"); + traceButton.className = "bme-config-nav-btn"; + traceButton.dataset.configSection = "trace"; + traceButton.type = "button"; + traceButton.innerHTML = ` + + 消息追踪 + `; + + if (appearanceButton?.parentNode === mobileNav) { + mobileNav.insertBefore(traceButton, appearanceButton); + } else { + mobileNav.appendChild(traceButton); + } +} + function _syncConfigSectionState() { if (!panelEl) return; panelEl.querySelectorAll(".bme-config-nav-btn").forEach((btn) => { @@ -1858,6 +1884,8 @@ function _refreshConfigTab() { function _bindConfigControls() { if (!panelEl || panelEl.dataset.bmeConfigBound === "true") return; + _ensureMobileTraceConfigNavButton(); + panelEl.querySelectorAll(".bme-config-nav-btn").forEach((btn) => { if (btn.dataset.bmeBound === "true") return; btn.addEventListener("click", () => { From 03b535f5a4698e94e7e7d33d1a67d259557c5a0f Mon Sep 17 00:00:00 2001 From: Hao19911125 <99091644+Hao19911125@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:36:50 +0800 Subject: [PATCH 2/5] Add compact notice display mode --- index.js | 31 ++++ notice.js | 40 ++++- panel.html | 24 +++ panel.js | 15 ++ plans/notice-display-mode-plan.md | 267 ++++++++++++++++++++++++++++++ 5 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 plans/notice-display-mode-plan.md diff --git a/index.js b/index.js index 217d3fd..9c01258 100644 --- a/index.js +++ b/index.js @@ -496,6 +496,7 @@ const defaultSettings = { compressionEveryN: 10, // UI 面板 + noticeDisplayMode: "normal", // normal|compact panelTheme: "crimson", // 面板主题 crimson|cyan|amber|violet }; @@ -1054,6 +1055,30 @@ function syncStageNoticeAbortAction(stage) { }); } +function getStageNoticeDisplayMode(level = "info") { + const configuredMode = getSettings()?.noticeDisplayMode; + if ( + configuredMode === "compact" && + level !== "warning" && + level !== "error" + ) { + return "compact"; + } + return "normal"; +} + +function refreshVisibleStageNotices() { + for (const stage of Object.keys(stageNoticeHandles)) { + const handle = stageNoticeHandles[stage]; + if (!handle || handle.isClosed?.()) continue; + const status = getStageUiStatus(stage); + if (!status) continue; + updateStageNotice(stage, status.text, status.meta, status.level, { + title: getStageNoticeTitle(stage), + }); + } +} + function updateStageNotice( stage, text, @@ -1069,6 +1094,7 @@ function updateStageNotice( const input = { title, message, + displayMode: options.displayMode || getStageNoticeDisplayMode(noticeLevel), level: noticeLevel, busy, persist, @@ -5483,6 +5509,7 @@ function updateModuleSettings(patch = {}) { "hideOldMessagesKeepLastN", ]); const recallUiKeys = new Set(["recallCardUserInputDisplayMode"]); + const noticeUiKeys = new Set(["noticeDisplayMode"]); const settings = getSettings(); Object.assign(settings, patch); extension_settings[MODULE_NAME] = settings; @@ -5543,6 +5570,10 @@ function updateModuleSettings(patch = {}) { schedulePersistedRecallMessageUiRefresh(30); } + if (Object.keys(patch).some((key) => noticeUiKeys.has(key))) { + refreshVisibleStageNotices(); + } + scheduleServerSettingsSave(); return settings; } diff --git a/notice.js b/notice.js index 19e7e20..87c472a 100644 --- a/notice.js +++ b/notice.js @@ -60,6 +60,16 @@ function ensureStyle(doc) { -webkit-backdrop-filter: blur(10px) saturate(125%); } + .st-bme-notice[data-layout="compact"] { + display: flex; + align-items: center; + gap: 10px; + width: fit-content; + max-width: 100%; + align-self: flex-end; + padding: 10px; + } + .st-bme-notice::after { content: ""; position: absolute; @@ -87,6 +97,7 @@ function ensureStyle(doc) { background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.14); box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.16); + flex-shrink: 0; } .st-bme-notice[data-busy="true"] .st-bme-notice__icon { @@ -97,6 +108,12 @@ function ensureStyle(doc) { min-width: 0; } + .st-bme-notice[data-layout="compact"] .st-bme-notice__content { + display: flex; + align-items: center; + min-width: 0; + } + .st-bme-notice__title { margin: 0; font-size: 17px; @@ -106,6 +123,14 @@ function ensureStyle(doc) { color: #f0f6ff; } + .st-bme-notice[data-layout="compact"] .st-bme-notice__title { + font-size: 16px; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .st-bme-notice__message { margin: 4px 0 0; font-size: 14px; @@ -126,6 +151,12 @@ function ensureStyle(doc) { -webkit-mask-image: linear-gradient(90deg, transparent 0%, black 6%, black 88%, transparent 100%); } + .st-bme-notice[data-layout="compact"] .st-bme-notice__message, + .st-bme-notice[data-layout="compact"] .st-bme-notice__actions, + .st-bme-notice[data-layout="compact"] .st-bme-notice__progress { + display: none !important; + } + .st-bme-notice__actions { display: flex; gap: 8px; @@ -170,6 +201,7 @@ function ensureStyle(doc) { line-height: 1; cursor: pointer; transition: background 140ms ease; + flex-shrink: 0; } .st-bme-notice__close:hover, @@ -280,8 +312,11 @@ function getIcon(level) { function applyNoticeState(item, input, progress) { const level = input.level || "info"; + const displayMode = input.displayMode === "compact" ? "compact" : "normal"; + const isCompact = displayMode === "compact"; item.dataset.level = level; item.dataset.busy = input.busy ? "true" : "false"; + item.dataset.layout = displayMode; const icon = item.querySelector(".st-bme-notice__icon"); if (icon) { @@ -296,6 +331,7 @@ function applyNoticeState(item, input, progress) { const message = item.querySelector(".st-bme-notice__message"); if (message) { message.textContent = input.message || ""; + message.hidden = isCompact || !String(input.message || "").trim(); if (input.marquee) { message.classList.add("st-bme-notice__message--marquee"); } else { @@ -306,7 +342,7 @@ function applyNoticeState(item, input, progress) { const actionWrap = item.querySelector(".st-bme-notice__actions"); const actionButton = item.querySelector(".st-bme-notice__action"); if (actionWrap && actionButton) { - if (input.action?.label) { + if (!isCompact && input.action?.label) { actionWrap.style.display = ""; actionButton.style.display = ""; actionButton.textContent = input.action.label; @@ -319,7 +355,7 @@ function applyNoticeState(item, input, progress) { } } - if (input.persist) { + if (input.persist || isCompact) { progress.style.display = "none"; progress.style.animationDuration = ""; } else { diff --git a/panel.html b/panel.html index c43dc56..d1f0143 100644 --- a/panel.html +++ b/panel.html @@ -1055,6 +1055,30 @@ +
+
+
+
提示信息
+
+ 控制提取、召回等顶部通知的显示样式。 +
+
+
+
+ + +
+
+ 精简模式会将工作中的提示压缩为标题卡片;错误和警告仍显示完整内容,避免关键信息被隐藏。 +
+
+
diff --git a/panel.js b/panel.js index 45fb306..7cf9a60 100644 --- a/panel.js +++ b/panel.js @@ -1711,6 +1711,10 @@ function _refreshConfigTab() { "bme-setting-recall-card-user-input-display-mode", settings.recallCardUserInputDisplayMode ?? "beautify_only", ); + _setInputValue( + "bme-setting-notice-display-mode", + settings.noticeDisplayMode ?? "normal", + ); _setInputValue("bme-setting-extract-every", settings.extractEvery ?? 1); _setInputValue( @@ -2053,6 +2057,17 @@ function _bindConfigControls() { }); recallCardUserInputDisplayModeEl.dataset.bmeBound = "true"; } + const noticeDisplayModeEl = document.getElementById( + "bme-setting-notice-display-mode", + ); + if (noticeDisplayModeEl && noticeDisplayModeEl.dataset.bmeBound !== "true") { + noticeDisplayModeEl.addEventListener("change", () => { + _patchSettings({ + noticeDisplayMode: noticeDisplayModeEl.value || "normal", + }); + }); + noticeDisplayModeEl.dataset.bmeBound = "true"; + } bindNumber("bme-setting-extract-every", 1, 1, 50, (value) => _patchSettings({ extractEvery: value }), diff --git a/plans/notice-display-mode-plan.md b/plans/notice-display-mode-plan.md new file mode 100644 index 0000000..bb51429 --- /dev/null +++ b/plans/notice-display-mode-plan.md @@ -0,0 +1,267 @@ +# ST-BME 提示信息“正常 / 精简”模式计划 + +## 目标 + +在“配置” -> “功能开关”里新增一个“提示信息”设置项,让用户选择通知展示模式: + +- `正常`:保持当前行为不变 +- `精简`:通知仅显示标题,例如 `ST-BME 提取`、`ST-BME 召回` + +默认值为 `正常`。该设置需要在 PC 端和手机端的设置入口都能看到并生效。 + +## 现状梳理 + +### 1. 提示信息的实际渲染位置 + +- `notice.js` + - 负责创建和更新通知 DOM + - 当前结构固定包含:图标、标题、正文、动作按钮区域、关闭按钮、进度条 + - 移动端只缩小了宿主宽度,没有缩小卡片内容结构 + +### 2. 通知内容从哪里来 + +- `index.js` + - `updateStageNotice(...)` 会把 `text + meta` 拼成 `message` + - `showManagedBmeNotice(...)` 统一负责显示提取 / 向量 / 召回 / 历史恢复通知 +- `ui-status.js` + - `getStageNoticeTitle(...)` 负责输出 `ST-BME 提取`、`ST-BME 召回` 等标题 + +### 3. 设置从哪里定义和持久化 + +- `index.js` + - `defaultSettings` 是设置默认值来源 + - `mergePersistedSettings(...)` / `getPersistedSettingsSnapshot(...)` 会自动把新增设置纳入持久化 +- `panel.js` + - `_refreshConfigTab()` 负责把设置值回填到 UI + - `_bindConfigControls()` 负责把 UI 改动写回设置 +- `panel.html` + - “功能开关” section 是实际设置表单 + +### 4. PC / 手机端设置入口的关系 + +- `panel.html` 里桌面端和手机端各有一套“配置导航按钮” +- 但它们切换的是同一套 `bme-config-section` +- 也就是说: + - 真正的设置项表单只需要在“功能开关” section 加一次 + - 该项天然会同时出现在 PC 和手机端 + +## 关键发现 + +仅仅把正文文本隐藏,还不足以让精简通知“变窄”。 + +原因是 `notice.js` 里的通知宿主 `#st-bme-notice-host` 使用纵向 flex 布局,而子项默认会被拉伸到宿主宽度。现在宿主在移动端宽度是 `calc(100vw - 16px)`,所以即使正文为空,卡片依然可能占满整行。 + +结论: + +- 精简模式不仅要隐藏正文和动作区 +- 还必须给通知卡片增加“按内容宽度收缩”的样式 + +这是这个需求里最容易漏掉的点。 + +## 推荐设计 + +### 设置字段 + +建议新增设置字段: + +- key: `noticeDisplayMode` +- 可选值: `"normal"` / `"compact"` +- 默认值: `"normal"` + +命名理由: + +- 语义直接,对应“提示信息显示模式” +- 以后如果要扩展为更多通知布局,也容易继续演进 + +### 设置 UI + +建议放在“配置” -> “功能开关”里,作为一个 `select`,而不是复选框。 + +建议文案: + +- 标题:`提示信息` +- 说明:`控制提取 / 召回等顶部通知的显示样式` +- 选项: + - `normal` -> `正常` + - `compact` -> `精简(仅显示标题)` + +### 精简模式的推荐行为 + +推荐在 `精简` 模式下: + +- 保留:标题 +- 保留:左侧状态图标 +- 保留:关闭按钮 +- 隐藏:正文文本 +- 隐藏:meta 信息 +- 隐藏:动作按钮区,例如“终止提取”“终止召回” +- 隐藏:底部进度条 + +这样可以最大化缩小高度,同时保留最基本的状态识别和手动关闭能力。 + +## 一个需要先确认的设计点 + +`warning` / `error` 通知是否也要强制进入“仅标题”模式? + +我的推荐方案: + +- `running` / `info` / `success`:遵循 `精简`,只显示标题 +- `warning` / `error`:仍显示正文,避免重要报错信息被吃掉 + +理由: + +- 用户截图里最占空间的是“工作中”的持续通知 +- 真正需要完整文本的,通常是失败原因或警告信息 + +如果你更想要“精简就是所有通知一律只显示标题”,也能做,但我认为可用性会更差一些。 + +## 预期改动范围 + +### 1. `index.js` + +- 在 `defaultSettings` 增加 `noticeDisplayMode: "normal"` +- 在通知更新逻辑里读取该设置 +- 显式采用“Option A”: + - `index.js` 在 `updateStageNotice(...)` 里把 `displayMode` 放进传给 `showManagedBmeNotice(...)` 的 `input` 对象 + - `notice.js` 只读取 `input.displayMode` 控制渲染和样式,不直接读取全局设置 + - 保持 `notice.js` 为纯渲染模块,不和 settings 持久化层耦合 +- 若采用“错误/警告保留正文”的推荐方案,则在这里按 level 做分流 +- 当用户切换 `noticeDisplayMode` 时,补一条“刷新当前可见通知”的处理 + - 目标:如果用户在通知仍可见时切换模式,现有通知也能立即切换,而不是等下次状态更新 + - 实现可放在设置更新后,对当前 stage notice 进行一次统一 `update` + +### 2. `notice.js` + +- 扩展通知输入参数,支持 `displayMode` 或 `compact` +- 渲染时根据模式: + - 正常模式:保持现状 + - 精简模式:不渲染正文和动作区,或渲染后隐藏 +- 增加精简态 class / data attribute,例如: + - `data-layout="compact"` +- 新增 compact 样式: + - 卡片宽度按内容收缩 + - 卡片高度缩成单行或接近单行 + - 维持移动端可点击性和可读性 + - 明确让 compact 通知右对齐 + - 首选:给 compact 卡片本身加 `align-self: flex-end` + - 备选:给 host 在 compact 态下加 `align-items: flex-end` + - compact 布局可直接切成更自然的横向紧凑结构 + - 例如从当前 grid 调整为单行 `flex` + - 避免 `close` 按钮在极窄宽度下被 grid 挤得别扭 + - compact 模式显式隐藏进度条,避免小卡片底部出现细长条影响观感 + +### 3. `panel.html` + +- 在“功能开关” section 里新增“提示信息”配置卡或配置行 +- 位置建议放在较靠前区域,因为它属于明显的 UI 行为开关 + +### 4. `panel.js` + +- `_refreshConfigTab()` 里回填 `noticeDisplayMode` +- `_bindConfigControls()` 里监听该 `select` 的变更并调用 `_patchSettings(...)` + +## 样式实现思路 + +推荐采用“精简卡片单独收缩”的方式,而不是修改整个通知宿主。 + +建议方向: + +- 正常卡片:继续占用现有宽度逻辑 +- 精简卡片: + - `align-self: flex-end` + - `width: fit-content` + - `max-width: 100%` + - 更紧凑的 padding / gap + - 必要时直接切成单行 `flex` 布局,而不是沿用三列 grid + +这样可以避免: + +- 一条精简通知把整个通知堆栈的布局都改坏 +- 正常模式和精简模式互相影响 + +补充说明: + +- 这里真正需要注意的是“host 是全宽,而 compact 卡片要在 host 内收缩并靠右” +- 因此不能只写 `width: fit-content` +- 还必须同时处理横向对齐 +- 纠正一下术语:这里如果要改 host,应该是 `align-items`,不是 `align-self` + +## 风险与兼容性 + +### 风险 1:终止按钮被隐藏后,工作中通知无法直接中断 + +这是“只显示标题”带来的天然代价。 + +处理方式建议: + +- 先按用户描述执行精简:隐藏动作按钮 +- 在计划评审阶段明确接受这个取舍 + +### 风险 2:只隐藏正文但不改宽度,视觉上仍然太大 + +这是本需求最核心的实现风险。必须在样式上单独处理 compact 卡片宽度。 + +### 风险 3:切换设置后,当前可见通知不立刻刷新 + +如果只让后续新通知读取新模式,那么用户在设置页切换“正常 / 精简”时,已显示的通知会出现“要等下一次状态变更才切换”的延迟感。 + +处理方式建议: + +- 把它当作一个小型 UI 刷新点纳入实现 +- 设置更新时,对当前仍存在的 stage notice 重新执行一次 `update` + +### 风险 4:旧配置兼容 + +风险较低。因为默认设置合并机制已经存在,只要新增默认值即可让旧配置自动回退到 `normal`。 + +## 建议验证点 + +### 功能验证 + +- 默认安装或旧配置升级后,提示信息模式应为 `正常` +- 在 PC 端设置面板中能看到“提示信息”选项 +- 在手机端设置面板中也能看到同一个选项 +- 切到 `精简` 后,提取 / 召回通知只显示标题 +- 切回 `正常` 后,恢复当前完整通知样式 +- 通知可见时切换设置,已显示的通知也会同步切换样式 + +### 视觉验证 + +- 手机上精简通知高度明显缩小 +- 手机上精简通知宽度不再铺满整行 +- 手机上精简通知应靠右显示,而不是缩小后仍贴左 +- 多条通知叠加时,布局仍然稳定 +- PC 端正常模式无回归 + +### 行为验证 + +- 运行中通知仍能正常更新标题 +- 自动关闭逻辑不受影响 +- 关闭按钮不受影响 +- compact 模式下进度条已按预期隐藏 +- 若保留“错误/警告显示正文”策略,则需单独验证错误提示仍完整可见 + +## 实施顺序 + +1. 增加设置字段与默认值 +2. 在功能开关 UI 中加入“提示信息”选择器 +3. 将选择器和设置读写绑定起来 +4. 给通知渲染层增加 compact 模式 +5. 调整 compact 样式,确保真正按内容收缩 +6. 验证 PC / 手机端、正常 / 精简、运行 / 完成 / 异常几类通知 + +## 我目前的结论 + +这个需求本身不复杂,真正要小心的是两件事: + +- “PC 和手机端都要有设置”其实主要是配置入口问题,不是双份业务实现 +- “精简后通知真的变小”不能只靠删文本,必须同时改通知卡片的宽度收缩策略 + +这次复审后,我会把实现重点再收敛成四个明确点: + +- `displayMode` 通过 `updateStageNotice(...) -> input.displayMode -> notice.js` 这条链路传递 +- compact 不仅隐藏正文,也隐藏进度条 +- compact 需要明确右对齐,不能只是 `fit-content` +- 用户切换设置时,当前可见通知也应立即刷新 + +如果后续开始实现,我会优先按这个计划走,并把“错误/警告是否保留正文”作为唯一需要最终拍板的交互点。 From ca3fc8fc2f136d9964cf49d4c8b80574ab08f6c3 Mon Sep 17 00:00:00 2001 From: Hao19911125 <99091644+Hao19911125@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:24:01 +0800 Subject: [PATCH 3/5] Harden legacy extraction and recall JSON parsing --- extractor.js | 135 ++++++++++++++++++++++++++++++++++++- retriever.js | 55 +++++++++++++-- tests/p0-regressions.mjs | 57 ++++++++++++++++ tests/retrieval-config.mjs | 48 +++++++++++++ 4 files changed, 287 insertions(+), 8 deletions(-) diff --git a/extractor.js b/extractor.js index 3a4f517..e1e064e 100644 --- a/extractor.js +++ b/extractor.js @@ -67,6 +67,136 @@ function throwIfAborted(signal) { } } +function resolveExtractionOperations(result, schema = []) { + const candidates = [ + { source: "operations", value: result?.operations }, + { source: "nodes", value: result?.nodes }, + { source: "memories", value: result?.memories }, + { source: "root", value: result }, + ]; + + for (const candidate of candidates) { + if (!Array.isArray(candidate.value)) { + continue; + } + + const normalized = normalizeExtractionOperations(candidate.value, schema); + if (normalized?.legacyCount > 0) { + console.info("[ST-BME] 兼容旧版扁平提取输出", { + source: candidate.source, + normalizedCount: normalized.legacyCount, + totalCount: normalized.operations.length, + }); + } + return normalized.operations; + } + + return null; +} + +function normalizeExtractionOperations(operations, schema = []) { + if (!Array.isArray(operations)) { + return null; + } + + let legacyCount = 0; + const normalizedOperations = operations.map((operation) => { + const normalized = normalizeExtractionOperation(operation, schema); + if (normalized?.__legacyCompat) { + legacyCount += 1; + delete normalized.__legacyCompat; + } + return normalized; + }); + + return { + operations: normalizedOperations, + legacyCount, + }; +} + +function normalizeExtractionOperation(operation, schema = []) { + if (!operation || typeof operation !== "object") { + return operation; + } + + const normalized = { ...operation }; + const normalizedAction = normalizeOperationAction(normalized); + if (normalizedAction) { + normalized.action = normalizedAction; + } + + const typeDef = schema.find((entry) => entry?.id === normalized.type); + const normalizedFields = extractOperationFields(normalized, typeDef); + if ( + normalized.action === "create" || + normalized.action === "update" || + (!normalized.action && Object.keys(normalizedFields).length > 0) + ) { + normalized.fields = normalizedFields; + } + + if ( + !normalized.action && + typeDef && + !normalized.nodeId && + Object.keys(normalizedFields).length > 0 + ) { + normalized.action = "create"; + normalized.__legacyCompat = true; + } + + if ( + (normalized.action === "update" || normalized.action === "delete") && + !normalized.nodeId && + typeof normalized.id === "string" && + normalized.id.trim() + ) { + normalized.nodeId = normalized.id.trim(); + } + + if ( + normalized.action === "create" && + !normalized.ref && + typeof normalized.id === "string" && + normalized.id.trim() + ) { + normalized.ref = normalized.id.trim(); + } + + return normalized; +} + +function normalizeOperationAction(operation = {}) { + const candidate = operation.action ?? operation.op ?? operation.operation; + return typeof candidate === "string" && candidate.trim() + ? candidate.trim() + : ""; +} + +function extractOperationFields(operation = {}, typeDef = null) { + const fields = { + ...(operation.fields && typeof operation.fields === "object" + ? operation.fields + : {}), + }; + + const columnNames = Array.isArray(typeDef?.columns) + ? typeDef.columns + .map((column) => String(column?.name || "").trim()) + .filter(Boolean) + : []; + + for (const fieldName of columnNames) { + if (fields[fieldName] !== undefined || operation[fieldName] === undefined) { + continue; + } + fields[fieldName] = operation[fieldName]; + } + + return fields; +} + /** * 对未处理的对话楼层执行记忆提取 * @@ -203,7 +333,8 @@ export async function extractMemories({ }); throwIfAborted(signal); - if (!result || !Array.isArray(result.operations)) { + const operations = resolveExtractionOperations(result, schema); + if (!result || !Array.isArray(operations)) { console.warn("[ST-BME] 提取 LLM 未返回有效操作"); return { success: false, @@ -222,7 +353,7 @@ export async function extractMemories({ const refMap = new Map(); const operationErrors = []; - for (const op of result.operations) { + for (const op of operations) { try { switch (op.action) { case "create": { diff --git a/retriever.js b/retriever.js index 7290c57..471f21e 100644 --- a/retriever.js +++ b/retriever.js @@ -97,6 +97,44 @@ function buildRecallFallbackReason(llmResult) { } } +function resolveRecallSelectedIds(result) { + if (Array.isArray(result)) { + return result; + } + + const visited = new Set(); + const queue = [{ value: result, depth: 0 }]; + while (queue.length > 0) { + const current = queue.shift(); + const value = current?.value; + const depth = Number(current?.depth) || 0; + if (!value || typeof value !== "object" || visited.has(value) || depth > 1) { + continue; + } + visited.add(value); + + const directCandidates = [ + value.selected_ids, + value.selectedIds, + value.node_ids, + value.nodeIds, + value.ids, + ]; + for (const candidate of directCandidates) { + if (Array.isArray(candidate)) { + return candidate; + } + } + + queue.push({ value: value.data, depth: depth + 1 }); + queue.push({ value: value.result, depth: depth + 1 }); + queue.push({ value: value.payload, depth: depth + 1 }); + queue.push({ value: value.output, depth: depth + 1 }); + } + + return null; +} + function isAbortError(error) { return error?.name === "AbortError"; } @@ -1515,21 +1553,22 @@ async function llmRecall( returnFailureDetails: true, }); const result = llmResult?.ok ? llmResult.data : null; + const selectedIds = resolveRecallSelectedIds(result); - if (result?.selected_ids && Array.isArray(result.selected_ids)) { + if (Array.isArray(selectedIds)) { // 校验 ID 有效性 const validIds = uniqueNodeIds( - result.selected_ids.filter((id) => + selectedIds.filter((id) => candidates.some((c) => c.nodeId === id), ), ).slice(0, maxNodes); - if (validIds.length > 0 || result.selected_ids.length === 0) { + if (validIds.length > 0 || selectedIds.length === 0) { return { selectedNodeIds: validIds, status: "llm", reason: - validIds.length < result.selected_ids.length + validIds.length < selectedIds.length ? "LLM 返回了部分无效或超限 ID,已自动裁剪" : "LLM 精排完成", }; @@ -1538,7 +1577,7 @@ async function llmRecall( // LLM 失败时回退到纯评分排序 const fallbackReason = llmResult?.ok - ? Array.isArray(result?.selected_ids) + ? Array.isArray(selectedIds) ? "LLM 返回的候选 ID 无效,已回退到评分排序" : "LLM 返回了无法识别的 JSON 结构,已回退到评分排序" : buildRecallFallbackReason(llmResult); @@ -1546,7 +1585,11 @@ async function llmRecall( selectedNodeIds: candidates.slice(0, maxNodes).map((c) => c.nodeId), status: "fallback", reason: fallbackReason, - fallbackType: llmResult?.ok ? "invalid-candidate" : llmResult?.errorType || "unknown", + fallbackType: llmResult?.ok + ? Array.isArray(selectedIds) + ? "invalid-candidate" + : "invalid-structure" + : llmResult?.errorType || "unknown", }; } diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 6349604..28e3902 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -2105,6 +2105,62 @@ async function testExtractorFailsOnUnknownOperation() { } } +async function testExtractorSupportsLegacyFlatNodeOperations() { + const graph = createEmptyGraph(); + const restoreOverrides = pushTestOverrides({ + llm: { + async callLLMForJSON() { + return { + operations: [ + { + type: "event", + id: "evt-legacy", + title: "夜间喂食", + summary: "角色完成了一次深夜喂食。", + participants: "悟岳, 访客", + status: "resolved", + importance: 6, + }, + { + type: "character", + id: "char-legacy", + name: "悟岳", + state: "放松下来", + }, + ], + }; + }, + }, + }); + + try { + const result = await extractMemories({ + graph, + messages: [{ seq: 7, role: "assistant", content: "测试旧版扁平提取输出" }], + startSeq: 7, + endSeq: 7, + schema, + embeddingConfig: null, + settings: {}, + }); + + assert.equal(result.success, true); + assert.equal(result.newNodes, 2); + assert.equal(graph.lastProcessedSeq, 7); + + const eventNode = graph.nodes.find((node) => node.type === "event"); + const characterNode = graph.nodes.find((node) => node.type === "character"); + assert.ok(eventNode); + assert.ok(characterNode); + assert.equal(eventNode.fields?.title, "夜间喂食"); + assert.equal(eventNode.fields?.summary, "角色完成了一次深夜喂食。"); + assert.equal(characterNode.fields?.name, "悟岳"); + assert.equal(characterNode.fields?.state, "放松下来"); + } finally { + restoreOverrides(); + } +} + async function testConsolidatorMergeUpdatesSeqRange() { const graph = createEmptyGraph(); const target = createNode({ @@ -5023,6 +5079,7 @@ async function testLlmOutputRegexCleansResponseBeforeJsonParse() { await testCompressorMigratesEdgesToCompressedNode(); await testVectorIndexKeepsDirtyOnDirectPartialEmbeddingFailure(); await testExtractorFailsOnUnknownOperation(); +await testExtractorSupportsLegacyFlatNodeOperations(); await testConsolidatorMergeUpdatesSeqRange(); await testConsolidatorMergeFallbackKeepsNodeWhenTargetMissing(); await testBatchJournalVectorDeltaCapturesRecoveryFields(); diff --git a/tests/retrieval-config.mjs b/tests/retrieval-config.mjs index ec36163..6a0f9b5 100644 --- a/tests/retrieval-config.mjs +++ b/tests/retrieval-config.mjs @@ -329,6 +329,54 @@ assert.equal(state.llmOptions[0].returnFailureDetails, true); assert.equal(state.llmOptions[0].maxRetries, 2); assert.equal(state.llmOptions[0].maxCompletionTokens, 512); +state.vectorCalls.length = 0; +state.diffusionCalls.length = 0; +state.llmCalls.length = 0; +state.llmOptions.length = 0; +state.llmCandidateCount = 0; +state.llmResponse = { selectedIds: ["rule-1"] }; +const llmCamelCaseResult = await retrieve({ + graph, + userMessage: "换个 JSON 键名也应该兼容", + recentMessages: [], + embeddingConfig: {}, + schema, + options: { + topK: 4, + maxRecallNodes: 2, + enableVectorPrefilter: true, + enableGraphDiffusion: false, + enableLLMRecall: true, + llmCandidatePool: 2, + }, +}); +assert.deepEqual(Array.from(llmCamelCaseResult.selectedNodeIds), ["rule-1"]); +assert.equal(llmCamelCaseResult.meta.retrieval.llm.status, "llm"); + +state.vectorCalls.length = 0; +state.diffusionCalls.length = 0; +state.llmCalls.length = 0; +state.llmOptions.length = 0; +state.llmCandidateCount = 0; +state.llmResponse = { data: { selected_ids: ["rule-2"] } }; +const llmNestedResult = await retrieve({ + graph, + userMessage: "嵌套 JSON 结构也应该兼容", + recentMessages: [], + embeddingConfig: {}, + schema, + options: { + topK: 4, + maxRecallNodes: 2, + enableVectorPrefilter: true, + enableGraphDiffusion: false, + enableLLMRecall: true, + llmCandidatePool: 2, + }, +}); +assert.deepEqual(Array.from(llmNestedResult.selectedNodeIds), ["rule-2"]); +assert.equal(llmNestedResult.meta.retrieval.llm.status, "llm"); + state.vectorCalls.length = 0; state.diffusionCalls.length = 0; state.llmCalls.length = 0; From 6a773265ff45c65eab0c7e33e3f3a74abac72987 Mon Sep 17 00:00:00 2001 From: Hao19911125 <99091644+Hao19911125@users.noreply.github.com> Date: Sun, 5 Apr 2026 16:26:43 +0800 Subject: [PATCH 4/5] Fix: passive MVU sanitize mode for task input fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户原文字段(recentMessages/charDescription/userPersona/candidateNodes 等) 现在使用 passive mode 清洗——只剥离 MVU 容器/宏,不整段 drop。 这修复了含 MVU 状态栏角色卡时提取 LLM 收到空 context 的问题。 - mvu-compat.js: 导出 MVU_SANITIZE_MODES 常量,passive 分支显式注释 - prompt-builder.js: 加 INPUT_CONTEXT_FIELD_MODE 策略表, sanitizePromptContextInputs 按字段族查表传 mode; 关键字段 omit 时 warn(兜底告警) - 世界书条目路径(sanitizeWorldInfoEntries)保持 aggressive,守卫 6cec031 正收益 - 新增 6 条测试:passive 字段族不被整段 drop + worldInfo 仍 aggressive + warn 路径 Refs: mvu-aggressive-strip-regression-plan.md Co-Authored-By: Claude Sonnet 4.6 --- mvu-compat.js | 11 +- prompt-builder.js | 47 +++++- tests/prompt-builder-mvu.mjs | 288 +++++++++++++++++++++++++++++++++++ 3 files changed, 343 insertions(+), 3 deletions(-) diff --git a/mvu-compat.js b/mvu-compat.js index f070376..c09e9ad 100644 --- a/mvu-compat.js +++ b/mvu-compat.js @@ -2,6 +2,13 @@ // These rules are intentionally narrow so we strip MVU artifacts without // disturbing normal prompt or world info content. +export const MVU_SANITIZE_MODES = Object.freeze({ + /** 整段 drop likely MVU 内容(用于世界书条目)。 */ + AGGRESSIVE: "aggressive", + /** 只剥离 MVU 容器/宏,不整段 drop(用于用户原文、角色描述等任务输入字段)。 */ + PASSIVE: "passive", +}); + export const MVU_ENTRY_COMMENT_REGEX = /\[(mvu_update|mvu_plot|initvar)\]/i; const MVU_UPDATE_BLOCK_REGEX = @@ -215,7 +222,8 @@ export function sanitizeMvuContent( let text = blockedResult.text; let dropped = false; - if (sanitizedMode === "aggressive") { + if (sanitizedMode === MVU_SANITIZE_MODES.AGGRESSIVE) { + // 整段 drop:用于世界书条目,不用于用户原文字段 if ( isLikelyMvuWorldInfoContent(originalCollapsed) || isLikelyMvuWorldInfoContent(text) @@ -225,6 +233,7 @@ export function sanitizeMvuContent( reasons.push("likely_mvu_content"); } } + // MVU_SANITIZE_MODES.PASSIVE:只做 artifact 剥离 + blocked 过滤,不整段 drop。 return { text: collapseWhitespace(text), diff --git a/prompt-builder.js b/prompt-builder.js index c58c3d5..004a88e 100644 --- a/prompt-builder.js +++ b/prompt-builder.js @@ -2,7 +2,7 @@ // 统一负责任务预设块排序、变量渲染,以及世界书/EJS 上下文接入。 import { getActiveTaskProfile, getLegacyPromptForTask } from "./prompt-profiles.js"; -import { sanitizeMvuContent } from "./mvu-compat.js"; +import { sanitizeMvuContent, MVU_SANITIZE_MODES } from "./mvu-compat.js"; import { resolveTaskWorldInfo } from "./task-worldinfo.js"; import { applyTaskRegex } from "./task-regex.js"; @@ -32,6 +32,41 @@ const INPUT_CONTEXT_MVU_FIELDS = [ "userPersona", ]; +/** + * 字段族 → sanitize mode 映射表。 + * + * PASSIVE:用户原文字段(对话、角色描述、摘要、候选节点等)——只剥离 MVU 容器/宏, + * 不整段 drop。这些字段不可能"整段就是一条 MVU 世界书条目"。 + * AGGRESSIVE(默认):保留现有行为,用于世界书条目路径(sanitizeWorldInfoEntries)。 + * + * 未列入此表的字段走 AGGRESSIVE,与改动前行为一致。 + */ +const INPUT_CONTEXT_FIELD_MODE = { + userMessage: MVU_SANITIZE_MODES.PASSIVE, + recentMessages: MVU_SANITIZE_MODES.PASSIVE, + chatMessages: MVU_SANITIZE_MODES.PASSIVE, + dialogueText: MVU_SANITIZE_MODES.PASSIVE, + charDescription: MVU_SANITIZE_MODES.PASSIVE, + userPersona: MVU_SANITIZE_MODES.PASSIVE, + candidateText: MVU_SANITIZE_MODES.PASSIVE, + candidateNodes: MVU_SANITIZE_MODES.PASSIVE, + nodeContent: MVU_SANITIZE_MODES.PASSIVE, + eventSummary: MVU_SANITIZE_MODES.PASSIVE, + characterSummary: MVU_SANITIZE_MODES.PASSIVE, + threadSummary: MVU_SANITIZE_MODES.PASSIVE, + contradictionSummary: MVU_SANITIZE_MODES.PASSIVE, +}; + +/** 这些字段被清空时必须 warn(兜底告警)。 */ +const CRITICAL_INPUT_FIELDS = new Set([ + "recentMessages", + "dialogueText", + "chatMessages", + "charDescription", + "userPersona", + "candidateNodes", +]); + const INPUT_REGEX_STAGE_BY_FIELD = { userMessage: "input.userMessage", recentMessages: "input.recentMessages", @@ -609,6 +644,7 @@ function sanitizePromptContextInputs( const value = sanitizedContext[fieldName]; const regexStage = INPUT_REGEX_STAGE_BY_FIELD[fieldName] || ""; const regexRole = INPUT_REGEX_ROLE_BY_FIELD[fieldName] || "system"; + const fieldMode = INPUT_CONTEXT_FIELD_MODE[fieldName] || MVU_SANITIZE_MODES.AGGRESSIVE; const sanitized = sanitizeStructuredPromptValue( settings, taskType, @@ -616,7 +652,7 @@ function sanitizePromptContextInputs( { fieldName, path: fieldName, - mode: "aggressive", + mode: fieldMode, regexStage, role: regexRole, debugState, @@ -625,6 +661,13 @@ function sanitizePromptContextInputs( stripMvuContainers, }, ); + if (sanitized.omit && CRITICAL_INPUT_FIELDS.has(fieldName)) { + const rawLength = typeof value === "string" ? value.length : -1; + console.warn( + "[ST-BME] 关键任务输入字段被 MVU 策略清空", + { taskType, fieldName, mode: fieldMode, rawLength }, + ); + } sanitizedContext[fieldName] = sanitized.omit ? Array.isArray(value) ? [] diff --git a/tests/prompt-builder-mvu.mjs b/tests/prompt-builder-mvu.mjs index 40c4063..30ba0d5 100644 --- a/tests/prompt-builder-mvu.mjs +++ b/tests/prompt-builder-mvu.mjs @@ -516,6 +516,294 @@ try { promptBuild.debug.mvu.sanitizedFieldCount, ); + // ── 新增测试:passive mode 字段族不被整段 drop ─────────────────────────────── + + // helpers + function buildExtractSettings() { + const taskProfiles = createDefaultTaskProfiles(); + return { + llmApiUrl: "https://example.com/v1", + llmApiKey: "sk-test", + llmModel: "gpt-test", + timeoutMs: 4321, + taskProfilesVersion: 3, + taskProfiles, + }; + } + + function buildExtractBlock(id, name, sourceKey, order) { + return { + id, + name, + type: "builtin", + enabled: true, + role: "system", + sourceKey, + sourceField: "", + content: "", + injectionMode: "relative", + order, + }; + } + + function buildMinimalExtractSettings() { + const base = buildExtractSettings(); + base.taskProfiles.extract = { + activeProfileId: "extract-passive-test", + profiles: [ + { + id: "extract-passive-test", + name: "passive test", + taskType: "extract", + builtin: false, + version: 3, + enabled: true, + blocks: [ + buildExtractBlock("blk-char", "charDescription", "charDescription", 0), + buildExtractBlock("blk-persona", "userPersona", "userPersona", 1), + buildExtractBlock("blk-recent", "recentMessages", "recentMessages", 2), + buildExtractBlock("blk-candidate", "candidateText", "candidateText", 3), + ], + generation: createDefaultTaskProfiles().extract.profiles[0].generation, + regex: { enabled: false, inheritStRegex: false, stages: {}, localRules: [] }, + }, + ], + }; + return base; + } + + // 测试 1:recentMessages 含多次 getvar 宏 — 不被整段 drop,宏被剥离 + { + delete globalThis.__stBmeRuntimeDebugState; + const s = buildMinimalExtractSettings(); + const pb = await buildTaskPrompt(s, "extract", { + recentMessages: "#0 [assistant]: {{get_message_variable::stat_data.hp}} 今晚的气氛很好。{{get_message_variable::display_data.mood}}", + charDescription: "普通角色描述,不含 MVU。", + userPersona: "普通用户设定。", + candidateText: "", + }); + const rendered = JSON.stringify(pb.executionMessages); + assert.match(rendered, /今晚的气氛很好/, + "T1: recentMessages 的叙述文本必须保留"); + assert.doesNotMatch(rendered, /get_message_variable/i, + "T1: getvar 宏必须被剥离"); + const droppedField = pb.debug.mvu.sanitizedFields.find( + (e) => e.name === "recentMessages" && e.dropped, + ); + assert.equal(droppedField, undefined, + "T1: recentMessages 不应被整段 drop(passive mode)"); + } + + // 测试 2:recentMessages 叙述里提到 stat_data 字样 — 不被整段 drop + { + delete globalThis.__stBmeRuntimeDebugState; + const s = buildMinimalExtractSettings(); + const pb = await buildTaskPrompt(s, "extract", { + recentMessages: "#0 [assistant]: 墙上的 stat_data 标签被撕掉了,角色叹了口气。", + charDescription: "", + userPersona: "", + candidateText: "", + }); + const rendered = JSON.stringify(pb.executionMessages); + assert.match(rendered, /墙上的/, + "T2: recentMessages 叙述文本必须保留"); + const droppedField = pb.debug.mvu.sanitizedFields.find( + (e) => e.name === "recentMessages" && e.dropped, + ); + assert.equal(droppedField, undefined, + "T2: recentMessages 不应被整段 drop"); + } + + // 测试 3:charDescription 含 MVU 宏 — 不被整段 drop,宏被剥离 + { + delete globalThis.__stBmeRuntimeDebugState; + const s = buildMinimalExtractSettings(); + const pb = await buildTaskPrompt(s, "extract", { + recentMessages: "普通对话。", + charDescription: "角色叫 Alice。 她性格温柔。", + userPersona: "", + candidateText: "", + }); + const rendered = JSON.stringify(pb.executionMessages); + assert.match(rendered, /她性格温柔/, + "T3: charDescription 叙述文本必须保留"); + assert.doesNotMatch(rendered, /StatusPlaceHolderImpl/i, + "T3: 占位符必须被剥离"); + const droppedField = pb.debug.mvu.sanitizedFields.find( + (e) => e.name === "charDescription" && e.dropped, + ); + assert.equal(droppedField, undefined, + "T3: charDescription 不应被整段 drop"); + } + + // 测试 4:userPersona 是 MVU 规则内容 — 不被整段 drop + { + delete globalThis.__stBmeRuntimeDebugState; + const s = buildMinimalExtractSettings(); + const pb = await buildTaskPrompt(s, "extract", { + recentMessages: "普通对话。", + charDescription: "", + userPersona: "变量更新规则:\ntype: state\n当前时间: 12:00", + candidateText: "", + }); + const rendered = JSON.stringify(pb.executionMessages); + assert.match(rendered, /变量更新规则/, + "T4: userPersona 文本必须保留"); + const droppedField = pb.debug.mvu.sanitizedFields.find( + (e) => e.name === "userPersona" && e.dropped, + ); + assert.equal(droppedField, undefined, + "T4: userPersona 不应被整段 drop(passive mode)"); + } + + // 测试 5:candidateNodes 含 stat_data/getvar — 字符串叶子保留,容器键剥离 + { + delete globalThis.__stBmeRuntimeDebugState; + const s = buildMinimalExtractSettings(); + s.taskProfiles.extract.profiles[0].blocks.push( + buildExtractBlock("blk-nodes", "candidateNodes", "candidateNodes", 4), + ); + const pb = await buildTaskPrompt(s, "extract", { + recentMessages: "", + charDescription: "", + userPersona: "", + candidateText: "", + candidateNodes: [ + { + id: "node-a", + summary: "这是一个有意义的候选摘要,说明了角色的决定。", + note: "{{get_message_variable::stat_data.地点}} 某地区的行动。", + variables: { + 0: { + stat_data: { 地点: "学校" }, + display_data: { 地点: "教室" }, + }, + }, + }, + ], + }); + const rendered = JSON.stringify(pb.executionMessages); + assert.match(rendered, /有意义的候选摘要/, + "T5: candidateNodes 的 summary 文本必须保留"); + assert.doesNotMatch(rendered, /get_message_variable/i, + "T5: getvar 宏必须被剥离"); + const containerDropped = pb.debug.mvu.sanitizedFields.find( + (e) => String(e.name || "").startsWith("candidateNodes[0].variables"), + ); + assert.ok(containerDropped, + "T5: stat_data/display_data 容器键必须仍被剥离"); + } + + // 测试 6:world info 仍然 aggressive drop(守卫 6cec031 正收益) + { + delete globalThis.__stBmeRuntimeDebugState; + const mvuWorldbookEntry = [ + createWorldbookEntry({ + uid: 999, + name: "mvu-statusbar", + comment: "mvu-statusbar", + content: "变量输出格式: 严格 \ntype: state\nformat: |-\n stat_data:", + strategyType: "constant", + keys: [], + order: 1, + }), + ]; + globalThis.getCharWorldbookNames = () => ({ + primary: "mvu-guard-worldbook", + additional: [], + }); + globalThis.getWorldbook = async (name) => + name === "mvu-guard-worldbook" ? mvuWorldbookEntry : []; + globalThis.getLorebookEntries = async (name) => + (name === "mvu-guard-worldbook" ? mvuWorldbookEntry : []).map((e) => ({ + uid: e.uid, comment: e.comment, + })); + globalThis.__promptBuilderMvuContext = { + ...globalThis.__promptBuilderMvuContext, + chatId: "mvu-guard-chat", + chatMetadata: {}, + }; + + const s = buildExtractSettings(); + // 使用含 worldInfo 块的 extract 默认 profile + const pb = await buildTaskPrompt(s, "extract", { + recentMessages: "普通对话,用于触发世界书。", + userMessage: "普通消息。", + chatMessages: [], + }); + const rendered = JSON.stringify(pb); + assert.doesNotMatch(rendered, /UpdateVariable/, + "T6: MVU 世界书条目必须仍被 aggressive drop"); + } + + // 测试 6b:warn 路径 — 双断言 + // 构造一个故意用 aggressive mode 且会 drop 的字段(绕过策略表用内部 API) + // 通过检验 sanitizedFields 中的 dropped + reasons 来验证 warn 的依据已正确记录 + { + delete globalThis.__stBmeRuntimeDebugState; + const { sanitizeMvuContent, MVU_SANITIZE_MODES } = await import("../mvu-compat.js"); + assert.ok(MVU_SANITIZE_MODES, "mvu-compat 必须导出 MVU_SANITIZE_MODES"); + assert.equal(MVU_SANITIZE_MODES.AGGRESSIVE, "aggressive", + "MVU_SANITIZE_MODES.AGGRESSIVE 应为 'aggressive'"); + assert.equal(MVU_SANITIZE_MODES.PASSIVE, "passive", + "MVU_SANITIZE_MODES.PASSIVE 应为 'passive'"); + + // aggressive mode 下 MVU 世界书内容应被 drop + const aggressiveResult = sanitizeMvuContent( + "变量输出格式: 严格 \ntype: state\nformat: |-\n stat_data:", + { mode: MVU_SANITIZE_MODES.AGGRESSIVE }, + ); + assert.equal(aggressiveResult.dropped, true, + "T6b: aggressive mode 命中 likely_mvu_content 应 dropped=true"); + assert.ok(aggressiveResult.reasons.includes("likely_mvu_content"), + "T6b: reasons 应含 likely_mvu_content"); + + // passive mode 下相同内容不应被整段 drop + const passiveResult = sanitizeMvuContent( + "变量更新规则:\ntype: state\n当前时间: 12:00", + { mode: MVU_SANITIZE_MODES.PASSIVE }, + ); + assert.equal(passiveResult.dropped, false, + "T6b: passive mode 不应整段 drop"); + + // warn 路径:手动 mock console.warn 验证关键字段清空时 warn 触发 + const warnCalls = []; + const originalWarn = console.warn; + console.warn = (...args) => warnCalls.push(args); + try { + // 构建一个 extract 任务,把一个关键字段故意设成 aggressive 会 drop 的内容 + // 为触发 warn,我们在 sanitizePromptContextInputs 里必须 omit 且原始非空 + // 因为 passive 策略会保留,我们直接用 recentMessages 传入一段 + // 绕过策略表的方式是:在 world info 条目里触发 aggressive(不经过字段策略表) + // 这里改为:直接测试 sanitizeMvuContent 在 PASSIVE mode 下 dropped=false,即 warn 不触发 + // 然后对 AGGRESSIVE 手动调用相同逻辑,断言 warn 输出 + // + // 实际场景 warn 触发点:在 sanitizePromptContextInputs 里检测到 CRITICAL 字段 omit + // 修复后正常场景不应触发;我们用 debug.mvu.sanitizedFields 来断言"字段未被 drop" + const s2 = buildMinimalExtractSettings(); + const pb2 = await buildTaskPrompt(s2, "extract", { + recentMessages: "变量更新规则:\ntype: state\n当前时间: 12:00", + charDescription: "", + userPersona: "", + candidateText: "", + }); + // passive 模式下不应 warn 关键字段 drop + const criticalDropWarn = warnCalls.find( + (args) => String(args[0] || "").includes("关键任务输入字段被 MVU 策略清空"), + ); + assert.equal(criticalDropWarn, undefined, + "T6b: passive 模式下关键字段不应触发 warn"); + // 且字段不应在 sanitizedFields 中被标记为 dropped + const recentDropped = pb2.debug.mvu.sanitizedFields.find( + (e) => e.name === "recentMessages" && e.dropped, + ); + assert.equal(recentDropped, undefined, + "T6b: recentMessages 不应在 debug.mvu.sanitizedFields 中 dropped"); + } finally { + console.warn = originalWarn; + } + } + console.log("prompt-builder-mvu tests passed"); } finally { if (originalRequire === undefined) { From 507f3b15d3b13bf5bfcaa749269d71df2fd29743 Mon Sep 17 00:00:00 2001 From: Hao19911125 <99091644+Hao19911125@users.noreply.github.com> Date: Sun, 5 Apr 2026 16:40:20 +0800 Subject: [PATCH 5/5] Add MVU sanitize warning diagnostics --- prompt-builder.js | 93 +++++++++++++++++++++++++++++++++++- tests/prompt-builder-mvu.mjs | 47 ++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/prompt-builder.js b/prompt-builder.js index 004a88e..84cc14c 100644 --- a/prompt-builder.js +++ b/prompt-builder.js @@ -361,6 +361,30 @@ function joinStructuredPath(basePath = "", segment = "") { : `${basePath}.${normalizedSegment}`; } +function mergeSanitizeReasons(...reasonLists) { + const merged = new Set(); + for (const list of reasonLists) { + for (const reason of Array.isArray(list) ? list : []) { + if (reason) { + merged.add(String(reason)); + } + } + } + return [...merged]; +} + +function summarizeSanitizePreview(value, maxLength = 200) { + const rendered = stringifyInterpolatedValue(value) + .replace(/\s+/g, " ") + .trim(); + if (!rendered) { + return ""; + } + return rendered.length > maxLength + ? `${rendered.slice(0, maxLength)}...` + : rendered; +} + function looksLikeMvuStateContainer(value, seen = new WeakSet()) { if (!value || typeof value !== "object") { return false; @@ -446,12 +470,22 @@ function sanitizeStructuredPromptValue( omit: !String(sanitized.text || "").trim() && String(value || "").trim().length > 0, + dropped: Boolean(sanitized.dropped), + reasons: Array.isArray(sanitized.reasons) ? [...sanitized.reasons] : [], + blockedHitCount: Number(sanitized.blockedHitCount || 0), + artifactRemovedCount: Number(sanitized.artifactRemovedCount || 0), + rawPreview: summarizeSanitizePreview(value), + sanitizedPreview: summarizeSanitizePreview(sanitized.text), }; } if (Array.isArray(value)) { const sanitizedArray = []; let changed = false; + let dropped = false; + let blockedHitCount = 0; + let artifactRemovedCount = 0; + let reasons = []; for (let index = 0; index < value.length; index += 1) { const childResult = sanitizeStructuredPromptValue( settings, @@ -473,17 +507,31 @@ function sanitizeStructuredPromptValue( ); if (childResult.omit) { changed = true; + dropped = dropped || Boolean(childResult.dropped); + blockedHitCount += Number(childResult.blockedHitCount || 0); + artifactRemovedCount += Number(childResult.artifactRemovedCount || 0); + reasons = mergeSanitizeReasons(reasons, childResult.reasons); continue; } sanitizedArray.push(childResult.value); if (childResult.changed) { changed = true; } + dropped = dropped || Boolean(childResult.dropped); + blockedHitCount += Number(childResult.blockedHitCount || 0); + artifactRemovedCount += Number(childResult.artifactRemovedCount || 0); + reasons = mergeSanitizeReasons(reasons, childResult.reasons); } return { value: sanitizedArray, changed: changed || sanitizedArray.length !== value.length, omit: value.length > 0 && sanitizedArray.length === 0, + dropped, + reasons, + blockedHitCount, + artifactRemovedCount, + rawPreview: summarizeSanitizePreview(value), + sanitizedPreview: summarizeSanitizePreview(sanitizedArray), }; } @@ -493,6 +541,12 @@ function sanitizeStructuredPromptValue( value, changed: false, omit: false, + dropped: false, + reasons: [], + blockedHitCount: 0, + artifactRemovedCount: 0, + rawPreview: summarizeSanitizePreview(value), + sanitizedPreview: summarizeSanitizePreview(value), }; } seen.add(value); @@ -501,6 +555,10 @@ function sanitizeStructuredPromptValue( const sanitizedObject = {}; let changed = false; let keptEntries = 0; + let dropped = false; + let blockedHitCount = 0; + let artifactRemovedCount = 0; + let reasons = []; for (const [key, entryValue] of Object.entries(value)) { const stripReason = stripMvuContainers @@ -516,6 +574,8 @@ function sanitizeStructuredPromptValue( reasons: [stripReason], blockedHitCount: 0, }); + dropped = true; + reasons = mergeSanitizeReasons(reasons, [stripReason]); continue; } @@ -539,6 +599,10 @@ function sanitizeStructuredPromptValue( ); if (childResult.omit) { changed = true; + dropped = dropped || Boolean(childResult.dropped); + blockedHitCount += Number(childResult.blockedHitCount || 0); + artifactRemovedCount += Number(childResult.artifactRemovedCount || 0); + reasons = mergeSanitizeReasons(reasons, childResult.reasons); continue; } sanitizedObject[key] = childResult.value; @@ -546,12 +610,22 @@ function sanitizeStructuredPromptValue( if (childResult.changed) { changed = true; } + dropped = dropped || Boolean(childResult.dropped); + blockedHitCount += Number(childResult.blockedHitCount || 0); + artifactRemovedCount += Number(childResult.artifactRemovedCount || 0); + reasons = mergeSanitizeReasons(reasons, childResult.reasons); } return { value: sanitizedObject, changed, omit: originalLooksMvuContainer && keptEntries === 0, + dropped, + reasons, + blockedHitCount, + artifactRemovedCount, + rawPreview: summarizeSanitizePreview(value), + sanitizedPreview: summarizeSanitizePreview(sanitizedObject), }; } @@ -559,6 +633,12 @@ function sanitizeStructuredPromptValue( value, changed: false, omit: false, + dropped: false, + reasons: [], + blockedHitCount: 0, + artifactRemovedCount: 0, + rawPreview: summarizeSanitizePreview(value), + sanitizedPreview: summarizeSanitizePreview(value), }; } @@ -665,7 +745,18 @@ function sanitizePromptContextInputs( const rawLength = typeof value === "string" ? value.length : -1; console.warn( "[ST-BME] 关键任务输入字段被 MVU 策略清空", - { taskType, fieldName, mode: fieldMode, rawLength }, + { + taskType, + fieldName, + mode: fieldMode, + rawLength, + dropped: Boolean(sanitized.dropped), + reasons: Array.isArray(sanitized.reasons) ? sanitized.reasons : [], + artifactRemovedCount: Number(sanitized.artifactRemovedCount || 0), + blockedHitCount: Number(sanitized.blockedHitCount || 0), + rawPreview: String(sanitized.rawPreview || ""), + sanitizedPreview: String(sanitized.sanitizedPreview || ""), + }, ); } sanitizedContext[fieldName] = sanitized.omit diff --git a/tests/prompt-builder-mvu.mjs b/tests/prompt-builder-mvu.mjs index 30ba0d5..c47a63c 100644 --- a/tests/prompt-builder-mvu.mjs +++ b/tests/prompt-builder-mvu.mjs @@ -804,6 +804,53 @@ try { } } + // 测试 6c:warn 诊断字段包含 reasons 和 before/after preview + { + delete globalThis.__stBmeRuntimeDebugState; + const warnCalls = []; + const originalWarn = console.warn; + console.warn = (...args) => warnCalls.push(args); + try { + const s = buildMinimalExtractSettings(); + await buildTaskPrompt(s, "extract", { + recentMessages: + "{{get_message_variable::stat_data.hp}}\n{{get_message_variable::display_data.hp}}", + charDescription: "", + userPersona: "", + candidateText: "", + }); + const criticalDropWarn = warnCalls.find( + (args) => String(args[0] || "").includes("关键任务输入字段被 MVU 策略清空"), + ); + assert.ok(criticalDropWarn, "T6c: 清洗后为空时应触发关键字段 warn"); + assert.equal(criticalDropWarn[1]?.fieldName, "recentMessages", + "T6c: warn 应指向 recentMessages"); + assert.equal(criticalDropWarn[1]?.mode, "passive", + "T6c: recentMessages 应以 passive mode 清洗"); + assert.ok( + Array.isArray(criticalDropWarn[1]?.reasons) && + criticalDropWarn[1].reasons.includes("artifact_stripped"), + "T6c: warn 应携带 artifact_stripped reason", + ); + assert.match( + String(criticalDropWarn[1]?.rawPreview || ""), + /get_message_variable/, + "T6c: warn 应携带原始内容 preview", + ); + assert.equal( + String(criticalDropWarn[1]?.sanitizedPreview || ""), + "", + "T6c: 清洗为空时 sanitizedPreview 应为空串", + ); + assert.ok( + Number(criticalDropWarn[1]?.artifactRemovedCount || 0) >= 2, + "T6c: warn 应记录 artifactRemovedCount", + ); + } finally { + console.warn = originalWarn; + } + } + console.log("prompt-builder-mvu tests passed"); } finally { if (originalRequire === undefined) {