@devilsdev/rag-pipeline-utils 2.5.0 → 2.6.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +1 -1
  3. package/dist/index.cjs +1484 -147
  4. package/dist/index.d.ts +49 -0
  5. package/dist/index.mjs +1 -1
  6. package/dist/testing.cjs +2 -0
  7. package/dist/testing.d.ts +1 -0
  8. package/dist/testing.mjs +5 -0
  9. package/dist/types/adapters/ai-sdk.d.ts +53 -0
  10. package/dist/types/cache/component-keys.d.ts +7 -0
  11. package/dist/types/cache/memory-cache.d.ts +54 -0
  12. package/dist/types/cache/pipeline-cache.d.ts +66 -0
  13. package/dist/types/cache/redis-cache.d.ts +46 -0
  14. package/dist/types/connectors/anthropic-connector.d.ts +7 -2
  15. package/dist/types/connectors/cohere-connector.d.ts +7 -2
  16. package/dist/types/connectors/local-embedder.d.ts +9 -0
  17. package/dist/types/connectors/memory-retriever.d.ts +8 -0
  18. package/dist/types/connectors/ollama-connector.d.ts +7 -2
  19. package/dist/types/connectors/openai-connector.d.ts +16 -1
  20. package/dist/types/core/create-pipeline.d.ts +8 -0
  21. package/dist/types/core/plugin-registry.d.ts +136 -0
  22. package/dist/types/core/stage-contracts.d.ts +36 -0
  23. package/dist/types/core/validate-plugin.d.ts +45 -0
  24. package/dist/types/evaluate/semantic-similarity.d.ts +14 -0
  25. package/dist/types/grounding/check-answer.d.ts +101 -0
  26. package/dist/types/retrieval/file-vector-store.d.ts +12 -0
  27. package/dist/types/testing.d.ts +168 -0
  28. package/package.json +22 -7
  29. package/src/adapters/ai-sdk.js +215 -0
  30. package/src/cache/component-keys.js +38 -0
  31. package/src/cache/memory-cache.js +87 -0
  32. package/src/cache/pipeline-cache.js +260 -0
  33. package/src/cache/redis-cache.js +83 -0
  34. package/src/cli/commands/ai.js +17 -12
  35. package/src/cli/commands/plugin-hub.js +18 -13
  36. package/src/cli/enhanced-cli-commands.js +45 -29
  37. package/src/cli/handlers/query-handler.js +174 -11
  38. package/src/cli/services/instrument.js +83 -0
  39. package/src/cli/services/rag-service.js +51 -12
  40. package/src/cli/wizard/init-wizard.js +2 -17
  41. package/src/config/enhanced-ragrc-schema.js +0 -2
  42. package/src/config/validate-schema.js +4 -1
  43. package/src/connectors/anthropic-connector.js +22 -2
  44. package/src/connectors/cohere-connector.js +20 -2
  45. package/src/connectors/local-embedder.js +20 -0
  46. package/src/connectors/memory-retriever.js +18 -0
  47. package/src/connectors/ollama-connector.js +29 -2
  48. package/src/connectors/openai-connector.js +49 -4
  49. package/src/core/create-pipeline.js +112 -15
  50. package/src/core/performance/parallel-processor.js +62 -1
  51. package/src/core/plugin-registry.js +42 -19
  52. package/src/core/stage-contracts.js +36 -0
  53. package/src/core/validate-plugin.js +85 -0
  54. package/src/debug/execution-tracer.js +14 -4
  55. package/src/dev/dev-server.js +8 -1
  56. package/src/dev/hot-reload.js +4 -3
  57. package/src/enterprise/sso-integration.js +4 -3
  58. package/src/evaluate/semantic-similarity.js +79 -0
  59. package/src/grounding/check-answer.js +173 -0
  60. package/src/index.js +22 -0
  61. package/src/loader/csv-loader.js +3 -2
  62. package/src/loader/html-loader.js +3 -1
  63. package/src/loader/markdown-loader.js +3 -2
  64. package/src/retrieval/file-vector-store.js +30 -1
  65. package/src/security/jwt-validator.js +5 -4
  66. package/src/testing.js +254 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { randomBytes } = require('crypto');
6
+ const { digest } = require('../cache/component-keys');
5
7
 
6
8
  const FORMAT_VERSION = 1;
7
9
 
@@ -25,6 +27,25 @@ class FileVectorStore {
25
27
  }
26
28
  this.path = path.resolve(options.path);
27
29
  this._index = null;
30
+ this._loadedDigest = null;
31
+ this._cacheId = randomBytes(8).toString('hex');
32
+ this._revision = 0;
33
+ }
34
+
35
+ /**
36
+ * Cache identity for createRagPipeline({ cache }): a digest of the index
37
+ * as loaded, so processes reading the same unchanged index share cached
38
+ * results; after this instance changes the index the key also carries the
39
+ * instance and a revision.
40
+ * @param {'retriever'} role
41
+ * @returns {string|null}
42
+ */
43
+ getCacheKey(role) {
44
+ if (role !== 'retriever') return null;
45
+ this.load();
46
+ return this._revision === 0
47
+ ? `file|${this._loadedDigest}`
48
+ : `file|${this._loadedDigest}|${this._cacheId}|${this._revision}`;
28
49
  }
29
50
 
30
51
  _emptyIndex() {
@@ -39,15 +60,18 @@ class FileVectorStore {
39
60
  if (this._index) return this._index;
40
61
  if (!fs.existsSync(this.path)) {
41
62
  this._index = this._emptyIndex();
63
+ this._loadedDigest = 'empty';
42
64
  return this._index;
43
65
  }
44
- const parsed = JSON.parse(fs.readFileSync(this.path, 'utf8'));
66
+ const raw = fs.readFileSync(this.path, 'utf8');
67
+ const parsed = JSON.parse(raw);
45
68
  if (parsed.version !== FORMAT_VERSION || !parsed.documents) {
46
69
  throw new Error(
47
70
  `Unsupported index format in ${this.path} (version ${parsed.version})`,
48
71
  );
49
72
  }
50
73
  this._index = parsed;
74
+ this._loadedDigest = digest(raw);
51
75
  return this._index;
52
76
  }
53
77
 
@@ -68,6 +92,7 @@ class FileVectorStore {
68
92
  /** @param {{name: string, dimensions: number, state?: object}} info */
69
93
  setEmbedderInfo(info) {
70
94
  this.load().embedder = info;
95
+ this._revision += 1;
71
96
  }
72
97
 
73
98
  /**
@@ -78,6 +103,7 @@ class FileVectorStore {
78
103
  */
79
104
  async store(docs) {
80
105
  const { documents } = this.load();
106
+ this._revision += 1;
81
107
  for (const doc of docs) {
82
108
  if (!doc.id || !Array.isArray(doc.vector)) {
83
109
  throw new Error(
@@ -99,6 +125,7 @@ class FileVectorStore {
99
125
  */
100
126
  removeBySource(source) {
101
127
  const { documents } = this.load();
128
+ this._revision += 1;
102
129
  let removed = 0;
103
130
  for (const [id, doc] of Object.entries(documents)) {
104
131
  if (doc.metadata?.source === source) {
@@ -111,7 +138,9 @@ class FileVectorStore {
111
138
 
112
139
  /** Remove every document and the recorded embedder info. */
113
140
  clear() {
141
+ this.load();
114
142
  this._index = this._emptyIndex();
143
+ this._revision += 1;
115
144
  }
116
145
 
117
146
  /** @returns {Array<{id: string, content: string, metadata: object}>} */
@@ -30,7 +30,8 @@
30
30
  * @since 2.3.0
31
31
  */
32
32
 
33
- const jwt = require('jsonwebtoken');
33
+ // jsonwebtoken loads on first use (about 90 ms otherwise, on every import).
34
+ const loadJwt = () => require('jsonwebtoken'); // eslint-disable-line global-require
34
35
  const crypto = require('crypto');
35
36
  const { EventEmitter } = require('events');
36
37
  const { logger } = require('../utils/logger');
@@ -315,7 +316,7 @@ class JWTValidator extends EventEmitter {
315
316
  }
316
317
 
317
318
  // Sign token with explicit algorithm
318
- const token = jwt.sign(completePayload, signingKey, {
319
+ const token = loadJwt().sign(completePayload, signingKey, {
319
320
  algorithm: this.config.algorithm,
320
321
  // Do not allow algorithm to be overridden
321
322
  header: {
@@ -433,7 +434,7 @@ class JWTValidator extends EventEmitter {
433
434
  verifyOptions.maxAge = options.maxAge || `${this.config.maxTokenAge}s`;
434
435
  }
435
436
 
436
- const payload = jwt.verify(token, verificationKey, verifyOptions);
437
+ const payload = loadJwt().verify(token, verificationKey, verifyOptions);
437
438
 
438
439
  // Additional claim validation
439
440
  this._validateRequiredClaims(payload);
@@ -734,7 +735,7 @@ class JWTValidator extends EventEmitter {
734
735
  */
735
736
  decode(token) {
736
737
  try {
737
- return jwt.decode(token, { complete: false });
738
+ return loadJwt().decode(token, { complete: false });
738
739
  } catch (error) {
739
740
  throw new Error(`Failed to decode token: ${error.message}`);
740
741
  }
package/src/testing.js ADDED
@@ -0,0 +1,254 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @devilsdev/rag-pipeline-utils/testing
5
+ *
6
+ * Deterministic, offline stand-ins for testing code that uses this library:
7
+ * no network, no API keys, the same output on every run.
8
+ *
9
+ * - HashEmbedder: bag-of-words vectors from hashed words.
10
+ * - EchoLLM: answers with the top retrieved document (or an answer you set)
11
+ * and records every call.
12
+ * - fixtures: a few short policy documents.
13
+ * - createTestPipeline(): a ready, indexed pipeline built from the above.
14
+ */
15
+
16
+ const { createRagPipeline } = require('./core/create-pipeline');
17
+ const { MemoryRetriever } = require('./connectors/memory-retriever');
18
+
19
+ function throwIfAborted(signal) {
20
+ if (signal && signal.aborted) {
21
+ throw signal.reason || new Error('Aborted');
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Deterministic embedder: hashed bag of words, L2-normalised. Texts that
27
+ * share words get similar vectors, so retrieval ranks sensibly in tests.
28
+ */
29
+ class HashEmbedder {
30
+ /**
31
+ * @param {Object} [options]
32
+ * @param {number} [options.dimensions=256] - Vector length
33
+ */
34
+ constructor({ dimensions = 256 } = {}) {
35
+ if (!Number.isInteger(dimensions) || dimensions < 1) {
36
+ throw new Error('dimensions must be a positive integer');
37
+ }
38
+ this.dimensions = dimensions;
39
+ this.metadata = {
40
+ name: 'hash-embedder',
41
+ version: '1.0.0',
42
+ type: 'embedder',
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Cache identity for createRagPipeline({ cache }).
48
+ * @param {string} role
49
+ * @returns {string|null}
50
+ */
51
+ getCacheKey(role) {
52
+ return role === 'embedder' ? `hash-embedder|${this.dimensions}` : null;
53
+ }
54
+
55
+ /** @private */
56
+ _vector(text) {
57
+ const vector = new Array(this.dimensions).fill(0);
58
+ for (const word of String(text)
59
+ .toLowerCase()
60
+ .match(/[\p{L}\p{N}]+/gu) || []) {
61
+ let hash = 2166136261;
62
+ for (const ch of word) {
63
+ hash ^= ch.codePointAt(0);
64
+ hash = Math.imul(hash, 16777619) >>> 0;
65
+ }
66
+ // FNV-1a's low bits are weak (e.g. "above" and "vacation" agree in
67
+ // their low 10 bits), so mix them (MurmurHash3 fmix32) before taking
68
+ // the bucket.
69
+ hash ^= hash >>> 16;
70
+ hash = Math.imul(hash, 0x85ebca6b);
71
+ hash ^= hash >>> 13;
72
+ hash = Math.imul(hash, 0xc2b2ae35);
73
+ hash ^= hash >>> 16;
74
+ hash >>>= 0;
75
+ // Signed feature hashing: the top bit picks the sign, so colliding
76
+ // words cancel out on average instead of adding up.
77
+ vector[hash % this.dimensions] += hash & 0x80000000 ? -1 : 1;
78
+ }
79
+ const norm = Math.hypot(...vector);
80
+ return norm > 0 ? vector.map((v) => v / norm) : vector;
81
+ }
82
+
83
+ /**
84
+ * @param {string} text
85
+ * @param {{ signal?: AbortSignal }} [options]
86
+ * @returns {Promise<number[]>}
87
+ */
88
+ async embed(text, { signal } = {}) {
89
+ throwIfAborted(signal);
90
+ return this._vector(text);
91
+ }
92
+
93
+ /**
94
+ * Same as embed().
95
+ * @param {string} text
96
+ * @param {{ signal?: AbortSignal }} [options]
97
+ * @returns {Promise<number[]>}
98
+ */
99
+ async embedQuery(text, options) {
100
+ return this.embed(text, options);
101
+ }
102
+
103
+ /**
104
+ * @param {string[]} texts
105
+ * @param {{ signal?: AbortSignal }} [options]
106
+ * @returns {Promise<number[][]>}
107
+ */
108
+ async embedBatch(texts, { signal } = {}) {
109
+ throwIfAborted(signal);
110
+ return texts.map((t) => this._vector(t));
111
+ }
112
+ }
113
+
114
+ /**
115
+ * LLM stand-in. Answers with `answer` (a string, or a function of the query
116
+ * and documents), or else with the top document's content. Every call is
117
+ * recorded in `calls`, so tests can assert on what the pipeline sent.
118
+ */
119
+ class EchoLLM {
120
+ /**
121
+ * @param {Object} [options]
122
+ * @param {string|function(string, Array<Object>): string} [options.answer]
123
+ * @param {string} [options.fallback='No information found.'] - Answer when
124
+ * there are no documents and no `answer`
125
+ */
126
+ constructor({ answer, fallback = 'No information found.' } = {}) {
127
+ this.answer = answer;
128
+ this.fallback = fallback;
129
+ /** @type {Array<{query: string, docs: Array<Object>, options: Object}>} */
130
+ this.calls = [];
131
+ this.metadata = { name: 'echo-llm', version: '1.0.0', type: 'llm' };
132
+ }
133
+
134
+ /**
135
+ * Cache identity for createRagPipeline({ cache }); none when `answer` is a
136
+ * function, since its output cannot be identified.
137
+ * @param {string} role
138
+ * @returns {string|null}
139
+ */
140
+ getCacheKey(role) {
141
+ if (role !== 'llm' || typeof this.answer === 'function') return null;
142
+ return `echo-llm|${JSON.stringify([this.answer ?? null, this.fallback])}`;
143
+ }
144
+
145
+ /** @private */
146
+ _answer(query, docs) {
147
+ if (typeof this.answer === 'function')
148
+ return String(this.answer(query, docs));
149
+ if (typeof this.answer === 'string') return this.answer;
150
+ const top = docs && docs[0];
151
+ if (!top) return this.fallback;
152
+ return typeof top === 'string'
153
+ ? top
154
+ : String(top.content ?? top.text ?? '');
155
+ }
156
+
157
+ /**
158
+ * @param {string} query
159
+ * @param {Array<Object>} [docs]
160
+ * @param {{ signal?: AbortSignal }} [options]
161
+ * @returns {Promise<string>}
162
+ */
163
+ async generate(query, docs = [], options = {}) {
164
+ throwIfAborted(options.signal);
165
+ this.calls.push({ query, docs, options });
166
+ return this._answer(query, docs);
167
+ }
168
+
169
+ /**
170
+ * Streams the same answer word by word, then a done chunk.
171
+ * @param {string} query
172
+ * @param {Array<Object>} [docs]
173
+ * @param {{ signal?: AbortSignal }} [options]
174
+ * @yields {{ token: string, done: boolean }}
175
+ */
176
+ async *generateStream(query, docs = [], options = {}) {
177
+ throwIfAborted(options.signal);
178
+ this.calls.push({ query, docs, options, stream: true });
179
+ for (const token of this._answer(query, docs).split(/(?<= )/)) {
180
+ throwIfAborted(options.signal);
181
+ yield { token, done: false };
182
+ }
183
+ yield { token: '', done: true };
184
+ }
185
+ }
186
+
187
+ /** Short documents for tests and examples. */
188
+ const fixtures = Object.freeze({
189
+ documents: Object.freeze([
190
+ Object.freeze({
191
+ id: 'vacation',
192
+ content:
193
+ 'Full-time employees receive twenty days of paid vacation per year. Unused days carry over to March.',
194
+ metadata: Object.freeze({ source: 'vacation.md' }),
195
+ }),
196
+ Object.freeze({
197
+ id: 'remote-work',
198
+ content:
199
+ 'Employees may work remotely up to three days per week with manager approval.',
200
+ metadata: Object.freeze({ source: 'remote-work.md' }),
201
+ }),
202
+ Object.freeze({
203
+ id: 'expenses',
204
+ content:
205
+ 'Submit receipts within thirty days. Travel above 500 EUR needs pre-approval from finance.',
206
+ metadata: Object.freeze({ source: 'expenses.md' }),
207
+ }),
208
+ ]),
209
+ });
210
+
211
+ /**
212
+ * A pipeline with HashEmbedder, a MemoryRetriever already holding
213
+ * `documents`, and EchoLLM (or the llm you pass).
214
+ *
215
+ * @example
216
+ * const { createTestPipeline } = require('@devilsdev/rag-pipeline-utils/testing');
217
+ * const { pipeline, llm } = await createTestPipeline();
218
+ * const result = await pipeline.run({ query: 'How many vacation days?' });
219
+ * // result.results[0].id === 'vacation'; llm.calls.length === 1
220
+ *
221
+ * @param {Object} [options]
222
+ * @param {Array<{id: string, content: string, metadata?: Object}>} [options.documents=fixtures.documents]
223
+ * @param {Object} [options.embedder] - Defaults to a new HashEmbedder
224
+ * @param {Object|null} [options.llm] - Defaults to a new EchoLLM; null for retrieval only
225
+ * @param {Object} [options.pipeline] - Extra createRagPipeline options (e.g. reranker)
226
+ * @returns {Promise<{ pipeline: Object, embedder: Object, retriever: MemoryRetriever, llm: Object|null }>}
227
+ */
228
+ async function createTestPipeline({
229
+ documents = fixtures.documents,
230
+ embedder = new HashEmbedder(),
231
+ llm = new EchoLLM(),
232
+ pipeline: pipelineOptions = {},
233
+ } = {}) {
234
+ const retriever = new MemoryRetriever();
235
+ const vectors = await Promise.all(
236
+ documents.map((d) => embedder.embed(d.content)),
237
+ );
238
+ await retriever.store(
239
+ documents.map((d, i) => ({
240
+ ...d,
241
+ metadata: { ...(d.metadata || {}) },
242
+ vector: vectors[i],
243
+ })),
244
+ );
245
+ const pipeline = createRagPipeline({
246
+ ...pipelineOptions,
247
+ embedder,
248
+ retriever,
249
+ ...(llm && { llm }),
250
+ });
251
+ return { pipeline, embedder, retriever, llm: llm || null };
252
+ }
253
+
254
+ module.exports = { HashEmbedder, EchoLLM, fixtures, createTestPipeline };