dsh-shared-kb 0.0.0-stage → 1.0.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,202 @@
1
+ /**
2
+ * 分块(chunking)。
3
+ *
4
+ * 目标不是「切得均匀」,而是「切出来的每一块能独立回答一个问题」:
5
+ *
6
+ * - 先按结构切(标题 / 条款 / 表格行组 / 幻灯片 / 工作表),结构是天然的语义边界;
7
+ * - 结构块过大再按段落 + 句子回退切,带重叠(overlap)保住跨句上下文;
8
+ * - 过小的块与相邻块合并(避免「第 3 条」这种只有编号的碎片进索引);
9
+ * - 每块记录 part 序号、页码、标题路径(heading path),
10
+ * 这样检索命中后能给出「文档 > 第 2 章 > 3.1 节,第 12 页」的可复核定位。
11
+ */
12
+
13
+ /** 约 1 token ≈ 1.6 个汉字;part 默认 1200 字符 ≈ 750 token。 */
14
+ export const DEFAULT_PART_CHARS = 1200;
15
+ export const DEFAULT_OVERLAP_CHARS = 150;
16
+ export const MIN_PART_CHARS = 60;
17
+
18
+ /** 中文按字符、英文按词估算 token(只用于控制块大小,不追求精确)。 */
19
+ export function estimateTokens(text) {
20
+ const s = String(text ?? "");
21
+ const cjk = (s.match(/[\u3400-\u9fff]/g) ?? []).length;
22
+ const rest = s.length - cjk;
23
+ return Math.ceil(cjk / 1.6 + rest / 4);
24
+ }
25
+
26
+ function collapse(text) {
27
+ return String(text ?? "").replace(/[ \t\u00a0]+/g, " ").trim();
28
+ }
29
+
30
+ /**
31
+ * 把一段长文本按段落切,超长段落再按句末标点切。
32
+ * 不引入第三方分词库:中英混排下按「段落 → 句子 → 硬切」三级回退足够。
33
+ */
34
+ function splitParagraphs(text) {
35
+ return String(text ?? "")
36
+ .split(/\n{2,}/)
37
+ .map((part) => part.trim())
38
+ .filter((part) => part !== "");
39
+ }
40
+
41
+ function splitSentences(text) {
42
+ // 句末标点后跟空白或直接跟非空白中文标点都算断句
43
+ const parts = String(text ?? "").split(/(?<=[。!?;.!?;])\s*/);
44
+ return parts.map((p) => p.trim()).filter((p) => p !== "");
45
+ }
46
+
47
+ function hardSplit(text, maxChars) {
48
+ const out = [];
49
+ let rest = text;
50
+ while (rest.length > maxChars) {
51
+ out.push(rest.slice(0, maxChars));
52
+ rest = rest.slice(maxChars);
53
+ }
54
+ if (rest !== "") out.push(rest);
55
+ return out;
56
+ }
57
+
58
+ /** 取一段文本的尾部作为下一块的重叠前缀(按句子对齐,不从字中间切)。 */
59
+ function overlapTail(text, overlapChars) {
60
+ if (overlapChars <= 0) return "";
61
+ const s = String(text ?? "");
62
+ if (s.length <= overlapChars) return s;
63
+ const tail = s.slice(-overlapChars);
64
+ // 尽量从句子边界开始
65
+ const match = tail.match(/[。!?;.!?;]\s*/);
66
+ if (match && match.index !== undefined && match.index < overlapChars * 0.6) {
67
+ return tail.slice(match.index + match[0].length);
68
+ }
69
+ return tail;
70
+ }
71
+
72
+ /**
73
+ * 把一个段落序列打包成块。
74
+ * @param {{text: string, heading?: string, level?: number, page?: number|null, meta?: object}[]} blocks
75
+ */
76
+ export function packBlocks(blocks, options = {}) {
77
+ const maxChars = Math.max(MIN_PART_CHARS, Math.round(Number(options.partChars) || DEFAULT_PART_CHARS));
78
+ const overlap = Math.max(0, Math.round(Number(options.overlapChars) ?? DEFAULT_OVERLAP_CHARS));
79
+ const minChars = Math.min(MIN_PART_CHARS, Math.floor(maxChars / 4));
80
+
81
+ const parts = [];
82
+ let buffer = "";
83
+ let bufferHeading = "";
84
+ let bufferLevel = 0;
85
+ let bufferPage = null;
86
+ let bufferMeta = null;
87
+
88
+ const flush = () => {
89
+ const text = collapse(buffer);
90
+ buffer = "";
91
+ if (text === "") return;
92
+ parts.push({
93
+ text,
94
+ heading: bufferHeading,
95
+ level: bufferLevel,
96
+ page: bufferPage,
97
+ meta: bufferMeta ?? {},
98
+ chars: text.length,
99
+ tokens: estimateTokens(text),
100
+ });
101
+ const tail = overlapTail(text, overlap);
102
+ buffer = tail;
103
+ bufferPage = bufferPage;
104
+ };
105
+
106
+ for (const block of blocks) {
107
+ const text = collapse(block?.text);
108
+ if (text === "") continue;
109
+ const heading = String(block?.heading ?? "");
110
+ const level = Number(block?.level) || 0;
111
+
112
+ // 标题变化:先把当前缓冲收口,避免「上一节的内容」混进下一节的块
113
+ const headingChanged = heading !== bufferHeading;
114
+ if (headingChanged && buffer.trim() !== "") flush();
115
+ if (headingChanged) {
116
+ bufferHeading = heading;
117
+ bufferLevel = level;
118
+ bufferPage = block?.page ?? null;
119
+ bufferMeta = block?.meta ?? null;
120
+ } else if (bufferPage === null && block?.page != null) {
121
+ bufferPage = block.page;
122
+ }
123
+
124
+ if (text.length > maxChars) {
125
+ if (buffer.trim() !== "") flush();
126
+ // 超长块:段落 → 句子 → 硬切
127
+ const paragraphs = splitParagraphs(text);
128
+ const units = paragraphs.flatMap((p) => (p.length > maxChars ? splitSentences(p) : [p]));
129
+ const finalUnits = units.flatMap((u) => (u.length > maxChars ? hardSplit(u, maxChars) : [u]));
130
+ let piece = "";
131
+ for (const unit of finalUnits) {
132
+ if (piece.length + unit.length + 2 > maxChars && piece.trim() !== "") {
133
+ buffer = piece;
134
+ bufferPage = block?.page ?? bufferPage;
135
+ flush();
136
+ piece = overlapTail(collapse(piece), overlap);
137
+ }
138
+ piece = piece === "" ? unit : `${piece}\n\n${unit}`;
139
+ }
140
+ if (piece.trim() !== "") {
141
+ buffer = buffer === "" ? piece : `${buffer}\n\n${piece}`;
142
+ }
143
+ continue;
144
+ }
145
+
146
+ if (buffer.trim() === "") {
147
+ buffer = text;
148
+ } else if (buffer.length + text.length + 2 > maxChars) {
149
+ flush();
150
+ buffer = buffer === "" ? text : `${buffer}\n\n${text}`;
151
+ } else {
152
+ buffer = `${buffer}\n\n${text}`;
153
+ }
154
+ }
155
+
156
+ // 收尾:最后一段不足 minChars 且已有前一块时,并进前一块
157
+ const tail = collapse(buffer);
158
+ if (tail !== "") {
159
+ if (parts.length > 0 && tail.length < minChars) {
160
+ parts[parts.length - 1].text = `${parts[parts.length - 1].text}\n\n${tail}`;
161
+ parts[parts.length - 1].chars = parts[parts.length - 1].text.length;
162
+ parts[parts.length - 1].tokens = estimateTokens(parts[parts.length - 1].text);
163
+ } else {
164
+ parts.push({
165
+ text: tail,
166
+ heading: bufferHeading,
167
+ level: bufferLevel,
168
+ page: bufferPage,
169
+ meta: bufferMeta ?? {},
170
+ chars: tail.length,
171
+ tokens: estimateTokens(tail),
172
+ });
173
+ }
174
+ }
175
+
176
+ // 再兜一层:单个碎片不足 minChars 的并入前一/后一块
177
+ const merged = [];
178
+ for (const part of parts) {
179
+ const prev = merged[merged.length - 1];
180
+ if (part.chars < minChars && prev && prev.chars + part.chars <= maxChars * 1.5) {
181
+ prev.text = `${prev.text}\n\n${part.text}`;
182
+ prev.chars = prev.text.length;
183
+ prev.tokens = estimateTokens(prev.text);
184
+ if (prev.page === null) prev.page = part.page;
185
+ continue;
186
+ }
187
+ merged.push(part);
188
+ }
189
+
190
+ return merged.map((part, index) => ({
191
+ ...part,
192
+ index: index + 1,
193
+ }));
194
+ }
195
+
196
+ /** 长文档的粗目录:取所有 level<=2 的标题作为大纲。 */
197
+ export function outlineOf(parts) {
198
+ return parts
199
+ .filter((part) => part.heading && part.level > 0 && part.level <= 2)
200
+ .map((part) => ({ title: part.heading, level: part.level, page: part.page, part: part.index }))
201
+ .filter((item, index, list) => index === 0 || item.title !== list[index - 1].title);
202
+ }