@kira905/dsh-memory-toolkit 0.1.2 → 0.2.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.
- package/CHANGELOG.md +130 -1
- package/README.en.md +225 -2
- package/README.md +208 -2
- package/cordis.patch.yml +32 -0
- package/lib/deploy-guard.mjs +463 -275
- package/lib/guard-http.mjs +59 -62
- package/lib/pitfall-form-map.mjs +94 -84
- package/lib/pitfall-score.mjs +262 -185
- package/lib/session-file-name.mjs +116 -100
- package/lib/zstd-frames.mjs +88 -67
- package/memory-db-tools/README.md +29 -1
- package/memory-db-tools/audit-analyze.mjs +1 -1
- package/memory-db-tools/audit-export.mjs +60 -16
- package/memory-db-tools/audit-struct-probe.mjs +29 -8
- package/memory-db-tools/lib/known-devices.mjs +137 -0
- package/memory-engine/README.md +58 -4
- package/memory-engine/bench/bench.mjs +1 -1
- package/memory-engine/cli/band-sentinel.mjs +164 -0
- package/memory-engine/cli/distill-rollback.mjs +112 -44
- package/memory-engine/cli/distill-sessions.mjs +237 -0
- package/memory-engine/cli/distill.mjs +196 -95
- package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
- package/memory-engine/cli/doc-index.mjs +47 -38
- package/memory-engine/cli/domain-tags.mjs +246 -162
- package/memory-engine/cli/layer-migrate.mjs +176 -0
- package/memory-engine/cli/layer-move.mjs +189 -0
- package/memory-engine/cli/pitfall-hook.mjs +136 -70
- package/memory-engine/cli/recall.mjs +243 -110
- package/memory-engine/cli/selfcheck.mjs +93 -44
- package/memory-engine/cli/serve-start.mjs +221 -140
- package/memory-engine/cli/sweep.mjs +83 -44
- package/memory-engine/cli/sync.mjs +93 -57
- package/memory-engine/config/audit-devices.json +6 -0
- package/memory-engine/config/bands.json +7 -0
- package/memory-engine/config/layers.json +7 -0
- package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
- package/memory-engine/dict/lexicon-config.json +11 -0
- package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
- package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
- package/memory-engine/lib/band-sentinel.mjs +417 -0
- package/memory-engine/lib/cli-strict.mjs +128 -0
- package/memory-engine/lib/cli-util.mjs +147 -125
- package/memory-engine/lib/config.mjs +83 -96
- package/memory-engine/lib/dirty-ledger.mjs +127 -94
- package/memory-engine/lib/distill-rollback.mjs +173 -133
- package/memory-engine/lib/distill.mjs +878 -585
- package/memory-engine/lib/doc-index-store.mjs +208 -235
- package/memory-engine/lib/doc-index.mjs +351 -350
- package/memory-engine/lib/doc-scan.mjs +178 -156
- package/memory-engine/lib/doc-summarize.mjs +220 -205
- package/memory-engine/lib/domain-tags.mjs +358 -277
- package/memory-engine/lib/embedder.mjs +175 -194
- package/memory-engine/lib/hashes.mjs +22 -7
- package/memory-engine/lib/layer-migrate.mjs +330 -0
- package/memory-engine/lib/layer-move.mjs +307 -0
- package/memory-engine/lib/layers.mjs +191 -0
- package/memory-engine/lib/lexicon-recall.mjs +473 -0
- package/memory-engine/lib/memory-source.mjs +78 -80
- package/memory-engine/lib/pitfall-source.mjs +115 -114
- package/memory-engine/lib/policy-source.mjs +279 -261
- package/memory-engine/lib/recall-log.mjs +285 -0
- package/memory-engine/lib/recall.mjs +565 -240
- package/memory-engine/lib/selfcheck.mjs +142 -106
- package/memory-engine/lib/session-distill.mjs +603 -0
- package/memory-engine/lib/session-source.mjs +421 -0
- package/memory-engine/lib/sweep.mjs +107 -130
- package/memory-engine/lib/sync.mjs +110 -140
- package/memory-engine/lib/tick-hook.mjs +123 -148
- package/memory-engine/lib/vectors-store.mjs +213 -188
- package/memory-engine/test/12-layering.test.mjs +539 -0
- package/memory-engine/test/13-session-distill.test.mjs +616 -0
- package/memory-engine/tools/bridge-smoke.mjs +94 -55
- package/memory-engine/tools/fetch-model.mjs +167 -87
- package/memory-engine/tools/smoke-recall.mjs +94 -38
- package/memory-engine/tools/verify-recall-chain.mjs +413 -0
- package/package.json +11 -1
|
@@ -1,108 +1,107 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 离线层:文档扫描面 + markdown 结构解析 + 索引条切分(方案 §3.2)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// · 扫描面 = `<docsRoot>/**/*.md
|
|
5
|
-
// ·
|
|
6
|
-
// · `path`
|
|
7
|
-
// ·
|
|
3
|
+
// 口径(照方案,别自己发挥):
|
|
4
|
+
// · 扫描面 = `<docsRoot>/**/*.md`,缺省 `<cwd>/docs`
|
|
5
|
+
// · 排除四类目录:技术变更流水 / 日报报告 / 坑目录 / `_archive`(流水类噪音大且天天变)
|
|
6
|
+
// · `path` 列一律是**相对 dshRoot** 的路径
|
|
7
|
+
// · 粒度:短文档(不到 2 万字)1 条;长文档按 `##` 小节聚成 2~5 条,多条时主键写 `path#§N`
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// docs 根(可注入;不写死机器名 —— 换宿主只改调用方)
|
|
12
12
|
export const DEFAULT_DOCS_ROOT = path.join(process.cwd(), 'docs');
|
|
13
|
-
|
|
13
|
+
// `path` 列的前缀锚(缺省就是当前工作目录)
|
|
14
14
|
export const DEFAULT_DSH_ROOT = process.cwd();
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
export const EXCLUDE_DIRS =
|
|
16
|
+
// 排除目录按「路径段」匹配,不按子串 —— 否则文件名里带这四个字会被误伤
|
|
17
|
+
export const EXCLUDE_DIRS = '变更流水,管家报告,坑库,_archive'.split(',');
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// 长/短文档分界:短文档不到 2 万字 ⇒ 1 条
|
|
20
20
|
export const LONG_DOC_CHARS = 20000;
|
|
21
|
-
|
|
21
|
+
// 长文档分条区间 2~5
|
|
22
22
|
export const MIN_LONG_ROWS = 2;
|
|
23
23
|
export const MAX_LONG_ROWS = 5;
|
|
24
|
-
|
|
24
|
+
// 每条摘要上限 500 字
|
|
25
25
|
export const SUMMARY_MAX_CHARS = 500;
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
'docs/插件升级决策表.md',
|
|
40
|
-
'docs/插件魔改维护清单.md',
|
|
41
|
-
'docs/升级方案-2026-09-10.md',
|
|
42
|
-
'docs/自愈与回滚-验收缺口与整改方案-20260911.md',
|
|
43
|
-
'docs/gitee备份使用说明.md',
|
|
44
|
-
];
|
|
27
|
+
// 首期要索引的 10 篇常用文档:判据是文档总索引里「最常用的 15 份」那张人工表的头 10 条
|
|
28
|
+
// (顺序即优先级,由主人与本鱼共同维护)。全量两百多篇不在这一批范围内。
|
|
29
|
+
const FIRST_BATCH_TEXT = `docs/主人使用手册.md
|
|
30
|
+
docs/DSH目录规范.md
|
|
31
|
+
docs/开发顺序统筹-20260914.md
|
|
32
|
+
docs/大管家-主人视角-一页纸-20260913.md
|
|
33
|
+
docs/大管家-开关矩阵与放行判据-20260913.md
|
|
34
|
+
docs/插件升级决策表.md
|
|
35
|
+
docs/插件魔改维护清单.md
|
|
36
|
+
docs/升级方案-2026-09-10.md
|
|
37
|
+
docs/自愈与回滚-验收缺口与整改方案-20260911.md
|
|
38
|
+
docs/gitee备份使用说明.md`;
|
|
45
39
|
|
|
46
|
-
|
|
40
|
+
export const FIRST_BATCH = FIRST_BATCH_TEXT.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
41
|
+
|
|
42
|
+
// 首期清单的出处(写进 state 与报告,省得日后没人知道这 10 篇哪来的)
|
|
47
43
|
export const FIRST_BATCH_SOURCE = 'docs/文档总索引.md §一「最常用的 15 份」(人工段)前 10 条';
|
|
48
44
|
|
|
49
|
-
/**
|
|
45
|
+
/** 反斜杠一律归一成 `/`(`path` 列口径) */
|
|
50
46
|
export function toPosix(p) {
|
|
51
47
|
return String(p).replace(/\\/g, '/');
|
|
52
48
|
}
|
|
53
49
|
|
|
54
|
-
/** 相对
|
|
50
|
+
/** 相对 dshRoot 的 path 口径(扫描面在 docs 下 ⇒ 结果以 `docs/` 打头) */
|
|
55
51
|
export function toRelPath(absPath, dshRoot = DEFAULT_DSH_ROOT) {
|
|
56
52
|
return toPosix(path.relative(dshRoot, absPath));
|
|
57
53
|
}
|
|
58
54
|
|
|
59
|
-
/**
|
|
55
|
+
/** 是否落在排除面内:只看「目录段」,顶层文件永远不算排除 */
|
|
60
56
|
export function isExcludedRel(relPath) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
return
|
|
57
|
+
const segs = toPosix(relPath).split('/');
|
|
58
|
+
segs.pop(); // 去掉文件名那一段
|
|
59
|
+
return segs.some((seg) => EXCLUDE_DIRS.includes(seg));
|
|
64
60
|
}
|
|
65
61
|
|
|
66
62
|
/**
|
|
67
|
-
*
|
|
63
|
+
* 递归扫描扫描面(只读;mtime 过滤交给差分逻辑,这里不过滤)。
|
|
68
64
|
* @param {string} docsRoot
|
|
69
65
|
* @param {{dshRoot?:string, maxFiles?:number}} [opts]
|
|
70
66
|
* @returns {Array<{rel:string, abs:string, mtime:number, size:number}>}
|
|
71
67
|
*/
|
|
72
68
|
export function scanDocs(docsRoot = DEFAULT_DOCS_ROOT, opts = {}) {
|
|
73
69
|
const dshRoot = opts.dshRoot ?? DEFAULT_DSH_ROOT;
|
|
74
|
-
const
|
|
75
|
-
const
|
|
70
|
+
const collected = [];
|
|
71
|
+
const todo = [docsRoot];
|
|
72
|
+
|
|
73
|
+
while (todo.length > 0) {
|
|
74
|
+
const dir = todo.pop();
|
|
76
75
|
let entries;
|
|
77
76
|
try {
|
|
78
77
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
79
78
|
} catch {
|
|
80
|
-
|
|
79
|
+
continue; // 目录读不动就跳过:fail-open,别拖垮整轮扫描
|
|
81
80
|
}
|
|
82
|
-
for (const
|
|
83
|
-
const abs = path.join(dir,
|
|
84
|
-
if (
|
|
85
|
-
if (EXCLUDE_DIRS.includes(
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const rel = toRelPath(abs, dshRoot);
|
|
95
|
-
if (isExcludedRel(rel)) continue;
|
|
96
|
-
out.push({ rel, abs, mtime: Math.trunc(st.mtimeMs), size: st.size });
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
const abs = path.join(dir, entry.name);
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
if (!EXCLUDE_DIRS.includes(entry.name)) todo.push(abs); // 整棵剪掉
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!entry.isFile() || !/\.md$/i.test(entry.name)) continue;
|
|
88
|
+
let stat;
|
|
89
|
+
try {
|
|
90
|
+
stat = fs.statSync(abs);
|
|
91
|
+
} catch {
|
|
92
|
+
continue;
|
|
97
93
|
}
|
|
94
|
+
const rel = toRelPath(abs, dshRoot);
|
|
95
|
+
if (isExcludedRel(rel)) continue;
|
|
96
|
+
collected.push({ rel, abs, mtime: Math.trunc(stat.mtimeMs), size: stat.size });
|
|
98
97
|
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return opts.maxFiles ?
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
collected.sort((left, right) => (left.rel < right.rel ? -1 : left.rel > right.rel ? 1 : 0));
|
|
101
|
+
return opts.maxFiles ? collected.slice(0, opts.maxFiles) : collected;
|
|
103
102
|
}
|
|
104
103
|
|
|
105
|
-
/**
|
|
104
|
+
/** 读文档并算字符数(粒度判定与摘要生成都要它) */
|
|
106
105
|
export function readDoc(absPath) {
|
|
107
106
|
const text = fs.readFileSync(absPath, 'utf8');
|
|
108
107
|
return { text, chars: text.length };
|
|
@@ -110,118 +109,138 @@ export function readDoc(absPath) {
|
|
|
110
109
|
|
|
111
110
|
// ---------------------------------------------------------------- 结构解析
|
|
112
111
|
|
|
113
|
-
|
|
112
|
+
// 清洗流水线:顺序即语义(先去代码块,再去内联标记,最后压空白)
|
|
113
|
+
const MD_CLEANUP = [
|
|
114
|
+
[/```[\s\S]*?```/g, ' '],
|
|
115
|
+
[/`([^`]*)`/g, '$1'],
|
|
116
|
+
[/!\[[^\]]*\]\([^)]*\)/g, ' '],
|
|
117
|
+
[/\[([^\]]*)\]\([^)]*\)/g, '$1'],
|
|
118
|
+
[/\*\*([^*]*)\*\*/g, '$1'],
|
|
119
|
+
[/(^|\s)[*_](\S[^*_]*)[*_]/g, '$1$2'],
|
|
120
|
+
[/^\s*>\s?/gm, ''],
|
|
121
|
+
[/\s+/g, ' '],
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
/** 去掉 markdown 内联标记(摘要里不要留 `**`、反引号、链接语法) */
|
|
114
125
|
export function stripMd(s) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
119
|
-
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
120
|
-
.replace(/\*\*([^*]*)\*\*/g, '$1')
|
|
121
|
-
.replace(/(^|\s)[*_](\S[^*_]*)[*_]/g, '$1$2')
|
|
122
|
-
.replace(/^\s*>\s?/gm, '')
|
|
123
|
-
.replace(/\s+/g, ' ')
|
|
124
|
-
.trim();
|
|
126
|
+
let text = String(s);
|
|
127
|
+
for (const [pattern, replacement] of MD_CLEANUP) text = text.replace(pattern, replacement);
|
|
128
|
+
return text.trim();
|
|
125
129
|
}
|
|
126
130
|
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
// 首句候选:中文句读 / 英文句点后跟空白 / 实在没有就整段。
|
|
132
|
+
// ⚠ 刻意不认分号:中文文档里分号常切出「主人 = 用户」这种半截短语,反而丢信息。
|
|
133
|
+
const FIRST_SENTENCE_RE = /^(.{4,}?[。!?!?]|.{4,}?\.\s|.{4,})/;
|
|
134
|
+
const TRAILING_STOP_RE = /[。!?!?]\s*$/;
|
|
135
|
+
|
|
136
|
+
/** 取首句(上限 maxChars 字,超长按字截断并加省略号) */
|
|
129
137
|
export function firstSentence(text, maxChars = 80) {
|
|
130
|
-
const
|
|
131
|
-
if (!
|
|
132
|
-
const
|
|
133
|
-
let
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return s;
|
|
138
|
+
const plain = stripMd(text);
|
|
139
|
+
if (!plain) return '';
|
|
140
|
+
const hit = FIRST_SENTENCE_RE.exec(plain);
|
|
141
|
+
let sentence = (hit ? hit[1] : plain).trim();
|
|
142
|
+
sentence = sentence.replace(TRAILING_STOP_RE, '').trim();
|
|
143
|
+
return sentence.length > maxChars ? `${sentence.slice(0, maxChars - 1)}…` : sentence;
|
|
137
144
|
}
|
|
138
145
|
|
|
139
|
-
|
|
140
|
-
const
|
|
146
|
+
// 正文里这几类行不算正文:章节标题、分隔线、代码围栏、引用块/表格、以及模板头(维护人/更新日期…)
|
|
147
|
+
const FENCE_LINE_RE = /^```/;
|
|
148
|
+
const HEADING_LINE_RE = /^#{1,6}\s/;
|
|
149
|
+
const RULE_LINE_RE = /^[-*_]{3,}$/;
|
|
150
|
+
const QUOTE_LINE_RE = /^[>|]/;
|
|
151
|
+
const BULLET_LINE_RE = /^[-*+]\s/;
|
|
152
|
+
const META_PREFIX_RE = /^(?:维护人|创建|最近更新|最后更新|最后修订|更新时间|文件位置|路径口径|版本)\s*[::]/;
|
|
141
153
|
|
|
142
154
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
155
|
+
* 取一节的正文首段:跳过引用块 / 表格 / 标题 / 代码块,去掉列表符号。
|
|
156
|
+
* 为什么非跳不可:文档头部普遍是 `> ↑本主题总纲见 …` 这类模板引用块,
|
|
157
|
+
* 直接取首句会让每篇索引条的说明完全雷同(实测过),对召回毫无区分度。
|
|
146
158
|
*/
|
|
147
159
|
export function pickParagraph(body, maxChars = 400) {
|
|
148
|
-
const
|
|
149
|
-
for (const
|
|
150
|
-
const
|
|
151
|
-
if (!
|
|
152
|
-
if (
|
|
160
|
+
const picked = [];
|
|
161
|
+
for (const rawLine of String(body ?? '').split(/\r?\n/)) {
|
|
162
|
+
const line = rawLine.trim();
|
|
163
|
+
if (!line) {
|
|
164
|
+
if (picked.length > 0) break; // 空行 = 段落收尾
|
|
153
165
|
continue;
|
|
154
166
|
}
|
|
155
|
-
if (
|
|
156
|
-
if (
|
|
157
|
-
if (
|
|
158
|
-
if (
|
|
159
|
-
if (
|
|
160
|
-
if (
|
|
161
|
-
if (
|
|
162
|
-
|
|
167
|
+
if (FENCE_LINE_RE.test(line)) break;
|
|
168
|
+
if (HEADING_LINE_RE.test(line)) continue;
|
|
169
|
+
if (RULE_LINE_RE.test(line)) continue;
|
|
170
|
+
if (picked.length === 0 && META_PREFIX_RE.test(line)) continue; // 模板头/元信息:往后找真正文
|
|
171
|
+
if (QUOTE_LINE_RE.test(line)) continue;
|
|
172
|
+
if (BULLET_LINE_RE.test(line)) {
|
|
173
|
+
if (picked.length > 0) break;
|
|
174
|
+
picked.push(line.replace(/^[-*+]\s+/, ''));
|
|
163
175
|
} else {
|
|
164
|
-
|
|
176
|
+
picked.push(line);
|
|
165
177
|
}
|
|
166
|
-
if (
|
|
178
|
+
if (picked.join(' ').length > maxChars) break;
|
|
167
179
|
}
|
|
168
|
-
return
|
|
180
|
+
return picked.join(' ').trim();
|
|
169
181
|
}
|
|
170
182
|
|
|
171
|
-
/** 章节标题瘦身(摘要里每节只留辨识度最高的前
|
|
183
|
+
/** 章节标题瘦身(摘要里每节只留辨识度最高的前 maxChars 字) */
|
|
172
184
|
export function shortTitle(t, maxChars = 28) {
|
|
173
|
-
const
|
|
174
|
-
return
|
|
185
|
+
const plain = stripMd(t);
|
|
186
|
+
return plain.length > maxChars ? `${plain.slice(0, maxChars - 1)}…` : plain;
|
|
175
187
|
}
|
|
176
188
|
|
|
189
|
+
const H1_RE = /^#\s+(.+?)\s*$/;
|
|
190
|
+
const H2_RE = /^##\s+(.+?)\s*$/;
|
|
191
|
+
|
|
177
192
|
/**
|
|
178
|
-
* 解析 markdown
|
|
179
|
-
* 只认
|
|
193
|
+
* 解析 markdown 结构:H1 标题、引言段、`##` 小节(其下的 `###` 归入所属小节)。
|
|
194
|
+
* 只认 `##`(方案:按 `##` 小节聚合)。
|
|
180
195
|
* @returns {{title:string, intro:string, sections:Array<{title:string,chars:number,body:string}>}}
|
|
181
196
|
*/
|
|
182
197
|
export function parseStructure(text) {
|
|
183
198
|
const lines = String(text).split(/\r?\n/);
|
|
184
|
-
|
|
185
|
-
let introStart = 0;
|
|
186
|
-
let i = 0;
|
|
199
|
+
|
|
187
200
|
// 首个 H1 = 文档标题
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
201
|
+
let title = '';
|
|
202
|
+
let introFrom = 0;
|
|
203
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
204
|
+
const hit = H1_RE.exec(lines[i]);
|
|
205
|
+
if (!hit) continue;
|
|
206
|
+
title = stripMd(hit[1]);
|
|
207
|
+
introFrom = i + 1;
|
|
208
|
+
break;
|
|
195
209
|
}
|
|
196
210
|
if (!title) {
|
|
197
|
-
//
|
|
198
|
-
const
|
|
199
|
-
title = firstSentence(
|
|
200
|
-
|
|
211
|
+
// 没有 H1:拿第一行非空文本兜底,摘要头不至于开天窗
|
|
212
|
+
const headLine = lines.find((line) => line.trim());
|
|
213
|
+
title = firstSentence(headLine || '', 60);
|
|
214
|
+
introFrom = 0;
|
|
201
215
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
216
|
+
|
|
217
|
+
const raw = [];
|
|
218
|
+
const introLines = [];
|
|
219
|
+
let openSection = null;
|
|
220
|
+
for (let i = introFrom; i < lines.length; i += 1) {
|
|
221
|
+
const line = lines[i];
|
|
222
|
+
const head = H2_RE.exec(line);
|
|
223
|
+
if (head) {
|
|
224
|
+
if (openSection) raw.push(openSection);
|
|
225
|
+
openSection = { title: stripMd(head[1]), body: [] };
|
|
211
226
|
continue;
|
|
212
227
|
}
|
|
213
|
-
if (
|
|
228
|
+
if (openSection) openSection.body.push(line);
|
|
214
229
|
else introLines.push(line);
|
|
215
230
|
}
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
231
|
+
if (openSection) raw.push(openSection);
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
title,
|
|
235
|
+
intro: introLines.join('\n').trim(),
|
|
236
|
+
sections: raw.map((section) => {
|
|
237
|
+
const bodyText = section.body.join('\n').trim();
|
|
238
|
+
return { title: section.title, chars: section.title.length + bodyText.length, body: bodyText };
|
|
239
|
+
}),
|
|
240
|
+
};
|
|
222
241
|
}
|
|
223
242
|
|
|
224
|
-
/** 目标分条数:2
|
|
243
|
+
/** 目标分条数:2 万字以内 1 条;超出按规模升到 2~5 条,且不超过 `##` 小节数 */
|
|
225
244
|
export function planRowCount(chars, sectionCount) {
|
|
226
245
|
if (chars < LONG_DOC_CHARS) return 1;
|
|
227
246
|
const bySize = Math.round(chars / LONG_DOC_CHARS) + 1;
|
|
@@ -230,21 +249,24 @@ export function planRowCount(chars, sectionCount) {
|
|
|
230
249
|
return Math.min(target, sectionCount);
|
|
231
250
|
}
|
|
232
251
|
|
|
233
|
-
/** 按 section
|
|
252
|
+
/** 按 section 个数均分(确定性、可复现;组数恰为 k,多出来的并进最后一组) */
|
|
234
253
|
export function splitSections(sections, k) {
|
|
235
|
-
const
|
|
236
|
-
if (k <= 1 ||
|
|
237
|
-
|
|
238
|
-
const
|
|
254
|
+
const total = sections.length;
|
|
255
|
+
if (k <= 1 || total <= 1) return [sections];
|
|
256
|
+
|
|
257
|
+
const base = Math.floor(total / k);
|
|
258
|
+
const extra = total % k;
|
|
239
259
|
const groups = [];
|
|
240
|
-
let
|
|
241
|
-
|
|
242
|
-
|
|
260
|
+
let cursor = 0;
|
|
261
|
+
let group = 0;
|
|
262
|
+
while (group < k) {
|
|
263
|
+
const take = base + (group < extra ? 1 : 0);
|
|
264
|
+
group += 1;
|
|
243
265
|
if (take === 0) continue;
|
|
244
|
-
groups.push(sections.slice(
|
|
245
|
-
|
|
266
|
+
groups.push(sections.slice(cursor, cursor + take));
|
|
267
|
+
cursor += take;
|
|
246
268
|
}
|
|
247
|
-
if (
|
|
269
|
+
if (cursor < total) groups[groups.length - 1] = groups[groups.length - 1].concat(sections.slice(cursor));
|
|
248
270
|
return groups;
|
|
249
271
|
}
|
|
250
272
|
|
|
@@ -255,17 +277,17 @@ export function splitSections(sections, k) {
|
|
|
255
277
|
*/
|
|
256
278
|
export function planRows(doc) {
|
|
257
279
|
const { rel, chars, structure } = doc;
|
|
258
|
-
const
|
|
259
|
-
if (
|
|
280
|
+
const wanted = planRowCount(chars, structure.sections.length);
|
|
281
|
+
if (wanted <= 1) {
|
|
260
282
|
return [{ key: rel, index: 0, total: 1, sections: structure.sections, intro: structure.intro }];
|
|
261
283
|
}
|
|
262
|
-
const groups = splitSections(structure.sections,
|
|
263
|
-
return groups.map((
|
|
284
|
+
const groups = splitSections(structure.sections, wanted);
|
|
285
|
+
return groups.map((group, i) => ({
|
|
264
286
|
key: `${rel}#§${i + 1}`,
|
|
265
287
|
index: i,
|
|
266
288
|
total: groups.length,
|
|
267
|
-
sections,
|
|
268
|
-
//
|
|
289
|
+
sections: group,
|
|
290
|
+
// 文档引言只挂在第 1 段(免得 2~5 条里重复同一段话)
|
|
269
291
|
intro: i === 0 ? structure.intro : '',
|
|
270
292
|
}));
|
|
271
293
|
}
|