nexus-ai-pro 1.21.0 → 1.22.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/API_STABILITY.md +16 -0
- package/CHANGELOG.md +35 -1
- package/README.md +3 -1
- package/dist/evaluate/evaluators.d.ts +18 -0
- package/dist/evaluate/evaluators.js +35 -0
- package/dist/evaluate/index.d.ts +1 -1
- package/dist/evaluate/index.js +1 -1
- package/dist/loaders/csv.d.ts +29 -0
- package/dist/loaders/csv.js +87 -0
- package/dist/loaders/git.d.ts +40 -0
- package/dist/loaders/git.js +83 -0
- package/dist/loaders/html.d.ts +35 -0
- package/dist/loaders/html.js +96 -0
- package/dist/loaders/index.d.ts +52 -0
- package/dist/loaders/index.js +46 -0
- package/dist/loaders/input.d.ts +20 -0
- package/dist/loaders/input.js +32 -0
- package/dist/loaders/json.d.ts +26 -0
- package/dist/loaders/json.js +49 -0
- package/dist/loaders/markdown.d.ts +33 -0
- package/dist/loaders/markdown.js +62 -0
- package/dist/loaders/pdf.d.ts +31 -0
- package/dist/loaders/pdf.js +38 -0
- package/dist/loaders/text.d.ts +35 -0
- package/dist/loaders/text.js +33 -0
- package/dist/loaders/web.d.ts +63 -0
- package/dist/loaders/web.js +137 -0
- package/dist/mcp/registry.d.ts +127 -0
- package/dist/mcp/registry.js +210 -0
- package/dist/rag/chroma.d.ts +63 -0
- package/dist/rag/chroma.js +103 -0
- package/dist/rag/ingestion.js +1 -1
- package/dist/rag/pinecone.d.ts +60 -0
- package/dist/rag/pinecone.js +95 -0
- package/dist/rag/qdrant.js +1 -7
- package/dist/rag/redis.d.ts +58 -0
- package/dist/rag/redis.js +158 -0
- package/dist/rag/retrievers.d.ts +214 -0
- package/dist/rag/retrievers.js +307 -0
- package/dist/rag/vector-helpers.d.ts +36 -0
- package/dist/rag/vector-helpers.js +70 -0
- package/dist/rag/weaviate.d.ts +67 -0
- package/dist/rag/weaviate.js +148 -0
- package/dist/sqlite/client.d.ts +5 -2
- package/dist/sqlite/index.d.ts +1 -0
- package/dist/sqlite/index.js +1 -0
- package/dist/sqlite/vectors.d.ts +47 -0
- package/dist/sqlite/vectors.js +105 -0
- package/dist/utils/safe-fetch.d.ts +6 -0
- package/dist-cjs/evaluate/evaluators.d.ts +18 -0
- package/dist-cjs/evaluate/evaluators.js +37 -0
- package/dist-cjs/evaluate/index.d.ts +1 -1
- package/dist-cjs/evaluate/index.js +3 -1
- package/dist-cjs/loaders/csv.d.ts +29 -0
- package/dist-cjs/loaders/csv.js +91 -0
- package/dist-cjs/loaders/git.d.ts +40 -0
- package/dist-cjs/loaders/git.js +121 -0
- package/dist-cjs/loaders/html.d.ts +35 -0
- package/dist-cjs/loaders/html.js +100 -0
- package/dist-cjs/loaders/index.d.ts +52 -0
- package/dist-cjs/loaders/index.js +50 -0
- package/dist-cjs/loaders/input.d.ts +20 -0
- package/dist-cjs/loaders/input.js +71 -0
- package/dist-cjs/loaders/json.d.ts +26 -0
- package/dist-cjs/loaders/json.js +52 -0
- package/dist-cjs/loaders/markdown.d.ts +33 -0
- package/dist-cjs/loaders/markdown.js +67 -0
- package/dist-cjs/loaders/pdf.d.ts +31 -0
- package/dist-cjs/loaders/pdf.js +43 -0
- package/dist-cjs/loaders/text.d.ts +35 -0
- package/dist-cjs/loaders/text.js +70 -0
- package/dist-cjs/loaders/web.d.ts +63 -0
- package/dist-cjs/loaders/web.js +177 -0
- package/dist-cjs/mcp/registry.d.ts +127 -0
- package/dist-cjs/mcp/registry.js +249 -0
- package/dist-cjs/rag/chroma.d.ts +63 -0
- package/dist-cjs/rag/chroma.js +108 -0
- package/dist-cjs/rag/ingestion.js +1 -1
- package/dist-cjs/rag/pinecone.d.ts +60 -0
- package/dist-cjs/rag/pinecone.js +100 -0
- package/dist-cjs/rag/qdrant.js +3 -9
- package/dist-cjs/rag/redis.d.ts +58 -0
- package/dist-cjs/rag/redis.js +162 -0
- package/dist-cjs/rag/retrievers.d.ts +214 -0
- package/dist-cjs/rag/retrievers.js +323 -0
- package/dist-cjs/rag/vector-helpers.d.ts +36 -0
- package/dist-cjs/rag/vector-helpers.js +81 -0
- package/dist-cjs/rag/weaviate.d.ts +67 -0
- package/dist-cjs/rag/weaviate.js +153 -0
- package/dist-cjs/sqlite/client.d.ts +5 -2
- package/dist-cjs/sqlite/index.d.ts +1 -0
- package/dist-cjs/sqlite/index.js +4 -1
- package/dist-cjs/sqlite/vectors.d.ts +47 -0
- package/dist-cjs/sqlite/vectors.js +110 -0
- package/dist-cjs/utils/safe-fetch.d.ts +6 -0
- package/package.json +210 -1
package/API_STABILITY.md
CHANGED
|
@@ -456,6 +456,22 @@ with nexus-ai-pro and declares the core versions it works with as a peer depende
|
|
|
456
456
|
- The access rules are a guarantee: loopback binding by default, a token on every request, the token
|
|
457
457
|
in a header for every change, and loopback hosts only unless others are allowed.
|
|
458
458
|
|
|
459
|
+
## Retrieval breadth stage (1.22.0)
|
|
460
|
+
|
|
461
|
+
The loaders (`nexus-ai-pro/loaders/*`), the Redis, Pinecone, Weaviate, Chroma, and SQLite vector
|
|
462
|
+
stores, the retrievers (`nexus-ai-pro/rag/retrievers`), and the MCP registry
|
|
463
|
+
(`nexus-ai-pro/mcp/registry`) are experimental in production readiness. Their contracts follow the
|
|
464
|
+
1.x rules.
|
|
465
|
+
|
|
466
|
+
- Every vector store keeps the `VectorStore` contract and its contract test, which checks that a
|
|
467
|
+
metadata filter matches type as well as value.
|
|
468
|
+
- A loader's document ids are stable: the path relative to a directory or repository, the final URL
|
|
469
|
+
of a page, and the file name plus key or position of a row or record. Chunk ids built from them do
|
|
470
|
+
not change in a minor release, so a reload keeps replacing the same chunks.
|
|
471
|
+
- The REST stores' payload layout — `content`, `source`, `metadata` as JSON, and `meta_` filter fields
|
|
472
|
+
— is stable across 1.x, so an index written by one release is read by the next.
|
|
473
|
+
- The registry reads `servers` and `mcpServers`; new configuration fields are optional.
|
|
474
|
+
|
|
459
475
|
## Durable execution stage (1.21.0)
|
|
460
476
|
|
|
461
477
|
Functional workflows (`nexus-ai-pro/graph/functional`) and the SQLite adapters (`nexus-ai-pro/sqlite`)
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ Notable changes to this project are recorded here. The format follows [Keep a Ch
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.22.0] - 2026-09-29
|
|
8
|
+
|
|
9
|
+
Retrieval and integration breadth. Getting documents in, finding the right ones, and reaching the
|
|
10
|
+
tools a team already runs, without a catalogue of dependencies.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Document loaders** on `nexus-ai-pro/loaders/*`, one entry point per format: text files and
|
|
15
|
+
directories, Markdown with front matter, HTML, CSV, JSON and JSON Lines, PDF through a parser you
|
|
16
|
+
inject, web pages and sitemaps through the SSRF-safe fetch, and Git repositories. Each streams
|
|
17
|
+
documents, and `loadIntoStore()` splits them into chunks and adds them to one or more stores in
|
|
18
|
+
batches.
|
|
19
|
+
- **Four more vector stores** behind the `VectorStore` contract, each through REST or an injected
|
|
20
|
+
client: Redis with RediSearch (`nexus-ai-pro/rag/redis`), Pinecone, Weaviate, and Chroma
|
|
21
|
+
(`nexus-ai-pro/rag/pinecone`, `/weaviate`, `/chroma`), plus `SqliteVectorStore` on
|
|
22
|
+
`nexus-ai-pro/sqlite/vectors`, ranked in JavaScript or by sqlite-vec. All pass the same contract
|
|
23
|
+
test, which now also checks that a filter matches a value's type.
|
|
24
|
+
- **Composable retrievers** on `nexus-ai-pro/rag/retrievers`: BM25 keyword search (`KeywordIndex`),
|
|
25
|
+
hybrid search fused by reciprocal rank, reranking through any scorer or a chat model, maximal
|
|
26
|
+
marginal relevance, parent-document retrieval, and multi-query retrieval.
|
|
27
|
+
- **Retrieval evaluators**: `recallAtK()` and `reciprocalRank()` on `nexus-ai-pro/evaluate`.
|
|
28
|
+
- **An MCP registry** on `nexus-ai-pro/mcp/registry`: many servers from one configuration file —
|
|
29
|
+
including the file desktop MCP clients use — with per-server allow and deny lists, credentials from
|
|
30
|
+
the environment by placeholder, health checks, and named tool bundles.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- `SqliteValue` includes bytes, which the drivers bind as blobs.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- `ingestDocuments()` honours `overlap: 0`. It used the default overlap of 150 characters instead.
|
|
39
|
+
|
|
7
40
|
## [1.21.0] - 2026-09-29
|
|
8
41
|
|
|
9
42
|
Durable execution everywhere. Durability no longer requires drawing a graph, and a crash costs a
|
|
@@ -1085,7 +1118,8 @@ shared policy vocabulary is in place for a future release that revisits this.
|
|
|
1085
1118
|
- URL fetching rejects unsafe private-network targets and limits response reads.
|
|
1086
1119
|
- CLI and audit findings no longer disclose detected secret values.
|
|
1087
1120
|
|
|
1088
|
-
[Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.
|
|
1121
|
+
[Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.22.0...HEAD
|
|
1122
|
+
[1.22.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.21.0...v1.22.0
|
|
1089
1123
|
[1.21.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.20.0...v1.21.0
|
|
1090
1124
|
[1.20.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.19.0...v1.20.0
|
|
1091
1125
|
[1.19.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.18.0...v1.19.0
|
package/README.md
CHANGED
|
@@ -109,7 +109,7 @@ from the doc comments. The guides live in the repository, so these links go to G
|
|
|
109
109
|
| [Graphs](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/graphs.md) | Parallel branches, interrupts, checkpoints, time travel, subgraphs, caching, diagrams |
|
|
110
110
|
| [Agents and tools](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/agents.md) | Graph-based agents with approvals and middleware, and the simple tool loop |
|
|
111
111
|
| [Long-term memory](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/memory.md) | Namespaced memory with semantic search, in memory or Redis |
|
|
112
|
-
| [MCP](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/mcp.md) | Borrowing tools from MCP servers and serving your own |
|
|
112
|
+
| [MCP](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/mcp.md) | Borrowing tools from MCP servers, a registry of many, and serving your own |
|
|
113
113
|
| [Traces](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/tracing.md) | Run trees, queries, feedback, comparison, and alerts |
|
|
114
114
|
| [Evaluation](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/evaluation.md) | Datasets, evaluators, experiments, comparisons with a verdict, review queues, LLM judges |
|
|
115
115
|
| [Prompts](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/prompts.md) | Typed templates, content versions, gated promotion, rollback, A/B splits, serving through outages |
|
|
@@ -120,6 +120,8 @@ from the doc comments. The guides live in the repository, so these links go to G
|
|
|
120
120
|
| [Batch tiers](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/batch.md) | The providers' discounted batch APIs, resumable from any process |
|
|
121
121
|
| [Caching](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/caching.md) | Exact and semantic response caches, with memory, Redis, and SQLite adapters |
|
|
122
122
|
| [Grounding](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/grounding.md) | Retrieval, citations, verification, self-consistency, and knowledge graphs |
|
|
123
|
+
| [Loaders](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/loaders.md) | Files, Markdown, HTML, CSV, JSON, PDF, web pages, sitemaps, and Git repositories into retrieval |
|
|
124
|
+
| [Retrieval](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/retrieval.md) | Hybrid search, reranking, MMR, parent documents, and Redis, Pinecone, Weaviate, and Chroma stores |
|
|
123
125
|
| [Embeddings and retrieval](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/embeddings.md) | Embeddings as a routed operation family, and RAG helpers |
|
|
124
126
|
| [Images (experimental)](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/images.md) | Generation and masked edits over three backends, input safety, moderation, asset stores |
|
|
125
127
|
| [Voice](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/voice.md) | Transcription, speech, voice turns, and voice sessions |
|
|
@@ -80,3 +80,21 @@ export declare function pairwise(options: {
|
|
|
80
80
|
export declare function passRate(key?: string): SummaryEvaluator;
|
|
81
81
|
/** Total cost of the experiment, so a quality gain that tripled the bill is visible. */
|
|
82
82
|
export declare function totalCost(key?: string): SummaryEvaluator;
|
|
83
|
+
/** Options for the retrieval evaluators. */
|
|
84
|
+
export interface RetrievalEvaluatorOptions {
|
|
85
|
+
/** Results considered, from the top. Defaults to every result. */
|
|
86
|
+
k?: number;
|
|
87
|
+
/** The metric's name. */
|
|
88
|
+
key?: string;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The share of an example's relevant ids found in the output's top `k`. The output is a ranked list
|
|
92
|
+
* of ids or of chunks with an `id`, such as a retriever's results; the example's `expected` is the
|
|
93
|
+
* relevant id, or a list of them.
|
|
94
|
+
*/
|
|
95
|
+
export declare function recallAtK(options?: RetrievalEvaluatorOptions): Evaluator;
|
|
96
|
+
/**
|
|
97
|
+
* One over the rank of the first relevant id in the output's top `k`, or 0 when none is there. Its
|
|
98
|
+
* mean over a dataset is the mean reciprocal rank, which rewards putting the right chunk first.
|
|
99
|
+
*/
|
|
100
|
+
export declare function reciprocalRank(options?: RetrievalEvaluatorOptions): Evaluator;
|
|
@@ -135,6 +135,41 @@ export function totalCost(key = 'total-cost') {
|
|
|
135
135
|
{ key, score: results.reduce((total, result) => total + (result.cost ?? 0), 0) },
|
|
136
136
|
];
|
|
137
137
|
}
|
|
138
|
+
export function recallAtK(options = {}) {
|
|
139
|
+
return (context) => {
|
|
140
|
+
const relevant = relevantIds(context.example.expected);
|
|
141
|
+
const found = rankedIds(context.output).slice(0, options.k);
|
|
142
|
+
const hits = relevant.filter((id) => found.includes(id));
|
|
143
|
+
const score = relevant.length === 0 ? 1 : hits.length / relevant.length;
|
|
144
|
+
return {
|
|
145
|
+
key: options.key ?? (options.k ? `recall@${options.k}` : 'recall'),
|
|
146
|
+
score,
|
|
147
|
+
passed: hits.length === relevant.length,
|
|
148
|
+
...(hits.length === relevant.length
|
|
149
|
+
? {}
|
|
150
|
+
: { comment: `missing: ${relevant.filter((id) => !hits.includes(id)).join(', ')}` }),
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
export function reciprocalRank(options = {}) {
|
|
155
|
+
return (context) => {
|
|
156
|
+
const relevant = new Set(relevantIds(context.example.expected));
|
|
157
|
+
const rank = rankedIds(context.output)
|
|
158
|
+
.slice(0, options.k)
|
|
159
|
+
.findIndex((id) => relevant.has(id));
|
|
160
|
+
return { key: options.key ?? 'reciprocal-rank', score: rank < 0 ? 0 : 1 / (rank + 1), passed: rank === 0 };
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function relevantIds(expected) {
|
|
164
|
+
if (Array.isArray(expected))
|
|
165
|
+
return expected.map(String);
|
|
166
|
+
return expected === undefined || expected === null ? [] : [String(expected)];
|
|
167
|
+
}
|
|
168
|
+
function rankedIds(output) {
|
|
169
|
+
if (!Array.isArray(output))
|
|
170
|
+
return [];
|
|
171
|
+
return output.map((item) => item !== null && typeof item === 'object' && 'id' in item ? String(item.id) : String(item));
|
|
172
|
+
}
|
|
138
173
|
function asText(value) {
|
|
139
174
|
if (value === undefined || value === null)
|
|
140
175
|
return '';
|
package/dist/evaluate/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { type CompareOptions, compareExperiments, type ExampleComparison, type ExperimentComparison, formatComparison, type MetricComparison, scoreMap, } from './compare.js';
|
|
2
2
|
export { contentVersion, createDataset, type CreateDatasetOptions, datasetFromTraces, FileDatasetStore, FileExperimentStore, type FromTracesOptions, MemoryDatasetStore, MemoryExperimentStore, readExperiment, splitOf, } from './datasets.js';
|
|
3
|
-
export { completed, contains, embeddingSimilarity, exactMatch, mustNotMatch, pairwise, passRate, totalCost, trajectory, type TrajectoryOptions, underCost, underLatency, } from './evaluators.js';
|
|
3
|
+
export { completed, contains, embeddingSimilarity, exactMatch, mustNotMatch, pairwise, passRate, recallAtK, reciprocalRank, type RetrievalEvaluatorOptions, totalCost, trajectory, type TrajectoryOptions, underCost, underLatency, } from './evaluators.js';
|
|
4
4
|
export { AnnotationQueue, type AnnotationQueueOptions, evaluateOnline, type OnlineEvaluationOptions, type OnlineEvaluationReport, type ReviewAnswer, type ReviewItem, type ReviewQuestion, } from './review.js';
|
|
5
5
|
export { evaluate, type EvaluateOptions, type EvaluationTarget, stats, summarize } from './run.js';
|
|
6
6
|
export type { Dataset, DatasetExample, DatasetStore, EvaluationContext, EvaluationScore, Evaluator, ExampleResult, Experiment, ExperimentStore, MetricSummary, SummaryEvaluator, } from '../types/evaluate.js';
|
package/dist/evaluate/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { compareExperiments, formatComparison, scoreMap, } from './compare.js';
|
|
2
2
|
export { contentVersion, createDataset, datasetFromTraces, FileDatasetStore, FileExperimentStore, MemoryDatasetStore, MemoryExperimentStore, readExperiment, splitOf, } from './datasets.js';
|
|
3
|
-
export { completed, contains, embeddingSimilarity, exactMatch, mustNotMatch, pairwise, passRate, totalCost, trajectory, underCost, underLatency, } from './evaluators.js';
|
|
3
|
+
export { completed, contains, embeddingSimilarity, exactMatch, mustNotMatch, pairwise, passRate, recallAtK, reciprocalRank, totalCost, trajectory, underCost, underLatency, } from './evaluators.js';
|
|
4
4
|
export { AnnotationQueue, evaluateOnline, } from './review.js';
|
|
5
5
|
export { evaluate, stats, summarize } from './run.js';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DocumentSource, FileInput } from './index.js';
|
|
2
|
+
import type { FileLoaderOptions } from './text.js';
|
|
3
|
+
/** Options for `parseCsv()`. */
|
|
4
|
+
export interface CsvParseOptions {
|
|
5
|
+
/** The field separator. Defaults to a comma; use `'\t'` for TSV. */
|
|
6
|
+
delimiter?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Parses CSV into rows of fields, following RFC 4180: quoted fields may hold the delimiter, line
|
|
10
|
+
* breaks, and doubled quotes; lines may end in CRLF or LF; and a trailing line break adds no row.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseCsv(text: string, options?: CsvParseOptions): string[][];
|
|
13
|
+
/** Options for `loadCsv()`. */
|
|
14
|
+
export interface CsvLoaderOptions extends FileLoaderOptions, CsvParseOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Columns that make up a row's text, written as `column: value` lines. Defaults to every column not
|
|
17
|
+
* named in `idColumn` or `metadataColumns`.
|
|
18
|
+
*/
|
|
19
|
+
textColumns?: readonly string[];
|
|
20
|
+
/** The column whose value becomes the row's id, after the file name. Defaults to the row number. */
|
|
21
|
+
idColumn?: string;
|
|
22
|
+
/** Columns copied into the metadata, where a filter can select on them. */
|
|
23
|
+
metadataColumns?: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* CSV files, one document per row. The first row names the columns. Each row's text lists its columns
|
|
27
|
+
* by name, so a model reading a chunk knows what each value is.
|
|
28
|
+
*/
|
|
29
|
+
export declare function loadCsv(inputs: FileInput | Iterable<FileInput>, options?: CsvLoaderOptions): AsyncGenerator<DocumentSource>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { inputsOf, readText } from './input.js';
|
|
2
|
+
export function parseCsv(text, options = {}) {
|
|
3
|
+
const delimiter = options.delimiter ?? ',';
|
|
4
|
+
if (delimiter.length !== 1 || delimiter === '"' || delimiter === '\n' || delimiter === '\r') {
|
|
5
|
+
throw new RangeError('delimiter must be one character other than a quote or a line break');
|
|
6
|
+
}
|
|
7
|
+
const rows = [];
|
|
8
|
+
let row = [];
|
|
9
|
+
let field = '';
|
|
10
|
+
let quoted = false;
|
|
11
|
+
let index = text.charCodeAt(0) === 0xfeff ? 1 : 0;
|
|
12
|
+
for (; index < text.length; index++) {
|
|
13
|
+
const char = text[index];
|
|
14
|
+
if (quoted) {
|
|
15
|
+
if (char !== '"')
|
|
16
|
+
field += char;
|
|
17
|
+
else if (text[index + 1] === '"') {
|
|
18
|
+
field += '"';
|
|
19
|
+
index++;
|
|
20
|
+
}
|
|
21
|
+
else
|
|
22
|
+
quoted = false;
|
|
23
|
+
}
|
|
24
|
+
else if (char === '"' && field === '')
|
|
25
|
+
quoted = true;
|
|
26
|
+
else if (char === delimiter) {
|
|
27
|
+
row.push(field);
|
|
28
|
+
field = '';
|
|
29
|
+
}
|
|
30
|
+
else if (char === '\n' || char === '\r') {
|
|
31
|
+
if (char === '\r' && text[index + 1] === '\n')
|
|
32
|
+
index++;
|
|
33
|
+
row.push(field);
|
|
34
|
+
rows.push(row);
|
|
35
|
+
row = [];
|
|
36
|
+
field = '';
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
field += char;
|
|
40
|
+
}
|
|
41
|
+
if (quoted)
|
|
42
|
+
throw new SyntaxError('The CSV ends inside a quoted field');
|
|
43
|
+
if (field !== '' || row.length > 0) {
|
|
44
|
+
row.push(field);
|
|
45
|
+
rows.push(row);
|
|
46
|
+
}
|
|
47
|
+
return rows;
|
|
48
|
+
}
|
|
49
|
+
export async function* loadCsv(inputs, options = {}) {
|
|
50
|
+
for (const input of inputsOf(inputs)) {
|
|
51
|
+
const { source, text } = await readText(input);
|
|
52
|
+
const [header, ...rows] = parseCsv(text, options);
|
|
53
|
+
if (!header)
|
|
54
|
+
continue;
|
|
55
|
+
const column = (name) => {
|
|
56
|
+
const index = header.indexOf(name);
|
|
57
|
+
if (index < 0)
|
|
58
|
+
throw new RangeError(`${source} has no column "${name}"`);
|
|
59
|
+
return index;
|
|
60
|
+
};
|
|
61
|
+
const idIndex = options.idColumn === undefined ? -1 : column(options.idColumn);
|
|
62
|
+
const metadataIndexes = (options.metadataColumns ?? []).map((name) => [name, column(name)]);
|
|
63
|
+
const textIndexes = options.textColumns
|
|
64
|
+
? options.textColumns.map(column)
|
|
65
|
+
: header
|
|
66
|
+
.map((_, index) => index)
|
|
67
|
+
.filter((index) => index !== idIndex && !metadataIndexes.some(([, metadata]) => metadata === index));
|
|
68
|
+
for (const [rowIndex, fields] of rows.entries()) {
|
|
69
|
+
if (fields.length === 1 && fields[0] === '')
|
|
70
|
+
continue;
|
|
71
|
+
const key = idIndex >= 0 ? fields[idIndex] : String(rowIndex + 1);
|
|
72
|
+
yield {
|
|
73
|
+
id: `${source}#${key}`,
|
|
74
|
+
text: textIndexes
|
|
75
|
+
.map((index) => `${header[index]}: ${fields[index] ?? ''}`)
|
|
76
|
+
.filter((line) => !line.endsWith(': '))
|
|
77
|
+
.join('\n'),
|
|
78
|
+
source,
|
|
79
|
+
metadata: {
|
|
80
|
+
...options.metadata,
|
|
81
|
+
...Object.fromEntries(metadataIndexes.map(([name, index]) => [name, fields[index] ?? ''])),
|
|
82
|
+
row: rowIndex + 1,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { DocumentSource } from './index.js';
|
|
2
|
+
import type { FileParser } from './text.js';
|
|
3
|
+
/** Options for `loadGitRepository()`. */
|
|
4
|
+
export interface GitLoaderOptions {
|
|
5
|
+
/** A branch or tag to read. Cloned at that ref; a local repository without it is read as checked out. */
|
|
6
|
+
ref?: string;
|
|
7
|
+
/** File extensions read, lower case with the dot, such as `['.md', '.ts']`. Defaults to every text file. */
|
|
8
|
+
extensions?: readonly string[];
|
|
9
|
+
/** Skips a path, relative to the repository root with `/` separators. */
|
|
10
|
+
ignore?: (path: string) => boolean;
|
|
11
|
+
/** A parser per extension, as for `loadDirectory()`. Other files load as plain text. */
|
|
12
|
+
parsers?: Record<string, FileParser>;
|
|
13
|
+
/** Files larger than this are skipped, in bytes. Defaults to 1 MB. */
|
|
14
|
+
maxFileBytes?: number;
|
|
15
|
+
/** The `git` executable. Defaults to `git` on the path. */
|
|
16
|
+
git?: string;
|
|
17
|
+
/** Time allowed for the clone, in milliseconds. Defaults to 2 minutes. */
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
/** Application data added to every document. */
|
|
20
|
+
metadata?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
/** Raised when `git` fails, with what it printed. */
|
|
23
|
+
export declare class GitLoaderError extends Error {
|
|
24
|
+
/** What `git` wrote to stderr. */
|
|
25
|
+
readonly stderr: string;
|
|
26
|
+
constructor(message: string,
|
|
27
|
+
/** What `git` wrote to stderr. */
|
|
28
|
+
stderr: string);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The files a Git repository tracks, one document each. A remote URL — `https://`, `ssh://`,
|
|
32
|
+
* `git://`, `file://`, or `user@host:path` — is shallow-cloned into a temporary directory that is
|
|
33
|
+
* removed afterwards; a local path is read in place. Only tracked files are read, binary files and
|
|
34
|
+
* files over `maxFileBytes` are skipped, and each document carries the repository and commit in its
|
|
35
|
+
* metadata, so a citation can link to the exact revision.
|
|
36
|
+
*
|
|
37
|
+
* Runs the `git` executable, without a shell, with credential prompts and the `ext::` transport
|
|
38
|
+
* disabled. A clone keeps the committed line endings, so a commit loads the same on every platform.
|
|
39
|
+
*/
|
|
40
|
+
export declare function loadGitRepository(repository: string, options?: GitLoaderOptions): AsyncGenerator<DocumentSource>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export class GitLoaderError extends Error {
|
|
2
|
+
stderr;
|
|
3
|
+
constructor(message, stderr) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.stderr = stderr;
|
|
6
|
+
this.name = 'GitLoaderError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const REMOTE = /^(?:https?|ssh|git|file):\/\/|^[\w.-]+@[\w.-]+:/;
|
|
10
|
+
export async function* loadGitRepository(repository, options = {}) {
|
|
11
|
+
if (repository.startsWith('-'))
|
|
12
|
+
throw new RangeError('A repository must not start with "-"');
|
|
13
|
+
const { mkdtemp, readFile, rm } = await import('node:fs/promises');
|
|
14
|
+
const { tmpdir } = await import('node:os');
|
|
15
|
+
const path = await import('node:path');
|
|
16
|
+
const remote = REMOTE.test(repository);
|
|
17
|
+
const clone = remote || options.ref !== undefined;
|
|
18
|
+
const directory = clone ? await mkdtemp(path.join(tmpdir(), 'nexus-git-')) : repository;
|
|
19
|
+
try {
|
|
20
|
+
if (clone) {
|
|
21
|
+
await git([
|
|
22
|
+
'clone',
|
|
23
|
+
...(remote ? ['--depth', '1'] : []),
|
|
24
|
+
'--no-tags',
|
|
25
|
+
...(options.ref ? ['--branch', options.ref] : []),
|
|
26
|
+
'--',
|
|
27
|
+
remote ? repository : path.resolve(repository),
|
|
28
|
+
directory,
|
|
29
|
+
], undefined, options);
|
|
30
|
+
}
|
|
31
|
+
const commit = (await git(['rev-parse', 'HEAD'], directory, options)).trim();
|
|
32
|
+
const files = (await git(['ls-files', '-z'], directory, options)).split('\0').filter(Boolean).sort();
|
|
33
|
+
const extensions = options.extensions && new Set(options.extensions.map((extension) => extension.toLowerCase()));
|
|
34
|
+
const maxFileBytes = options.maxFileBytes ?? 1_000_000;
|
|
35
|
+
for (const file of files) {
|
|
36
|
+
const extension = path.extname(file).toLowerCase();
|
|
37
|
+
if (extensions && !extensions.has(extension))
|
|
38
|
+
continue;
|
|
39
|
+
if (options.ignore?.(file))
|
|
40
|
+
continue;
|
|
41
|
+
let bytes;
|
|
42
|
+
try {
|
|
43
|
+
bytes = new Uint8Array(await readFile(path.join(directory, file)));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (bytes.length > maxFileBytes || bytes.subarray(0, 8000).includes(0))
|
|
49
|
+
continue;
|
|
50
|
+
const metadata = { ...options.metadata, repository, commit, path: file };
|
|
51
|
+
const parse = options.parsers?.[extension];
|
|
52
|
+
if (parse) {
|
|
53
|
+
for await (const document of parse({ source: file, content: bytes })) {
|
|
54
|
+
yield { ...document, metadata: { ...metadata, ...document.metadata } };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
yield { id: file, text: new TextDecoder().decode(bytes), source: file, metadata };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
if (clone)
|
|
64
|
+
await rm(directory, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function git(args, cwd, options) {
|
|
68
|
+
const { execFile } = await import('node:child_process');
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
execFile(options.git ?? 'git', ['-c', 'protocol.ext.allow=never', '-c', 'core.quotePath=false', '-c', 'core.autocrlf=false', ...args], {
|
|
71
|
+
cwd,
|
|
72
|
+
timeout: options.timeoutMs ?? 120_000,
|
|
73
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
74
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '' },
|
|
75
|
+
windowsHide: true,
|
|
76
|
+
}, (error, stdout, stderr) => {
|
|
77
|
+
if (error)
|
|
78
|
+
reject(new GitLoaderError(`git ${args[0]} failed: ${stderr.trim() || error.message}`, stderr));
|
|
79
|
+
else
|
|
80
|
+
resolve(stdout);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DocumentSource, FileInput } from './index.js';
|
|
2
|
+
import type { FileLoaderOptions } from './text.js';
|
|
3
|
+
/** An HTML page as text, with what a loader needs from its head and its links. */
|
|
4
|
+
export interface HtmlText {
|
|
5
|
+
/** The readable text. Headings become Markdown `#` lines and list items `- ` lines. */
|
|
6
|
+
text: string;
|
|
7
|
+
/** The `<title>`. */
|
|
8
|
+
title?: string;
|
|
9
|
+
/** The `<meta name="description">`. */
|
|
10
|
+
description?: string;
|
|
11
|
+
/** Every `href` on an `<a>`, resolved against `baseUrl` when one is given. */
|
|
12
|
+
links: string[];
|
|
13
|
+
}
|
|
14
|
+
/** Options for `htmlToText()`. */
|
|
15
|
+
export interface HtmlToTextOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Keeps only `<main>`, or else `<article>`, when the page has one, dropping navigation, headers,
|
|
18
|
+
* and footers. On by default.
|
|
19
|
+
*/
|
|
20
|
+
mainContent?: boolean;
|
|
21
|
+
/** Resolves relative links. */
|
|
22
|
+
baseUrl?: string | URL;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Turns HTML into readable text without a DOM: scripts, styles, and the head are dropped, block
|
|
26
|
+
* elements become line breaks, headings become Markdown headings so `splitOnMarkdownHeadings` splits
|
|
27
|
+
* a page at its sections, and entities are decoded. A heuristic, not a browser: it is built for pages
|
|
28
|
+
* whose content is in the HTML, not rendered by script.
|
|
29
|
+
*/
|
|
30
|
+
export declare function htmlToText(html: string, options?: HtmlToTextOptions): HtmlText;
|
|
31
|
+
/** Options for `loadHtml()`. */
|
|
32
|
+
export interface HtmlLoaderOptions extends FileLoaderOptions, HtmlToTextOptions {
|
|
33
|
+
}
|
|
34
|
+
/** HTML files, one document each, with the page title and description in the metadata. */
|
|
35
|
+
export declare function loadHtml(inputs: FileInput | Iterable<FileInput>, options?: HtmlLoaderOptions): AsyncGenerator<DocumentSource>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { inputsOf, readText } from './input.js';
|
|
2
|
+
const DROPPED = /<(script|style|noscript|template|svg|iframe|canvas|head)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
|
|
3
|
+
const BLOCK = /<\/?(p|div|section|article|main|header|footer|nav|aside|blockquote|pre|table|thead|tbody|tfoot|tr|ul|ol|dl|dt|dd|figure|figcaption|form|fieldset|address|hr)\b[^>]*>/gi;
|
|
4
|
+
const ENTITIES = {
|
|
5
|
+
amp: '&',
|
|
6
|
+
lt: '<',
|
|
7
|
+
gt: '>',
|
|
8
|
+
quot: '"',
|
|
9
|
+
apos: "'",
|
|
10
|
+
nbsp: ' ',
|
|
11
|
+
mdash: '—',
|
|
12
|
+
ndash: '–',
|
|
13
|
+
hellip: '…',
|
|
14
|
+
copy: '©',
|
|
15
|
+
reg: '®',
|
|
16
|
+
trade: '™',
|
|
17
|
+
lsquo: '‘',
|
|
18
|
+
rsquo: '’',
|
|
19
|
+
ldquo: '“',
|
|
20
|
+
rdquo: '”',
|
|
21
|
+
laquo: '«',
|
|
22
|
+
raquo: '»',
|
|
23
|
+
middot: '·',
|
|
24
|
+
bull: '•',
|
|
25
|
+
euro: '€',
|
|
26
|
+
pound: '£',
|
|
27
|
+
deg: '°',
|
|
28
|
+
};
|
|
29
|
+
export function htmlToText(html, options = {}) {
|
|
30
|
+
const title = /<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(html)?.[1];
|
|
31
|
+
const description = /<meta\b[^>]*name\s*=\s*["']description["'][^>]*content\s*=\s*["']([^"']*)["']/i.exec(html)?.[1] ??
|
|
32
|
+
/<meta\b[^>]*content\s*=\s*["']([^"']*)["'][^>]*name\s*=\s*["']description["']/i.exec(html)?.[1];
|
|
33
|
+
let body = html.replace(/<!--[\s\S]*?-->/g, '').replace(DROPPED, '');
|
|
34
|
+
if (options.mainContent ?? true) {
|
|
35
|
+
const region = /<main\b[^>]*>([\s\S]*?)<\/main\s*>/i.exec(body) ?? /<article\b[^>]*>([\s\S]*?)<\/article\s*>/i.exec(body);
|
|
36
|
+
if (region)
|
|
37
|
+
body = region[1];
|
|
38
|
+
}
|
|
39
|
+
const links = [];
|
|
40
|
+
for (const match of body.matchAll(/<a\b[^>]*href\s*=\s*["']([^"'#][^"']*)["']/gi)) {
|
|
41
|
+
const href = decodeEntities(match[1]);
|
|
42
|
+
try {
|
|
43
|
+
links.push(options.baseUrl ? new URL(href, options.baseUrl).href : href);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const text = body
|
|
49
|
+
.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi, (_, level, inner) => {
|
|
50
|
+
return `\n\n${'#'.repeat(Number(level))} ${inner.replace(/<[^>]+>/g, '').trim()}\n\n`;
|
|
51
|
+
})
|
|
52
|
+
.replace(/<li\b[^>]*>/gi, '\n- ')
|
|
53
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
54
|
+
.replace(/<\/(td|th)\s*>/gi, '\t')
|
|
55
|
+
.replace(BLOCK, '\n')
|
|
56
|
+
.replace(/<[^>]+>/g, '');
|
|
57
|
+
return {
|
|
58
|
+
text: tidy(decodeEntities(text)),
|
|
59
|
+
...(title ? { title: tidy(decodeEntities(title)) } : {}),
|
|
60
|
+
...(description ? { description: decodeEntities(description).trim() } : {}),
|
|
61
|
+
links: [...new Set(links)],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export async function* loadHtml(inputs, options = {}) {
|
|
65
|
+
for (const input of inputsOf(inputs)) {
|
|
66
|
+
const { source, text } = await readText(input);
|
|
67
|
+
const page = htmlToText(text, options);
|
|
68
|
+
yield {
|
|
69
|
+
id: source,
|
|
70
|
+
text: page.text,
|
|
71
|
+
source,
|
|
72
|
+
metadata: {
|
|
73
|
+
...options.metadata,
|
|
74
|
+
...(page.title ? { title: page.title } : {}),
|
|
75
|
+
...(page.description ? { description: page.description } : {}),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function decodeEntities(text) {
|
|
81
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, name) => {
|
|
82
|
+
if (name[0] === '#') {
|
|
83
|
+
const code = name[1] === 'x' || name[1] === 'X' ? Number.parseInt(name.slice(2), 16) : Number(name.slice(1));
|
|
84
|
+
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : entity;
|
|
85
|
+
}
|
|
86
|
+
return ENTITIES[name.toLowerCase()] ?? entity;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function tidy(text) {
|
|
90
|
+
return text
|
|
91
|
+
.split('\n')
|
|
92
|
+
.map((line) => line.replace(/[ \t\f\v ]+/g, ' ').trim())
|
|
93
|
+
.join('\n')
|
|
94
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
95
|
+
.trim();
|
|
96
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { RagChunk } from '../hallucination/rag.js';
|
|
2
|
+
import { type DocumentSource, type IngestionOptions } from '../rag/ingestion.js';
|
|
3
|
+
export type { DocumentSource } from '../rag/ingestion.js';
|
|
4
|
+
/**
|
|
5
|
+
* Documents produced one at a time. Every loader returns one, so a corpus larger than memory streams
|
|
6
|
+
* through ingestion instead of being read whole, and an array of documents works as well.
|
|
7
|
+
*/
|
|
8
|
+
export type DocumentLoader = AsyncIterable<DocumentSource> | Iterable<DocumentSource>;
|
|
9
|
+
/**
|
|
10
|
+
* A file to load: a path, a `file:` URL, or content already in memory with a name for it. In-memory
|
|
11
|
+
* content is how a loader reads an upload, a database blob, or a file on a runtime without a disk.
|
|
12
|
+
*/
|
|
13
|
+
export type FileInput = string | URL | {
|
|
14
|
+
source: string;
|
|
15
|
+
content: string | Uint8Array;
|
|
16
|
+
};
|
|
17
|
+
/** Reads every document from one or more loaders, in order, into an array. */
|
|
18
|
+
export declare function collectDocuments(...loaders: DocumentLoader[]): Promise<DocumentSource[]>;
|
|
19
|
+
/**
|
|
20
|
+
* Where loaded chunks go: a vector store, a `KeywordIndex`, or anything else with an `add()` for
|
|
21
|
+
* chunks. Loading into several at once fills a vector store and the keyword index hybrid search reads
|
|
22
|
+
* in one pass over the corpus.
|
|
23
|
+
*/
|
|
24
|
+
export interface ChunkSink {
|
|
25
|
+
/** Adds chunks, replacing those whose id is already held. */
|
|
26
|
+
add(chunks: RagChunk[]): unknown;
|
|
27
|
+
}
|
|
28
|
+
/** Options for `loadIntoStore()`: how documents are split, and how many are embedded at once. */
|
|
29
|
+
export interface LoadIntoStoreOptions extends IngestionOptions {
|
|
30
|
+
/** Documents split and added to the store together. Defaults to 32. */
|
|
31
|
+
batchSize?: number;
|
|
32
|
+
/** Stops loading between batches. */
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
/** Called after each batch is stored, for progress reporting. */
|
|
35
|
+
onBatch?: (progress: LoadIntoStoreResult) => void;
|
|
36
|
+
}
|
|
37
|
+
/** What `loadIntoStore()` stored. */
|
|
38
|
+
export interface LoadIntoStoreResult {
|
|
39
|
+
/** Documents read from the loaders. */
|
|
40
|
+
documents: number;
|
|
41
|
+
/** Chunks added to the store. */
|
|
42
|
+
chunks: number;
|
|
43
|
+
/** Characters across every document. */
|
|
44
|
+
totalCharacters: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Streams documents from loaders into one or more stores: each batch is split into chunks and added to
|
|
48
|
+
* every store at once, so memory holds one batch at a time however large the corpus is. Chunk ids
|
|
49
|
+
* derive from document ids, so loading the same corpus again replaces its chunks rather than
|
|
50
|
+
* duplicating them. A document with no id is numbered in load order.
|
|
51
|
+
*/
|
|
52
|
+
export declare function loadIntoStore(stores: ChunkSink | readonly ChunkSink[], loaders: DocumentLoader | readonly DocumentLoader[], options?: LoadIntoStoreOptions): Promise<LoadIntoStoreResult>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ingestDocuments } from '../rag/ingestion.js';
|
|
2
|
+
export async function collectDocuments(...loaders) {
|
|
3
|
+
const documents = [];
|
|
4
|
+
for (const loader of loaders) {
|
|
5
|
+
for await (const document of loader)
|
|
6
|
+
documents.push(document);
|
|
7
|
+
}
|
|
8
|
+
return documents;
|
|
9
|
+
}
|
|
10
|
+
export async function loadIntoStore(stores, loaders, options = {}) {
|
|
11
|
+
const batchSize = options.batchSize ?? 32;
|
|
12
|
+
if (!(Number.isInteger(batchSize) && batchSize > 0))
|
|
13
|
+
throw new RangeError('batchSize must be a positive integer');
|
|
14
|
+
const result = { documents: 0, chunks: 0, totalCharacters: 0 };
|
|
15
|
+
let batch = [];
|
|
16
|
+
const flush = async () => {
|
|
17
|
+
if (batch.length === 0)
|
|
18
|
+
return;
|
|
19
|
+
const ingested = ingestDocuments(batch, options);
|
|
20
|
+
const targets = 'add' in stores ? [stores] : stores;
|
|
21
|
+
await Promise.all(targets.map((store) => store.add(ingested.chunks)));
|
|
22
|
+
result.chunks += ingested.chunks.length;
|
|
23
|
+
result.totalCharacters += ingested.totalCharacters;
|
|
24
|
+
batch = [];
|
|
25
|
+
options.onBatch?.({ ...result });
|
|
26
|
+
};
|
|
27
|
+
const sources = isLoaderList(loaders) ? loaders : [loaders];
|
|
28
|
+
for (const loader of sources) {
|
|
29
|
+
for await (const document of loader) {
|
|
30
|
+
options.signal?.throwIfAborted();
|
|
31
|
+
result.documents++;
|
|
32
|
+
batch.push(document.id ? document : { ...document, id: `doc-${result.documents}` });
|
|
33
|
+
if (batch.length >= batchSize)
|
|
34
|
+
await flush();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
options.signal?.throwIfAborted();
|
|
38
|
+
await flush();
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
function isLoaderList(value) {
|
|
42
|
+
if (!Array.isArray(value))
|
|
43
|
+
return false;
|
|
44
|
+
const first = value[0];
|
|
45
|
+
return first !== undefined && typeof first === 'object' && first !== null && !('text' in first);
|
|
46
|
+
}
|