@zenera/rag 1.1.9 → 1.1.11
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 +195 -10
- package/dist/command.js +2 -1
- package/dist/common/cache.d.ts +66 -0
- package/dist/common/cache.js +172 -0
- package/dist/common/embedder.d.ts +11 -1
- package/dist/common/embedder.js +6 -17
- package/dist/common/manifest.d.ts +7 -1
- package/dist/common/progress.d.ts +16 -1
- package/dist/common/progress.js +35 -3
- package/dist/common/prose.d.ts +7 -0
- package/dist/common/prose.js +13 -0
- package/dist/docs/assemble.d.ts +52 -0
- package/dist/docs/assemble.js +127 -0
- package/dist/docs/build.d.ts +54 -0
- package/dist/docs/build.js +134 -0
- package/dist/docs/chunk.d.ts +73 -0
- package/dist/docs/chunk.js +586 -0
- package/dist/docs/command.d.ts +3 -0
- package/dist/docs/command.js +575 -0
- package/dist/docs/files.d.ts +94 -0
- package/dist/docs/files.js +80 -0
- package/dist/docs/index.d.ts +13 -0
- package/dist/docs/index.js +13 -0
- package/dist/docs/load.d.ts +42 -0
- package/dist/docs/load.js +203 -0
- package/dist/docs/lookup.d.ts +80 -0
- package/dist/docs/lookup.js +147 -0
- package/dist/docs/outline.d.ts +11 -0
- package/dist/docs/outline.js +55 -0
- package/dist/docs/parse-cache.d.ts +20 -0
- package/dist/docs/parse-cache.js +60 -0
- package/dist/docs/parse-worker.d.ts +13 -0
- package/dist/docs/parse-worker.js +21 -0
- package/dist/docs/parse.d.ts +95 -0
- package/dist/docs/parse.js +372 -0
- package/dist/docs/pool.d.ts +27 -0
- package/dist/docs/pool.js +133 -0
- package/dist/docs/readme.d.ts +6 -0
- package/dist/docs/readme.js +142 -0
- package/dist/docs/render.d.ts +13 -0
- package/dist/docs/render.js +46 -0
- package/dist/docs/repl.d.ts +7 -0
- package/dist/docs/repl.js +130 -0
- package/dist/docs/search.d.ts +92 -0
- package/dist/docs/search.js +251 -0
- package/dist/docs/store.d.ts +79 -0
- package/dist/docs/store.js +214 -0
- package/dist/docs/tools.d.ts +10 -0
- package/dist/docs/tools.js +300 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/schema/build.d.ts +16 -2
- package/dist/schema/build.js +37 -25
- package/dist/schema/command.js +39 -10
- package/dist/schema/query.js +1 -0
- package/dist/schema/readme.js +6 -2
- package/dist/schema/search.d.ts +2 -0
- package/dist/schema/search.js +18 -2
- package/dist/schema/store.d.ts +13 -3
- package/dist/schema/store.js +73 -24
- package/dist/schema/tools.js +21 -2
- package/package.json +17 -4
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Cache, cacheKey } from '@zenera/cli/lib';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Not parsing the same document twice
|
|
5
|
+
//
|
|
6
|
+
// Once vectors are cached, parsing is what a rebuild of an unchanged corpus
|
|
7
|
+
// spends all of its time on — the threads make it several times faster, and
|
|
8
|
+
// this makes it nothing at all. A document that has not changed produces
|
|
9
|
+
// exactly the chunks it produced last time, so the chunks are what is kept.
|
|
10
|
+
//
|
|
11
|
+
// The key is the file's bytes together with everything that decides how they
|
|
12
|
+
// are cut: change the document, the chunk settings or this module, and the
|
|
13
|
+
// entry is a miss rather than a wrong answer. It sits in the machine's shared
|
|
14
|
+
// cache next to the vectors, so a corpus indexed into two directories is read
|
|
15
|
+
// once, and it follows the same rule everything there does — every error is a
|
|
16
|
+
// miss and nothing else.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
export const PARSE_KIND = 'docs-parse';
|
|
19
|
+
/** Bumped when chunking changes shape, which invalidates every entry. */
|
|
20
|
+
export const PARSE_VERSION = 1;
|
|
21
|
+
export const NO_PARSE_CACHE = {
|
|
22
|
+
get: () => undefined,
|
|
23
|
+
put: () => { },
|
|
24
|
+
commit: () => { },
|
|
25
|
+
hits: 0,
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* What the chunks depend on, and nothing else. `tokenCount` is a function and
|
|
29
|
+
* cannot be hashed; a caller that supplies one gets no cache rather than a key
|
|
30
|
+
* that quietly ignores it.
|
|
31
|
+
*/
|
|
32
|
+
export function parseKey(bytes, name, options) {
|
|
33
|
+
if (typeof options.tokenCount === 'function') {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return cacheKey(PARSE_VERSION, name, options.chunkTokens, options.minChunkTokens, options.maxChunkTokens, options.tableSliceTokens, createHash('sha256').update(bytes).digest('hex'));
|
|
37
|
+
}
|
|
38
|
+
export function openParseCache(dir) {
|
|
39
|
+
return new StoredParses(new Cache(PARSE_KIND, { dir }));
|
|
40
|
+
}
|
|
41
|
+
class StoredParses {
|
|
42
|
+
#store;
|
|
43
|
+
constructor(store) {
|
|
44
|
+
this.#store = store;
|
|
45
|
+
}
|
|
46
|
+
get hits() {
|
|
47
|
+
return this.#store.hits;
|
|
48
|
+
}
|
|
49
|
+
get(key) {
|
|
50
|
+
const found = this.#store.get(key);
|
|
51
|
+
return found?.chunks && found.outline ? found : undefined;
|
|
52
|
+
}
|
|
53
|
+
put(key, parsed) {
|
|
54
|
+
this.#store.put(key, { chunks: parsed.chunks, outline: parsed.outline });
|
|
55
|
+
}
|
|
56
|
+
commit() {
|
|
57
|
+
this.#store.commit();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=parse-cache.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface ParseJob {
|
|
2
|
+
at: number;
|
|
3
|
+
name: string;
|
|
4
|
+
text: string;
|
|
5
|
+
format: 'markdown' | 'text';
|
|
6
|
+
}
|
|
7
|
+
export interface ParseDone {
|
|
8
|
+
at: number;
|
|
9
|
+
chunks?: unknown;
|
|
10
|
+
outline?: unknown;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=parse-worker.d.ts.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { chunkDocument } from "./chunk.js";
|
|
3
|
+
import { outlineOf } from "./outline.js";
|
|
4
|
+
import { parseDocument } from "./parse.js";
|
|
5
|
+
const options = (workerData ?? {});
|
|
6
|
+
parentPort?.on('message', (job) => {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = parseDocument(job.text, job.name, job.format);
|
|
9
|
+
const chunks = chunkDocument(parsed, options);
|
|
10
|
+
parentPort.postMessage({ at: job.at, chunks, outline: outlineOf(parsed, chunks.length) });
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
// Sent back rather than thrown: an uncaught throw here kills the thread,
|
|
14
|
+
// and the pool would lose the other documents queued behind this one.
|
|
15
|
+
parentPort.postMessage({
|
|
16
|
+
at: job.at,
|
|
17
|
+
error: err instanceof Error ? err.message : String(err),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
//# sourceMappingURL=parse-worker.js.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** What a line, a block or a chunk is. The vocabulary is closed on purpose. */
|
|
2
|
+
export type StructureKind = 'heading' | 'paragraph' | 'blockquote' | 'list' | 'list_item' | 'table' | 'table_header' | 'table_row' | 'code' | 'frontmatter' | 'html' | 'hr';
|
|
3
|
+
/** The root of every path: a document is the structure everything else sits in. */
|
|
4
|
+
export declare const ROOT_SEGMENT = "doc";
|
|
5
|
+
export type DocFormat = 'markdown' | 'text';
|
|
6
|
+
/**
|
|
7
|
+
* A heading and everything under it, until a heading of the same depth or
|
|
8
|
+
* shallower. mdast has no such node — a heading there is a SIBLING of the
|
|
9
|
+
* paragraphs that follow it — so the nesting is built here.
|
|
10
|
+
*/
|
|
11
|
+
export interface Section {
|
|
12
|
+
id: string;
|
|
13
|
+
path: string;
|
|
14
|
+
title: string;
|
|
15
|
+
/** 0 for the document itself, otherwise the heading depth */
|
|
16
|
+
level: number;
|
|
17
|
+
/** the heading's own line; absent on the document root */
|
|
18
|
+
line: number | undefined;
|
|
19
|
+
parent: Section | undefined;
|
|
20
|
+
}
|
|
21
|
+
export interface TableRowBlock {
|
|
22
|
+
id: string;
|
|
23
|
+
path: string;
|
|
24
|
+
line: number;
|
|
25
|
+
cells: string[];
|
|
26
|
+
/** the row as prose, so it carries its own column names */
|
|
27
|
+
text: string;
|
|
28
|
+
}
|
|
29
|
+
export interface TableBlock {
|
|
30
|
+
columns: string[];
|
|
31
|
+
headerLine: number;
|
|
32
|
+
/** the alignment row; mdast emits no node for it, so it is derived */
|
|
33
|
+
separatorLine: number | undefined;
|
|
34
|
+
/** the paragraph before the table, which is the only caption one ever gets */
|
|
35
|
+
caption: string;
|
|
36
|
+
/** the column that identifies a row, repeated into every slice of it */
|
|
37
|
+
keyColumn: number;
|
|
38
|
+
rows: TableRowBlock[];
|
|
39
|
+
}
|
|
40
|
+
export interface ListItemBlock {
|
|
41
|
+
id: string;
|
|
42
|
+
path: string;
|
|
43
|
+
start: number;
|
|
44
|
+
end: number;
|
|
45
|
+
text: string;
|
|
46
|
+
}
|
|
47
|
+
export interface Block {
|
|
48
|
+
id: string;
|
|
49
|
+
path: string;
|
|
50
|
+
kind: StructureKind;
|
|
51
|
+
/** 1-based, inclusive */
|
|
52
|
+
start: number;
|
|
53
|
+
end: number;
|
|
54
|
+
section: Section;
|
|
55
|
+
/** normalized, never shown: this is what is embedded and indexed */
|
|
56
|
+
text: string;
|
|
57
|
+
items?: ListItemBlock[];
|
|
58
|
+
table?: TableBlock;
|
|
59
|
+
}
|
|
60
|
+
export interface ParsedDoc {
|
|
61
|
+
/** the document's name within the index — its path, and its identity */
|
|
62
|
+
name: string;
|
|
63
|
+
title: string;
|
|
64
|
+
format: DocFormat;
|
|
65
|
+
/** verbatim, CRLF normalized, 1-based when indexed as `lines[n - 1]` */
|
|
66
|
+
lines: string[];
|
|
67
|
+
sections: Section[];
|
|
68
|
+
blocks: Block[];
|
|
69
|
+
}
|
|
70
|
+
/** Every ancestor of a section, the document root first, itself last. */
|
|
71
|
+
export declare function ancestry(section: Section): Section[];
|
|
72
|
+
/** The breadcrumb, which prefixes every chunk of text under this section. */
|
|
73
|
+
export declare const headingPath: (section: Section) => string;
|
|
74
|
+
/** The heading lines a chunk under this section must be rendered with. */
|
|
75
|
+
export declare const headingLines: (section: Section) => number[];
|
|
76
|
+
/**
|
|
77
|
+
* Everything downstream indexes `lines` by number, and mdast positions refer to
|
|
78
|
+
* the exact string handed to the parser — so the normalization happens once,
|
|
79
|
+
* here, and the normalized text is the only version anything ever sees.
|
|
80
|
+
*/
|
|
81
|
+
export declare const normalize: (text: string) => string;
|
|
82
|
+
export declare function parseDocument(source: string, name: string, format: DocFormat): ParsedDoc;
|
|
83
|
+
/**
|
|
84
|
+
* A data row does not contain its own column names. Stripping the pipes leaves
|
|
85
|
+
* `V-200-30 3" WCC 98.2`, in which the word `Cv` appears nowhere, so a query
|
|
86
|
+
* naming a column could only ever match the header line and never a row — and
|
|
87
|
+
* hybrid search would silently become vector-only for every table in the
|
|
88
|
+
* corpus. Pairing each cell with its header is what fixes that, and it embeds
|
|
89
|
+
* better too, because `Cv: 45` is a sentence and `| 45 |` is not.
|
|
90
|
+
*/
|
|
91
|
+
export declare function rowText(columns: readonly string[], cells: readonly string[]): string;
|
|
92
|
+
export declare function cellText(column: string | undefined, cell: string): string;
|
|
93
|
+
/** Soft wraps inside a paragraph are wrapping, not meaning, so they rejoin. */
|
|
94
|
+
export declare const collapse: (text: string) => string;
|
|
95
|
+
//# sourceMappingURL=parse.d.ts.map
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import remarkFrontmatter from 'remark-frontmatter';
|
|
2
|
+
import remarkGfm from 'remark-gfm';
|
|
3
|
+
import remarkParse from 'remark-parse';
|
|
4
|
+
import { unified } from 'unified';
|
|
5
|
+
/** Path segments carry their kind, so heading ancestors stay recoverable. */
|
|
6
|
+
const SEGMENT = {
|
|
7
|
+
heading: 'sec',
|
|
8
|
+
paragraph: 'para',
|
|
9
|
+
blockquote: 'quote',
|
|
10
|
+
list: 'list',
|
|
11
|
+
list_item: 'item',
|
|
12
|
+
table: 'table',
|
|
13
|
+
table_header: 'header',
|
|
14
|
+
table_row: 'row',
|
|
15
|
+
code: 'code',
|
|
16
|
+
frontmatter: 'fm',
|
|
17
|
+
html: 'html',
|
|
18
|
+
hr: 'hr',
|
|
19
|
+
};
|
|
20
|
+
/** The root of every path: a document is the structure everything else sits in. */
|
|
21
|
+
export const ROOT_SEGMENT = 'doc';
|
|
22
|
+
const processor = unified()
|
|
23
|
+
.use(remarkParse)
|
|
24
|
+
.use(remarkGfm)
|
|
25
|
+
.use(remarkFrontmatter, ['yaml', 'toml']);
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
/** Every ancestor of a section, the document root first, itself last. */
|
|
28
|
+
export function ancestry(section) {
|
|
29
|
+
const out = [];
|
|
30
|
+
for (let at = section; at; at = at.parent) {
|
|
31
|
+
out.unshift(at);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** The breadcrumb, which prefixes every chunk of text under this section. */
|
|
36
|
+
export const headingPath = (section) => ancestry(section)
|
|
37
|
+
.map((s) => s.title)
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.join(' > ');
|
|
40
|
+
/** The heading lines a chunk under this section must be rendered with. */
|
|
41
|
+
export const headingLines = (section) => ancestry(section)
|
|
42
|
+
.map((s) => s.line)
|
|
43
|
+
.filter((line) => line !== undefined);
|
|
44
|
+
/**
|
|
45
|
+
* Everything downstream indexes `lines` by number, and mdast positions refer to
|
|
46
|
+
* the exact string handed to the parser — so the normalization happens once,
|
|
47
|
+
* here, and the normalized text is the only version anything ever sees.
|
|
48
|
+
*/
|
|
49
|
+
export const normalize = (text) => text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
|
|
50
|
+
export function parseDocument(source, name, format) {
|
|
51
|
+
const text = normalize(source);
|
|
52
|
+
const lines = text.split('\n');
|
|
53
|
+
return format === 'text' ? plain(text, lines, name) : markdown(text, lines, name);
|
|
54
|
+
}
|
|
55
|
+
function markdown(text, lines, name) {
|
|
56
|
+
const tree = processor.parse(text);
|
|
57
|
+
const root = {
|
|
58
|
+
id: ROOT_SEGMENT,
|
|
59
|
+
path: ROOT_SEGMENT,
|
|
60
|
+
title: stripExtension(name),
|
|
61
|
+
level: 0,
|
|
62
|
+
line: undefined,
|
|
63
|
+
parent: undefined,
|
|
64
|
+
};
|
|
65
|
+
const walk = { lines, counters: new Map(), sections: [root], blocks: [] };
|
|
66
|
+
let section = root;
|
|
67
|
+
let title = '';
|
|
68
|
+
// Only ever holds the paragraph immediately before the current position,
|
|
69
|
+
// which is the whole of what a markdown table gets for a caption.
|
|
70
|
+
let lead = '';
|
|
71
|
+
for (const node of tree.children) {
|
|
72
|
+
if (node.type === 'heading') {
|
|
73
|
+
const heading = node;
|
|
74
|
+
const line = lineOf(heading);
|
|
75
|
+
const text = collapse(inline(heading.children));
|
|
76
|
+
while (section.parent && section.level >= heading.depth) {
|
|
77
|
+
section = section.parent;
|
|
78
|
+
}
|
|
79
|
+
section = {
|
|
80
|
+
id: id(walk, 'heading'),
|
|
81
|
+
path: '',
|
|
82
|
+
title: text,
|
|
83
|
+
level: heading.depth,
|
|
84
|
+
line,
|
|
85
|
+
parent: section,
|
|
86
|
+
};
|
|
87
|
+
section.path = `${section.parent.path}/${section.id}`;
|
|
88
|
+
walk.sections.push(section);
|
|
89
|
+
if (!title && heading.depth <= 2) {
|
|
90
|
+
title = text;
|
|
91
|
+
}
|
|
92
|
+
lead = '';
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const block = blockOf(node, section, walk, lead);
|
|
96
|
+
if (block) {
|
|
97
|
+
walk.blocks.push(block);
|
|
98
|
+
lead = block.kind === 'paragraph' ? block.text : '';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
name,
|
|
103
|
+
title: title || stripExtension(basenameOf(name)),
|
|
104
|
+
format: 'markdown',
|
|
105
|
+
lines,
|
|
106
|
+
sections: walk.sections,
|
|
107
|
+
blocks: walk.blocks,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function blockOf(node, section, walk, lead) {
|
|
111
|
+
const start = lineOf(node);
|
|
112
|
+
const end = endLineOf(node);
|
|
113
|
+
switch (node.type) {
|
|
114
|
+
case 'paragraph':
|
|
115
|
+
return make('paragraph', collapse(inline(node.children)));
|
|
116
|
+
case 'blockquote':
|
|
117
|
+
return make('blockquote', collapse(blocks(node.children)));
|
|
118
|
+
case 'code': {
|
|
119
|
+
const code = node;
|
|
120
|
+
return make('code', code.value);
|
|
121
|
+
}
|
|
122
|
+
case 'html': {
|
|
123
|
+
const stripped = collapse(tags(node.value));
|
|
124
|
+
return stripped.length >= 3 ? make('html', stripped) : undefined;
|
|
125
|
+
}
|
|
126
|
+
case 'yaml':
|
|
127
|
+
return make('frontmatter', String(node.value ?? ''));
|
|
128
|
+
case 'thematicBreak':
|
|
129
|
+
return make('hr', '');
|
|
130
|
+
case 'list':
|
|
131
|
+
return list(node, section, walk, start, end);
|
|
132
|
+
case 'table':
|
|
133
|
+
return table(node, section, walk, start, end, lead);
|
|
134
|
+
default:
|
|
135
|
+
// A `+++` block. remark-frontmatter puts it in the tree; mdast's own
|
|
136
|
+
// node union names only the `---` one.
|
|
137
|
+
return node.type === 'toml'
|
|
138
|
+
? make('frontmatter', String(node.value ?? ''))
|
|
139
|
+
: undefined;
|
|
140
|
+
}
|
|
141
|
+
function make(kind, text) {
|
|
142
|
+
const own = id(walk, kind);
|
|
143
|
+
return { id: own, path: `${section.path}/${own}`, kind, start, end, section, text };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function list(node, section, walk, start, end) {
|
|
147
|
+
const own = id(walk, 'list');
|
|
148
|
+
const path = `${section.path}/${own}`;
|
|
149
|
+
const ordinal = node.start ?? 1;
|
|
150
|
+
const items = node.children.map((child, at) => {
|
|
151
|
+
const item = child;
|
|
152
|
+
const mine = id(walk, 'list_item');
|
|
153
|
+
const marker = node.ordered ? `${ordinal + at}.` : '-';
|
|
154
|
+
// The marker is kept: enumeration and step order are meaning, and a
|
|
155
|
+
// numbered step reads differently from a bullet.
|
|
156
|
+
return {
|
|
157
|
+
id: mine,
|
|
158
|
+
path: `${path}/${mine}`,
|
|
159
|
+
start: lineOf(item),
|
|
160
|
+
end: endLineOf(item),
|
|
161
|
+
text: `${marker} ${collapse(blocks(item.children))}`.trim(),
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
return {
|
|
165
|
+
id: own,
|
|
166
|
+
path,
|
|
167
|
+
kind: 'list',
|
|
168
|
+
start,
|
|
169
|
+
end,
|
|
170
|
+
section,
|
|
171
|
+
text: items.map((i) => i.text).join('\n'),
|
|
172
|
+
items,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function table(node, section, walk, start, end, caption) {
|
|
176
|
+
const own = id(walk, 'table');
|
|
177
|
+
const path = `${section.path}/${own}`;
|
|
178
|
+
const [header, ...body] = node.children;
|
|
179
|
+
const columns = header ? cellsOf(header) : [];
|
|
180
|
+
const headerLine = header ? lineOf(header) : start;
|
|
181
|
+
const firstRow = body[0] ? lineOf(body[0]) : undefined;
|
|
182
|
+
// mdast has no node for the alignment row — alignment lives on the table —
|
|
183
|
+
// so it is the line after the header, when there is room for it.
|
|
184
|
+
const separatorLine = firstRow === undefined || firstRow > headerLine + 1 ? headerLine + 1 : undefined;
|
|
185
|
+
const rows = body.map((row) => {
|
|
186
|
+
const mine = id(walk, 'table_row');
|
|
187
|
+
const cells = cellsOf(row);
|
|
188
|
+
return {
|
|
189
|
+
id: mine,
|
|
190
|
+
path: `${path}/${mine}`,
|
|
191
|
+
line: lineOf(row),
|
|
192
|
+
cells,
|
|
193
|
+
text: rowText(columns, cells),
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
id: own,
|
|
198
|
+
path,
|
|
199
|
+
kind: 'table',
|
|
200
|
+
start,
|
|
201
|
+
end,
|
|
202
|
+
section,
|
|
203
|
+
text: rows.map((r) => r.text).join('\n'),
|
|
204
|
+
table: {
|
|
205
|
+
columns,
|
|
206
|
+
headerLine,
|
|
207
|
+
separatorLine,
|
|
208
|
+
caption,
|
|
209
|
+
keyColumn: keyColumnOf(columns, rows),
|
|
210
|
+
rows,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* A data row does not contain its own column names. Stripping the pipes leaves
|
|
216
|
+
* `V-200-30 3" WCC 98.2`, in which the word `Cv` appears nowhere, so a query
|
|
217
|
+
* naming a column could only ever match the header line and never a row — and
|
|
218
|
+
* hybrid search would silently become vector-only for every table in the
|
|
219
|
+
* corpus. Pairing each cell with its header is what fixes that, and it embeds
|
|
220
|
+
* better too, because `Cv: 45` is a sentence and `| 45 |` is not.
|
|
221
|
+
*/
|
|
222
|
+
export function rowText(columns, cells) {
|
|
223
|
+
return cells
|
|
224
|
+
.map((cell, at) => cellText(columns[at], cell))
|
|
225
|
+
.filter(Boolean)
|
|
226
|
+
.join(' ');
|
|
227
|
+
}
|
|
228
|
+
export function cellText(column, cell) {
|
|
229
|
+
const value = cell.trim();
|
|
230
|
+
if (!value) {
|
|
231
|
+
return '';
|
|
232
|
+
}
|
|
233
|
+
return column ? `${column}: ${value}.` : `${value}.`;
|
|
234
|
+
}
|
|
235
|
+
/** Column 1, unless it is numeric — a slice with no name on it is unattributable. */
|
|
236
|
+
function keyColumnOf(columns, rows) {
|
|
237
|
+
const numeric = (at) => rows.length > 0 &&
|
|
238
|
+
rows.every((row) => {
|
|
239
|
+
const cell = (row.cells[at] ?? '').trim();
|
|
240
|
+
return cell === '' || /^[-+]?[\d.,%\s]+$/.test(cell);
|
|
241
|
+
});
|
|
242
|
+
for (let at = 0; at < columns.length; at++) {
|
|
243
|
+
if (!numeric(at)) {
|
|
244
|
+
return at;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
const cellsOf = (row) => row.children.map((cell) => collapse(inline(cell.children)));
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// plain text
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
/**
|
|
254
|
+
* A `.txt` file is read as paragraphs and nothing else. It would be easy to run
|
|
255
|
+
* it through the markdown parser and get headings for free, but they would be
|
|
256
|
+
* invented: a line beginning with `#` in a log or a licence is not a title, and
|
|
257
|
+
* an index that says it is would scope searches to sections nobody wrote.
|
|
258
|
+
*/
|
|
259
|
+
function plain(text, lines, name) {
|
|
260
|
+
const root = {
|
|
261
|
+
id: ROOT_SEGMENT,
|
|
262
|
+
path: ROOT_SEGMENT,
|
|
263
|
+
title: stripExtension(name),
|
|
264
|
+
level: 0,
|
|
265
|
+
line: undefined,
|
|
266
|
+
parent: undefined,
|
|
267
|
+
};
|
|
268
|
+
const blocks = [];
|
|
269
|
+
let at = 0;
|
|
270
|
+
let ordinal = 0;
|
|
271
|
+
while (at < lines.length) {
|
|
272
|
+
if ((lines[at] ?? '').trim() === '') {
|
|
273
|
+
at++;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const start = at;
|
|
277
|
+
while (at < lines.length && (lines[at] ?? '').trim() !== '') {
|
|
278
|
+
at++;
|
|
279
|
+
}
|
|
280
|
+
const own = `${SEGMENT.paragraph}:${++ordinal}`;
|
|
281
|
+
blocks.push({
|
|
282
|
+
id: own,
|
|
283
|
+
path: `${root.path}/${own}`,
|
|
284
|
+
kind: 'paragraph',
|
|
285
|
+
start: start + 1,
|
|
286
|
+
end: at,
|
|
287
|
+
section: root,
|
|
288
|
+
text: collapse(lines.slice(start, at).join(' ')),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const first = lines.find((line) => line.trim() !== '')?.trim() ?? '';
|
|
292
|
+
return {
|
|
293
|
+
name,
|
|
294
|
+
title: first.length > 0 && first.length <= 80 ? first : stripExtension(basenameOf(name)),
|
|
295
|
+
format: 'text',
|
|
296
|
+
lines,
|
|
297
|
+
sections: [root],
|
|
298
|
+
blocks,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// serialization — walking the inline tree, never a regex over the markup
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
function inline(nodes) {
|
|
305
|
+
return nodes.map(one).join('');
|
|
306
|
+
}
|
|
307
|
+
function one(node) {
|
|
308
|
+
switch (node.type) {
|
|
309
|
+
case 'text':
|
|
310
|
+
return node.value;
|
|
311
|
+
// The value, not the markup: this is the node a regex stripper eats.
|
|
312
|
+
case 'inlineCode':
|
|
313
|
+
return node.value;
|
|
314
|
+
case 'image':
|
|
315
|
+
case 'imageReference':
|
|
316
|
+
// Distinguishable from a sentence on purpose.
|
|
317
|
+
return node.alt ? `image: ${node.alt}` : '';
|
|
318
|
+
case 'break':
|
|
319
|
+
return ' ';
|
|
320
|
+
// Inline html carries no words worth indexing, and its angle brackets
|
|
321
|
+
// would only ever match a query by accident.
|
|
322
|
+
case 'html':
|
|
323
|
+
return '';
|
|
324
|
+
case 'footnoteReference':
|
|
325
|
+
return '';
|
|
326
|
+
default:
|
|
327
|
+
return 'children' in node ? inline(node.children) : '';
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/** Nested block content — a blockquote's paragraphs, a list item's children. */
|
|
331
|
+
function blocks(nodes) {
|
|
332
|
+
return nodes
|
|
333
|
+
.map((node) => {
|
|
334
|
+
switch (node.type) {
|
|
335
|
+
case 'paragraph':
|
|
336
|
+
case 'heading':
|
|
337
|
+
return inline(node.children);
|
|
338
|
+
case 'code':
|
|
339
|
+
return node.value;
|
|
340
|
+
case 'blockquote':
|
|
341
|
+
return blocks(node.children);
|
|
342
|
+
case 'list':
|
|
343
|
+
return node.children
|
|
344
|
+
.map((item) => blocks(item.children))
|
|
345
|
+
.join(' ');
|
|
346
|
+
case 'table':
|
|
347
|
+
return node.children
|
|
348
|
+
.slice(1)
|
|
349
|
+
.map((row) => rowText(cellsOf(node.children[0]), cellsOf(row)))
|
|
350
|
+
.join(' ');
|
|
351
|
+
default:
|
|
352
|
+
return '';
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
.filter(Boolean)
|
|
356
|
+
.join(' ');
|
|
357
|
+
}
|
|
358
|
+
/** Soft wraps inside a paragraph are wrapping, not meaning, so they rejoin. */
|
|
359
|
+
export const collapse = (text) => text.replace(/\s+/g, ' ').trim();
|
|
360
|
+
const tags = (html) => html.replace(/<[^>]*>/g, ' ');
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
function id(walk, kind) {
|
|
363
|
+
const segment = SEGMENT[kind];
|
|
364
|
+
const next = (walk.counters.get(segment) ?? 0) + 1;
|
|
365
|
+
walk.counters.set(segment, next);
|
|
366
|
+
return `${segment}:${next}`;
|
|
367
|
+
}
|
|
368
|
+
const lineOf = (node) => node.position?.start.line ?? 1;
|
|
369
|
+
const endLineOf = (node) => node.position?.end.line ?? 1;
|
|
370
|
+
const basenameOf = (name) => name.split('/').pop() ?? name;
|
|
371
|
+
const stripExtension = (name) => name.replace(/\.[^./]+$/, '');
|
|
372
|
+
//# sourceMappingURL=parse.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Chunk, type ChunkOptions } from './chunk.ts';
|
|
2
|
+
import type { FileOutline } from './files.ts';
|
|
3
|
+
export interface ParseInput {
|
|
4
|
+
name: string;
|
|
5
|
+
text: string;
|
|
6
|
+
format: 'markdown' | 'text';
|
|
7
|
+
}
|
|
8
|
+
export interface Parsed {
|
|
9
|
+
chunks: Chunk[];
|
|
10
|
+
outline: FileOutline;
|
|
11
|
+
}
|
|
12
|
+
/** One short of the machine, so the thread doing everything else keeps a core. */
|
|
13
|
+
export declare const poolSize: () => number;
|
|
14
|
+
export interface ParseAllOptions {
|
|
15
|
+
chunk?: ChunkOptions;
|
|
16
|
+
/** how many threads to allow; 1 keeps everything here */
|
|
17
|
+
workers?: number;
|
|
18
|
+
onProgress?: (done: number, total: number, pending: readonly string[]) => void;
|
|
19
|
+
}
|
|
20
|
+
export declare function parseAll(inputs: readonly ParseInput[], options?: ParseAllOptions): Promise<Parsed[]>;
|
|
21
|
+
/**
|
|
22
|
+
* The threaded path on its own. `parseAll` falls back to this thread when the
|
|
23
|
+
* workers cannot run, which is right for a build and useless for a test: a
|
|
24
|
+
* worker that never starts would look exactly like a fast one. Tests call this.
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseThreaded(inputs: readonly ParseInput[], chunk: ChunkOptions, size: number, onProgress?: (done: number, total: number, pending: readonly string[]) => void): Promise<Parsed[]>;
|
|
27
|
+
//# sourceMappingURL=pool.d.ts.map
|