@v1nvn/readability 0.44.0 → 0.45.1
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/README.md +3 -3
- package/package.json +7 -9
- package/src/cli.js +99 -0
- package/src/cli.ts +134 -0
- package/src/config.js +29 -0
- package/src/config.ts +42 -0
- package/src/dev.js +55 -0
- package/src/dev.ts +77 -0
- package/src/errors.js +16 -0
- package/src/errors.ts +19 -0
- package/src/handler.js +4 -0
- package/src/handler.ts +8 -0
- package/src/host-sampling.js +57 -0
- package/src/host-sampling.ts +86 -0
- package/src/index.js +23 -0
- package/src/index.ts +27 -0
- package/src/logger.js +2 -0
- package/src/logger.ts +3 -0
- package/src/output/format.js +92 -0
- package/src/output/format.ts +132 -0
- package/src/pipeline/cell-text.js +51 -0
- package/src/pipeline/cell-text.ts +56 -0
- package/src/pipeline/context.js +1 -0
- package/src/pipeline/context.ts +88 -0
- package/src/pipeline/dom.js +16 -0
- package/src/pipeline/dom.ts +23 -0
- package/src/pipeline/footnotes.js +148 -0
- package/src/pipeline/footnotes.ts +181 -0
- package/src/pipeline/math.js +113 -0
- package/src/pipeline/math.ts +130 -0
- package/src/pipeline/normalize.js +507 -0
- package/src/pipeline/normalize.ts +594 -0
- package/src/pipeline/readability.js +10 -0
- package/src/pipeline/readability.ts +36 -0
- package/src/pipeline/resolver.js +113 -0
- package/src/pipeline/resolver.ts +143 -0
- package/src/pipeline/sanitize.js +26 -0
- package/src/pipeline/sanitize.ts +42 -0
- package/src/pipeline/tables.js +239 -0
- package/src/pipeline/tables.ts +286 -0
- package/src/pipeline/turndown.js +153 -0
- package/src/pipeline/turndown.ts +191 -0
- package/src/pipeline/urls.js +14 -0
- package/src/pipeline/urls.ts +13 -0
- package/src/policy/chunk.js +365 -0
- package/src/policy/chunk.ts +482 -0
- package/src/policy/diagnostics.js +53 -0
- package/src/policy/diagnostics.ts +96 -0
- package/src/policy/explain.js +104 -0
- package/src/policy/explain.ts +182 -0
- package/src/policy/fallback.js +68 -0
- package/src/policy/fallback.ts +110 -0
- package/src/policy/gating.js +90 -0
- package/src/policy/gating.ts +106 -0
- package/src/policy/grid-detector.js +207 -0
- package/src/policy/grid-detector.ts +313 -0
- package/src/policy/identifiers.js +62 -0
- package/src/policy/identifiers.ts +75 -0
- package/src/policy/images.js +49 -0
- package/src/policy/images.ts +64 -0
- package/src/policy/list-detector.js +232 -0
- package/src/policy/list-detector.ts +308 -0
- package/src/policy/lost-signal.js +44 -0
- package/src/policy/lost-signal.ts +69 -0
- package/src/policy/markdown.js +58 -0
- package/src/policy/markdown.ts +76 -0
- package/src/policy/metadata.js +216 -0
- package/src/policy/metadata.ts +312 -0
- package/src/policy/outline-chains.js +165 -0
- package/src/policy/outline-chains.ts +215 -0
- package/src/policy/outline.js +71 -0
- package/src/policy/outline.ts +89 -0
- package/src/policy/pagination.js +79 -0
- package/src/policy/pagination.ts +104 -0
- package/src/policy/presets.js +100 -0
- package/src/policy/presets.ts +142 -0
- package/src/policy/section.js +111 -0
- package/src/policy/section.ts +127 -0
- package/src/policy/selector-lint.js +143 -0
- package/src/policy/selector-lint.ts +186 -0
- package/src/policy/sibling-scan.js +92 -0
- package/src/policy/sibling-scan.ts +99 -0
- package/src/policy/text.js +18 -0
- package/src/policy/text.ts +36 -0
- package/src/policy/truncate.js +45 -0
- package/src/policy/truncate.ts +59 -0
- package/src/preset-cache.js +165 -0
- package/src/preset-cache.ts +221 -0
- package/src/resources.js +227 -0
- package/src/resources.ts +317 -0
- package/src/sampling.js +45 -0
- package/src/sampling.ts +74 -0
- package/src/server.js +73 -0
- package/src/server.ts +85 -0
- package/src/tools/chunk_text.js +40 -0
- package/src/tools/chunk_text.ts +65 -0
- package/src/tools/explain.js +200 -0
- package/src/tools/explain.ts +299 -0
- package/src/tools/extract.js +263 -0
- package/src/tools/extract.ts +368 -0
- package/src/tools/extract_grid.js +56 -0
- package/src/tools/extract_grid.ts +90 -0
- package/src/tools/extract_links.js +98 -0
- package/src/tools/extract_links.ts +138 -0
- package/src/tools/extract_list.js +66 -0
- package/src/tools/extract_list.ts +97 -0
- package/src/tools/extract_metadata.js +67 -0
- package/src/tools/extract_metadata.ts +102 -0
- package/src/tools/extract_section.js +56 -0
- package/src/tools/extract_section.ts +84 -0
- package/src/tools/extract_tables.js +59 -0
- package/src/tools/extract_tables.ts +101 -0
- package/src/tools/html-source.js +30 -0
- package/src/tools/html-source.ts +40 -0
- package/src/tools/html_to_markdown.js +113 -0
- package/src/tools/html_to_markdown.ts +180 -0
- package/src/tools/outline.js +47 -0
- package/src/tools/outline.ts +77 -0
- package/src/tools/output-schema.js +504 -0
- package/src/tools/output-schema.ts +715 -0
- package/src/tools/schemas.js +288 -0
- package/src/tools/schemas.ts +442 -0
- package/src/tools/suggest-preset.js +504 -0
- package/src/tools/suggest-preset.ts +708 -0
- package/src/types/jsdom.d.ts +15 -0
- package/src/types/turndown-plugin-gfm.d.ts +11 -0
- package/dist/assets/cli-Dfjg7dOQ.js +0 -76
- package/dist/assets/cli-Dfjg7dOQ.js.map +0 -1
- package/dist/assets/extract-CdDp-KwX.js +0 -2880
- package/dist/assets/extract-CdDp-KwX.js.map +0 -1
- package/dist/index.js +0 -2267
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// Token-bounded chunking of extracted markdown for RAG/embedding. Chunk
|
|
2
|
+
// `tokenCount` uses the shared chars/4 estimator (policy/text.ts), keeping it
|
|
3
|
+
// directly comparable to `metadata.tokenEstimate`. This is the server-side
|
|
4
|
+
// chunking path for large documents; transport-level streaming was rejected —
|
|
5
|
+
// MCP tool-result progress isn't universally host-supported, and this option
|
|
6
|
+
// already keeps a multi-MB page from returning as one payload.
|
|
7
|
+
|
|
8
|
+
import { hardSplitLines, headingText, parseBlocks } from './markdown.ts';
|
|
9
|
+
import { estimateTokens } from './text.ts';
|
|
10
|
+
|
|
11
|
+
export interface Chunk {
|
|
12
|
+
readonly headingContext: string;
|
|
13
|
+
readonly index: number;
|
|
14
|
+
readonly text: string;
|
|
15
|
+
readonly tokenCount: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ChunkStrategy = 'char' | 'semantic';
|
|
19
|
+
|
|
20
|
+
export interface ChunkOptions {
|
|
21
|
+
readonly maxTokens: number;
|
|
22
|
+
readonly overlap: number;
|
|
23
|
+
readonly strategy: ChunkStrategy;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Block {
|
|
27
|
+
readonly headingContext: string;
|
|
28
|
+
readonly text: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Character offsets of a contributing unit within the in-progress chunk text;
|
|
32
|
+
// used to recover the heading context at the start of an overlap tail.
|
|
33
|
+
interface Span {
|
|
34
|
+
readonly end: number;
|
|
35
|
+
readonly headingContext: string;
|
|
36
|
+
readonly start: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const HEADING_FIRST_LINE = /^#{1,6}\s/;
|
|
40
|
+
|
|
41
|
+
function splitBlocks(markdown: string): Block[] {
|
|
42
|
+
const parts = markdown.split(/\n{2,}/);
|
|
43
|
+
const blocks: Block[] = [];
|
|
44
|
+
let heading = '';
|
|
45
|
+
for (const part of parts) {
|
|
46
|
+
const text = part.trim();
|
|
47
|
+
if (!text) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (HEADING_FIRST_LINE.test(text)) {
|
|
51
|
+
heading = headingText(text);
|
|
52
|
+
}
|
|
53
|
+
blocks.push({ headingContext: heading, text });
|
|
54
|
+
}
|
|
55
|
+
return blocks;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Char strategy may break a code block — the semantic strategy avoids that.
|
|
59
|
+
function splitOversizedBlock(block: Block, maxChars: number): Block[] {
|
|
60
|
+
return hardSplitLines(block.text, maxChars).map(text => ({
|
|
61
|
+
headingContext: block.headingContext,
|
|
62
|
+
text,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toUnits(blocks: readonly Block[], maxChars: number): Block[] {
|
|
67
|
+
const units: Block[] = [];
|
|
68
|
+
for (const block of blocks) {
|
|
69
|
+
if (block.text.length <= maxChars) {
|
|
70
|
+
units.push(block);
|
|
71
|
+
} else {
|
|
72
|
+
units.push(...splitOversizedBlock(block, maxChars));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return units;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function chunkMarkdownChar(
|
|
79
|
+
markdown: string,
|
|
80
|
+
options: Readonly<ChunkOptions>,
|
|
81
|
+
): Chunk[] {
|
|
82
|
+
if (!markdown) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const maxTokens = Math.max(1, Math.floor(options.maxTokens));
|
|
86
|
+
const maxChars = maxTokens * 4;
|
|
87
|
+
const overlapTokens = Math.max(0, Math.floor(options.overlap));
|
|
88
|
+
// Overlap must be strictly less than maxChars — otherwise a chunk's tail
|
|
89
|
+
// could consume the entire budget and we'd loop forever re-emitting it.
|
|
90
|
+
const overlapChars = Math.min(overlapTokens * 4, maxChars - 1);
|
|
91
|
+
|
|
92
|
+
const blocks = splitBlocks(markdown);
|
|
93
|
+
if (blocks.length === 0) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const units = toUnits(blocks, maxChars);
|
|
97
|
+
if (units.length === 0) {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const chunks: Chunk[] = [];
|
|
102
|
+
let i = 0;
|
|
103
|
+
let overlapText = '';
|
|
104
|
+
let overlapHeading = '';
|
|
105
|
+
|
|
106
|
+
while (i < units.length) {
|
|
107
|
+
const spans: Span[] = [];
|
|
108
|
+
let chunkText = '';
|
|
109
|
+
|
|
110
|
+
if (overlapText) {
|
|
111
|
+
chunkText = overlapText;
|
|
112
|
+
spans.push({
|
|
113
|
+
end: overlapText.length,
|
|
114
|
+
headingContext: overlapHeading,
|
|
115
|
+
start: 0,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Force at least one new unit per chunk so the loop terminates. If carrying
|
|
120
|
+
// the overlap would push the first new unit past maxChars, drop the overlap
|
|
121
|
+
// for this chunk (the unit was pre-split to <= maxChars so it always fits alone).
|
|
122
|
+
const firstUnit = units[i];
|
|
123
|
+
const sepLen = chunkText ? 2 : 0;
|
|
124
|
+
if (
|
|
125
|
+
chunkText &&
|
|
126
|
+
chunkText.length + sepLen + firstUnit.text.length > maxChars
|
|
127
|
+
) {
|
|
128
|
+
chunkText = '';
|
|
129
|
+
spans.length = 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
{
|
|
133
|
+
const sep = chunkText ? '\n\n' : '';
|
|
134
|
+
const start = chunkText.length + sep.length;
|
|
135
|
+
chunkText = chunkText + sep + firstUnit.text;
|
|
136
|
+
spans.push({
|
|
137
|
+
end: chunkText.length,
|
|
138
|
+
headingContext: firstUnit.headingContext,
|
|
139
|
+
start,
|
|
140
|
+
});
|
|
141
|
+
i += 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
while (i < units.length) {
|
|
145
|
+
const unit = units[i];
|
|
146
|
+
const candidate = `${chunkText}\n\n${unit.text}`;
|
|
147
|
+
if (candidate.length > maxChars) {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
const start = chunkText.length + 2;
|
|
151
|
+
chunkText = candidate;
|
|
152
|
+
spans.push({
|
|
153
|
+
end: chunkText.length,
|
|
154
|
+
headingContext: unit.headingContext,
|
|
155
|
+
start,
|
|
156
|
+
});
|
|
157
|
+
i += 1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const text = chunkText.trim();
|
|
161
|
+
if (text) {
|
|
162
|
+
chunks.push({
|
|
163
|
+
headingContext: spans[0]?.headingContext ?? '',
|
|
164
|
+
index: chunks.length,
|
|
165
|
+
text,
|
|
166
|
+
tokenCount: estimateTokens(text).tokenEstimate,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (overlapChars > 0 && i < units.length) {
|
|
171
|
+
const overlapStart = Math.max(0, chunkText.length - overlapChars);
|
|
172
|
+
const carrier =
|
|
173
|
+
spans.find(
|
|
174
|
+
span => overlapStart >= span.start && overlapStart < span.end,
|
|
175
|
+
) ?? spans.find(span => span.start >= overlapStart);
|
|
176
|
+
overlapHeading = carrier?.headingContext ?? '';
|
|
177
|
+
overlapText = chunkText.slice(overlapStart).replace(/^\n+/, '');
|
|
178
|
+
} else {
|
|
179
|
+
overlapText = '';
|
|
180
|
+
overlapHeading = '';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return chunks;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- Semantic strategy ------------------------------------------------------
|
|
188
|
+
//
|
|
189
|
+
// Break on heading/section boundaries and never split a fenced code block. Each
|
|
190
|
+
// top-level mdast node is one unit; a fenced code block is a single `code` node
|
|
191
|
+
// so it stays atomic for free.
|
|
192
|
+
|
|
193
|
+
type SemanticUnitKind = 'code' | 'heading' | 'text';
|
|
194
|
+
|
|
195
|
+
interface SemanticUnit {
|
|
196
|
+
readonly headingContext: string;
|
|
197
|
+
readonly kind: SemanticUnitKind;
|
|
198
|
+
readonly text: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface SemanticSection {
|
|
202
|
+
readonly headingContext: string;
|
|
203
|
+
readonly units: readonly SemanticUnit[];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// A group is the unit-list slice that becomes one chunk's body, plus the
|
|
207
|
+
// heading-context of its first non-carrier unit (so prepending an overlap
|
|
208
|
+
// carrier cannot steal the heading's context).
|
|
209
|
+
interface SemanticGroup {
|
|
210
|
+
readonly headingContext: string;
|
|
211
|
+
readonly units: readonly SemanticUnit[];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseSemanticUnits(markdown: string): SemanticUnit[] {
|
|
215
|
+
const units: SemanticUnit[] = [];
|
|
216
|
+
const stack: { level: number; text: string }[] = [];
|
|
217
|
+
|
|
218
|
+
function context(): string {
|
|
219
|
+
return stack.map(h => h.text).join(' > ');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const block of parseBlocks(markdown)) {
|
|
223
|
+
const text = markdown.slice(block.start, block.end);
|
|
224
|
+
if (block.kind === 'heading') {
|
|
225
|
+
while (stack.length > 0 && stack[stack.length - 1].level >= block.depth) {
|
|
226
|
+
stack.pop();
|
|
227
|
+
}
|
|
228
|
+
stack.push({ level: block.depth, text: headingText(text) });
|
|
229
|
+
units.push({ headingContext: context(), kind: 'heading', text });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
units.push({
|
|
233
|
+
headingContext: context(),
|
|
234
|
+
kind: block.kind === 'code' ? 'code' : 'text',
|
|
235
|
+
text,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return units;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// A section = a heading unit plus its following non-heading units until the
|
|
242
|
+
// next heading. Pre-first-heading content forms its own section with an empty
|
|
243
|
+
// hierarchy path.
|
|
244
|
+
function groupSections(units: readonly SemanticUnit[]): SemanticSection[] {
|
|
245
|
+
const sections: SemanticSection[] = [];
|
|
246
|
+
let current: SemanticUnit[] = [];
|
|
247
|
+
let currentContext = '';
|
|
248
|
+
|
|
249
|
+
function flush(): void {
|
|
250
|
+
if (current.length > 0) {
|
|
251
|
+
sections.push({ headingContext: currentContext, units: current });
|
|
252
|
+
current = [];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (const unit of units) {
|
|
257
|
+
if (unit.kind === 'heading') {
|
|
258
|
+
flush();
|
|
259
|
+
current = [unit];
|
|
260
|
+
currentContext = unit.headingContext;
|
|
261
|
+
} else {
|
|
262
|
+
if (current.length === 0) {
|
|
263
|
+
currentContext = unit.headingContext;
|
|
264
|
+
}
|
|
265
|
+
current.push(unit);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
flush();
|
|
269
|
+
return sections;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function joinedLength(units: readonly SemanticUnit[]): number {
|
|
273
|
+
if (units.length === 0) {
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
let total = units[0].text.length;
|
|
277
|
+
for (let i = 1; i < units.length; i++) {
|
|
278
|
+
total += 2 + units[i].text.length;
|
|
279
|
+
}
|
|
280
|
+
return total;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Hard-split a text unit that alone exceeds the budget — line-aware first, then
|
|
284
|
+
// hard-split any oversized line. Only ever applied to paragraphs; code blocks
|
|
285
|
+
// are never passed through here.
|
|
286
|
+
function splitOversizedTextUnit(
|
|
287
|
+
unit: SemanticUnit,
|
|
288
|
+
maxChars: number,
|
|
289
|
+
): SemanticUnit[] {
|
|
290
|
+
return hardSplitLines(unit.text, maxChars).map(text => ({
|
|
291
|
+
headingContext: unit.headingContext,
|
|
292
|
+
kind: 'text',
|
|
293
|
+
text,
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Pack sections into chunk groups <= maxChars, preferring heading boundaries.
|
|
298
|
+
// A section that fits is never split; an oversized section is split into
|
|
299
|
+
// sub-chunks by body unit (paragraphs may split; code blocks never do).
|
|
300
|
+
function buildBaseGroups(
|
|
301
|
+
sections: readonly SemanticSection[],
|
|
302
|
+
maxChars: number,
|
|
303
|
+
): SemanticUnit[][] {
|
|
304
|
+
const groups: SemanticUnit[][] = [];
|
|
305
|
+
let current: SemanticUnit[] = [];
|
|
306
|
+
let currentLen = 0;
|
|
307
|
+
|
|
308
|
+
function emit(): void {
|
|
309
|
+
if (current.length > 0) {
|
|
310
|
+
groups.push(current);
|
|
311
|
+
current = [];
|
|
312
|
+
currentLen = 0;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function append(unit: SemanticUnit): void {
|
|
317
|
+
const sep = current.length > 0 ? 2 : 0;
|
|
318
|
+
current.push(unit);
|
|
319
|
+
currentLen += sep + unit.text.length;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
for (const section of sections) {
|
|
323
|
+
const sectionLen = joinedLength(section.units);
|
|
324
|
+
|
|
325
|
+
const sep = current.length > 0 ? 2 : 0;
|
|
326
|
+
if (currentLen + sep + sectionLen <= maxChars) {
|
|
327
|
+
for (const unit of section.units) {
|
|
328
|
+
append(unit);
|
|
329
|
+
}
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Doesn't fit merged — close the current group and retry the section alone
|
|
334
|
+
// so it has first dibs on the next group (it may still merge with a later
|
|
335
|
+
// section that fits behind it).
|
|
336
|
+
emit();
|
|
337
|
+
|
|
338
|
+
if (sectionLen <= maxChars) {
|
|
339
|
+
for (const unit of section.units) {
|
|
340
|
+
append(unit);
|
|
341
|
+
}
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Oversized section: pack body units under the heading; split oversized
|
|
346
|
+
// paragraphs, emit oversized code blocks whole.
|
|
347
|
+
const [headingUnit, ...body] = section.units;
|
|
348
|
+
current.push(headingUnit);
|
|
349
|
+
currentLen = headingUnit.text.length;
|
|
350
|
+
for (const unit of body) {
|
|
351
|
+
if (unit.kind === 'code' && unit.text.length > maxChars) {
|
|
352
|
+
// Semantic never splits a code fence: a fence cut mid-way is
|
|
353
|
+
// unrecoverable, so the block is emitted as its own chunk even though
|
|
354
|
+
// it exceeds the token budget. Deliberate tradeoff vs. the char cap.
|
|
355
|
+
emit();
|
|
356
|
+
groups.push([unit]);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const sepNow = current.length > 0 ? 2 : 0;
|
|
360
|
+
if (
|
|
361
|
+
unit.text.length <= maxChars &&
|
|
362
|
+
currentLen + sepNow + unit.text.length <= maxChars
|
|
363
|
+
) {
|
|
364
|
+
append(unit);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const pieces =
|
|
368
|
+
unit.text.length > maxChars
|
|
369
|
+
? splitOversizedTextUnit(unit, maxChars)
|
|
370
|
+
: [unit];
|
|
371
|
+
for (const piece of pieces) {
|
|
372
|
+
const pieceSep = current.length > 0 ? 2 : 0;
|
|
373
|
+
if (
|
|
374
|
+
current.length > 0 &&
|
|
375
|
+
currentLen + pieceSep + piece.text.length > maxChars
|
|
376
|
+
) {
|
|
377
|
+
emit();
|
|
378
|
+
}
|
|
379
|
+
append(piece);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// Leave the trailing sub-chunk in `current` so a following section can
|
|
383
|
+
// merge into it if it fits.
|
|
384
|
+
}
|
|
385
|
+
emit();
|
|
386
|
+
return groups;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Carry the trailing text (non-code) units from the previous group as the
|
|
390
|
+
// prefix of the next, up to overlapChars. Never carry into or out of a code
|
|
391
|
+
// block: a trailing code run is skipped, then contiguous text units before it
|
|
392
|
+
// are carried (stop at the first heading or code block encountered).
|
|
393
|
+
function applySemanticOverlap(
|
|
394
|
+
groups: readonly SemanticUnit[][],
|
|
395
|
+
overlapChars: number,
|
|
396
|
+
): SemanticGroup[] {
|
|
397
|
+
const result: SemanticGroup[] = [];
|
|
398
|
+
for (let i = 0; i < groups.length; i++) {
|
|
399
|
+
const group = groups[i];
|
|
400
|
+
const carrier: SemanticUnit[] = [];
|
|
401
|
+
if (i > 0 && overlapChars > 0) {
|
|
402
|
+
const prev = groups[i - 1];
|
|
403
|
+
let end = prev.length;
|
|
404
|
+
while (end > 0 && prev[end - 1].kind === 'code') {
|
|
405
|
+
end -= 1;
|
|
406
|
+
}
|
|
407
|
+
let carrierLen = 0;
|
|
408
|
+
for (let j = end - 1; j >= 0; j--) {
|
|
409
|
+
const unit = prev[j];
|
|
410
|
+
if (unit.kind !== 'text') {
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
const sep = carrier.length > 0 ? 2 : 0;
|
|
414
|
+
if (carrierLen + sep + unit.text.length > overlapChars) {
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
carrier.unshift(unit);
|
|
418
|
+
carrierLen += sep + unit.text.length;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const units = carrier.length === 0 ? group : [...carrier, ...group];
|
|
422
|
+
result.push({
|
|
423
|
+
// The chunk's heading context follows its first non-carrier unit, so a
|
|
424
|
+
// heading-leading chunk keeps its heading's path even with overlap.
|
|
425
|
+
headingContext: group[0]?.headingContext ?? '',
|
|
426
|
+
units,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function chunkMarkdownSemantic(
|
|
433
|
+
markdown: string,
|
|
434
|
+
options: Readonly<ChunkOptions>,
|
|
435
|
+
): Chunk[] {
|
|
436
|
+
if (!markdown) {
|
|
437
|
+
return [];
|
|
438
|
+
}
|
|
439
|
+
const maxTokens = Math.max(1, Math.floor(options.maxTokens));
|
|
440
|
+
const maxChars = maxTokens * 4;
|
|
441
|
+
const overlapTokens = Math.max(0, Math.floor(options.overlap));
|
|
442
|
+
const overlapChars = Math.min(overlapTokens * 4, maxChars - 1);
|
|
443
|
+
|
|
444
|
+
const units = parseSemanticUnits(markdown);
|
|
445
|
+
if (units.length === 0) {
|
|
446
|
+
return [];
|
|
447
|
+
}
|
|
448
|
+
const sections = groupSections(units);
|
|
449
|
+
const baseGroups = buildBaseGroups(sections, maxChars);
|
|
450
|
+
const groups = applySemanticOverlap(baseGroups, overlapChars);
|
|
451
|
+
|
|
452
|
+
const chunks: Chunk[] = [];
|
|
453
|
+
for (const group of groups) {
|
|
454
|
+
if (group.units.length === 0) {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const text = group.units
|
|
458
|
+
.map(unit => unit.text)
|
|
459
|
+
.join('\n\n')
|
|
460
|
+
.trim();
|
|
461
|
+
if (!text) {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
chunks.push({
|
|
465
|
+
headingContext: group.headingContext,
|
|
466
|
+
index: chunks.length,
|
|
467
|
+
text,
|
|
468
|
+
tokenCount: estimateTokens(text).tokenEstimate,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return chunks;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function chunkMarkdown(
|
|
475
|
+
markdown: string,
|
|
476
|
+
options: Readonly<ChunkOptions>,
|
|
477
|
+
): Chunk[] {
|
|
478
|
+
if (options.strategy === 'semantic') {
|
|
479
|
+
return chunkMarkdownSemantic(markdown, options);
|
|
480
|
+
}
|
|
481
|
+
return chunkMarkdownChar(markdown, options);
|
|
482
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
function countElements(html, window) {
|
|
2
|
+
if (!window || !html) {
|
|
3
|
+
return 0;
|
|
4
|
+
}
|
|
5
|
+
const template = window.document.createElement('div');
|
|
6
|
+
template.innerHTML = html;
|
|
7
|
+
return template.querySelectorAll('*').length;
|
|
8
|
+
}
|
|
9
|
+
export function assembleDiagnostics(input) {
|
|
10
|
+
const articleElementCount = countElements(input.articleHtml ?? '', input.window);
|
|
11
|
+
const removedNodes = Math.max(0, (input.documentElementCount ?? 0) - articleElementCount);
|
|
12
|
+
return {
|
|
13
|
+
readerable: input.readerable,
|
|
14
|
+
extractedNode: input.extractedNode,
|
|
15
|
+
fallbackUsed: input.fallbackUsed ?? false,
|
|
16
|
+
gated: input.gated,
|
|
17
|
+
imagesResolved: input.imagesResolved,
|
|
18
|
+
pagination: input.pagination,
|
|
19
|
+
removedNodes,
|
|
20
|
+
boilerplateRemoved: input.boilerplateRemoved,
|
|
21
|
+
chromeRemoved: input.chromeRemoved,
|
|
22
|
+
sanitization: input.sanitization,
|
|
23
|
+
trace: input.trace,
|
|
24
|
+
truncated: input.truncated ?? false,
|
|
25
|
+
...(input.cache ? { cache: input.cache } : {}),
|
|
26
|
+
...(input.preset ? { preset: input.preset } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// Emits trace entries only when enabled, so the debug-off hot path allocates
|
|
30
|
+
// nothing (the fn runs bare). Used by the orchestrator tools to time pipeline
|
|
31
|
+
// stages without threading timers into lower layers.
|
|
32
|
+
export class TraceCollector {
|
|
33
|
+
enabled;
|
|
34
|
+
entries = [];
|
|
35
|
+
constructor(enabled) {
|
|
36
|
+
this.enabled = enabled;
|
|
37
|
+
}
|
|
38
|
+
collect() {
|
|
39
|
+
return this.enabled ? this.entries : undefined;
|
|
40
|
+
}
|
|
41
|
+
run(stage, fn) {
|
|
42
|
+
if (!this.enabled) {
|
|
43
|
+
return fn();
|
|
44
|
+
}
|
|
45
|
+
const start = performance.now();
|
|
46
|
+
try {
|
|
47
|
+
return fn();
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
this.entries.push({ ms: performance.now() - start, stage });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CacheSignal,
|
|
3
|
+
Diagnostics,
|
|
4
|
+
SanitizationDiagnostics,
|
|
5
|
+
TraceStage,
|
|
6
|
+
} from '../pipeline/context.ts';
|
|
7
|
+
import type { GatingSignal } from './gating.ts';
|
|
8
|
+
import type { PaginationSignal } from './pagination.ts';
|
|
9
|
+
import type { PresetSignal } from './presets.ts';
|
|
10
|
+
|
|
11
|
+
export interface DiagnosticsInput {
|
|
12
|
+
readonly articleHtml?: string;
|
|
13
|
+
readonly boilerplateRemoved?: number;
|
|
14
|
+
readonly cache?: CacheSignal;
|
|
15
|
+
readonly chromeRemoved?: number;
|
|
16
|
+
readonly document?: Document;
|
|
17
|
+
readonly documentElementCount?: number;
|
|
18
|
+
readonly extractedNode?: string;
|
|
19
|
+
readonly fallbackUsed?: boolean;
|
|
20
|
+
readonly gated?: GatingSignal;
|
|
21
|
+
readonly imagesResolved?: number;
|
|
22
|
+
readonly pagination?: PaginationSignal;
|
|
23
|
+
readonly preset?: PresetSignal;
|
|
24
|
+
readonly readerable?: boolean;
|
|
25
|
+
readonly sanitization?: SanitizationDiagnostics;
|
|
26
|
+
readonly trace?: readonly TraceStage[];
|
|
27
|
+
readonly truncated?: boolean;
|
|
28
|
+
readonly window?: Window;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function countElements(html: string, window?: Window): number {
|
|
32
|
+
if (!window || !html) {
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
const template = window.document.createElement('div');
|
|
36
|
+
template.innerHTML = html;
|
|
37
|
+
return template.querySelectorAll('*').length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function assembleDiagnostics(
|
|
41
|
+
input: Readonly<DiagnosticsInput>,
|
|
42
|
+
): Diagnostics {
|
|
43
|
+
const articleElementCount = countElements(
|
|
44
|
+
input.articleHtml ?? '',
|
|
45
|
+
input.window,
|
|
46
|
+
);
|
|
47
|
+
const removedNodes = Math.max(
|
|
48
|
+
0,
|
|
49
|
+
(input.documentElementCount ?? 0) - articleElementCount,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
readerable: input.readerable,
|
|
54
|
+
extractedNode: input.extractedNode,
|
|
55
|
+
fallbackUsed: input.fallbackUsed ?? false,
|
|
56
|
+
gated: input.gated,
|
|
57
|
+
imagesResolved: input.imagesResolved,
|
|
58
|
+
pagination: input.pagination,
|
|
59
|
+
removedNodes,
|
|
60
|
+
boilerplateRemoved: input.boilerplateRemoved,
|
|
61
|
+
chromeRemoved: input.chromeRemoved,
|
|
62
|
+
sanitization: input.sanitization,
|
|
63
|
+
trace: input.trace,
|
|
64
|
+
truncated: input.truncated ?? false,
|
|
65
|
+
...(input.cache ? { cache: input.cache } : {}),
|
|
66
|
+
...(input.preset ? { preset: input.preset } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Emits trace entries only when enabled, so the debug-off hot path allocates
|
|
71
|
+
// nothing (the fn runs bare). Used by the orchestrator tools to time pipeline
|
|
72
|
+
// stages without threading timers into lower layers.
|
|
73
|
+
export class TraceCollector {
|
|
74
|
+
private readonly enabled: boolean;
|
|
75
|
+
private readonly entries: TraceStage[] = [];
|
|
76
|
+
|
|
77
|
+
constructor(enabled: boolean) {
|
|
78
|
+
this.enabled = enabled;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
collect(): readonly TraceStage[] | undefined {
|
|
82
|
+
return this.enabled ? this.entries : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
run<T>(stage: string, fn: () => T): T {
|
|
86
|
+
if (!this.enabled) {
|
|
87
|
+
return fn();
|
|
88
|
+
}
|
|
89
|
+
const start = performance.now();
|
|
90
|
+
try {
|
|
91
|
+
return fn();
|
|
92
|
+
} finally {
|
|
93
|
+
this.entries.push({ ms: performance.now() - start, stage });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|