@leonhardthust/dsh-decision-engine 1.9.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,501 @@
1
+ /**
2
+ * 决策日志的**纯函数**层:分片命名、文件名清洗、记录紧凑化。
3
+ *
4
+ * ## 为什么把这些搬出 decision-store.js
5
+ *
6
+ * 存储层(`DecisionStore`)要碰文件系统,测试它必须造目录、收尾清理;
7
+ * 而「日期键怎么算」「文件名怎么洗」「记录怎么压」这三件事全是**纯变换**:
8
+ * 同样的输入永远给同样的输出,一次也不碰磁盘。把它们单独放在这里,
9
+ * 就能用一个文件把边界全钉死(时区、Windows 保留名、截断哈希、往返压缩),
10
+ * 不必为了验一个字符就建临时目录。
11
+ *
12
+ * ## 三个必须留在这里的理由
13
+ *
14
+ * 1. **日期键不能用 `toISOString().slice(0, 10)`**。那是 UTC 日期:在
15
+ * Asia/Shanghai(UTC+8)凌晨 00:30 写下的记录会被记到**前一天**,
16
+ * 于是「今天的日志」里看不到今天的记录,跨零点还会把一天拆成两半。
17
+ * 这里一律用 `Intl.DateTimeFormat.formatToParts` 按**配置时区**取年月日。
18
+ * 2. **文件名里可能有会话名 / 工作区名**。它们是用户内容:要过 NFKC、
19
+ * 剔除路径分隔符与控制字符、避开 Windows 保留设备名,再截断;发生
20
+ * 改动或截断时追加短哈希,保证两个不同的名字不会落到同一个文件上
21
+ * (否则两个会话的日志会串在一起)。
22
+ * 3. **紧凑化必须可逆**。删掉的字段都带「还原依据」:与 `scores` 逐字节
23
+ * 相同的 `modelEvaluation` 记在 `dedup` 里,已知问题集折叠成
24
+ * `{ref, sha256, bytes}`,默认空值有固定的缺省表。读取路径一律先
25
+ * `expandRecord()`,界面拿到的记录与写盘前**同形**。
26
+ *
27
+ * @module dsh-decision-engine/decision-log
28
+ */
29
+
30
+ import { createHash } from "node:crypto";
31
+
32
+ import { GUARD_QUESTIONS_VERSION, guardQuestions } from "./guard/advisory.js";
33
+
34
+ // ---------------------------------------------------------------- 常量
35
+
36
+ /** 分文件模式的四种取值。 */
37
+ export const SPLIT_MODES = ["single", "date", "session", "workspace"];
38
+
39
+ /**
40
+ * 默认分文件模式:**按日期**。
41
+ *
42
+ * 为什么默认不是「按会话」:用户问「今天的日志」比问「某个会话的日志」更
43
+ * 常见;而按会话/工作区分文件会让文件名带上用户内容(见模块头隐私说明),
44
+ * 默认取信息量最小的那个维度。
45
+ */
46
+ export const DEFAULT_SPLIT_MODE = "date";
47
+
48
+ /** 日期分隔符的可选值。 */
49
+ export const DATE_SEPARATORS = ["-", "_", "."];
50
+
51
+ /** 默认日期分隔符(与前缀 `YYYY-MM-DD` 的直觉一致)。 */
52
+ export const DEFAULT_DATE_SEPARATOR = "-";
53
+
54
+ /** 默认保留天数;0 = 不按天删(只受总量上限约束)。 */
55
+ export const DEFAULT_RETENTION_DAYS = 30;
56
+
57
+ /** 默认日志总量上限:64 MB。 */
58
+ export const DEFAULT_TOTAL_MAX_BYTES = 64 * 1024 * 1024;
59
+
60
+ /** 文件名里会话名 / 工作区名的默认最大长度(按码点算,哈希后缀另计)。 */
61
+ export const DEFAULT_SEGMENT_MAX_LENGTH = 48;
62
+
63
+ /** 会话 id 在文件名里保留的字符数。 */
64
+ export const SESSION_ID_SEGMENT_LENGTH = 8;
65
+
66
+ /** 单一文件模式的默认文件名(也是旧版本唯一的文件名,兼容用它)。 */
67
+ export const SINGLE_FILE_NAME = "decisions.jsonl";
68
+
69
+ /** 已知问题集折叠后的引用名(与 guard/advisory.js 的版本常量同源)。 */
70
+ export const QUESTIONS_REF = GUARD_QUESTIONS_VERSION;
71
+
72
+ /**
73
+ * 紧凑化时**可以省略**的空串字段。
74
+ *
75
+ * 只挑「读端一定知道缺省值」的展示型字段:它们占了每条记录几十字节,
76
+ * 而内容一个字节都没少。身份字段(`v` / `seq` / `id` / `ts` / `sessionId` /
77
+ * `point` / `mode` / `capture`)**绝不省略**——读端的会话预筛(按
78
+ * `"sessionId":"…"` 子串判断)与「不可归属」判定都直接依赖它们。
79
+ */
80
+ const OMIT_EMPTY_STRINGS = ["tag", "topMode", "model", "action", "detail"];
81
+
82
+ /** 紧凑化时**可以省略**的 0 值字段(读端按 0 还原)。 */
83
+ const OMIT_ZERO_NUMBERS = ["latencyMs", "inputTokens", "outputTokens"];
84
+
85
+ /**
86
+ * 展开时补回的默认值表。
87
+ *
88
+ * 字段集合必须与 `buildDecisionRecord()` 的输出对齐:缺了哪个,界面就会
89
+ * 在「写盘前有的字段、读回来后没有」这种形态下翻车(表格列忽然变成 `—`)。
90
+ */
91
+ const EXPAND_DEFAULTS = {
92
+ tag: "",
93
+ topMode: "",
94
+ model: "",
95
+ action: "",
96
+ detail: "",
97
+ cached: false,
98
+ degraded: false,
99
+ latencyMs: 0,
100
+ inputTokens: 0,
101
+ outputTokens: 0,
102
+ };
103
+
104
+ /** 非法文件名字符:Windows 保留的 `<>:"/\|?*` 加上 C0 / C1 控制字符。 */
105
+ const ILLEGAL_IN_NAME = /[<>:"/\\|?*\u0000-\u001f\u007f-\u009f]/g;
106
+
107
+ /** Windows 保留设备名(含带扩展名的形态,如 `con.txt`)。 */
108
+ const RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
109
+
110
+ // ---------------------------------------------------------------- 日期
111
+
112
+ /**
113
+ * 把任意时间表示归一成 `Date`。
114
+ *
115
+ * 非法输入返回**当前时间**而不是 `Invalid Date`:分片名必须永远可算,
116
+ * 一条 ts 坏掉的记录不该让整条写盘链路炸掉(旁路永远不拖累主流程)。
117
+ *
118
+ * @param ts - ISO 字符串 / 毫秒数 / Date / 可被 Date 解析的字符串。
119
+ * @returns 合法 Date。
120
+ */
121
+ function toDate(ts) {
122
+ if (ts instanceof Date) return Number.isNaN(ts.getTime()) ? new Date() : ts;
123
+ if (typeof ts === "number" && Number.isFinite(ts)) {
124
+ const date = new Date(ts);
125
+ return Number.isNaN(date.getTime()) ? new Date() : date;
126
+ }
127
+ if (typeof ts === "string" && ts.trim().length > 0) {
128
+ const date = new Date(ts.trim());
129
+ if (!Number.isNaN(date.getTime())) return date;
130
+ }
131
+ return new Date();
132
+ }
133
+
134
+ /** 两位补零。 */
135
+ function pad2(value) {
136
+ return String(value).padStart(2, "0");
137
+ }
138
+
139
+ /**
140
+ * 取某时刻在指定时区下的日期键(`YYYY-MM-DD`)。
141
+ *
142
+ * 为什么不用 `toISOString().slice(0, 10)`:那是 UTC 日期。东八区凌晨
143
+ * 00:30 的记录会被算到前一天(`2026-11-15T16:30Z` → `2026-11-15`),
144
+ * 「今天的日志」里就看不到今天凌晨的记录。这里按 `timeZone` 取本地年月日,
145
+ * 与用户看到的日历一致。
146
+ *
147
+ * @param ts - 时间(ISO 字符串 / 毫秒数 / Date)。
148
+ * @param timeZone - IANA 时区名(如 `Asia/Shanghai`);留空用系统时区。
149
+ * @returns `YYYY-MM-DD`;日期取不到时退回空串(调用方按「无日期」处理)。
150
+ */
151
+ export function dateKeyOf(ts, timeZone) {
152
+ const date = toDate(ts);
153
+ const wanted = typeof timeZone === "string" && timeZone.trim().length > 0 ? timeZone.trim() : undefined;
154
+ const options = { year: "numeric", month: "2-digit", day: "2-digit" };
155
+ let parts;
156
+ try {
157
+ parts = new Intl.DateTimeFormat("en-US", wanted === undefined ? options : { ...options, timeZone: wanted })
158
+ .formatToParts(date);
159
+ } catch {
160
+ // 时区名写错了不该拖垮写盘:退回系统时区,日期仍可用。
161
+ try {
162
+ parts = new Intl.DateTimeFormat("en-US", options).formatToParts(date);
163
+ } catch {
164
+ return "";
165
+ }
166
+ }
167
+ const pick = (type) => parts.find((part) => part.type === type)?.value ?? "";
168
+ const year = pick("year");
169
+ const month = pick("month");
170
+ const day = pick("day");
171
+ if (!/^\d{4}$/.test(year) || !/^\d{1,2}$/.test(month) || !/^\d{1,2}$/.test(day)) return "";
172
+ return `${year}-${pad2(month)}-${pad2(day)}`;
173
+ }
174
+
175
+ /** 系统时区(IANA 名);拿不到返回 `UTC`。 */
176
+ export function systemTimeZone() {
177
+ try {
178
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
179
+ } catch {
180
+ return "UTC";
181
+ }
182
+ }
183
+
184
+ // ---------------------------------------------------------------- 文件名
185
+
186
+ /** 规范化分文件模式;无法识别时退回默认档。 */
187
+ export function normalizeSplitMode(value) {
188
+ return SPLIT_MODES.includes(value) ? value : DEFAULT_SPLIT_MODE;
189
+ }
190
+
191
+ /** 规范化日期分隔符;无法识别时退回默认档。 */
192
+ export function normalizeDateSeparator(value) {
193
+ return DATE_SEPARATORS.includes(value) ? value : DEFAULT_DATE_SEPARATOR;
194
+ }
195
+
196
+ /** sha1 十六进制摘要。 */
197
+ function sha1Hex(text) {
198
+ return createHash("sha1").update(String(text), "utf8").digest("hex");
199
+ }
200
+
201
+ /** sha256 十六进制摘要。 */
202
+ function sha256Hex(text) {
203
+ return createHash("sha256").update(String(text), "utf8").digest("hex");
204
+ }
205
+
206
+ /** 取第一个非空字符串。 */
207
+ function firstNonEmpty(...values) {
208
+ for (const value of values) {
209
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
210
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
211
+ }
212
+ return "";
213
+ }
214
+
215
+ /**
216
+ * 把用户内容洗成**可安全放进文件名**的一段。
217
+ *
218
+ * 处理顺序(顺序本身有意义):
219
+ *
220
+ * 1. NFKC 归一——全角 `A` / 兼容字符先折叠,否则同一个名字会因输入法
221
+ * 差异落成两个文件;
222
+ * 2. 非法字符与 C0/C1 控制字符替换为 `_`(路径分隔符出现在文件名里,
223
+ * Windows 会把它当目录,直接写失败);
224
+ * 3. 折叠连续空白 / 下划线 / 点,再去掉首尾的点与空格(Windows 会静默
225
+ * 吃掉结尾的点,导致「写了却读不到」);
226
+ * 4. Windows 保留设备名加前缀 `_`(`CON`、`CON.txt` 这类名字建不出来);
227
+ * 5. 按**码点**截断(不能按 UTF-16 代码单元切:会把 emoji / 生僻字劈成
228
+ * 半个代理对,文件名就变成乱码);
229
+ * 6. 空串 → `unknown`。
230
+ *
231
+ * 冲突兜底:只要清洗**改动过**原文或发生截断,就在末尾追加 `~<sha1 前 6 位>`
232
+ * (对 NFKC 后的原文取哈希)。两个不同的长名字共享前 48 个码点时不会
233
+ * 撞进同一个文件;而没被改动过的名字保持原样,排查时一眼能认出来。
234
+ *
235
+ * @param name - 原始名字(会话名 / 工作区名 / id)。
236
+ * @param options.maxLength - 最大码点数,默认 48;哈希后缀不计入。
237
+ * @returns 安全的分名片段;永不返回空串。
238
+ */
239
+ export function sanitizeSegment(name, options = {}) {
240
+ const requested = Number(options.maxLength);
241
+ const maxLength = Number.isFinite(requested) && requested > 0
242
+ ? Math.max(1, Math.floor(requested))
243
+ : DEFAULT_SEGMENT_MAX_LENGTH;
244
+ const source = name === undefined || name === null ? "" : String(name);
245
+ const normalized = source.normalize("NFKC");
246
+ let text = normalized
247
+ .replace(ILLEGAL_IN_NAME, "_")
248
+ .replace(/\s+/gu, " ")
249
+ .replace(/_+/g, "_")
250
+ .replace(/\.{2,}/g, ".")
251
+ .replace(/^[. ]+|[. ]+$/g, "");
252
+ if (RESERVED_NAME.test(text)) text = `_${text}`;
253
+ const codePoints = Array.from(text);
254
+ const truncated = codePoints.length > maxLength;
255
+ if (truncated) text = codePoints.slice(0, maxLength).join("");
256
+ if (text.length === 0) return "unknown";
257
+ if (truncated || text !== normalized) return `${text}~${sha1Hex(normalized).slice(0, 6)}`;
258
+ return text;
259
+ }
260
+
261
+ /**
262
+ * 算出一分片的文件名(不含目录)。
263
+ *
264
+ * 四种模式:
265
+ *
266
+ * - `single` → `decisions.jsonl`(旧版本的唯一文件,升级后仍可读)
267
+ * - `date` → `YYYY<sep>MM<sep>DD.log`
268
+ * - `session` → `<clean(会话名或会话id)>__<sessionId 前 8>.log`
269
+ * - `workspace` → `<clean(工作区名)>.log`
270
+ *
271
+ * 会话模式为什么要带 id 后缀:会话标题可以重名(「新会话」),只按名字
272
+ * 分文件会让两个会话的日志串在一起;id 前 8 位足以在单机范围内区分,
273
+ * 又不会把文件名撑得太长。
274
+ *
275
+ * @param options.splitMode - 分文件模式。
276
+ * @param options.ts - 记录时间(date 模式用)。
277
+ * @param options.timeZone - IANA 时区(date 模式用)。
278
+ * @param options.separator - 日期分隔符(date 模式用)。
279
+ * @param options.sessionId - 会话 id(session 模式用)。
280
+ * @param options.sessionName - 会话名(session 模式用;空则退化为 id)。
281
+ * @param options.workspaceName - 工作区名(workspace 模式用)。
282
+ * @param options.fileName - single 模式的文件名,默认 `decisions.jsonl`。
283
+ * @returns 文件名;任何模式下都不含路径分隔符。
284
+ */
285
+ export function shardFileName(options = {}) {
286
+ const mode = normalizeSplitMode(options.splitMode);
287
+ if (mode === "single") {
288
+ const name = firstNonEmpty(options.fileName, SINGLE_FILE_NAME);
289
+ return name;
290
+ }
291
+ if (mode === "date") {
292
+ const separator = normalizeDateSeparator(options.separator);
293
+ const key = dateKeyOf(options.ts, options.timeZone) || dateKeyOf(Date.now(), options.timeZone);
294
+ return `${key.replaceAll("-", separator)}.log`;
295
+ }
296
+ if (mode === "session") {
297
+ const sessionId = typeof options.sessionId === "string" ? options.sessionId.trim() : "";
298
+ const idPart = sanitizeSegment(
299
+ sessionId.length > 0 ? sessionId.slice(0, SESSION_ID_SEGMENT_LENGTH) : "unknown",
300
+ { maxLength: SESSION_ID_SEGMENT_LENGTH },
301
+ );
302
+ const readable = firstNonEmpty(options.sessionName, sessionId, "unknown");
303
+ return `${sanitizeSegment(readable)}__${idPart}.log`;
304
+ }
305
+ const workspace = firstNonEmpty(options.workspaceName, "unknown");
306
+ return `${sanitizeSegment(workspace)}.log`;
307
+ }
308
+
309
+ /**
310
+ * 从分片文件名里解出日期(保留期判断用)。
311
+ *
312
+ * 接受三种分隔符与轮转后缀:`2026-11-15.log`、`2026-11-15.1.log`、
313
+ * `2026_11_15.log`、`2026.11.15.log`。不是日期分片(会话/工作区/单一文件)
314
+ * 返回 `null`——调用方会退回用 mtime 判断保留期。
315
+ *
316
+ * 校验必须严格:`2026-13-45` 这种「看起来是日期」的名字不能被当成
317
+ * 合法分片,否则保留期清理会按一个不存在的日期算出错误的截止点。
318
+ *
319
+ * @param fileName - 文件名或路径。
320
+ * @returns `{ year, month, day, key, rotation }`;无法解析返回 `null`。
321
+ */
322
+ export function parseShardDate(fileName) {
323
+ const name = String(fileName ?? "").split(/[\\/]/).pop() ?? "";
324
+ const matched = /^(\d{4})([-_.])(\d{2})\2(\d{2})(?:[.](\d+))?[.]log$/i.exec(name);
325
+ if (matched === null) return null;
326
+ const year = Number(matched[1]);
327
+ const month = Number(matched[3]);
328
+ const day = Number(matched[4]);
329
+ if (month < 1 || month > 12 || day < 1 || day > 31) return null;
330
+ const date = new Date(Date.UTC(year, month - 1, day));
331
+ if (
332
+ date.getUTCFullYear() !== year ||
333
+ date.getUTCMonth() !== month - 1 ||
334
+ date.getUTCDate() !== day
335
+ ) {
336
+ return null;
337
+ }
338
+ return {
339
+ year,
340
+ month,
341
+ day,
342
+ key: `${matched[1]}-${matched[3]}-${matched[4]}`,
343
+ rotation: matched[5] === undefined ? 0 : Number(matched[5]),
344
+ };
345
+ }
346
+
347
+ /** 轮转后的文件名:`x.log` → `x.1.log`;`x.jsonl` → `x.jsonl.1`。 */
348
+ export function rotatedNameOf(fileName) {
349
+ const name = String(fileName ?? "");
350
+ if (name.endsWith(".log")) return `${name.slice(0, -4)}.1.log`;
351
+ return `${name}.1`;
352
+ }
353
+
354
+ // ---------------------------------------------------------------- 紧凑格式
355
+
356
+ /** JSON 深拷贝;不可序列化时返回 `undefined`。 */
357
+ function cloneJson(value) {
358
+ try {
359
+ return JSON.parse(JSON.stringify(value));
360
+ } catch {
361
+ return undefined;
362
+ }
363
+ }
364
+
365
+ /** 稳定的 JSON 文本;失败返回 `undefined`(用于「逐字节相同」的比较)。 */
366
+ function safeStringify(value) {
367
+ try {
368
+ return JSON.stringify(value);
369
+ } catch {
370
+ return undefined;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * 紧凑化一条记录(写盘前调用)。
376
+ *
377
+ * 三件事,全部**可逆**:
378
+ *
379
+ * 1. **去重 `modelEvaluation`**:授权点的 `scores.modelEvaluation` 与
380
+ * `content.state.modelEvaluation` / `content.answers.modelEvaluation`
381
+ * 是同一份对象(见 lib/guard/listener.js),一条记录里存三遍。只有与
382
+ * `scores` **逐字节相同**时才删——不同的内容可能是别的现场,不能猜。
383
+ * 删掉的位置记进 `dedup`,读取时照原样补回。
384
+ * 2. **折叠已知问题集**:守卫的问题集定义是常量(`guardQuestions()`),
385
+ * 每条记录都带一份纯属浪费。折叠成 `{ref, sha256, bytes}`,读取时
386
+ * 在哈希一致的前提下还原。
387
+ * 3. **省略默认空值**:空串 / `false` / `0` 这些「缺省就是它」的展示字段。
388
+ *
389
+ * @param record - 一条决策明细记录。
390
+ * @returns 新的紧凑记录;入参非法时原样返回。
391
+ */
392
+ export function compactRecord(record) {
393
+ if (record === null || typeof record !== "object" || Array.isArray(record)) return record;
394
+ const source = cloneJson(record);
395
+ if (source === null || typeof source !== "object" || Array.isArray(source)) return record;
396
+
397
+ // 1) modelEvaluation 去重(只有逐字节相同才动)。
398
+ const scoresEvaluation = source.scores?.modelEvaluation;
399
+ if (scoresEvaluation !== undefined) {
400
+ const expected = safeStringify(scoresEvaluation);
401
+ const deduped = [];
402
+ for (const slot of ["state", "answers"]) {
403
+ const holder = source.content?.[slot];
404
+ if (holder === null || typeof holder !== "object") continue;
405
+ if (!Object.hasOwn(holder, "modelEvaluation")) continue;
406
+ if (safeStringify(holder.modelEvaluation) !== expected) continue;
407
+ delete holder.modelEvaluation;
408
+ deduped.push(`${slot}.modelEvaluation`);
409
+ }
410
+ if (deduped.length > 0) source.dedup = deduped;
411
+ }
412
+
413
+ // 2) 已知问题集折叠(哈希写进引用,读取时用来确认「还是同一份定义」)。
414
+ const content = source.content;
415
+ if (content !== null && typeof content === "object" && content.questions !== null && typeof content.questions === "object") {
416
+ const known = guardQuestions();
417
+ const knownText = safeStringify(known);
418
+ if (knownText !== undefined && safeStringify(content.questions) === knownText) {
419
+ content.questions = {
420
+ ref: QUESTIONS_REF,
421
+ sha256: sha256Hex(knownText),
422
+ bytes: Buffer.byteLength(knownText, "utf8"),
423
+ };
424
+ }
425
+ }
426
+
427
+ // 3) 省略默认空值。
428
+ for (const key of OMIT_EMPTY_STRINGS) {
429
+ if (source[key] === "") delete source[key];
430
+ }
431
+ for (const key of OMIT_ZERO_NUMBERS) {
432
+ if (source[key] === 0) delete source[key];
433
+ }
434
+ if (source.cached === false) delete source.cached;
435
+ if (source.degraded === false) delete source.degraded;
436
+
437
+ return source;
438
+ }
439
+
440
+ /**
441
+ * 还原一条紧凑记录(读取路径必须**先**调用它)。
442
+ *
443
+ * 与 `compactRecord()` 严格对称:补回 `dedup` 删掉的两个位置、按 ref 展开
444
+ * 已知问题集、补回默认空值。任何一步拿不到依据时**保持原样**——宁可让
445
+ * 界面看到折叠后的引用,也不凭空造一份「看起来一样」的内容。
446
+ *
447
+ * 两点必须说清(否则会被误当成缺陷):
448
+ *
449
+ * 1. **老记录会被补齐成当前形状**。升级前落盘的记录本来就没有 `tag` /
450
+ * `latencyMs` 这些字段(早期的 `buildDecisionRecord` 还没写它们),
451
+ * 展开时会按默认值补上,与今天的记录同形——这正是界面想要的。
452
+ * 2. **字段顺序不保证逐字节一致**。还原的 `modelEvaluation` 会追加在对象
453
+ * 末尾;语义(深比较)完全相同,但 `JSON.stringify` 的字节序可能变。
454
+ * 任何依赖「逐字节相同」的比较都不要放在读取路径上。
455
+ *
456
+ * @param record - 从磁盘读出的记录(可能是紧凑格式)。
457
+ * @returns 还原后的记录;入参非法时原样返回。
458
+ */
459
+ export function expandRecord(record) {
460
+ if (record === null || typeof record !== "object" || Array.isArray(record)) return record;
461
+ const source = cloneJson(record);
462
+ if (source === null || typeof source !== "object" || Array.isArray(source)) return record;
463
+
464
+ // 1) 问题集还原:哈希必须对得上(定义改过就说明不是同一份,不能还原成新版)。
465
+ const content = source.content;
466
+ if (content !== null && typeof content === "object" && content.questions !== null && typeof content.questions === "object") {
467
+ const folded = content.questions;
468
+ if (folded.ref === QUESTIONS_REF && typeof folded.sha256 === "string") {
469
+ const known = guardQuestions();
470
+ const knownText = safeStringify(known);
471
+ if (knownText !== undefined && sha256Hex(knownText) === folded.sha256) {
472
+ content.questions = known;
473
+ }
474
+ }
475
+ }
476
+
477
+ // 2) modelEvaluation 还原:依据只有 scores 里那一份。
478
+ if (Array.isArray(source.dedup)) {
479
+ const scoresEvaluation = source.scores?.modelEvaluation;
480
+ for (const entry of source.dedup) {
481
+ const [slot, field] = String(entry).split(".");
482
+ if ((slot !== "state" && slot !== "answers") || field !== "modelEvaluation") continue;
483
+ if (scoresEvaluation === undefined) continue;
484
+ if (source.content === null || typeof source.content !== "object") source.content = {};
485
+ if (source.content[slot] === null || typeof source.content[slot] !== "object") {
486
+ source.content[slot] = {};
487
+ }
488
+ if (!Object.hasOwn(source.content[slot], "modelEvaluation")) {
489
+ source.content[slot].modelEvaluation = cloneJson(scoresEvaluation);
490
+ }
491
+ }
492
+ delete source.dedup;
493
+ }
494
+
495
+ // 3) 缺省字段补回(与 buildDecisionRecord 的输出对齐)。
496
+ for (const [key, value] of Object.entries(EXPAND_DEFAULTS)) {
497
+ if (!Object.hasOwn(source, key)) source[key] = value;
498
+ }
499
+
500
+ return source;
501
+ }