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) {