diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index c0a62a5..82ffb01 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +## 🐛 修复 + +- **一条只错在分类词上的窗口,被当成「模型没按格式写」反复重放(#502,社区报告 #493 追加的第二形态)**:#493 让蒸馏解冻之后,报告者贴出对照——`summarize_compress` 18:32 成功、18:35 又失败,而失败行的 `metadata` 里 `raw_has_bracket = true`、`raw_len` 2574/3299:**JSON 是合法的,条目是被 `type` 枚举全部滤掉的**。模型自造了中文分类(`"type": "环境/版本"`、`"用户需求"`)并多带 `id` / `source` 键,于是 `entries.length === 0` 而 `arr.length > 0` → `ok:false` → 记 `invalid summary JSON` → 游标不推进 → 同一个窗口一遍遍重放。根因是两套不一致(对照 `main` 已核):`summarize.js` 的 `VALID` 吃 **7** 个类型,`lang.js` 里普通 `summary` 的中英两版只列 **4** 个(`codingSummary` 才列全),且两个 prompt 都没有一句「只能用这些值 / 不要输出额外键」(grep `只能用这些值|枚举外的值|额外键` = 0 命中)——喂给模型的菜单和卡内容的闸门对不上,模型按最像中文标签的方式发挥是合理结果。修法按报告者排的 1 → 3 做,**都零行为变更**:① 失败形态分开(`describeParseFailure`:`no-array` / `syntax` / `filtered-empty`),`filtered-empty` 的错误消息写成 `no valid summary entries (arr=N, valid=0)`,审计 `metadata` 补 `failure_kind` / `arr_count` / `invalid_entries` / `sample_types`(模型实际用的分类词原样带出,不看这一眼就得去猜);② `summary` 的中英两版枚举列全为 7 个,并各自写明「`type` 只能取上面枚举里的值,不要自造分类;每项只输出上面列出的键,不要附加 id、source 等额外字段」——两种语言必须一起改,只改一种的话另一种语言的窗口照样被滤空,而孪生守卫看不见「两边一起过期」。另外补了 `raw_tail`(正文 > 500 字时给末 200 字):有些模型先写几条合法条目、再在后面跑偏,前 300 字看不出来。**`type` 归一化(中文近义词 → 枚举)刻意不做**:它能把内容救回入库,但会改变入库分类分布,等 ① 的统计出来再判断值不值——这条判断也是报告者的排序(1 → 3 → 2)。「早退」路径(连方括号都没有,#493 的 B 臂)以前返回 `{ok:false, entries:[]}` 什么上下都不带,现在也带 `failure`,否则下游只能靠「输出里有没有 `[`」再猜一次,而猜出来的东西不该写进错误消息。测试 5 条 + 1 条既有断言升级(全无效摘要那三条用例从「等于 invalid summary JSON」改成「等于 `arr=N, valid=0`」并加锁 `failure_kind`,行为断言一条不放松:状态仍是 error、窗口仍可重试、重试收到逐字节相同的转录)。 + ## [0.9.4] - 2026-10-21 ## 🆕 新增 diff --git a/dsh-mneme/lib/lang.js b/dsh-mneme/lib/lang.js index 3d0f038..2a4fe54 100644 --- a/dsh-mneme/lib/lang.js +++ b/dsh-mneme/lib/lang.js @@ -11,8 +11,8 @@ export function langOf(config) { const PROMPTS = { "summary": { - "zh": "你是记忆库提炼助手。根据下面的会话内容,提炼值得跨会话记住的原子记忆。\n原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、名字、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。\n只输出 JSON 数组,每项形如 {\"type\":\"preference|project|decision|history\",\"title\":\"简短标题\",\"content\":\"保留原始细节的一句话\",\"importance\":1-5,\"occurred_at\":\"可选:事件发生的绝对时间,ISO-8601\"}。\noccurred_at(可选):这条记忆所述事件**实际发生**的绝对时间,ISO-8601 带时区(如 2026-10-05T09:30:00+08:00)。会话里出现「昨天/前天/上周/上个月」这类相对时间时,按提示末尾给出的当前时间换算成绝对时间再填;没有任何时间依据、或换算不出来时,整条省略该字段——严禁编造,也不要把当前时间当成事件时间填进去。\n不要输出任何其他文字。", - "en": "You are the memory curation assistant. From the conversation content below, distill atomic memories worth remembering across sessions.\nAtomic-memory principle: each memory holds exactly one standalone fact/preference/decision — short and self-contained (keep raw details like numbers, names, paths, and conclusions verbatim in content; do not abstract or summarize them away). Prefer splitting into multiple entries over merging and losing detail. Extract 2-4 entries normally, 4-8 for information-dense conversations.\nWrite every title and content in English.\nOutput only a JSON array, each item shaped {\"type\":\"preference|project|decision|history\",\"title\":\"short title\",\"content\":\"one sentence preserving original detail\",\"importance\":1-5,\"occurred_at\":\"optional: absolute time the event happened, ISO-8601\"}.\noccurred_at (optional): the absolute time the described event **actually happened**, in ISO-8601 with a timezone (e.g. 2026-10-05T09:30:00+08:00). When the conversation uses a relative time (yesterday / the day before / last week / last month), convert it into an absolute time against the current time given at the end of this prompt. Omit the field entirely when there is no temporal basis or it cannot be converted — never invent a value, and never pass the current time off as the event time.\nOutput no other text." + "zh": "你是记忆库提炼助手。根据下面的会话内容,提炼值得跨会话记住的原子记忆。\n原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、名字、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。\n只输出 JSON 数组,每项形如 {\"type\":\"preference|project|decision|history|rejected_solution|pitfall|constraint\",\"title\":\"简短标题\",\"content\":\"保留原始细节的一句话\",\"importance\":1-5,\"occurred_at\":\"可选:事件发生的绝对时间,ISO-8601\"}。\noccurred_at(可选):这条记忆所述事件**实际发生**的绝对时间,ISO-8601 带时区(如 2026-10-05T09:30:00+08:00)。会话里出现「昨天/前天/上周/上个月」这类相对时间时,按提示末尾给出的当前时间换算成绝对时间再填;没有任何时间依据、或换算不出来时,整条省略该字段——严禁编造,也不要把当前时间当成事件时间填进去。\n`type` 只能取上面枚举里的值,不要自造分类;每项只输出上面列出的键,不要附加 id、source 等额外字段。\n不要输出任何其他文字。", + "en": "You are the memory curation assistant. From the conversation content below, distill atomic memories worth remembering across sessions.\nAtomic-memory principle: each memory holds exactly one standalone fact/preference/decision — short and self-contained (keep raw details like numbers, names, paths, and conclusions verbatim in content; do not abstract or summarize them away). Prefer splitting into multiple entries over merging and losing detail. Extract 2-4 entries normally, 4-8 for information-dense conversations.\nWrite every title and content in English.\nOutput only a JSON array, each item shaped {\"type\":\"preference|project|decision|history|rejected_solution|pitfall|constraint\",\"title\":\"short title\",\"content\":\"one sentence preserving original detail\",\"importance\":1-5,\"occurred_at\":\"optional: absolute time the event happened, ISO-8601\"}.\noccurred_at (optional): the absolute time the described event **actually happened**, in ISO-8601 with a timezone (e.g. 2026-10-05T09:30:00+08:00). When the conversation uses a relative time (yesterday / the day before / last week / last month), convert it into an absolute time against the current time given at the end of this prompt. Omit the field entirely when there is no temporal basis or it cannot be converted — never invent a value, and never pass the current time off as the event time.\n`type` must be one of the enumerated values above (do not invent categories); each item may contain only the listed keys - no id, source or other extra fields.\nOutput no other text." }, "codingSummary": { "zh": "你是记忆库提炼助手。根据下面的会话内容(含用户输入、助手回答、工具调用与结果),提炼值得跨会话记住的原子记忆。\n原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、报错信息、命令、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。\n只输出 JSON 数组,每项形如 {\"type\":\"preference|project|decision|history|rejected_solution|pitfall|constraint\",\"title\":\"简短标题\",\"content\":\"保留原始细节的一句话\",\"importance\":1-5,\"occurred_at\":\"可选:事件发生的绝对时间,ISO-8601\"}。\noccurred_at(可选):这条记忆所述事件**实际发生**的绝对时间,ISO-8601 带时区(如 2026-10-05T09:30:00+08:00)。会话里出现「昨天/前天/上周/上个月」这类相对时间时,按提示末尾给出的当前时间换算成绝对时间再填;没有任何时间依据、或换算不出来时,整条省略该字段——严禁编造,也不要把当前时间当成事件时间填进去。\n若对话涉及编码/调试,可额外提取编码类记忆:\n- rejected_solution:被否决/废弃的实现方案(content 含方案简述 + 被否决原因 + 最终采用方案)\n- pitfall:调试踩坑记录(content 含现象/报错 + 根因 + 解决/规避方法)\n- constraint:项目工程约束(content 含约束描述 + 来源)\n普通闲聊、临时无关对话一律不提取编码类记忆。不要输出任何其他文字。", diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js index 8e64f13..cc19bb0 100644 --- a/dsh-mneme/lib/summarize.js +++ b/dsh-mneme/lib/summarize.js @@ -33,11 +33,56 @@ function normalizeDistilledOccurredAt(raw, nowMs = Date.now()) { } } -function parseSummaryJsonResult(raw) { +/** + * 失败形态分类(#502),只在结果不 ok 时产出: + * - no-array:连 `[` 都没有——模型在说话,不是没按格式; + * - syntax:有方括号、整体 parse 失败且括号扫描也没救回条目(#339 那一族); + * - filtered-empty:JSON 合法、`arr` 非空,条目却被 VALID 全数滤掉。**这一种最贵**: + * 读起来像「没按格式写」,实际是分类词不在枚举内,而游标不推进会让整窗反复重放。 + */ +function describeParseFailure(arr, entries, salvaged, hasBracket) { + if (!Array.isArray(arr) || salvaged) return { kind: hasBracket ? "syntax" : "no-array", arrCount: 0, sampleTypes: [] }; + if (arr.length === 0) return { kind: "no-array", arrCount: 0, sampleTypes: [] }; + if (entries.length > 0) return null; + const sampleTypes = [...new Set(arr.map((item) => (typeof item?.type === "string" ? item.type.trim() : "")).filter(Boolean))].slice(0, 5); + return { kind: "filtered-empty", arrCount: arr.length, sampleTypes }; +} + +/** + * 失败行的审计读数(纯函数:不驱动整条蒸馏管线也能测)。filtered-empty 的 + * errorMessage 直接写出 arr/valid 两个数,一眼可辨;metadata 补 failure_kind / + * arr_count / invalid_entries / sample_types / raw_tail——尾巴是给「先写几条合法 + * 条目再跑偏」那种输出准备的,前 300 字看不出来。 + */ +export function summarizeParseFailureAudit(raw, failure) { + const text = String(raw ?? ""); + const kind = failure?.kind ?? (text.includes("[") ? "syntax" : "no-array"); + const tail = text.length > 500 ? text.slice(-200) : ""; + const filtered = kind === "filtered-empty" ? failure : null; + return { + errorMessage: filtered ? `no valid summary entries (arr=${filtered.arrCount}, valid=0)` : "invalid summary JSON", + metadata: { + parse_failure: true, + failure_kind: kind, + raw_len: text.length, + raw_head: text.slice(0, 300), + ...(tail ? { raw_tail: tail } : {}), + raw_has_bracket: text.includes("["), + ...(filtered ? { arr_count: filtered.arrCount, invalid_entries: filtered.arrCount, sample_types: filtered.sampleTypes } : {}) + } + }; +} + +/** 导出的原因:#502 要能锁住「哪一种失败」这条判据本身,而不是只锁它的下游读数。 */ +export function parseSummaryJsonResult(raw) { const text = String(raw ?? ""); const start = text.indexOf("["); const end = text.lastIndexOf("]"); - if (start === -1 || end === -1 || end <= start) return { ok: false, entries: [] }; + // 连方括号都没有 = 模型在说话(#493 的 B 臂正是这一型)。早退也要带上下:否则下游 + // 只能靠「输出里有没有 [」再猜一次,而猜出来的东西不该写进错误消息。 + if (start === -1 || end === -1 || end <= start) { + return { ok: false, entries: [], failure: describeParseFailure(null, [], false, text.includes("[")) }; + } let arr; let salvaged = false; try { @@ -77,10 +122,16 @@ function parseSummaryJsonResult(raw) { // 空数组表示模型明确判断本轮没有可沉淀内容;非空数组若全部无效, // 则不能消费窗口,否则无效输出会永久推进 seq 游标。salvage 路径 // 没有「显式空数组」可言——ok 只看是否救回了条目。 + const ok = salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0); return { - ok: salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0), + ok, entries, - salvaged + salvaged, + // #502:失败时要能说出**是哪一种**失败。「压根没写数组」与「写了合法数组、但条目 + // type 全不在枚举里」过去共用一句 invalid summary JSON,而两者要修的地方完全不同 + // ——后者是菜单与闸门不一致(summary prompt 只列 4 个类型、VALID 吃 7 个),且失败 + // 路径游标不推进,同一窗口会被一遍遍重放(社区报告 #493 追加的实测形态)。 + ...(!ok ? { failure: describeParseFailure(arr, entries, salvaged, text.includes("[")) } : {}) }; } @@ -918,15 +969,13 @@ export function createSummarizer(ctx, service, config, deps = {}) { } if (audit) { audit.status = "error"; - audit.errorMessage = "invalid summary JSON"; + // 读数与错误文案由同一个纯函数产出(#502):三种失败形态各有各的说法, + // 「模型在说话」与「分类词不在枚举内」不能都写成 invalid summary JSON。 + const failureAudit = summarizeParseFailureAudit(raw, parsedResult?.failure); + audit.errorMessage = failureAudit.errorMessage; audit.metadata = { distillChars, - parse_failure: true, - raw_len: raw.length, - raw_head: raw.slice(0, 300), - // 有没有出现过 `[` 是区分「模型没按格式写」与「格式对但内容救不回来」 - // 的唯一低成本判据,也正是 #493 现场那 20 条失败的关键特征。 - raw_has_bracket: raw.includes("["), + ...failureAudit.metadata, streak, ...(maxParseRetries > 0 ? { maxRetries: maxParseRetries } : {}), ...(skipWindow ? { skipped: "parse-failure-streak" } : {}) diff --git a/dsh-mneme/src/lang.js b/dsh-mneme/src/lang.js index 3d0f038..2a4fe54 100644 --- a/dsh-mneme/src/lang.js +++ b/dsh-mneme/src/lang.js @@ -11,8 +11,8 @@ export function langOf(config) { const PROMPTS = { "summary": { - "zh": "你是记忆库提炼助手。根据下面的会话内容,提炼值得跨会话记住的原子记忆。\n原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、名字、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。\n只输出 JSON 数组,每项形如 {\"type\":\"preference|project|decision|history\",\"title\":\"简短标题\",\"content\":\"保留原始细节的一句话\",\"importance\":1-5,\"occurred_at\":\"可选:事件发生的绝对时间,ISO-8601\"}。\noccurred_at(可选):这条记忆所述事件**实际发生**的绝对时间,ISO-8601 带时区(如 2026-10-05T09:30:00+08:00)。会话里出现「昨天/前天/上周/上个月」这类相对时间时,按提示末尾给出的当前时间换算成绝对时间再填;没有任何时间依据、或换算不出来时,整条省略该字段——严禁编造,也不要把当前时间当成事件时间填进去。\n不要输出任何其他文字。", - "en": "You are the memory curation assistant. From the conversation content below, distill atomic memories worth remembering across sessions.\nAtomic-memory principle: each memory holds exactly one standalone fact/preference/decision — short and self-contained (keep raw details like numbers, names, paths, and conclusions verbatim in content; do not abstract or summarize them away). Prefer splitting into multiple entries over merging and losing detail. Extract 2-4 entries normally, 4-8 for information-dense conversations.\nWrite every title and content in English.\nOutput only a JSON array, each item shaped {\"type\":\"preference|project|decision|history\",\"title\":\"short title\",\"content\":\"one sentence preserving original detail\",\"importance\":1-5,\"occurred_at\":\"optional: absolute time the event happened, ISO-8601\"}.\noccurred_at (optional): the absolute time the described event **actually happened**, in ISO-8601 with a timezone (e.g. 2026-10-05T09:30:00+08:00). When the conversation uses a relative time (yesterday / the day before / last week / last month), convert it into an absolute time against the current time given at the end of this prompt. Omit the field entirely when there is no temporal basis or it cannot be converted — never invent a value, and never pass the current time off as the event time.\nOutput no other text." + "zh": "你是记忆库提炼助手。根据下面的会话内容,提炼值得跨会话记住的原子记忆。\n原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、名字、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。\n只输出 JSON 数组,每项形如 {\"type\":\"preference|project|decision|history|rejected_solution|pitfall|constraint\",\"title\":\"简短标题\",\"content\":\"保留原始细节的一句话\",\"importance\":1-5,\"occurred_at\":\"可选:事件发生的绝对时间,ISO-8601\"}。\noccurred_at(可选):这条记忆所述事件**实际发生**的绝对时间,ISO-8601 带时区(如 2026-10-05T09:30:00+08:00)。会话里出现「昨天/前天/上周/上个月」这类相对时间时,按提示末尾给出的当前时间换算成绝对时间再填;没有任何时间依据、或换算不出来时,整条省略该字段——严禁编造,也不要把当前时间当成事件时间填进去。\n`type` 只能取上面枚举里的值,不要自造分类;每项只输出上面列出的键,不要附加 id、source 等额外字段。\n不要输出任何其他文字。", + "en": "You are the memory curation assistant. From the conversation content below, distill atomic memories worth remembering across sessions.\nAtomic-memory principle: each memory holds exactly one standalone fact/preference/decision — short and self-contained (keep raw details like numbers, names, paths, and conclusions verbatim in content; do not abstract or summarize them away). Prefer splitting into multiple entries over merging and losing detail. Extract 2-4 entries normally, 4-8 for information-dense conversations.\nWrite every title and content in English.\nOutput only a JSON array, each item shaped {\"type\":\"preference|project|decision|history|rejected_solution|pitfall|constraint\",\"title\":\"short title\",\"content\":\"one sentence preserving original detail\",\"importance\":1-5,\"occurred_at\":\"optional: absolute time the event happened, ISO-8601\"}.\noccurred_at (optional): the absolute time the described event **actually happened**, in ISO-8601 with a timezone (e.g. 2026-10-05T09:30:00+08:00). When the conversation uses a relative time (yesterday / the day before / last week / last month), convert it into an absolute time against the current time given at the end of this prompt. Omit the field entirely when there is no temporal basis or it cannot be converted — never invent a value, and never pass the current time off as the event time.\n`type` must be one of the enumerated values above (do not invent categories); each item may contain only the listed keys - no id, source or other extra fields.\nOutput no other text." }, "codingSummary": { "zh": "你是记忆库提炼助手。根据下面的会话内容(含用户输入、助手回答、工具调用与结果),提炼值得跨会话记住的原子记忆。\n原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、报错信息、命令、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。\n只输出 JSON 数组,每项形如 {\"type\":\"preference|project|decision|history|rejected_solution|pitfall|constraint\",\"title\":\"简短标题\",\"content\":\"保留原始细节的一句话\",\"importance\":1-5,\"occurred_at\":\"可选:事件发生的绝对时间,ISO-8601\"}。\noccurred_at(可选):这条记忆所述事件**实际发生**的绝对时间,ISO-8601 带时区(如 2026-10-05T09:30:00+08:00)。会话里出现「昨天/前天/上周/上个月」这类相对时间时,按提示末尾给出的当前时间换算成绝对时间再填;没有任何时间依据、或换算不出来时,整条省略该字段——严禁编造,也不要把当前时间当成事件时间填进去。\n若对话涉及编码/调试,可额外提取编码类记忆:\n- rejected_solution:被否决/废弃的实现方案(content 含方案简述 + 被否决原因 + 最终采用方案)\n- pitfall:调试踩坑记录(content 含现象/报错 + 根因 + 解决/规避方法)\n- constraint:项目工程约束(content 含约束描述 + 来源)\n普通闲聊、临时无关对话一律不提取编码类记忆。不要输出任何其他文字。", diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js index 8e64f13..cc19bb0 100644 --- a/dsh-mneme/src/summarize.js +++ b/dsh-mneme/src/summarize.js @@ -33,11 +33,56 @@ function normalizeDistilledOccurredAt(raw, nowMs = Date.now()) { } } -function parseSummaryJsonResult(raw) { +/** + * 失败形态分类(#502),只在结果不 ok 时产出: + * - no-array:连 `[` 都没有——模型在说话,不是没按格式; + * - syntax:有方括号、整体 parse 失败且括号扫描也没救回条目(#339 那一族); + * - filtered-empty:JSON 合法、`arr` 非空,条目却被 VALID 全数滤掉。**这一种最贵**: + * 读起来像「没按格式写」,实际是分类词不在枚举内,而游标不推进会让整窗反复重放。 + */ +function describeParseFailure(arr, entries, salvaged, hasBracket) { + if (!Array.isArray(arr) || salvaged) return { kind: hasBracket ? "syntax" : "no-array", arrCount: 0, sampleTypes: [] }; + if (arr.length === 0) return { kind: "no-array", arrCount: 0, sampleTypes: [] }; + if (entries.length > 0) return null; + const sampleTypes = [...new Set(arr.map((item) => (typeof item?.type === "string" ? item.type.trim() : "")).filter(Boolean))].slice(0, 5); + return { kind: "filtered-empty", arrCount: arr.length, sampleTypes }; +} + +/** + * 失败行的审计读数(纯函数:不驱动整条蒸馏管线也能测)。filtered-empty 的 + * errorMessage 直接写出 arr/valid 两个数,一眼可辨;metadata 补 failure_kind / + * arr_count / invalid_entries / sample_types / raw_tail——尾巴是给「先写几条合法 + * 条目再跑偏」那种输出准备的,前 300 字看不出来。 + */ +export function summarizeParseFailureAudit(raw, failure) { + const text = String(raw ?? ""); + const kind = failure?.kind ?? (text.includes("[") ? "syntax" : "no-array"); + const tail = text.length > 500 ? text.slice(-200) : ""; + const filtered = kind === "filtered-empty" ? failure : null; + return { + errorMessage: filtered ? `no valid summary entries (arr=${filtered.arrCount}, valid=0)` : "invalid summary JSON", + metadata: { + parse_failure: true, + failure_kind: kind, + raw_len: text.length, + raw_head: text.slice(0, 300), + ...(tail ? { raw_tail: tail } : {}), + raw_has_bracket: text.includes("["), + ...(filtered ? { arr_count: filtered.arrCount, invalid_entries: filtered.arrCount, sample_types: filtered.sampleTypes } : {}) + } + }; +} + +/** 导出的原因:#502 要能锁住「哪一种失败」这条判据本身,而不是只锁它的下游读数。 */ +export function parseSummaryJsonResult(raw) { const text = String(raw ?? ""); const start = text.indexOf("["); const end = text.lastIndexOf("]"); - if (start === -1 || end === -1 || end <= start) return { ok: false, entries: [] }; + // 连方括号都没有 = 模型在说话(#493 的 B 臂正是这一型)。早退也要带上下:否则下游 + // 只能靠「输出里有没有 [」再猜一次,而猜出来的东西不该写进错误消息。 + if (start === -1 || end === -1 || end <= start) { + return { ok: false, entries: [], failure: describeParseFailure(null, [], false, text.includes("[")) }; + } let arr; let salvaged = false; try { @@ -77,10 +122,16 @@ function parseSummaryJsonResult(raw) { // 空数组表示模型明确判断本轮没有可沉淀内容;非空数组若全部无效, // 则不能消费窗口,否则无效输出会永久推进 seq 游标。salvage 路径 // 没有「显式空数组」可言——ok 只看是否救回了条目。 + const ok = salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0); return { - ok: salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0), + ok, entries, - salvaged + salvaged, + // #502:失败时要能说出**是哪一种**失败。「压根没写数组」与「写了合法数组、但条目 + // type 全不在枚举里」过去共用一句 invalid summary JSON,而两者要修的地方完全不同 + // ——后者是菜单与闸门不一致(summary prompt 只列 4 个类型、VALID 吃 7 个),且失败 + // 路径游标不推进,同一窗口会被一遍遍重放(社区报告 #493 追加的实测形态)。 + ...(!ok ? { failure: describeParseFailure(arr, entries, salvaged, text.includes("[")) } : {}) }; } @@ -918,15 +969,13 @@ export function createSummarizer(ctx, service, config, deps = {}) { } if (audit) { audit.status = "error"; - audit.errorMessage = "invalid summary JSON"; + // 读数与错误文案由同一个纯函数产出(#502):三种失败形态各有各的说法, + // 「模型在说话」与「分类词不在枚举内」不能都写成 invalid summary JSON。 + const failureAudit = summarizeParseFailureAudit(raw, parsedResult?.failure); + audit.errorMessage = failureAudit.errorMessage; audit.metadata = { distillChars, - parse_failure: true, - raw_len: raw.length, - raw_head: raw.slice(0, 300), - // 有没有出现过 `[` 是区分「模型没按格式写」与「格式对但内容救不回来」 - // 的唯一低成本判据,也正是 #493 现场那 20 条失败的关键特征。 - raw_has_bracket: raw.includes("["), + ...failureAudit.metadata, streak, ...(maxParseRetries > 0 ? { maxRetries: maxParseRetries } : {}), ...(skipWindow ? { skipped: "parse-failure-streak" } : {}) diff --git a/dsh-mneme/test/summarize-parse-diagnostics.test.js b/dsh-mneme/test/summarize-parse-diagnostics.test.js new file mode 100644 index 0000000..4f94b0d --- /dev/null +++ b/dsh-mneme/test/summarize-parse-diagnostics.test.js @@ -0,0 +1,75 @@ +// issue #502:#493 的第二种形态。JSON 是合法的,条目却被 `VALID` 全部滤掉——过去这 +// 和「模型压根没写数组」共用一句 `invalid summary JSON`,而两者要修的地方完全不同, +// 更要命的是失败路径游标不推进:同一个窗口会被一遍遍重放(社区报告的实测:0.9.4 装上 +// 之后 summarize 解冻了,隔 19 秒又来一条 invalid,输入 2711→输出 2047)。 +// 本文件锁三层:判据分得开、读数说得出是哪一种、以及 prompt 与枚举从此一致(中英两份)。 +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseSummaryJsonResult, summarizeParseFailureAudit } from "../src/summarize.js"; +import { STR } from "../src/lang.js"; + +const GOOD = { type: "project", title: "发布只在包目录内", content: "根目录发过坏包。" }; +// 报告现场的原样形状:自造中文分类 + 多带 id/source 键 +const BOGUS = [ + { id: 1, type: "环境/版本", content: "本地安装的 @modusensus/dsh-mneme 版本为 0.9.4", source: "工具结果:版本确认" }, + { id: "user_intent", type: "用户需求", content: "希望插件不再重复询问", source: "用户输入" } +]; + +test("filtered-empty:JSON 合法、条目全被枚举滤掉,要能报出 arr/valid 两个数", () => { + const r = parseSummaryJsonResult(JSON.stringify(BOGUS)); + assert.equal(r.ok, false, "全被滤掉的窗口不能算成功(否则会永久推进游标)"); + assert.equal(r.failure.kind, "filtered-empty"); + assert.equal(r.failure.arrCount, 2); + assert.deepEqual(r.failure.sampleTypes, ["环境/版本", "用户需求"], + "把模型实际用的分类词原样带出来——不看这一眼就得去猜"); + + const audit = summarizeParseFailureAudit(JSON.stringify(BOGUS), r.failure); + assert.equal(audit.errorMessage, "no valid summary entries (arr=2, valid=0)"); + assert.equal(audit.metadata.failure_kind, "filtered-empty"); + assert.equal(audit.metadata.arr_count, 2); + assert.equal(audit.metadata.invalid_entries, 2); + assert.equal(audit.metadata.raw_has_bracket, true, "有方括号正是这一型的特征:它不是「没写格式」"); + assert.equal(audit.metadata.parse_failure, true); +}); + +test("no-array:模型在说话而不是在输出,判据与 filtered-empty 必须分开", () => { + const raw = "我觉得这批内容挺有意思,我来点评一下:第一,版本确认很重要……"; + const r = parseSummaryJsonResult(raw); + assert.equal(r.ok, false); + assert.equal(r.failure.kind, "no-array"); + const audit = summarizeParseFailureAudit(raw, r.failure); + assert.equal(audit.errorMessage, "invalid summary JSON", "这一型才该用原来那句话"); + assert.equal(audit.metadata.raw_has_bracket, false); + assert.equal(audit.metadata.sample_types, undefined, "没有数组可言,不许编出 arr_count"); + assert.equal(audit.metadata.arr_count, undefined); +}); + +test("有合法条目时不产出 failure(成功路径不被诊断字段污染)", () => { + const r = parseSummaryJsonResult(JSON.stringify([GOOD])); + assert.equal(r.ok, true); + assert.equal(r.failure, undefined); + assert.equal(r.entries.length, 1); +}); + +test("raw_tail 只在正文确实长于头部窗口时给(短输出不重复贴一遍)", () => { + const short = JSON.stringify(BOGUS); + assert.equal(summarizeParseFailureAudit(short, { kind: "filtered-empty", arrCount: 2, sampleTypes: [] }).metadata.raw_tail, undefined); + const long = JSON.stringify(BOGUS) + " ".repeat(600) + "尾巴在这里:后面其实还有半段点评"; + const meta = summarizeParseFailureAudit(long, { kind: "filtered-empty", arrCount: 2, sampleTypes: [] }).metadata; + assert.ok(meta.raw_tail.includes("尾巴在这里"), "「先写几条合法再跑偏」的输出,只有尾巴能看出来"); + assert.ok(meta.raw_head.length <= 300); +}); + +// prompt 与闸门对齐(报告者的第 3 条,零行为变更):菜单给全 7 个值、并写明不许自造。 +// 中英两份都要锁——只改一种语言,另一种语言的窗口照样被滤空,而守卫看不见「两边一起过期」。 +test("summary 提示词的枚举与 VALID 对齐,且中英两份都写明不许自造分类", () => { + const all = ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"]; + for (const lang of ["zh", "en"]) { + const p = STR.prompts.summary[lang]; + for (const t of all) assert.ok(p.includes(t), `summary(${lang}) 枚举缺 ${t}`); + assert.match(lang === "zh" ? p : p, /只能取上面枚举|must be one of the enumerated/, `summary(${lang}) 缺「不许自造分类」的约束`); + assert.match(p, /id、source|id, source/, `summary(${lang}) 要写明不许附加 id/source 等额外键`); + } + // codingSummary 本来就列全了,确认没被这次批量替换改坏(它多出的说明句还在) + assert.ok(STR.prompts.codingSummary.zh.includes("rejected_solution") && STR.prompts.codingSummary.zh.includes("pitfall")); +}); diff --git a/dsh-mneme/test/summarize.test.js b/dsh-mneme/test/summarize.test.js index 82f7fc3..d1e6617 100644 --- a/dsh-mneme/test/summarize.test.js +++ b/dsh-mneme/test/summarize.test.js @@ -861,9 +861,22 @@ test("an all-invalid summary leaves the same window retryable until valid memori assert.equal(retryTranscript, firstTranscript, "retry receives the original transcript"); assert.equal(service.count(), 1); assert.equal(service.list()[0].content, valid.content); + // #502:这一族(JSON 合法、条目全被枚举滤掉)从此报成 filtered-empty 的专属消息, + // 不再与「模型压根没写数组」共用 invalid summary JSON。状态与「窗口可重试」不变, + // 所以下面三条断言一条都不放松。 assert.ok(service.listLlmAudits({ source: "autoSummarize" }).some( - (audit) => audit.status === "error" && audit.error_message === "invalid summary JSON" - ), "an all-invalid summary must be audited as an error"); + (audit) => audit.status === "error" && audit.error_message === `no valid summary entries (arr=${invalid.length}, valid=0)` + ), "an all-invalid summary must be audited as an error, named as a filtered-empty window"); + // metadata 读回来可能是串也可能是对象(store 存的是 JSON 串,列表接口按形状 + // 解析过),两种形状都归一成串再判——否则这条锁会随下游解析行为假红。 + const metaText = (audit) => { + const m = audit.metadata; + if (typeof m === "string") return m; + try { return JSON.stringify(m ?? ""); } catch { return ""; } + }; + assert.ok(service.listLlmAudits({ source: "autoSummarize" }).some( + (audit) => audit.status === "error" && metaText(audit).includes("filtered-empty") + ), "metadata 要能说出是滤空而不是没写格式(sample_types 带着模型自造的分类词)"); await handler(session, { seq: 2, type: "turn/end" }); assert.equal(calls.length, 2, "the successfully retried window is consumed once");