@denizsvnc/ai-rag-engine 0.0.0-stage → 2.2.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/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +167 -2
- package/dist/adapters/cache/index.d.ts +1 -0
- package/dist/adapters/cache/redis-cache.adapter.d.ts +49 -0
- package/dist/adapters/faq/local-faq.adapter.d.ts +29 -0
- package/dist/adapters/faq/url-faq.adapter.d.ts +14 -0
- package/dist/adapters/llm/custom.adapter.d.ts +42 -0
- package/dist/adapters/llm/fallback.adapter.d.ts +10 -0
- package/dist/adapters/llm/gemini.adapter.d.ts +9 -0
- package/dist/adapters/llm/ollama.adapter.d.ts +30 -0
- package/dist/adapters/llm/openai.adapter.d.ts +10 -0
- package/dist/adapters/llm/openrouter.adapter.d.ts +6 -0
- package/dist/adapters/llm/tiered.adapter.d.ts +22 -0
- package/dist/adapters/rules/static-rules.adapter.d.ts +6 -0
- package/dist/adapters/vector/memory-vector.adapter.d.ts +33 -0
- package/dist/adapters/vector/postgres-vector.adapter.d.ts +51 -0
- package/dist/api/auth.d.ts +47 -0
- package/dist/api/middleware.d.ts +3 -0
- package/dist/api/rate-limit.d.ts +8 -0
- package/dist/api/routes.d.ts +9 -0
- package/dist/api/server.d.ts +1 -0
- package/dist/api/server.js +4433 -0
- package/dist/config/default.d.ts +22 -0
- package/dist/config/ingestion-runtime.d.ts +11 -0
- package/dist/core/cache-manager.d.ts +29 -0
- package/dist/core/engine.d.ts +75 -0
- package/dist/core/ingestion/ingestion-worker.d.ts +19 -0
- package/dist/core/ingestion/postgres-ingestion-store.d.ts +56 -0
- package/dist/core/ingestion/queued-ingestion.service.d.ts +35 -0
- package/dist/core/metrics/prometheus-exporter.d.ts +12 -0
- package/dist/core/metrics/rag-evaluator.d.ts +63 -0
- package/dist/core/metrics/token-tracker.d.ts +69 -0
- package/dist/core/prompt-builder.d.ts +20 -0
- package/dist/core/queue/bullmq-queue.d.ts +39 -0
- package/dist/core/queue/in-memory-queue.d.ts +24 -0
- package/dist/core/queue/job-queue.interface.d.ts +22 -0
- package/dist/core/queue/queue-factory.d.ts +12 -0
- package/dist/core/security/confirmation.d.ts +64 -0
- package/dist/core/security/injection-detector.d.ts +25 -0
- package/dist/core/security/nonce-store.d.ts +21 -0
- package/dist/core/security/pii-guard.d.ts +38 -0
- package/dist/core/security/sanitizer.d.ts +23 -0
- package/dist/core/security/secrets.d.ts +2 -0
- package/dist/core/security/security-guard.d.ts +18 -0
- package/dist/core/security/security.types.d.ts +38 -0
- package/dist/core/sml-engine.d.ts +65 -0
- package/dist/core/tool-registry.d.ts +17 -0
- package/dist/core/vector/bm25.d.ts +44 -0
- package/dist/core/vector/hybrid-search.d.ts +15 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +4509 -0
- package/dist/interfaces/cache.interface.d.ts +8 -0
- package/dist/interfaces/engine-config.interface.d.ts +73 -0
- package/dist/interfaces/faq.interface.d.ts +17 -0
- package/dist/interfaces/ingestion.interface.d.ts +14 -0
- package/dist/interfaces/llm.interface.d.ts +22 -0
- package/dist/interfaces/query-context.interface.d.ts +33 -0
- package/dist/interfaces/rules.interface.d.ts +3 -0
- package/dist/interfaces/tool.interface.d.ts +50 -0
- package/dist/interfaces/vector.interface.d.ts +15 -0
- package/dist/scripts/migrate-vector.d.ts +1 -0
- package/dist/scripts/migrate-vector.js +627 -0
- package/dist/services/document-ingestion.service.d.ts +116 -0
- package/dist/utils/http.d.ts +2 -0
- package/dist/utils/math.d.ts +3 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +1787 -0
- package/docs/RELEASING.md +55 -0
- package/docs/USAGE.md +154 -0
- package/package.json +95 -4
package/dist/worker.js
ADDED
|
@@ -0,0 +1,1787 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/worker.ts
|
|
4
|
+
import "dotenv/config";
|
|
5
|
+
|
|
6
|
+
// src/config/ingestion-runtime.ts
|
|
7
|
+
import { Pool as Pool2 } from "pg";
|
|
8
|
+
|
|
9
|
+
// src/adapters/vector/postgres-vector.adapter.ts
|
|
10
|
+
import { Pool } from "pg";
|
|
11
|
+
|
|
12
|
+
// src/core/sml-engine.ts
|
|
13
|
+
import { LRUCache } from "lru-cache";
|
|
14
|
+
|
|
15
|
+
// src/utils/math.ts
|
|
16
|
+
function dotProduct(vecA, vecB) {
|
|
17
|
+
if (vecA.length !== vecB.length) {
|
|
18
|
+
throw new Error("Vector dimensions do not match");
|
|
19
|
+
}
|
|
20
|
+
let sum = 0;
|
|
21
|
+
for (let i = 0; i < vecA.length; i++) {
|
|
22
|
+
sum += (vecA[i] ?? 0) * (vecB[i] ?? 0);
|
|
23
|
+
}
|
|
24
|
+
return sum;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/core/sml-engine.ts
|
|
28
|
+
var CircuitBreakerOpenError = class extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "CircuitBreakerOpenError";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var SMLEngine = class {
|
|
35
|
+
modelName;
|
|
36
|
+
cacheDir;
|
|
37
|
+
localFilesOnly;
|
|
38
|
+
dtype;
|
|
39
|
+
extractorPromise = null;
|
|
40
|
+
embeddingCache;
|
|
41
|
+
// Circuit Breaker State Machine
|
|
42
|
+
circuitState = "CLOSED";
|
|
43
|
+
failureCount = 0;
|
|
44
|
+
lastFailureTime = 0;
|
|
45
|
+
failureThreshold;
|
|
46
|
+
cooldownMs;
|
|
47
|
+
constructor(config) {
|
|
48
|
+
this.cacheDir = config?.cacheDir ?? process.env.RAG_MODEL_CACHE_DIR ?? "./data/models";
|
|
49
|
+
this.localFilesOnly = config?.localFilesOnly ?? false;
|
|
50
|
+
this.modelName = config?.modelName ?? "Xenova/all-MiniLM-L6-v2";
|
|
51
|
+
this.dtype = config?.dtype ?? (config?.quantized === false ? "fp32" : "q8");
|
|
52
|
+
this.failureThreshold = config?.circuitBreakerThreshold ?? 3;
|
|
53
|
+
this.cooldownMs = config?.circuitBreakerCooldownMs ?? 15e3;
|
|
54
|
+
this.embeddingCache = new LRUCache({
|
|
55
|
+
max: config?.cacheSize ?? 1e3
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resets the circuit breaker state (useful for testing and recovery).
|
|
60
|
+
*/
|
|
61
|
+
resetCircuit() {
|
|
62
|
+
this.circuitState = "CLOSED";
|
|
63
|
+
this.failureCount = 0;
|
|
64
|
+
this.lastFailureTime = 0;
|
|
65
|
+
this.extractorPromise = null;
|
|
66
|
+
}
|
|
67
|
+
getCircuitState() {
|
|
68
|
+
if (this.circuitState === "OPEN") {
|
|
69
|
+
if (Date.now() - this.lastFailureTime >= this.cooldownMs) {
|
|
70
|
+
this.circuitState = "HALF_OPEN";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return this.circuitState;
|
|
74
|
+
}
|
|
75
|
+
getFailureCount() {
|
|
76
|
+
return this.failureCount;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Thread-safe pipeline initializer guarded by a Circuit Breaker.
|
|
80
|
+
*/
|
|
81
|
+
async init() {
|
|
82
|
+
if (this.circuitState === "OPEN") {
|
|
83
|
+
const timeSinceLastFailure = Date.now() - this.lastFailureTime;
|
|
84
|
+
if (timeSinceLastFailure < this.cooldownMs) {
|
|
85
|
+
const remainingSec = Math.ceil((this.cooldownMs - timeSinceLastFailure) / 1e3);
|
|
86
|
+
throw new CircuitBreakerOpenError(
|
|
87
|
+
`[SML CircuitBreaker] Model '${this.modelName}' is temporarily tripped OPEN after ${this.failureCount} consecutive failures. Cooldown active for another ${remainingSec}s.`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
this.circuitState = "HALF_OPEN";
|
|
91
|
+
console.log(`[SML CircuitBreaker] Cooldown expired. Transitioned to HALF_OPEN for probe.`);
|
|
92
|
+
}
|
|
93
|
+
if (!this.extractorPromise) {
|
|
94
|
+
console.log(`[SML] Loading ${this.modelName} (dtype: ${this.dtype})...`);
|
|
95
|
+
this.extractorPromise = import("@huggingface/transformers").then(async ({ pipeline: pipeline2, env }) => {
|
|
96
|
+
const previousCacheDir = env.cacheDir;
|
|
97
|
+
const previousAllowRemote = env.allowRemoteModels;
|
|
98
|
+
try {
|
|
99
|
+
if (this.cacheDir) {
|
|
100
|
+
env.cacheDir = this.cacheDir;
|
|
101
|
+
}
|
|
102
|
+
if (this.localFilesOnly) {
|
|
103
|
+
env.allowRemoteModels = false;
|
|
104
|
+
}
|
|
105
|
+
return await pipeline2("feature-extraction", this.modelName, {
|
|
106
|
+
dtype: this.dtype
|
|
107
|
+
});
|
|
108
|
+
} finally {
|
|
109
|
+
env.cacheDir = previousCacheDir;
|
|
110
|
+
env.allowRemoteModels = previousAllowRemote;
|
|
111
|
+
}
|
|
112
|
+
}).then((pipelineInstance) => {
|
|
113
|
+
this.circuitState = "CLOSED";
|
|
114
|
+
this.failureCount = 0;
|
|
115
|
+
const extract = async (text, options) => {
|
|
116
|
+
const output = await pipelineInstance(text, options);
|
|
117
|
+
const values = output.data;
|
|
118
|
+
const data = output.data instanceof Float32Array ? output.data : Float32Array.from({ length: values.length }, (_, index) => Number(values[index]));
|
|
119
|
+
return { data, dims: output.dims };
|
|
120
|
+
};
|
|
121
|
+
return extract;
|
|
122
|
+
}).catch((err) => {
|
|
123
|
+
this.failureCount++;
|
|
124
|
+
this.lastFailureTime = Date.now();
|
|
125
|
+
this.extractorPromise = null;
|
|
126
|
+
if (this.failureCount >= this.failureThreshold) {
|
|
127
|
+
this.circuitState = "OPEN";
|
|
128
|
+
console.error(
|
|
129
|
+
`[SML CircuitBreaker] \u26A0\uFE0F Tripped to OPEN state! Model failed ${this.failureCount} times. Pausing requests for ${this.cooldownMs / 1e3}s.`
|
|
130
|
+
);
|
|
131
|
+
} else {
|
|
132
|
+
console.error(`[SML] Model loading error (attempt ${this.failureCount}/${this.failureThreshold}):`, err);
|
|
133
|
+
}
|
|
134
|
+
throw err;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return this.extractorPromise;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Generates a normalized embedding vector for a single text input (with LRU caching).
|
|
141
|
+
*/
|
|
142
|
+
async getEmbedding(text) {
|
|
143
|
+
const cleanedText = text.trim();
|
|
144
|
+
if (!cleanedText) {
|
|
145
|
+
throw new Error("Text input cannot be empty.");
|
|
146
|
+
}
|
|
147
|
+
const cached = this.embeddingCache.get(cleanedText);
|
|
148
|
+
if (cached) return cached;
|
|
149
|
+
const extractor = await this.init();
|
|
150
|
+
const output = await extractor(cleanedText, {
|
|
151
|
+
pooling: "mean",
|
|
152
|
+
normalize: true
|
|
153
|
+
});
|
|
154
|
+
const embedding = output.data;
|
|
155
|
+
this.embeddingCache.set(cleanedText, embedding);
|
|
156
|
+
return embedding;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Generates embedding vectors for multiple texts in batch mode.
|
|
160
|
+
*/
|
|
161
|
+
async getBatchEmbeddings(texts) {
|
|
162
|
+
if (texts.length === 0) return [];
|
|
163
|
+
const extractor = await this.init();
|
|
164
|
+
const output = await extractor(texts, {
|
|
165
|
+
pooling: "mean",
|
|
166
|
+
normalize: true
|
|
167
|
+
});
|
|
168
|
+
const rawData = output.data;
|
|
169
|
+
const dimensions = texts.length > 0 ? Math.floor(rawData.length / texts.length) : 384;
|
|
170
|
+
const results = [];
|
|
171
|
+
for (let i = 0; i < texts.length; i++) {
|
|
172
|
+
const start = i * dimensions;
|
|
173
|
+
results.push(rawData.subarray(start, start + dimensions));
|
|
174
|
+
}
|
|
175
|
+
return results;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Compares the query against target embeddings and returns the highest scoring match above the threshold.
|
|
179
|
+
*/
|
|
180
|
+
async findBestMatch(query, targets, threshold = 0.65) {
|
|
181
|
+
if (targets.length === 0) return null;
|
|
182
|
+
const queryVec = await this.getEmbedding(query);
|
|
183
|
+
let bestTarget = null;
|
|
184
|
+
let highestScore = -1;
|
|
185
|
+
for (let i = 0; i < targets.length; i++) {
|
|
186
|
+
const target = targets[i];
|
|
187
|
+
const score = dotProduct(
|
|
188
|
+
queryVec,
|
|
189
|
+
target.embedding
|
|
190
|
+
);
|
|
191
|
+
if (score > highestScore) {
|
|
192
|
+
highestScore = score;
|
|
193
|
+
bestTarget = target;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (bestTarget && highestScore >= threshold) {
|
|
197
|
+
return { target: bestTarget, score: highestScore };
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// src/core/vector/hybrid-search.ts
|
|
204
|
+
function reciprocalRankFusion(denseResults, sparseResults, topK = 3, rrfK = 60) {
|
|
205
|
+
const scoredDocs = /* @__PURE__ */ new Map();
|
|
206
|
+
denseResults.forEach((doc, index) => {
|
|
207
|
+
const rank = index + 1;
|
|
208
|
+
const key = JSON.stringify([doc.metadata?.tenantId ?? null, doc.id]);
|
|
209
|
+
const existing = scoredDocs.get(key) || { doc, rrfScore: 0 };
|
|
210
|
+
existing.denseRank = rank;
|
|
211
|
+
existing.rrfScore += 1 / (rrfK + rank);
|
|
212
|
+
scoredDocs.set(key, existing);
|
|
213
|
+
});
|
|
214
|
+
sparseResults.forEach((doc, index) => {
|
|
215
|
+
const rank = index + 1;
|
|
216
|
+
const key = JSON.stringify([doc.metadata?.tenantId ?? null, doc.id]);
|
|
217
|
+
const existing = scoredDocs.get(key) || { doc, rrfScore: 0 };
|
|
218
|
+
existing.sparseRank = rank;
|
|
219
|
+
existing.rrfScore += 1 / (rrfK + rank);
|
|
220
|
+
scoredDocs.set(key, existing);
|
|
221
|
+
});
|
|
222
|
+
const fusedList = Array.from(scoredDocs.values());
|
|
223
|
+
fusedList.sort((a, b) => b.rrfScore - a.rrfScore);
|
|
224
|
+
return fusedList.slice(0, topK).map(({ doc, denseRank, sparseRank, rrfScore }) => ({
|
|
225
|
+
...doc,
|
|
226
|
+
denseRank,
|
|
227
|
+
sparseRank,
|
|
228
|
+
rrfScore,
|
|
229
|
+
score: rrfScore
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/adapters/vector/postgres-vector.adapter.ts
|
|
234
|
+
var PostgresVectorAdapter = class {
|
|
235
|
+
constructor(options) {
|
|
236
|
+
this.options = options;
|
|
237
|
+
const name = options.tableName ?? "rag_documents";
|
|
238
|
+
if (!/^[a-z][a-z0-9_]{0,39}$/.test(name)) throw new Error("Invalid vector table name.");
|
|
239
|
+
this.table = `"${name}"`;
|
|
240
|
+
this.configTable = `"${name}_config"`;
|
|
241
|
+
this.manifests = `"${name}_manifests"`;
|
|
242
|
+
this.epochs = `"${name}_epochs"`;
|
|
243
|
+
this.dimensions = options.dimensions ?? 384;
|
|
244
|
+
if (!Number.isInteger(this.dimensions) || this.dimensions < 1 || this.dimensions > 2e3) {
|
|
245
|
+
throw new Error("Vector dimensions must be an integer between 1 and 2000.");
|
|
246
|
+
}
|
|
247
|
+
this.modelName = options.modelName ?? "Xenova/all-MiniLM-L6-v2";
|
|
248
|
+
if (!options.pool && !options.connectionString) throw new Error("PostgreSQL connectionString or pool is required.");
|
|
249
|
+
this.pool = options.pool ?? new Pool({
|
|
250
|
+
connectionString: options.connectionString,
|
|
251
|
+
max: 5,
|
|
252
|
+
connectionTimeoutMillis: 5e3,
|
|
253
|
+
statement_timeout: 1e4
|
|
254
|
+
});
|
|
255
|
+
if (!options.pool) {
|
|
256
|
+
this.pool.on("error", (error) => console.error("[PostgresVectorAdapter] Idle connection failed:", error.message));
|
|
257
|
+
}
|
|
258
|
+
this.embeddings = options.embeddingProvider ?? new SMLEngine({ modelName: this.modelName });
|
|
259
|
+
}
|
|
260
|
+
options;
|
|
261
|
+
pool;
|
|
262
|
+
table;
|
|
263
|
+
configTable;
|
|
264
|
+
manifests;
|
|
265
|
+
epochs;
|
|
266
|
+
modelName;
|
|
267
|
+
dimensions;
|
|
268
|
+
embeddings;
|
|
269
|
+
initPromise;
|
|
270
|
+
async init() {
|
|
271
|
+
if (!this.initPromise) {
|
|
272
|
+
this.initPromise = this.initialize().catch((error) => {
|
|
273
|
+
this.initPromise = void 0;
|
|
274
|
+
throw error;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return this.initPromise;
|
|
278
|
+
}
|
|
279
|
+
async initialize() {
|
|
280
|
+
await this.transaction(async (client) => {
|
|
281
|
+
if (this.options.autoMigrate !== false) {
|
|
282
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", ["rag-vector-schema"]);
|
|
283
|
+
await client.query("CREATE EXTENSION IF NOT EXISTS vector");
|
|
284
|
+
await client.query(`CREATE TABLE IF NOT EXISTS ${this.configTable} (
|
|
285
|
+
singleton boolean PRIMARY KEY CHECK (singleton), model_name text NOT NULL, dimensions integer NOT NULL
|
|
286
|
+
)`);
|
|
287
|
+
await client.query(`INSERT INTO ${this.configTable} VALUES (true, $1, $2) ON CONFLICT DO NOTHING`, [this.modelName, this.dimensions]);
|
|
288
|
+
await client.query(`CREATE TABLE IF NOT EXISTS ${this.table} (
|
|
289
|
+
tenant_id text NOT NULL, id text NOT NULL, content text NOT NULL, metadata jsonb NOT NULL,
|
|
290
|
+
embedding vector(${this.dimensions}) NOT NULL, version integer NOT NULL DEFAULT 1,
|
|
291
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
292
|
+
search_text tsvector GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED,
|
|
293
|
+
PRIMARY KEY (tenant_id, id)
|
|
294
|
+
)`);
|
|
295
|
+
await client.query(`CREATE TABLE IF NOT EXISTS ${this.manifests} (
|
|
296
|
+
tenant_id text NOT NULL, document_id text NOT NULL, version integer NOT NULL,
|
|
297
|
+
PRIMARY KEY (tenant_id, document_id)
|
|
298
|
+
)`);
|
|
299
|
+
await client.query(`CREATE TABLE IF NOT EXISTS ${this.epochs} (
|
|
300
|
+
tenant_id text PRIMARY KEY, revision bigint NOT NULL
|
|
301
|
+
)`);
|
|
302
|
+
const name = this.options.tableName ?? "rag_documents";
|
|
303
|
+
await client.query(`CREATE INDEX IF NOT EXISTS "${name}_metadata_idx" ON ${this.table} USING gin (metadata)`);
|
|
304
|
+
await client.query(`CREATE INDEX IF NOT EXISTS "${name}_text_idx" ON ${this.table} USING gin (search_text)`);
|
|
305
|
+
if (this.options.createHnswIndex) {
|
|
306
|
+
await client.query(`CREATE INDEX IF NOT EXISTS "${name}_vector_idx" ON ${this.table} USING hnsw (embedding vector_cosine_ops)`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const result = await client.query(`SELECT model_name, dimensions FROM ${this.configTable} WHERE singleton = true`);
|
|
310
|
+
const config = result.rows[0];
|
|
311
|
+
if (!config || config.model_name !== this.modelName || config.dimensions !== this.dimensions) {
|
|
312
|
+
throw new Error("Embedding model/dimensions mismatch. Reindex into a new table before changing models.");
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
/** Upserts are atomic per batch and IDs are unique within a tenant. */
|
|
317
|
+
async addDocuments(documents) {
|
|
318
|
+
const rows = await this.prepare(documents);
|
|
319
|
+
if (!rows.length) return;
|
|
320
|
+
await this.init();
|
|
321
|
+
await this.transaction(async (client) => {
|
|
322
|
+
for (const tenant of await this.write(client, rows)) await this.bumpRevision(client, tenant);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
/** Atomically replaces all chunks; retries and older document revisions cannot overwrite newer work. */
|
|
326
|
+
async replaceDocument(documentId, tenantId, version, documents) {
|
|
327
|
+
this.tenant({ tenantId });
|
|
328
|
+
if (!documentId || !Number.isSafeInteger(version) || version < 1 || !documents.length) throw new Error("Invalid document replacement.");
|
|
329
|
+
if (documents.some((doc) => doc.metadata?.tenantId !== tenantId || doc.metadata?.documentId !== documentId)) {
|
|
330
|
+
throw new Error("Replacement tenant/document mismatch.");
|
|
331
|
+
}
|
|
332
|
+
const rows = await this.prepare(documents);
|
|
333
|
+
await this.init();
|
|
334
|
+
return this.transaction(async (client) => {
|
|
335
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [JSON.stringify([this.table, tenantId, documentId])]);
|
|
336
|
+
const previous = await client.query(`SELECT version FROM ${this.manifests} WHERE tenant_id=$1 AND document_id=$2`, [tenantId, documentId]);
|
|
337
|
+
if (previous.rows[0] && previous.rows[0].version >= version) return previous.rows[0].version === version ? "unchanged" : "superseded";
|
|
338
|
+
await client.query(`DELETE FROM ${this.table} WHERE tenant_id=$1 AND metadata @> $2::jsonb`, [tenantId, JSON.stringify({ documentId })]);
|
|
339
|
+
await this.write(client, rows);
|
|
340
|
+
await client.query(`INSERT INTO ${this.manifests} VALUES ($1,$2,$3)
|
|
341
|
+
ON CONFLICT (tenant_id,document_id) DO UPDATE SET version=EXCLUDED.version`, [tenantId, documentId, version]);
|
|
342
|
+
await this.bumpRevision(client, tenantId);
|
|
343
|
+
return "applied";
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
async getRevision(tenantId) {
|
|
347
|
+
this.tenant({ tenantId });
|
|
348
|
+
await this.init();
|
|
349
|
+
const result = await this.pool.query(`SELECT revision::text FROM ${this.epochs} WHERE tenant_id=$1`, [tenantId]);
|
|
350
|
+
return result.rows[0]?.revision ?? "0";
|
|
351
|
+
}
|
|
352
|
+
async prepare(documents) {
|
|
353
|
+
const rows = [];
|
|
354
|
+
for (const doc of documents) {
|
|
355
|
+
if (typeof doc.id !== "string" || !doc.id.trim() || typeof doc.content !== "string" || !doc.content.trim()) {
|
|
356
|
+
throw new Error("Document id and content must be non-empty strings.");
|
|
357
|
+
}
|
|
358
|
+
rows.push({ doc, tenant: this.tenant(doc.metadata), metadata: JSON.stringify(doc.metadata ?? {}), vector: this.vector(await this.embeddings.getEmbedding(doc.content)) });
|
|
359
|
+
}
|
|
360
|
+
return rows;
|
|
361
|
+
}
|
|
362
|
+
async write(client, rows) {
|
|
363
|
+
const changed = /* @__PURE__ */ new Set();
|
|
364
|
+
for (const { doc, tenant, metadata, vector } of rows) {
|
|
365
|
+
const result = await client.query(`INSERT INTO ${this.table} AS current (tenant_id,id,content,metadata,embedding)
|
|
366
|
+
VALUES ($1,$2,$3,$4::jsonb,$5::vector)
|
|
367
|
+
ON CONFLICT (tenant_id,id) DO UPDATE SET content=EXCLUDED.content,metadata=EXCLUDED.metadata,embedding=EXCLUDED.embedding,
|
|
368
|
+
version=current.version+1,updated_at=now()
|
|
369
|
+
WHERE current.content IS DISTINCT FROM EXCLUDED.content OR current.metadata IS DISTINCT FROM EXCLUDED.metadata
|
|
370
|
+
OR current.embedding IS DISTINCT FROM EXCLUDED.embedding`, [tenant, doc.id, doc.content, metadata, vector]);
|
|
371
|
+
if (result.rowCount) changed.add(tenant);
|
|
372
|
+
}
|
|
373
|
+
return changed;
|
|
374
|
+
}
|
|
375
|
+
async bumpRevision(client, tenant) {
|
|
376
|
+
await client.query(`INSERT INTO ${this.epochs} VALUES ($1,1) ON CONFLICT (tenant_id) DO UPDATE SET revision=${this.epochs}.revision+1`, [tenant]);
|
|
377
|
+
}
|
|
378
|
+
async search(query, topK = 3, filter) {
|
|
379
|
+
const tenant = this.tenant(filter);
|
|
380
|
+
this.validateTopK(topK);
|
|
381
|
+
const vector = this.vector(await this.embeddings.getEmbedding(query));
|
|
382
|
+
await this.init();
|
|
383
|
+
const result = await this.pool.query(
|
|
384
|
+
`SELECT id, content, metadata, version,
|
|
385
|
+
1 - (embedding <=> $3::vector) AS score FROM ${this.table}
|
|
386
|
+
WHERE tenant_id = $1 AND metadata @> $2::jsonb
|
|
387
|
+
ORDER BY embedding <=> $3::vector, id LIMIT $4`,
|
|
388
|
+
[tenant, JSON.stringify(filter), vector, topK]
|
|
389
|
+
);
|
|
390
|
+
return result.rows.map((row) => ({ ...row, score: Number(row.score) }));
|
|
391
|
+
}
|
|
392
|
+
/** PostgreSQL lexical search + dense search, fused with RRF. This is not BM25. */
|
|
393
|
+
async searchHybrid(query, topK = 3, filter) {
|
|
394
|
+
const tenant = this.tenant(filter);
|
|
395
|
+
this.validateTopK(topK);
|
|
396
|
+
const candidates = Math.min(1e3, Math.max(20, topK * 3));
|
|
397
|
+
const dense = await this.search(query, candidates, filter);
|
|
398
|
+
const sparse = await this.pool.query(`SELECT id, content, metadata, version,
|
|
399
|
+
ts_rank_cd(search_text, websearch_to_tsquery('simple', $3)) AS score FROM ${this.table}
|
|
400
|
+
WHERE tenant_id = $1 AND metadata @> $2::jsonb
|
|
401
|
+
AND search_text @@ websearch_to_tsquery('simple', $3)
|
|
402
|
+
ORDER BY score DESC, id LIMIT $4`, [tenant, JSON.stringify(filter), query, candidates]);
|
|
403
|
+
return reciprocalRankFusion(dense, sparse.rows, topK);
|
|
404
|
+
}
|
|
405
|
+
async deleteDocument(id, filter) {
|
|
406
|
+
const tenant = this.tenant(filter);
|
|
407
|
+
await this.init();
|
|
408
|
+
return this.transaction(async (client) => {
|
|
409
|
+
const result = await client.query(`DELETE FROM ${this.table} WHERE tenant_id=$1 AND id=$2 AND metadata @> $3::jsonb`, [tenant, id, JSON.stringify(filter)]);
|
|
410
|
+
if (result.rowCount) await this.bumpRevision(client, tenant);
|
|
411
|
+
return (result.rowCount ?? 0) > 0;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
async clear(filter) {
|
|
415
|
+
const tenant = this.tenant(filter);
|
|
416
|
+
await this.init();
|
|
417
|
+
await this.transaction(async (client) => {
|
|
418
|
+
const result = await client.query(`DELETE FROM ${this.table} WHERE tenant_id=$1 AND metadata @> $2::jsonb`, [tenant, JSON.stringify(filter)]);
|
|
419
|
+
if (result.rowCount) await this.bumpRevision(client, tenant);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
async close() {
|
|
423
|
+
if (!this.options.pool) await this.pool.end();
|
|
424
|
+
}
|
|
425
|
+
tenant(filter) {
|
|
426
|
+
const tenant = filter?.tenantId;
|
|
427
|
+
if (typeof tenant !== "string" || !tenant.trim()) throw new Error("An explicit tenantId is required for PostgreSQL vector operations.");
|
|
428
|
+
return tenant;
|
|
429
|
+
}
|
|
430
|
+
vector(embedding) {
|
|
431
|
+
const values = Array.from(embedding);
|
|
432
|
+
if (values.length !== this.dimensions || !values.every(Number.isFinite) || !values.some((value) => value !== 0)) {
|
|
433
|
+
throw new Error(`Embedding must contain ${this.dimensions} finite values and have non-zero magnitude.`);
|
|
434
|
+
}
|
|
435
|
+
return JSON.stringify(values);
|
|
436
|
+
}
|
|
437
|
+
validateTopK(topK) {
|
|
438
|
+
if (!Number.isInteger(topK) || topK < 1 || topK > 1e3) throw new Error("topK must be an integer between 1 and 1000.");
|
|
439
|
+
}
|
|
440
|
+
async transaction(work) {
|
|
441
|
+
const client = await this.pool.connect();
|
|
442
|
+
try {
|
|
443
|
+
await client.query("BEGIN");
|
|
444
|
+
const result = await work(client);
|
|
445
|
+
await client.query("COMMIT");
|
|
446
|
+
return result;
|
|
447
|
+
} catch (error) {
|
|
448
|
+
await client.query("ROLLBACK").catch(() => {
|
|
449
|
+
});
|
|
450
|
+
throw error;
|
|
451
|
+
} finally {
|
|
452
|
+
client.release();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
// src/core/queue/bullmq-queue.ts
|
|
458
|
+
import { Queue, Worker, UnrecoverableError } from "bullmq";
|
|
459
|
+
import { Redis } from "ioredis";
|
|
460
|
+
var BullMQJobQueue = class {
|
|
461
|
+
constructor(options = {}) {
|
|
462
|
+
this.options = options;
|
|
463
|
+
this.name = options.queueName ?? "rag-ingestion-queue";
|
|
464
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(this.name)) throw new Error("Invalid queueName.");
|
|
465
|
+
this.attempts = options.attempts ?? 3;
|
|
466
|
+
for (const [name, value] of Object.entries({ attempts: this.attempts, concurrency: options.concurrency ?? 2, globalConcurrency: options.globalConcurrency ?? 4 })) {
|
|
467
|
+
if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer.`);
|
|
468
|
+
}
|
|
469
|
+
this.producer = this.connection(false);
|
|
470
|
+
this.queue = new Queue(this.name, { connection: this.producer });
|
|
471
|
+
this.queue.on("error", (error) => this.report(error));
|
|
472
|
+
}
|
|
473
|
+
options;
|
|
474
|
+
queue;
|
|
475
|
+
producer;
|
|
476
|
+
consumer;
|
|
477
|
+
worker;
|
|
478
|
+
processingType;
|
|
479
|
+
closed = false;
|
|
480
|
+
name;
|
|
481
|
+
attempts;
|
|
482
|
+
connection(worker) {
|
|
483
|
+
const settings = { maxRetriesPerRequest: worker ? null : 1, enableOfflineQueue: worker, connectTimeout: 2e3 };
|
|
484
|
+
const client = this.options.redisUrl ? new Redis(this.options.redisUrl, settings) : new Redis({ host: this.options.connection?.host ?? "127.0.0.1", port: this.options.connection?.port ?? 6379, ...this.options.connection?.password !== void 0 ? { password: this.options.connection.password } : {}, ...settings });
|
|
485
|
+
client.on("error", (error) => this.report(error));
|
|
486
|
+
return client;
|
|
487
|
+
}
|
|
488
|
+
report(error) {
|
|
489
|
+
this.options.onError?.(error);
|
|
490
|
+
}
|
|
491
|
+
async ready() {
|
|
492
|
+
let timeout;
|
|
493
|
+
try {
|
|
494
|
+
await Promise.race([this.queue.waitUntilReady(), new Promise((_, reject) => {
|
|
495
|
+
timeout = setTimeout(() => reject(new Error("Redis queue readiness timed out.")), 5e3);
|
|
496
|
+
})]);
|
|
497
|
+
await this.queue.setGlobalConcurrency(this.options.globalConcurrency ?? 4);
|
|
498
|
+
} finally {
|
|
499
|
+
if (timeout) clearTimeout(timeout);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
async add(type, data, jobId) {
|
|
503
|
+
if (this.closed) throw new Error("Queue is closed.");
|
|
504
|
+
const job = await this.queue.add(type, data, {
|
|
505
|
+
...jobId ? { jobId } : {},
|
|
506
|
+
attempts: this.attempts,
|
|
507
|
+
backoff: { type: "exponential", delay: this.options.backoffMs ?? 1e3 },
|
|
508
|
+
removeOnComplete: { age: 7 * 86400, count: 1e4 },
|
|
509
|
+
removeOnFail: { age: 7 * 86400, count: 1e4 }
|
|
510
|
+
});
|
|
511
|
+
return this.map(job, "queued");
|
|
512
|
+
}
|
|
513
|
+
async getJob(id) {
|
|
514
|
+
const job = await this.queue.getJob(id);
|
|
515
|
+
if (!job) return null;
|
|
516
|
+
const state = await job.getState();
|
|
517
|
+
const status = state === "active" ? "processing" : state === "completed" ? "completed" : state === "failed" ? "failed" : "queued";
|
|
518
|
+
return this.map(job, status);
|
|
519
|
+
}
|
|
520
|
+
async process(type, handler) {
|
|
521
|
+
if (this.closed) throw new Error("Queue is closed.");
|
|
522
|
+
if (this.processingType && this.processingType !== type) throw new Error("Use one job type per queue.");
|
|
523
|
+
this.processingType = type;
|
|
524
|
+
if (!this.worker) {
|
|
525
|
+
this.consumer = this.connection(true);
|
|
526
|
+
this.worker = new Worker(this.name, async (job) => {
|
|
527
|
+
if (job.name !== type) throw new UnrecoverableError(`Unsupported job type: ${job.name}`);
|
|
528
|
+
return handler(this.map(job, "processing"), (progress) => job.updateProgress(progress));
|
|
529
|
+
}, {
|
|
530
|
+
connection: this.consumer,
|
|
531
|
+
concurrency: this.options.concurrency ?? 2,
|
|
532
|
+
lockDuration: this.options.lockDurationMs ?? 3e4,
|
|
533
|
+
stalledInterval: this.options.stalledIntervalMs ?? 3e4,
|
|
534
|
+
maxStalledCount: 2
|
|
535
|
+
});
|
|
536
|
+
this.worker.on("error", (error) => this.report(error));
|
|
537
|
+
}
|
|
538
|
+
await this.worker.waitUntilReady();
|
|
539
|
+
}
|
|
540
|
+
async retry(id) {
|
|
541
|
+
const job = await this.queue.getJob(id);
|
|
542
|
+
if (!job || await job.getState() !== "failed") throw new Error("Only a failed job can be retried.");
|
|
543
|
+
await job.retry("failed", { resetAttemptsMade: true });
|
|
544
|
+
}
|
|
545
|
+
async close() {
|
|
546
|
+
if (this.closed) return;
|
|
547
|
+
this.closed = true;
|
|
548
|
+
try {
|
|
549
|
+
await this.worker?.close();
|
|
550
|
+
if (this.producer.status !== "ready") this.producer.disconnect();
|
|
551
|
+
await this.queue.close();
|
|
552
|
+
} finally {
|
|
553
|
+
this.consumer?.disconnect();
|
|
554
|
+
this.producer.disconnect();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
map(job, status) {
|
|
558
|
+
return {
|
|
559
|
+
id: job.id,
|
|
560
|
+
type: job.name,
|
|
561
|
+
data: job.data,
|
|
562
|
+
status,
|
|
563
|
+
progress: typeof job.progress === "number" ? job.progress : 0,
|
|
564
|
+
result: job.returnvalue,
|
|
565
|
+
error: job.failedReason,
|
|
566
|
+
createdAt: job.timestamp,
|
|
567
|
+
updatedAt: job.finishedOn ?? job.processedOn ?? job.timestamp,
|
|
568
|
+
completedAt: job.finishedOn,
|
|
569
|
+
attemptsMade: job.attemptsMade,
|
|
570
|
+
maxAttempts: job.opts.attempts ?? 1
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
// src/core/ingestion/postgres-ingestion-store.ts
|
|
576
|
+
import { createHash } from "node:crypto";
|
|
577
|
+
var IngestionQuotaError = class extends Error {
|
|
578
|
+
};
|
|
579
|
+
var IngestionConflictError = class extends Error {
|
|
580
|
+
};
|
|
581
|
+
var PostgresIngestionStore = class {
|
|
582
|
+
constructor(pool, tableName = "rag_ingestion") {
|
|
583
|
+
this.pool = pool;
|
|
584
|
+
if (!/^[a-z][a-z0-9_]{0,39}$/.test(tableName)) throw new Error("Invalid ingestion table name.");
|
|
585
|
+
this.tableName = tableName;
|
|
586
|
+
this.table = `"${tableName}"`;
|
|
587
|
+
this.heads = `"${tableName}_heads"`;
|
|
588
|
+
}
|
|
589
|
+
pool;
|
|
590
|
+
table;
|
|
591
|
+
heads;
|
|
592
|
+
tableName;
|
|
593
|
+
async migrate() {
|
|
594
|
+
await this.transaction(async (client) => {
|
|
595
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", ["rag-ingestion-schema"]);
|
|
596
|
+
await client.query(`CREATE TABLE IF NOT EXISTS ${this.heads} (
|
|
597
|
+
tenant_id text NOT NULL, document_id text NOT NULL, version integer NOT NULL,
|
|
598
|
+
PRIMARY KEY (tenant_id, document_id)
|
|
599
|
+
)`);
|
|
600
|
+
await client.query(`CREATE TABLE IF NOT EXISTS ${this.table} (
|
|
601
|
+
id text PRIMARY KEY, tenant_id text NOT NULL, user_id text NOT NULL, document_id text NOT NULL,
|
|
602
|
+
filename text NOT NULL, version integer NOT NULL, digest text NOT NULL, settings jsonb NOT NULL,
|
|
603
|
+
content bytea, idempotency_key text, status text NOT NULL CHECK (status IN ('pending','queued','processing','completed','failed')),
|
|
604
|
+
result jsonb, error text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
|
|
605
|
+
)`);
|
|
606
|
+
await client.query(`CREATE UNIQUE INDEX IF NOT EXISTS "${this.tableName}_idempotency_idx" ON ${this.table} (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL`);
|
|
607
|
+
await client.query(`CREATE INDEX IF NOT EXISTS "${this.tableName}_pending_idx" ON ${this.table} (status, updated_at)`);
|
|
608
|
+
await client.query(`CREATE INDEX IF NOT EXISTS "${this.tableName}_tenant_idx" ON ${this.table} (tenant_id)`);
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
async ready() {
|
|
612
|
+
await this.pool.query(`SELECT id FROM ${this.table} LIMIT 0`);
|
|
613
|
+
}
|
|
614
|
+
async put(input, limits) {
|
|
615
|
+
const digest = createHash("sha256").update(input.content).digest("hex");
|
|
616
|
+
return this.transaction(async (client) => {
|
|
617
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`ingestion:${this.tableName}:${input.tenantId}`]);
|
|
618
|
+
const previous = input.idempotencyKey ? await client.query(`SELECT * FROM ${this.table} WHERE tenant_id = $1 AND idempotency_key = $2`, [input.tenantId, input.idempotencyKey]) : await client.query(`SELECT j.* FROM ${this.table} j JOIN ${this.heads} h
|
|
619
|
+
ON j.tenant_id = h.tenant_id AND j.document_id = h.document_id AND j.version = h.version
|
|
620
|
+
WHERE j.tenant_id = $1 AND j.document_id = $2`, [input.tenantId, input.documentId]);
|
|
621
|
+
const old = previous.rows[0];
|
|
622
|
+
if (old && old.digest === digest && old.document_id === input.documentId && old.filename === input.filename && old.settings.chunkSize === input.settings.chunkSize && old.settings.chunkOverlap === input.settings.chunkOverlap) return this.row(old);
|
|
623
|
+
if (old && input.idempotencyKey) throw new IngestionConflictError("Idempotency key already used for different content.");
|
|
624
|
+
const quota = await client.query(`SELECT count(*) FILTER (WHERE status IN ('pending','queued','processing')) AS pending,
|
|
625
|
+
coalesce(sum(octet_length(content)), 0) AS bytes FROM ${this.table} WHERE tenant_id = $1`, [input.tenantId]);
|
|
626
|
+
if (Number(quota.rows[0].pending) >= limits.maxPending || Number(quota.rows[0].bytes) + input.content.length > limits.maxStoredBytes) {
|
|
627
|
+
throw new IngestionQuotaError("Tenant ingestion quota exceeded.");
|
|
628
|
+
}
|
|
629
|
+
const head = await client.query(`INSERT INTO ${this.heads} AS h VALUES ($1, $2, 1)
|
|
630
|
+
ON CONFLICT (tenant_id, document_id) DO UPDATE SET version = h.version + 1 RETURNING version`, [input.tenantId, input.documentId]);
|
|
631
|
+
const id = createHash("sha256").update(JSON.stringify([input.tenantId, input.documentId, head.rows[0].version, digest])).digest("hex");
|
|
632
|
+
const saved = await client.query(
|
|
633
|
+
`INSERT INTO ${this.table}
|
|
634
|
+
(id,tenant_id,user_id,document_id,filename,version,digest,settings,content,idempotency_key,status)
|
|
635
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,'pending') RETURNING *`,
|
|
636
|
+
[id, input.tenantId, input.userId, input.documentId, input.filename, head.rows[0].version, digest, JSON.stringify(input.settings), input.content, input.idempotencyKey ?? null]
|
|
637
|
+
);
|
|
638
|
+
return this.row(saved.rows[0]);
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
async get(id, tenantId) {
|
|
642
|
+
if (!tenantId) return null;
|
|
643
|
+
const result = await this.pool.query(`SELECT * FROM ${this.table} WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
|
644
|
+
return result.rows[0] ? this.row(result.rows[0]) : null;
|
|
645
|
+
}
|
|
646
|
+
async dispatchable(limit = 100) {
|
|
647
|
+
const result = await this.pool.query(`SELECT id, tenant_id FROM ${this.table} WHERE status IN ('pending','queued','processing')
|
|
648
|
+
ORDER BY (status = 'pending') DESC, updated_at LIMIT $1`, [limit]);
|
|
649
|
+
return result.rows.map((row) => ({ id: row.id, tenantId: row.tenant_id }));
|
|
650
|
+
}
|
|
651
|
+
async mark(id, status, error) {
|
|
652
|
+
const condition = status === "queued" ? "AND status = 'pending'" : "AND status NOT IN ('completed','failed')";
|
|
653
|
+
await this.pool.query(`UPDATE ${this.table} SET status = $2, error = $3, updated_at = now() WHERE id = $1 ${condition}`, [id, status, error ?? null]);
|
|
654
|
+
}
|
|
655
|
+
async complete(id, result) {
|
|
656
|
+
await this.pool.query(`UPDATE ${this.table} SET status = 'completed', result = $2::jsonb, error = NULL,
|
|
657
|
+
content = NULL, updated_at = now() WHERE id = $1`, [id, JSON.stringify(result)]);
|
|
658
|
+
}
|
|
659
|
+
async resetFailed(id, tenantId) {
|
|
660
|
+
const result = await this.pool.query(`UPDATE ${this.table} SET status = 'pending', error = NULL, updated_at = now()
|
|
661
|
+
WHERE id = $1 AND tenant_id = $2 AND status = 'failed' AND content IS NOT NULL`, [id, tenantId]);
|
|
662
|
+
if (!result.rowCount) throw new Error("Failed job missing or its retry payload has expired.");
|
|
663
|
+
}
|
|
664
|
+
async expireFailedBlobs(retentionDays = 7) {
|
|
665
|
+
await this.pool.query(`UPDATE ${this.table} SET content = NULL WHERE status = 'failed' AND content IS NOT NULL
|
|
666
|
+
AND updated_at < now() - ($1 * interval '1 day')`, [retentionDays]);
|
|
667
|
+
}
|
|
668
|
+
row(row) {
|
|
669
|
+
return {
|
|
670
|
+
id: row.id,
|
|
671
|
+
tenantId: row.tenant_id,
|
|
672
|
+
userId: row.user_id,
|
|
673
|
+
documentId: row.document_id,
|
|
674
|
+
filename: row.filename,
|
|
675
|
+
version: row.version,
|
|
676
|
+
digest: row.digest,
|
|
677
|
+
settings: row.settings,
|
|
678
|
+
content: row.content,
|
|
679
|
+
status: row.status,
|
|
680
|
+
result: row.result,
|
|
681
|
+
error: row.error,
|
|
682
|
+
createdAt: new Date(row.created_at).getTime(),
|
|
683
|
+
updatedAt: new Date(row.updated_at).getTime()
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
async transaction(work) {
|
|
687
|
+
const client = await this.pool.connect();
|
|
688
|
+
try {
|
|
689
|
+
await client.query("BEGIN");
|
|
690
|
+
const result = await work(client);
|
|
691
|
+
await client.query("COMMIT");
|
|
692
|
+
return result;
|
|
693
|
+
} catch (error) {
|
|
694
|
+
await client.query("ROLLBACK").catch(() => {
|
|
695
|
+
});
|
|
696
|
+
throw error;
|
|
697
|
+
} finally {
|
|
698
|
+
client.release();
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
// src/services/document-ingestion.service.ts
|
|
704
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
705
|
+
import { PassThrough, pipeline as streamPipeline } from "node:stream";
|
|
706
|
+
import { promisify } from "node:util";
|
|
707
|
+
import fs from "node:fs";
|
|
708
|
+
import fsp from "node:fs/promises";
|
|
709
|
+
import path from "node:path";
|
|
710
|
+
import os from "node:os";
|
|
711
|
+
|
|
712
|
+
// src/core/security/pii-guard.ts
|
|
713
|
+
var PIIGuard = class {
|
|
714
|
+
/**
|
|
715
|
+
* Validates Turkish Republic Identity Number (TCKN) using official 11-digit checksum rules:
|
|
716
|
+
* 1. 11 digits, cannot start with 0.
|
|
717
|
+
* 2. 10th digit = ((d1+d3+d5+d7+d9)*7 - (d2+d4+d6+d8)) mod 10
|
|
718
|
+
* 3. 11th digit = (d1+d2+...+d10) mod 10
|
|
719
|
+
*/
|
|
720
|
+
static isValidTCKN(candidate) {
|
|
721
|
+
if (!/^[1-9]\d{10}$/.test(candidate)) {
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
const digits = candidate.split("").map(Number);
|
|
725
|
+
const d = digits;
|
|
726
|
+
const oddSum = d[0] + d[2] + d[4] + d[6] + d[8];
|
|
727
|
+
const evenSum = d[1] + d[3] + d[5] + d[7];
|
|
728
|
+
const tenthDigit = ((oddSum * 7 - evenSum) % 10 + 10) % 10;
|
|
729
|
+
if (d[9] !== tenthDigit) {
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
const sumFirstTen = digits.slice(0, 10).reduce((acc, curr) => acc + curr, 0);
|
|
733
|
+
const eleventhDigit = sumFirstTen % 10;
|
|
734
|
+
if (d[10] !== eleventhDigit) {
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Validates Credit Card number using the Luhn mod-10 algorithm.
|
|
741
|
+
*/
|
|
742
|
+
static isValidCreditCard(candidate) {
|
|
743
|
+
const clean = candidate.replace(/[\s-]/g, "");
|
|
744
|
+
if (!/^\d{13,19}$/.test(clean)) {
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
let sum = 0;
|
|
748
|
+
let shouldDouble = false;
|
|
749
|
+
for (let i = clean.length - 1; i >= 0; i--) {
|
|
750
|
+
let digit = parseInt(clean.charAt(i), 10);
|
|
751
|
+
if (shouldDouble) {
|
|
752
|
+
digit *= 2;
|
|
753
|
+
if (digit > 9) digit -= 9;
|
|
754
|
+
}
|
|
755
|
+
sum += digit;
|
|
756
|
+
shouldDouble = !shouldDouble;
|
|
757
|
+
}
|
|
758
|
+
return sum % 10 === 0;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Sanitizes input text, detecting and masking all sensitive PII entities.
|
|
762
|
+
* Respects data sovereignty policies: strictly masks for Cloud LLMs.
|
|
763
|
+
*/
|
|
764
|
+
static sanitize(text, policy) {
|
|
765
|
+
if (!text || policy?.enabled === false) {
|
|
766
|
+
return {
|
|
767
|
+
sanitizedText: text || "",
|
|
768
|
+
hasPII: false,
|
|
769
|
+
detectedEntities: [],
|
|
770
|
+
bypassedForLocalLLM: false
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
const detectedEntities = [];
|
|
774
|
+
let workingText = text;
|
|
775
|
+
const maskTCKN = policy?.maskTCKN !== false;
|
|
776
|
+
const maskCards = policy?.maskCreditCards !== false;
|
|
777
|
+
const maskIBAN = policy?.maskIBAN !== false;
|
|
778
|
+
const maskEmails = policy?.maskEmails !== false;
|
|
779
|
+
const maskPhones = policy?.maskPhones !== false;
|
|
780
|
+
if (maskTCKN) {
|
|
781
|
+
const tcknRegex = /(?:\b|[^\d])([1-9]\d{10})(?:\b|[^\d])/g;
|
|
782
|
+
let match;
|
|
783
|
+
const tcknCandidates = /* @__PURE__ */ new Set();
|
|
784
|
+
while ((match = tcknRegex.exec(workingText)) !== null) {
|
|
785
|
+
const candidate = match[1];
|
|
786
|
+
if (this.isValidTCKN(candidate)) {
|
|
787
|
+
tcknCandidates.add(candidate);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
for (const tckn of tcknCandidates) {
|
|
791
|
+
const masked = `[TCKN_${tckn.slice(0, 3)}*****${tckn.slice(9)}]`;
|
|
792
|
+
detectedEntities.push({
|
|
793
|
+
type: "TCKN",
|
|
794
|
+
originalValue: tckn,
|
|
795
|
+
maskedValue: masked
|
|
796
|
+
});
|
|
797
|
+
workingText = workingText.split(tckn).join(masked);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (maskCards) {
|
|
801
|
+
const ccRegex = /\b(?:\d{4}[ -]?){3}\d{1,4}\b|\b\d{13,19}\b/g;
|
|
802
|
+
let match;
|
|
803
|
+
const cardCandidates = /* @__PURE__ */ new Set();
|
|
804
|
+
while ((match = ccRegex.exec(workingText)) !== null) {
|
|
805
|
+
const raw = match[0];
|
|
806
|
+
if (this.isValidCreditCard(raw)) {
|
|
807
|
+
cardCandidates.add(raw);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
for (const card of cardCandidates) {
|
|
811
|
+
const clean = card.replace(/[\s-]/g, "");
|
|
812
|
+
const masked = `[CARD_****_****_****_${clean.slice(-4)}]`;
|
|
813
|
+
detectedEntities.push({
|
|
814
|
+
type: "CREDIT_CARD",
|
|
815
|
+
originalValue: card,
|
|
816
|
+
maskedValue: masked
|
|
817
|
+
});
|
|
818
|
+
workingText = workingText.split(card).join(masked);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (maskIBAN) {
|
|
822
|
+
const ibanRegex = /\bTR\d{2}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{2}\b|\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g;
|
|
823
|
+
let match;
|
|
824
|
+
const ibans = /* @__PURE__ */ new Set();
|
|
825
|
+
while ((match = ibanRegex.exec(workingText)) !== null) {
|
|
826
|
+
ibans.add(match[0]);
|
|
827
|
+
}
|
|
828
|
+
for (const iban of ibans) {
|
|
829
|
+
const clean = iban.replace(/\s/g, "");
|
|
830
|
+
const masked = `[IBAN_${clean.slice(0, 4)}...${clean.slice(-4)}]`;
|
|
831
|
+
detectedEntities.push({
|
|
832
|
+
type: "IBAN",
|
|
833
|
+
originalValue: iban,
|
|
834
|
+
maskedValue: masked
|
|
835
|
+
});
|
|
836
|
+
workingText = workingText.split(iban).join(masked);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (maskEmails) {
|
|
840
|
+
const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b/g;
|
|
841
|
+
let match;
|
|
842
|
+
const emails = /* @__PURE__ */ new Set();
|
|
843
|
+
while ((match = emailRegex.exec(workingText)) !== null) {
|
|
844
|
+
emails.add(match[0]);
|
|
845
|
+
}
|
|
846
|
+
for (const email of emails) {
|
|
847
|
+
const [user, domain] = email.split("@");
|
|
848
|
+
const masked = `[EMAIL_${user.slice(0, 2)}***@${domain}]`;
|
|
849
|
+
detectedEntities.push({
|
|
850
|
+
type: "EMAIL",
|
|
851
|
+
originalValue: email,
|
|
852
|
+
maskedValue: masked
|
|
853
|
+
});
|
|
854
|
+
workingText = workingText.split(email).join(masked);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (maskPhones) {
|
|
858
|
+
const phoneRegex = /(?:\+?90\s?|0)?5\d{2}[\s.-]?\d{3}[\s.-]?\d{2}[\s.-]?\d{2}\b/g;
|
|
859
|
+
let match;
|
|
860
|
+
const phones = /* @__PURE__ */ new Set();
|
|
861
|
+
while ((match = phoneRegex.exec(workingText)) !== null) {
|
|
862
|
+
phones.add(match[0]);
|
|
863
|
+
}
|
|
864
|
+
for (const phone of phones) {
|
|
865
|
+
const clean = phone.replace(/[\s.-]/g, "");
|
|
866
|
+
const masked = `[PHONE_***${clean.slice(-4)}]`;
|
|
867
|
+
detectedEntities.push({
|
|
868
|
+
type: "PHONE",
|
|
869
|
+
originalValue: phone,
|
|
870
|
+
maskedValue: masked
|
|
871
|
+
});
|
|
872
|
+
workingText = workingText.split(phone).join(masked);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
const hasPII = detectedEntities.length > 0;
|
|
876
|
+
const isLocalProvider = policy?.providerType === "local" || policy?.providerType === "custom";
|
|
877
|
+
const bypassedForLocalLLM = Boolean(hasPII && isLocalProvider && policy?.allowUnmaskedForLocalLLM);
|
|
878
|
+
return {
|
|
879
|
+
sanitizedText: bypassedForLocalLLM ? text : workingText,
|
|
880
|
+
hasPII,
|
|
881
|
+
detectedEntities,
|
|
882
|
+
bypassedForLocalLLM
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
// src/core/queue/in-memory-queue.ts
|
|
888
|
+
import { randomUUID } from "node:crypto";
|
|
889
|
+
var InMemoryJobQueue = class {
|
|
890
|
+
jobs = /* @__PURE__ */ new Map();
|
|
891
|
+
handlers = /* @__PURE__ */ new Map();
|
|
892
|
+
pendingQueue = [];
|
|
893
|
+
activeJobsCount = 0;
|
|
894
|
+
isClosed = false;
|
|
895
|
+
maxPendingJobs;
|
|
896
|
+
drainWaiters = [];
|
|
897
|
+
concurrency;
|
|
898
|
+
maxRetainedJobs;
|
|
899
|
+
constructor(options) {
|
|
900
|
+
this.maxPendingJobs = options?.maxPendingJobs ?? 1e3;
|
|
901
|
+
this.concurrency = options?.concurrency ?? 2;
|
|
902
|
+
this.maxRetainedJobs = options?.maxRetainedJobs ?? 500;
|
|
903
|
+
for (const value of [this.concurrency, this.maxRetainedJobs, this.maxPendingJobs]) {
|
|
904
|
+
if (!Number.isInteger(value) || value < 1) throw new Error("Queue limits must be positive integers.");
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
async add(type, data, customJobId) {
|
|
908
|
+
if (this.isClosed) {
|
|
909
|
+
throw new Error("[InMemoryJobQueue] Queue is closed. Cannot add new jobs.");
|
|
910
|
+
}
|
|
911
|
+
const id = customJobId || `job_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
912
|
+
const existing = this.jobs.get(id);
|
|
913
|
+
if (existing) return { ...existing };
|
|
914
|
+
if (this.pendingQueue.length + this.activeJobsCount >= this.maxPendingJobs) throw new Error("Pending job limit exceeded.");
|
|
915
|
+
const now = Date.now();
|
|
916
|
+
const job = {
|
|
917
|
+
id,
|
|
918
|
+
type,
|
|
919
|
+
data,
|
|
920
|
+
status: "queued",
|
|
921
|
+
progress: 0,
|
|
922
|
+
createdAt: now,
|
|
923
|
+
updatedAt: now
|
|
924
|
+
};
|
|
925
|
+
this.jobs.set(id, job);
|
|
926
|
+
this.pendingQueue.push(id);
|
|
927
|
+
this.pruneOldJobs();
|
|
928
|
+
setImmediate(() => this.dispatch());
|
|
929
|
+
return { ...job };
|
|
930
|
+
}
|
|
931
|
+
async getJob(id) {
|
|
932
|
+
const job = this.jobs.get(id);
|
|
933
|
+
if (!job) return null;
|
|
934
|
+
return { ...job };
|
|
935
|
+
}
|
|
936
|
+
process(type, handler) {
|
|
937
|
+
this.handlers.set(type, handler);
|
|
938
|
+
setImmediate(() => this.dispatch());
|
|
939
|
+
}
|
|
940
|
+
async close() {
|
|
941
|
+
this.isClosed = true;
|
|
942
|
+
this.pendingQueue = [];
|
|
943
|
+
if (this.activeJobsCount > 0) await new Promise((resolve) => this.drainWaiters.push(resolve));
|
|
944
|
+
}
|
|
945
|
+
async dispatch() {
|
|
946
|
+
if (this.isClosed || this.activeJobsCount >= this.concurrency) return;
|
|
947
|
+
if (this.pendingQueue.length === 0) return;
|
|
948
|
+
const nextJobId = this.pendingQueue.shift();
|
|
949
|
+
if (!nextJobId) return;
|
|
950
|
+
const job = this.jobs.get(nextJobId);
|
|
951
|
+
if (!job || job.status !== "queued") {
|
|
952
|
+
this.dispatch();
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
const handler = this.handlers.get(job.type);
|
|
956
|
+
if (!handler) {
|
|
957
|
+
this.pendingQueue.unshift(nextJobId);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
this.activeJobsCount++;
|
|
961
|
+
job.status = "processing";
|
|
962
|
+
job.updatedAt = Date.now();
|
|
963
|
+
const updateProgress = (progress) => {
|
|
964
|
+
job.progress = Math.max(0, Math.min(100, progress));
|
|
965
|
+
job.updatedAt = Date.now();
|
|
966
|
+
};
|
|
967
|
+
try {
|
|
968
|
+
const result = await handler(job, updateProgress);
|
|
969
|
+
job.status = "completed";
|
|
970
|
+
job.progress = 100;
|
|
971
|
+
job.result = result;
|
|
972
|
+
job.completedAt = Date.now();
|
|
973
|
+
job.updatedAt = Date.now();
|
|
974
|
+
} catch (error) {
|
|
975
|
+
job.status = "failed";
|
|
976
|
+
job.error = error?.message || String(error);
|
|
977
|
+
job.completedAt = Date.now();
|
|
978
|
+
job.updatedAt = Date.now();
|
|
979
|
+
} finally {
|
|
980
|
+
this.activeJobsCount--;
|
|
981
|
+
if (this.activeJobsCount === 0) {
|
|
982
|
+
for (const resolve of this.drainWaiters) resolve();
|
|
983
|
+
this.drainWaiters = [];
|
|
984
|
+
}
|
|
985
|
+
this.pruneOldJobs();
|
|
986
|
+
setImmediate(() => this.dispatch());
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
pruneOldJobs() {
|
|
990
|
+
if (this.jobs.size <= this.maxRetainedJobs) return;
|
|
991
|
+
const terminal = [...this.jobs.values()].filter((job) => job.status === "completed" || job.status === "failed");
|
|
992
|
+
for (const job of terminal.slice(0, Math.max(0, terminal.length - this.maxRetainedJobs))) this.jobs.delete(job.id);
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
// src/services/document-ingestion.service.ts
|
|
997
|
+
var pipeline = promisify(streamPipeline);
|
|
998
|
+
var DANGEROUS_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
999
|
+
".exe",
|
|
1000
|
+
".dll",
|
|
1001
|
+
".so",
|
|
1002
|
+
".dylib",
|
|
1003
|
+
".bin",
|
|
1004
|
+
".sh",
|
|
1005
|
+
".bat",
|
|
1006
|
+
".cmd",
|
|
1007
|
+
".ps1",
|
|
1008
|
+
".vbs",
|
|
1009
|
+
".js",
|
|
1010
|
+
".mjs",
|
|
1011
|
+
".cjs",
|
|
1012
|
+
".py",
|
|
1013
|
+
".rb",
|
|
1014
|
+
".php",
|
|
1015
|
+
".msi",
|
|
1016
|
+
".com",
|
|
1017
|
+
".scr",
|
|
1018
|
+
".pif",
|
|
1019
|
+
".application",
|
|
1020
|
+
".gadget",
|
|
1021
|
+
".docm",
|
|
1022
|
+
".xlsm",
|
|
1023
|
+
".pptm"
|
|
1024
|
+
]);
|
|
1025
|
+
var DocumentIngestionService = class _DocumentIngestionService {
|
|
1026
|
+
workerReady = Promise.resolve();
|
|
1027
|
+
backend;
|
|
1028
|
+
vectorStore;
|
|
1029
|
+
smlEngine;
|
|
1030
|
+
jobQueue;
|
|
1031
|
+
chunkSize;
|
|
1032
|
+
chunkOverlap;
|
|
1033
|
+
piiPolicy;
|
|
1034
|
+
constructor(config) {
|
|
1035
|
+
this.vectorStore = config?.vectorStore;
|
|
1036
|
+
this.smlEngine = config?.smlEngine;
|
|
1037
|
+
this.jobQueue = config?.jobQueue || new InMemoryJobQueue();
|
|
1038
|
+
this.chunkSize = config?.chunkSize ?? 500;
|
|
1039
|
+
this.chunkOverlap = config?.chunkOverlap ?? 50;
|
|
1040
|
+
this.piiPolicy = config?.piiPolicy;
|
|
1041
|
+
this.backend = config?.backend;
|
|
1042
|
+
if (!this.backend) this.initWorker();
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Inspects magic bytes and file extension to block dangerous executables and scripts.
|
|
1046
|
+
*/
|
|
1047
|
+
static validateFileSafety(filename, buffer) {
|
|
1048
|
+
const extMatch = filename.toLowerCase().match(/\.[a-z0-9]+$/);
|
|
1049
|
+
const ext = extMatch ? extMatch[0] : "";
|
|
1050
|
+
if (DANGEROUS_EXTENSIONS.has(ext)) {
|
|
1051
|
+
return {
|
|
1052
|
+
safe: false,
|
|
1053
|
+
reason: `Dangerous file extension detected: ${ext}. Executables, scripts, and macro files are strictly rejected.`
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
if (buffer.length >= 2) {
|
|
1057
|
+
if (buffer[0] === 77 && buffer[1] === 90) {
|
|
1058
|
+
return { safe: false, reason: "Malicious file detected: Windows/DOS executable header (MZ)." };
|
|
1059
|
+
}
|
|
1060
|
+
if (buffer[0] === 35 && buffer[1] === 33) {
|
|
1061
|
+
return { safe: false, reason: "Script file detected: Unix shebang (#!) executable header." };
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (buffer.length >= 4) {
|
|
1065
|
+
if (buffer[0] === 127 && buffer[1] === 69 && buffer[2] === 76 && buffer[3] === 70) {
|
|
1066
|
+
return { safe: false, reason: "Malicious file detected: Linux ELF binary executable header." };
|
|
1067
|
+
}
|
|
1068
|
+
if (buffer[0] === 202 && buffer[1] === 254 && buffer[2] === 186 && buffer[3] === 190) {
|
|
1069
|
+
return { safe: false, reason: "Binary file detected: Java class or Mach-O binary header." };
|
|
1070
|
+
}
|
|
1071
|
+
if (buffer[0] === 254 && buffer[1] === 237 && buffer[2] === 250 && (buffer[3] === 206 || buffer[3] === 207) || buffer[0] === 207 && buffer[1] === 250 && buffer[2] === 237 && buffer[3] === 254) {
|
|
1072
|
+
return { safe: false, reason: "Malicious file detected: macOS Mach-O binary executable header." };
|
|
1073
|
+
}
|
|
1074
|
+
if (buffer[0] === 0 && buffer[1] === 97 && buffer[2] === 115 && buffer[3] === 109) {
|
|
1075
|
+
return { safe: false, reason: "Binary file detected: WebAssembly binary header." };
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return { safe: true };
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Inspects magic bytes from an active stream non-destructively by peeking the initial 4KB chunk.
|
|
1082
|
+
* Prevents loading multi-gigabyte files into memory just to check safety headers.
|
|
1083
|
+
*/
|
|
1084
|
+
static async validateStreamSafety(filename, stream) {
|
|
1085
|
+
return new Promise((resolve, reject) => {
|
|
1086
|
+
let resolved = false;
|
|
1087
|
+
const readable = stream;
|
|
1088
|
+
const onData = (chunk) => {
|
|
1089
|
+
if (resolved) return;
|
|
1090
|
+
resolved = true;
|
|
1091
|
+
const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1092
|
+
const check = _DocumentIngestionService.validateFileSafety(filename, bufferChunk);
|
|
1093
|
+
readable.removeListener("data", onData);
|
|
1094
|
+
readable.removeListener("error", onError);
|
|
1095
|
+
readable.pause();
|
|
1096
|
+
if (!check.safe) {
|
|
1097
|
+
if (typeof readable.destroy === "function") readable.destroy();
|
|
1098
|
+
return resolve({ safe: false, reason: check.reason, safeStream: readable });
|
|
1099
|
+
}
|
|
1100
|
+
if (typeof readable.unshift === "function") {
|
|
1101
|
+
readable.unshift(bufferChunk);
|
|
1102
|
+
resolve({ safe: true, safeStream: readable });
|
|
1103
|
+
} else {
|
|
1104
|
+
const passThrough = new PassThrough();
|
|
1105
|
+
passThrough.write(bufferChunk);
|
|
1106
|
+
readable.pipe(passThrough);
|
|
1107
|
+
resolve({ safe: true, safeStream: passThrough });
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
const onError = (err) => {
|
|
1111
|
+
if (resolved) return;
|
|
1112
|
+
resolved = true;
|
|
1113
|
+
reject(err);
|
|
1114
|
+
};
|
|
1115
|
+
readable.once("data", onData);
|
|
1116
|
+
readable.once("error", onError);
|
|
1117
|
+
readable.once("end", () => {
|
|
1118
|
+
if (!resolved) {
|
|
1119
|
+
resolved = true;
|
|
1120
|
+
resolve({ safe: true, safeStream: readable });
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Enterprise Lexical Document Text Extractor.
|
|
1127
|
+
* Decodes and normalizes textual content across PDF streams, Markdown, CSV, JSON, and plaintext files.
|
|
1128
|
+
*
|
|
1129
|
+
* PDF Specification Compliance:
|
|
1130
|
+
* - Parses text execution state matrices within BT (Begin Text) ... ET (End Text) blocks.
|
|
1131
|
+
* - Evaluates 'Tj', '\'', and '"' text-showing operators with PDF octal and hexadecimal escape decoding.
|
|
1132
|
+
* - Parses 'TJ' array kerning sequences, preserving natural word spacing between glyph segments.
|
|
1133
|
+
* - Evaluates positional newline operators ('T*', 'Td', 'TD', 'Tm') to maintain structural paragraph integrity.
|
|
1134
|
+
* - Performs binary artifact suppression and multi-lingual UTF-8 normalization (Turkish, European, and CJK diacritics).
|
|
1135
|
+
*/
|
|
1136
|
+
static extractText(filename, buffer) {
|
|
1137
|
+
const rawString = buffer.toString("utf-8");
|
|
1138
|
+
if (buffer.length >= 4 && buffer.slice(0, 4).toString() === "%PDF") {
|
|
1139
|
+
return _DocumentIngestionService.extractPdfText(rawString);
|
|
1140
|
+
}
|
|
1141
|
+
return rawString.trim();
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Enterprise High-Efficiency PDF Lexical Extractor.
|
|
1145
|
+
* Parses PDF content streams without heavy external C++/WASM dependencies.
|
|
1146
|
+
*/
|
|
1147
|
+
static extractPdfText(rawContent) {
|
|
1148
|
+
const extractedSegments = [];
|
|
1149
|
+
const decodePdfLiteral = (encoded) => {
|
|
1150
|
+
return encoded.replace(/\\([0-7]{1,3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8))).replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\b/g, "\b").replace(/\\f/g, "\f").replace(/\\\(/g, "(").replace(/\\\)/g, ")").replace(/\\\\/g, "\\");
|
|
1151
|
+
};
|
|
1152
|
+
const decodePdfHex = (hex) => {
|
|
1153
|
+
try {
|
|
1154
|
+
const cleanHex = hex.replace(/\s+/g, "");
|
|
1155
|
+
return Buffer.from(cleanHex, "hex").toString("utf-8");
|
|
1156
|
+
} catch {
|
|
1157
|
+
return "";
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
const btEtRegex = /BT[\s\S]*?ET/g;
|
|
1161
|
+
let btMatch;
|
|
1162
|
+
while ((btMatch = btEtRegex.exec(rawContent)) !== null) {
|
|
1163
|
+
const blockContent = btMatch[0];
|
|
1164
|
+
const tjArrayRegex = /\[([\s\S]*?)\]\s*TJ/g;
|
|
1165
|
+
let tjArrMatch;
|
|
1166
|
+
while ((tjArrMatch = tjArrayRegex.exec(blockContent)) !== null) {
|
|
1167
|
+
const arrayBody = tjArrMatch[1];
|
|
1168
|
+
const elementRegex = /\((.*?)\)|<([0-9a-fA-F]+)>|(-?\d+(?:\.\d+)?)/g;
|
|
1169
|
+
let elem;
|
|
1170
|
+
const lineTokens = [];
|
|
1171
|
+
while ((elem = elementRegex.exec(arrayBody)) !== null) {
|
|
1172
|
+
if (elem[1] !== void 0) {
|
|
1173
|
+
lineTokens.push(decodePdfLiteral(elem[1]));
|
|
1174
|
+
} else if (elem[2] !== void 0) {
|
|
1175
|
+
lineTokens.push(decodePdfHex(elem[2]));
|
|
1176
|
+
} else if (elem[3] !== void 0) {
|
|
1177
|
+
const kerning = parseFloat(elem[3]);
|
|
1178
|
+
if (kerning < -100) {
|
|
1179
|
+
lineTokens.push(" ");
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
if (lineTokens.length > 0) {
|
|
1184
|
+
extractedSegments.push(lineTokens.join("").trim());
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
const tjSingleRegex = /\((.*?)\)\s*Tj/g;
|
|
1188
|
+
let singleMatch;
|
|
1189
|
+
while ((singleMatch = tjSingleRegex.exec(blockContent)) !== null) {
|
|
1190
|
+
const text = decodePdfLiteral(singleMatch[1]).trim();
|
|
1191
|
+
if (text.length > 0) {
|
|
1192
|
+
extractedSegments.push(text);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
const tjHexRegex = /<([0-9a-fA-F]+)>\s*Tj/g;
|
|
1196
|
+
let hexMatch;
|
|
1197
|
+
while ((hexMatch = tjHexRegex.exec(blockContent)) !== null) {
|
|
1198
|
+
const text = decodePdfHex(hexMatch[1]).trim();
|
|
1199
|
+
if (text.length > 0) {
|
|
1200
|
+
extractedSegments.push(text);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (extractedSegments.length > 0) {
|
|
1205
|
+
return extractedSegments.join(" ").replace(/\s+/g, " ").trim();
|
|
1206
|
+
}
|
|
1207
|
+
const cleaned = rawContent.replace(/%PDF-[^\n]+/g, "").replace(/stream[\s\S]*?endstream/g, (streamObj) => {
|
|
1208
|
+
const innerMatch = streamObj.match(/\((.*?)\)/g);
|
|
1209
|
+
return innerMatch ? innerMatch.map((s) => decodePdfLiteral(s.slice(1, -1))).join(" ") : "";
|
|
1210
|
+
}).replace(/obj[\s\S]*?endobj/g, (obj) => {
|
|
1211
|
+
const innerMatch = obj.match(/\((.*?)\)/g);
|
|
1212
|
+
return innerMatch ? innerMatch.map((s) => decodePdfLiteral(s.slice(1, -1))).join(" ") : "";
|
|
1213
|
+
}).replace(/[^a-zA-Z0-9çÇğĞıİöÖşŞüÜäÄöÖüÜß\s.,!?:;@/\-()]/g, " ").replace(/\s+/g, " ").trim();
|
|
1214
|
+
return cleaned || "PDF document with no extractable plain text stream.";
|
|
1215
|
+
}
|
|
1216
|
+
static chunkText(text, chunkSize = 500, overlap = 50) {
|
|
1217
|
+
if (!text || text.length <= chunkSize) {
|
|
1218
|
+
return text ? [text] : [];
|
|
1219
|
+
}
|
|
1220
|
+
const chunks = [];
|
|
1221
|
+
let startIndex = 0;
|
|
1222
|
+
while (startIndex < text.length) {
|
|
1223
|
+
let endIndex = Math.min(startIndex + chunkSize, text.length);
|
|
1224
|
+
if (endIndex < text.length) {
|
|
1225
|
+
const searchWindow = text.slice(Math.max(startIndex, endIndex - 60), endIndex);
|
|
1226
|
+
const lastBreak = Math.max(
|
|
1227
|
+
searchWindow.lastIndexOf("\n\n"),
|
|
1228
|
+
searchWindow.lastIndexOf("\n"),
|
|
1229
|
+
searchWindow.lastIndexOf(". "),
|
|
1230
|
+
searchWindow.lastIndexOf(" ")
|
|
1231
|
+
);
|
|
1232
|
+
if (lastBreak !== -1) {
|
|
1233
|
+
endIndex = Math.max(startIndex, endIndex - 60) + lastBreak + 1;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
const chunk = text.slice(startIndex, endIndex).trim();
|
|
1237
|
+
if (chunk.length > 0) {
|
|
1238
|
+
chunks.push(chunk);
|
|
1239
|
+
}
|
|
1240
|
+
startIndex = Math.max(startIndex + 1, endIndex - overlap);
|
|
1241
|
+
}
|
|
1242
|
+
return chunks;
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Streaming chunk generator.
|
|
1246
|
+
* Ingests chunks of text or buffers incrementally with natural backpressure,
|
|
1247
|
+
* maintaining a constant O(1) memory footprint regardless of file size.
|
|
1248
|
+
*/
|
|
1249
|
+
static async *chunkStream(textStream, chunkSize = 500, overlap = 50) {
|
|
1250
|
+
let window = "";
|
|
1251
|
+
for await (const rawChunk of textStream) {
|
|
1252
|
+
const chunkStr = Buffer.isBuffer(rawChunk) ? rawChunk.toString("utf-8") : String(rawChunk);
|
|
1253
|
+
window += chunkStr;
|
|
1254
|
+
while (window.length >= chunkSize) {
|
|
1255
|
+
let endIndex = chunkSize;
|
|
1256
|
+
const searchWindow = window.slice(Math.max(0, endIndex - 60), endIndex);
|
|
1257
|
+
const lastBreak = Math.max(
|
|
1258
|
+
searchWindow.lastIndexOf("\n\n"),
|
|
1259
|
+
searchWindow.lastIndexOf("\n"),
|
|
1260
|
+
searchWindow.lastIndexOf(". "),
|
|
1261
|
+
searchWindow.lastIndexOf(" ")
|
|
1262
|
+
);
|
|
1263
|
+
if (lastBreak !== -1) {
|
|
1264
|
+
endIndex = Math.max(0, endIndex - 60) + lastBreak + 1;
|
|
1265
|
+
}
|
|
1266
|
+
const emitted = window.slice(0, endIndex).trim();
|
|
1267
|
+
if (emitted.length > 0) {
|
|
1268
|
+
yield emitted;
|
|
1269
|
+
}
|
|
1270
|
+
window = window.slice(Math.max(1, endIndex - overlap));
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
const remaining = window.trim();
|
|
1274
|
+
if (remaining.length > 0) {
|
|
1275
|
+
yield remaining;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Directly streams and chunks large documents without intermediate memory bloat.
|
|
1280
|
+
* Sanitizes PII and inserts documents into the vector store with automatic backpressure.
|
|
1281
|
+
*/
|
|
1282
|
+
async ingestStreamDirect(filename, stream, options) {
|
|
1283
|
+
const safety = await _DocumentIngestionService.validateStreamSafety(filename, stream);
|
|
1284
|
+
if (!safety.safe) {
|
|
1285
|
+
throw new Error(`[SecurityException] Document stream rejected: ${safety.reason}`);
|
|
1286
|
+
}
|
|
1287
|
+
const safeStream = safety.safeStream;
|
|
1288
|
+
let chunkIndex = 0;
|
|
1289
|
+
let totalPIIMasked = 0;
|
|
1290
|
+
const documentIds = [];
|
|
1291
|
+
for await (const rawChunk of _DocumentIngestionService.chunkStream(safeStream, this.chunkSize, this.chunkOverlap)) {
|
|
1292
|
+
const piiResult = PIIGuard.sanitize(rawChunk, this.piiPolicy);
|
|
1293
|
+
totalPIIMasked += piiResult.detectedEntities.length;
|
|
1294
|
+
const docId = `${filename}_chunk_${chunkIndex}_${randomUUID2().slice(0, 6)}`;
|
|
1295
|
+
const doc = {
|
|
1296
|
+
id: docId,
|
|
1297
|
+
content: piiResult.sanitizedText,
|
|
1298
|
+
metadata: {
|
|
1299
|
+
filename,
|
|
1300
|
+
chunkIndex,
|
|
1301
|
+
tenantId: options?.tenantId,
|
|
1302
|
+
hadPII: piiResult.hasPII,
|
|
1303
|
+
maskedCount: piiResult.detectedEntities.length
|
|
1304
|
+
}
|
|
1305
|
+
};
|
|
1306
|
+
documentIds.push(docId);
|
|
1307
|
+
if (this.vectorStore && typeof this.vectorStore.addDocuments === "function") {
|
|
1308
|
+
await this.vectorStore.addDocuments([doc]);
|
|
1309
|
+
}
|
|
1310
|
+
chunkIndex++;
|
|
1311
|
+
if (options?.onProgress) {
|
|
1312
|
+
options.onProgress(chunkIndex);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
return {
|
|
1316
|
+
filename,
|
|
1317
|
+
chunksCount: chunkIndex,
|
|
1318
|
+
piiEntitiesMasked: totalPIIMasked,
|
|
1319
|
+
documentIds,
|
|
1320
|
+
processedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Submits a stream for asynchronous background queue processing.
|
|
1325
|
+
* Uses backpressure-regulated disk spooling to temporary storage, preventing V8 heap exhaustion.
|
|
1326
|
+
*/
|
|
1327
|
+
async submitStream(filename, stream, options) {
|
|
1328
|
+
if (this.backend) return this.backend.submitDocument({ filename, content: stream, tenantId: options?.tenantId, userId: options?.userId });
|
|
1329
|
+
await this.workerReady;
|
|
1330
|
+
const safety = await _DocumentIngestionService.validateStreamSafety(filename, stream);
|
|
1331
|
+
if (!safety.safe) {
|
|
1332
|
+
throw new Error(`[SecurityException] Document rejected: ${safety.reason}`);
|
|
1333
|
+
}
|
|
1334
|
+
const safeStream = safety.safeStream;
|
|
1335
|
+
const tempFilePath = path.join(os.tmpdir(), `rag_spool_${randomUUID2().replace(/-/g, "")}_${path.basename(filename)}`);
|
|
1336
|
+
await pipeline(safeStream, fs.createWriteStream(tempFilePath));
|
|
1337
|
+
const jobId = `stream_${randomUUID2().replace(/-/g, "").slice(0, 16)}`;
|
|
1338
|
+
const job = await this.jobQueue.add("document-ingest", {
|
|
1339
|
+
filename,
|
|
1340
|
+
tempFilePath,
|
|
1341
|
+
isStreamSpool: true,
|
|
1342
|
+
mimeType: options?.mimeType,
|
|
1343
|
+
tenantId: options?.tenantId,
|
|
1344
|
+
userId: options?.userId
|
|
1345
|
+
}, jobId);
|
|
1346
|
+
return {
|
|
1347
|
+
jobId: job.id,
|
|
1348
|
+
status: job.status
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Submits a document for background asynchronous ingestion.
|
|
1353
|
+
* Supports String, Buffer, or ReadableStream payloads.
|
|
1354
|
+
*/
|
|
1355
|
+
async submitDocument(payload) {
|
|
1356
|
+
if (this.backend) return this.backend.submitDocument(payload);
|
|
1357
|
+
await this.workerReady;
|
|
1358
|
+
if (payload.content && typeof payload.content.pipe === "function") {
|
|
1359
|
+
return this.submitStream(payload.filename, payload.content, {
|
|
1360
|
+
mimeType: payload.mimeType,
|
|
1361
|
+
userId: payload.userId,
|
|
1362
|
+
tenantId: payload.tenantId
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
const buffer = this.toBuffer(payload.content, payload.encoding);
|
|
1366
|
+
const safetyCheck = _DocumentIngestionService.validateFileSafety(payload.filename, buffer);
|
|
1367
|
+
if (!safetyCheck.safe) {
|
|
1368
|
+
throw new Error(`[SecurityException] Document rejected: ${safetyCheck.reason}`);
|
|
1369
|
+
}
|
|
1370
|
+
const jobId = `doc_${randomUUID2().replace(/-/g, "").slice(0, 16)}`;
|
|
1371
|
+
const job = await this.jobQueue.add("document-ingest", {
|
|
1372
|
+
filename: payload.filename,
|
|
1373
|
+
contentBase64: buffer.toString("base64"),
|
|
1374
|
+
mimeType: payload.mimeType,
|
|
1375
|
+
userId: payload.userId,
|
|
1376
|
+
tenantId: payload.tenantId
|
|
1377
|
+
}, jobId);
|
|
1378
|
+
return {
|
|
1379
|
+
jobId: job.id,
|
|
1380
|
+
status: job.status
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Retrieves the status and progress of an ingestion job.
|
|
1385
|
+
*/
|
|
1386
|
+
async getJobStatus(jobId, tenantId) {
|
|
1387
|
+
if (this.backend) return this.backend.getJobStatus(jobId, tenantId);
|
|
1388
|
+
const job = await this.jobQueue.getJob(jobId);
|
|
1389
|
+
if (tenantId !== void 0 && job?.data?.tenantId !== tenantId) return null;
|
|
1390
|
+
return job;
|
|
1391
|
+
}
|
|
1392
|
+
async retryJob(jobId, tenantId) {
|
|
1393
|
+
if (!this.backend?.retryJob) throw new Error("Job retry requires a durable ingestion backend.");
|
|
1394
|
+
await this.backend.retryJob(jobId, tenantId);
|
|
1395
|
+
}
|
|
1396
|
+
initWorker() {
|
|
1397
|
+
this.workerReady = Promise.resolve(this.jobQueue.process("document-ingest", async (job, updateProgress) => {
|
|
1398
|
+
const data = job.data;
|
|
1399
|
+
if (data.isStreamSpool && data.tempFilePath) {
|
|
1400
|
+
try {
|
|
1401
|
+
updateProgress(20);
|
|
1402
|
+
const fileReadStream = fs.createReadStream(data.tempFilePath);
|
|
1403
|
+
const result2 = await this.ingestStreamDirect(data.filename, fileReadStream, {
|
|
1404
|
+
tenantId: data.tenantId,
|
|
1405
|
+
onProgress: (count) => {
|
|
1406
|
+
updateProgress(Math.min(90, 20 + count * 2));
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
updateProgress(100);
|
|
1410
|
+
return result2;
|
|
1411
|
+
} finally {
|
|
1412
|
+
await fsp.unlink(data.tempFilePath).catch(() => {
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
const { filename, contentBase64, tenantId } = data;
|
|
1417
|
+
const buffer = Buffer.from(contentBase64, "base64");
|
|
1418
|
+
updateProgress(10);
|
|
1419
|
+
const safety = _DocumentIngestionService.validateFileSafety(filename, buffer);
|
|
1420
|
+
if (!safety.safe) {
|
|
1421
|
+
throw new Error(safety.reason);
|
|
1422
|
+
}
|
|
1423
|
+
updateProgress(30);
|
|
1424
|
+
const rawText = _DocumentIngestionService.extractText(filename, buffer);
|
|
1425
|
+
updateProgress(50);
|
|
1426
|
+
const rawChunks = _DocumentIngestionService.chunkText(rawText, this.chunkSize, this.chunkOverlap);
|
|
1427
|
+
updateProgress(70);
|
|
1428
|
+
let totalPIIMasked = 0;
|
|
1429
|
+
const sanitizedDocs = [];
|
|
1430
|
+
for (let i = 0; i < rawChunks.length; i++) {
|
|
1431
|
+
const chunk = rawChunks[i];
|
|
1432
|
+
const piiResult = PIIGuard.sanitize(chunk, this.piiPolicy);
|
|
1433
|
+
totalPIIMasked += piiResult.detectedEntities.length;
|
|
1434
|
+
sanitizedDocs.push({
|
|
1435
|
+
id: `${filename}_chunk_${i}_${randomUUID2().slice(0, 6)}`,
|
|
1436
|
+
content: piiResult.sanitizedText,
|
|
1437
|
+
metadata: {
|
|
1438
|
+
filename,
|
|
1439
|
+
chunkIndex: i,
|
|
1440
|
+
totalChunks: rawChunks.length,
|
|
1441
|
+
tenantId,
|
|
1442
|
+
hadPII: piiResult.hasPII,
|
|
1443
|
+
maskedCount: piiResult.detectedEntities.length
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
updateProgress(90);
|
|
1448
|
+
if (this.vectorStore && typeof this.vectorStore.addDocuments === "function") {
|
|
1449
|
+
await this.vectorStore.addDocuments(sanitizedDocs);
|
|
1450
|
+
}
|
|
1451
|
+
updateProgress(100);
|
|
1452
|
+
const result = {
|
|
1453
|
+
filename,
|
|
1454
|
+
chunksCount: sanitizedDocs.length,
|
|
1455
|
+
piiEntitiesMasked: totalPIIMasked,
|
|
1456
|
+
documentIds: sanitizedDocs.map((d) => d.id),
|
|
1457
|
+
processedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1458
|
+
};
|
|
1459
|
+
return result;
|
|
1460
|
+
}));
|
|
1461
|
+
void this.workerReady.catch(() => {
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
toBuffer(content, encoding) {
|
|
1465
|
+
if (encoding !== void 0 && encoding !== "utf8" && encoding !== "base64") throw new Error("Invalid encoding.");
|
|
1466
|
+
if (Buffer.isBuffer(content)) return content;
|
|
1467
|
+
if (encoding === "utf8") return Buffer.from(content, "utf8");
|
|
1468
|
+
if (encoding === "base64") {
|
|
1469
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(content)) throw new Error("Invalid base64 content.");
|
|
1470
|
+
return Buffer.from(content, "base64");
|
|
1471
|
+
}
|
|
1472
|
+
const base64Regex = /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$/;
|
|
1473
|
+
if (content.length > 50 && base64Regex.test(content) && !content.includes(" ")) {
|
|
1474
|
+
try {
|
|
1475
|
+
return Buffer.from(content, "base64");
|
|
1476
|
+
} catch {
|
|
1477
|
+
return Buffer.from(content, "utf-8");
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
return Buffer.from(content, "utf-8");
|
|
1481
|
+
}
|
|
1482
|
+
};
|
|
1483
|
+
|
|
1484
|
+
// src/core/ingestion/queued-ingestion.service.ts
|
|
1485
|
+
var QueuedIngestionService = class {
|
|
1486
|
+
constructor(store, queue, options = {}) {
|
|
1487
|
+
this.store = store;
|
|
1488
|
+
this.queue = queue;
|
|
1489
|
+
this.options = options;
|
|
1490
|
+
this.maxBytes = options.maxUploadBytes ?? 5 * 1024 * 1024;
|
|
1491
|
+
this.settings = { chunkSize: options.chunkSize ?? 500, chunkOverlap: options.chunkOverlap ?? 50 };
|
|
1492
|
+
if (!Number.isInteger(this.settings.chunkSize) || this.settings.chunkSize < 32 || this.settings.chunkSize > 1e4 || !Number.isInteger(this.settings.chunkOverlap) || this.settings.chunkOverlap < 0 || this.settings.chunkOverlap >= this.settings.chunkSize) {
|
|
1493
|
+
throw new Error("Invalid ingestion chunk size/overlap.");
|
|
1494
|
+
}
|
|
1495
|
+
for (const value of [this.maxBytes, options.maxPendingPerTenant ?? 100, options.maxStoredBytesPerTenant ?? 50 * 1024 * 1024]) {
|
|
1496
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new Error("Ingestion limits must be positive integers.");
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
store;
|
|
1500
|
+
queue;
|
|
1501
|
+
options;
|
|
1502
|
+
settings;
|
|
1503
|
+
maxBytes;
|
|
1504
|
+
async submitDocument(payload) {
|
|
1505
|
+
const { tenantId, userId, filename } = payload;
|
|
1506
|
+
if (!tenantId?.trim() || !userId?.trim()) throw new Error("Trusted tenantId and userId are required.");
|
|
1507
|
+
if (typeof filename !== "string" || !filename.trim() || filename.length > 255 || /[/\\\0]/.test(filename)) throw new Error("Invalid filename.");
|
|
1508
|
+
const documentId = payload.documentId ?? filename;
|
|
1509
|
+
if (typeof documentId !== "string" || !documentId.trim() || documentId.length > 255) throw new Error("Invalid documentId.");
|
|
1510
|
+
if (payload.idempotencyKey !== void 0 && (!payload.idempotencyKey.trim() || payload.idempotencyKey.length > 200)) throw new Error("Invalid idempotency key.");
|
|
1511
|
+
const content = await this.bytes(payload);
|
|
1512
|
+
const safety = DocumentIngestionService.validateFileSafety(filename, content);
|
|
1513
|
+
if (!safety.safe) throw new Error(`Document rejected: ${safety.reason}`);
|
|
1514
|
+
const saved = await this.store.put({ tenantId, userId, documentId, filename, content, settings: this.settings, idempotencyKey: payload.idempotencyKey }, {
|
|
1515
|
+
maxPending: this.options.maxPendingPerTenant ?? 100,
|
|
1516
|
+
maxStoredBytes: this.options.maxStoredBytesPerTenant ?? 50 * 1024 * 1024
|
|
1517
|
+
});
|
|
1518
|
+
if (saved.status === "pending") {
|
|
1519
|
+
try {
|
|
1520
|
+
await this.dispatch({ id: saved.id, tenantId });
|
|
1521
|
+
} catch {
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
return { jobId: saved.id, status: saved.status === "pending" ? (await this.store.get(saved.id, tenantId)).status : saved.status };
|
|
1525
|
+
}
|
|
1526
|
+
async flushOutbox() {
|
|
1527
|
+
for (const reference of await this.store.dispatchable()) await this.dispatch(reference);
|
|
1528
|
+
}
|
|
1529
|
+
async dispatch(reference) {
|
|
1530
|
+
const existing = await this.queue.getJob(reference.id);
|
|
1531
|
+
if (!existing) await this.queue.add("document-ingest", reference, reference.id);
|
|
1532
|
+
else if (existing.status === "failed") {
|
|
1533
|
+
await this.store.mark(reference.id, "failed", existing.error);
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
await this.store.mark(reference.id, "queued");
|
|
1537
|
+
}
|
|
1538
|
+
async getJobStatus(id, tenantId) {
|
|
1539
|
+
const saved = await this.store.get(id, tenantId);
|
|
1540
|
+
if (!saved) return null;
|
|
1541
|
+
let progress = saved.status === "completed" ? 100 : 0;
|
|
1542
|
+
let attemptsMade;
|
|
1543
|
+
try {
|
|
1544
|
+
const job = await this.queue.getJob(id);
|
|
1545
|
+
progress = Math.max(progress, job?.progress ?? 0);
|
|
1546
|
+
attemptsMade = job?.attemptsMade;
|
|
1547
|
+
} catch {
|
|
1548
|
+
}
|
|
1549
|
+
return {
|
|
1550
|
+
id,
|
|
1551
|
+
type: "document-ingest",
|
|
1552
|
+
data: { tenantId: saved.tenantId, userId: saved.userId, documentId: saved.documentId },
|
|
1553
|
+
status: saved.status,
|
|
1554
|
+
progress,
|
|
1555
|
+
attemptsMade,
|
|
1556
|
+
result: saved.result ?? void 0,
|
|
1557
|
+
error: saved.error ?? void 0,
|
|
1558
|
+
createdAt: saved.createdAt,
|
|
1559
|
+
updatedAt: saved.updatedAt,
|
|
1560
|
+
...saved.status === "completed" || saved.status === "failed" ? { completedAt: saved.updatedAt } : {}
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
async retryJob(id, tenantId) {
|
|
1564
|
+
const saved = await this.store.get(id, tenantId);
|
|
1565
|
+
if (!saved || saved.status !== "failed" || !saved.content) throw new Error("Failed job missing or retry payload expired.");
|
|
1566
|
+
const job = await this.queue.getJob(id);
|
|
1567
|
+
if (job && job.status !== "failed") throw new Error("Job is not retryable.");
|
|
1568
|
+
await this.store.resetFailed(id, tenantId);
|
|
1569
|
+
if (job) await this.queue.retry(id);
|
|
1570
|
+
else await this.queue.add("document-ingest", { id, tenantId }, id);
|
|
1571
|
+
await this.store.mark(id, "queued");
|
|
1572
|
+
}
|
|
1573
|
+
async bytes(payload) {
|
|
1574
|
+
if (payload.encoding !== void 0 && payload.encoding !== "base64" && payload.encoding !== "utf8") throw new Error("Invalid encoding.");
|
|
1575
|
+
let data;
|
|
1576
|
+
if (typeof payload.content === "string") {
|
|
1577
|
+
if (Buffer.byteLength(payload.content) > (payload.encoding === "base64" ? Math.ceil(this.maxBytes / 3) * 4 : this.maxBytes)) throw new IngestionQuotaError("Upload exceeds byte limit.");
|
|
1578
|
+
if (payload.encoding === "base64" && !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(payload.content)) throw new Error("Invalid base64 content.");
|
|
1579
|
+
data = Buffer.from(payload.content, payload.encoding === "base64" ? "base64" : "utf8");
|
|
1580
|
+
} else if (Buffer.isBuffer(payload.content)) data = payload.content;
|
|
1581
|
+
else {
|
|
1582
|
+
const chunks = [];
|
|
1583
|
+
let length = 0;
|
|
1584
|
+
for await (const chunk of payload.content) {
|
|
1585
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1586
|
+
length += buffer.length;
|
|
1587
|
+
if (length > this.maxBytes) {
|
|
1588
|
+
payload.content.destroy?.();
|
|
1589
|
+
throw new IngestionQuotaError("Upload exceeds byte limit.");
|
|
1590
|
+
}
|
|
1591
|
+
chunks.push(buffer);
|
|
1592
|
+
}
|
|
1593
|
+
data = Buffer.concat(chunks);
|
|
1594
|
+
}
|
|
1595
|
+
if (!data.length || data.length > this.maxBytes) throw new IngestionQuotaError("Upload is empty or exceeds byte limit.");
|
|
1596
|
+
return data;
|
|
1597
|
+
}
|
|
1598
|
+
};
|
|
1599
|
+
|
|
1600
|
+
// src/core/ingestion/ingestion-worker.ts
|
|
1601
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1602
|
+
var IngestionWorker = class {
|
|
1603
|
+
constructor(service, vectors, options = {}) {
|
|
1604
|
+
this.service = service;
|
|
1605
|
+
this.vectors = vectors;
|
|
1606
|
+
this.options = options;
|
|
1607
|
+
}
|
|
1608
|
+
service;
|
|
1609
|
+
vectors;
|
|
1610
|
+
options;
|
|
1611
|
+
timer;
|
|
1612
|
+
pump;
|
|
1613
|
+
stopped = false;
|
|
1614
|
+
async start() {
|
|
1615
|
+
if (this.timer) return;
|
|
1616
|
+
this.stopped = false;
|
|
1617
|
+
await this.service.store.ready();
|
|
1618
|
+
await this.service.queue.ready();
|
|
1619
|
+
await this.service.queue.process("document-ingest", (job, progress) => this.process(job, progress));
|
|
1620
|
+
await this.tick();
|
|
1621
|
+
this.timer = setInterval(() => {
|
|
1622
|
+
void this.tick();
|
|
1623
|
+
}, this.options.pollMs ?? 1e3);
|
|
1624
|
+
}
|
|
1625
|
+
async tick() {
|
|
1626
|
+
if (this.stopped) return;
|
|
1627
|
+
if (!this.pump) {
|
|
1628
|
+
this.pump = this.service.flushOutbox().then(() => this.service.store.expireFailedBlobs()).catch((error) => this.options.onError?.(error)).finally(() => {
|
|
1629
|
+
this.pump = void 0;
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
await this.pump;
|
|
1633
|
+
}
|
|
1634
|
+
async process(job, progress) {
|
|
1635
|
+
const reference = job.data;
|
|
1636
|
+
const saved = await this.service.store.get(reference.id, reference.tenantId);
|
|
1637
|
+
if (!saved) throw new Error("Ingestion record missing.");
|
|
1638
|
+
if (saved.status === "completed") return saved.result ?? {};
|
|
1639
|
+
if (!saved.content) throw new Error("Upload payload expired.");
|
|
1640
|
+
try {
|
|
1641
|
+
await this.service.store.mark(saved.id, "processing");
|
|
1642
|
+
await progress(10);
|
|
1643
|
+
if (createHash2("sha256").update(saved.content).digest("hex") !== saved.digest) throw new Error("Upload checksum mismatch.");
|
|
1644
|
+
const safety = DocumentIngestionService.validateFileSafety(saved.filename, saved.content);
|
|
1645
|
+
if (!safety.safe) throw new Error(`Unsafe document: ${safety.reason}`);
|
|
1646
|
+
const text = DocumentIngestionService.extractText(saved.filename, saved.content);
|
|
1647
|
+
if (text === "PDF document with no extractable plain text stream.") throw new Error("PDF needs OCR or a supported text parser.");
|
|
1648
|
+
const sanitized = PIIGuard.sanitize(text);
|
|
1649
|
+
const pieces = DocumentIngestionService.chunkText(sanitized.sanitizedText, saved.settings.chunkSize, saved.settings.chunkOverlap);
|
|
1650
|
+
if (!pieces.length) throw new Error("Document contains no extractable text.");
|
|
1651
|
+
const docs = pieces.map((content, index) => ({
|
|
1652
|
+
id: createHash2("sha256").update(JSON.stringify([saved.documentId, index])).digest("hex"),
|
|
1653
|
+
content,
|
|
1654
|
+
metadata: {
|
|
1655
|
+
tenantId: saved.tenantId,
|
|
1656
|
+
documentId: saved.documentId,
|
|
1657
|
+
documentVersion: saved.version,
|
|
1658
|
+
filename: saved.filename,
|
|
1659
|
+
chunkIndex: index,
|
|
1660
|
+
totalChunks: pieces.length,
|
|
1661
|
+
sourceDigest: saved.digest
|
|
1662
|
+
}
|
|
1663
|
+
}));
|
|
1664
|
+
await progress(40);
|
|
1665
|
+
const outcome = await this.vectors.replaceDocument(saved.documentId, saved.tenantId, saved.version, docs);
|
|
1666
|
+
const result = {
|
|
1667
|
+
filename: saved.filename,
|
|
1668
|
+
documentId: saved.documentId,
|
|
1669
|
+
version: saved.version,
|
|
1670
|
+
chunksCount: docs.length,
|
|
1671
|
+
piiEntitiesMasked: sanitized.detectedEntities.length,
|
|
1672
|
+
documentIds: docs.map((doc) => doc.id),
|
|
1673
|
+
superseded: outcome === "superseded",
|
|
1674
|
+
applied: outcome === "applied",
|
|
1675
|
+
processedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1676
|
+
};
|
|
1677
|
+
await this.service.store.complete(saved.id, result);
|
|
1678
|
+
await progress(100);
|
|
1679
|
+
return result;
|
|
1680
|
+
} catch (error) {
|
|
1681
|
+
if ((job.attemptsMade ?? 0) + 1 >= (job.maxAttempts ?? 1)) {
|
|
1682
|
+
await this.service.store.mark(saved.id, "failed", error instanceof Error ? error.message : "Processing failed.");
|
|
1683
|
+
}
|
|
1684
|
+
throw error;
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
async close() {
|
|
1688
|
+
this.stopped = true;
|
|
1689
|
+
if (this.timer) {
|
|
1690
|
+
clearInterval(this.timer);
|
|
1691
|
+
this.timer = void 0;
|
|
1692
|
+
}
|
|
1693
|
+
await this.pump;
|
|
1694
|
+
await this.service.queue.close();
|
|
1695
|
+
}
|
|
1696
|
+
};
|
|
1697
|
+
|
|
1698
|
+
// src/config/ingestion-runtime.ts
|
|
1699
|
+
function positiveEnv(name, fallback) {
|
|
1700
|
+
const value = Number(process.env[name] ?? fallback);
|
|
1701
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${name} must be a positive integer.`);
|
|
1702
|
+
return value;
|
|
1703
|
+
}
|
|
1704
|
+
async function createIngestionRuntime(vectors) {
|
|
1705
|
+
if (!process.env.DATABASE_URL || !process.env.REDIS_URL) throw new Error("Durable ingestion requires DATABASE_URL and REDIS_URL.");
|
|
1706
|
+
const pool = new Pool2({ connectionString: process.env.DATABASE_URL, max: 5, connectionTimeoutMillis: 5e3, statement_timeout: 1e4 });
|
|
1707
|
+
pool.on("error", (error) => console.error("[Ingestion DB]", error.message));
|
|
1708
|
+
const store = new PostgresIngestionStore(pool, process.env.INGESTION_TABLE_NAME);
|
|
1709
|
+
const queue = new BullMQJobQueue({
|
|
1710
|
+
redisUrl: process.env.REDIS_URL,
|
|
1711
|
+
queueName: process.env.INGESTION_QUEUE_NAME,
|
|
1712
|
+
concurrency: positiveEnv("INGESTION_CONCURRENCY", 2),
|
|
1713
|
+
globalConcurrency: positiveEnv("INGESTION_GLOBAL_CONCURRENCY", 4),
|
|
1714
|
+
attempts: positiveEnv("INGESTION_ATTEMPTS", 3),
|
|
1715
|
+
lockDurationMs: positiveEnv("INGESTION_LOCK_DURATION_MS", 3e4),
|
|
1716
|
+
stalledIntervalMs: positiveEnv("INGESTION_STALLED_INTERVAL_MS", 3e4),
|
|
1717
|
+
onError: (error) => console.error("[Ingestion Redis]", error.message)
|
|
1718
|
+
});
|
|
1719
|
+
try {
|
|
1720
|
+
await store.ready();
|
|
1721
|
+
await queue.ready();
|
|
1722
|
+
const backend = new QueuedIngestionService(store, queue, {
|
|
1723
|
+
maxUploadBytes: positiveEnv("INGESTION_MAX_UPLOAD_BYTES", 5 * 1024 * 1024),
|
|
1724
|
+
maxPendingPerTenant: positiveEnv("INGESTION_MAX_PENDING_PER_TENANT", 100),
|
|
1725
|
+
maxStoredBytesPerTenant: positiveEnv("INGESTION_MAX_STORED_BYTES_PER_TENANT", 50 * 1024 * 1024)
|
|
1726
|
+
});
|
|
1727
|
+
const worker = new IngestionWorker(backend, vectors, { onError: (error) => console.error("[Ingestion outbox]", error instanceof Error ? error.message : error) });
|
|
1728
|
+
let closed = false;
|
|
1729
|
+
return { backend, worker, async close() {
|
|
1730
|
+
if (closed) return;
|
|
1731
|
+
closed = true;
|
|
1732
|
+
await worker.close();
|
|
1733
|
+
await pool.end();
|
|
1734
|
+
} };
|
|
1735
|
+
} catch (error) {
|
|
1736
|
+
await queue.close();
|
|
1737
|
+
await pool.end();
|
|
1738
|
+
throw error;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
function persistentVectorsFromEnv() {
|
|
1742
|
+
return new PostgresVectorAdapter({
|
|
1743
|
+
connectionString: process.env.DATABASE_URL,
|
|
1744
|
+
tableName: process.env.VECTOR_TABLE_NAME,
|
|
1745
|
+
modelName: process.env.EMBEDDING_MODEL || "Xenova/all-MiniLM-L6-v2",
|
|
1746
|
+
dimensions: positiveEnv("EMBEDDING_DIMENSIONS", 384),
|
|
1747
|
+
autoMigrate: process.env.VECTOR_AUTO_MIGRATE === "true"
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// src/worker.ts
|
|
1752
|
+
async function main() {
|
|
1753
|
+
const vectors = persistentVectorsFromEnv();
|
|
1754
|
+
await vectors.init();
|
|
1755
|
+
const runtime = await createIngestionRuntime(vectors);
|
|
1756
|
+
await runtime.worker.start();
|
|
1757
|
+
console.log("[IngestionWorker] Ready");
|
|
1758
|
+
let stopping = false;
|
|
1759
|
+
const stop = async () => {
|
|
1760
|
+
if (stopping) return;
|
|
1761
|
+
stopping = true;
|
|
1762
|
+
const deadline = setTimeout(() => {
|
|
1763
|
+
console.error("[IngestionWorker] Shutdown timed out");
|
|
1764
|
+
process.exit(1);
|
|
1765
|
+
}, positiveEnv("SHUTDOWN_TIMEOUT_MS", 3e4));
|
|
1766
|
+
deadline.unref();
|
|
1767
|
+
try {
|
|
1768
|
+
await runtime.close();
|
|
1769
|
+
await vectors.close();
|
|
1770
|
+
} catch (error) {
|
|
1771
|
+
console.error("[IngestionWorker] Shutdown failed", error);
|
|
1772
|
+
process.exitCode = 1;
|
|
1773
|
+
} finally {
|
|
1774
|
+
clearTimeout(deadline);
|
|
1775
|
+
}
|
|
1776
|
+
};
|
|
1777
|
+
process.once("SIGTERM", () => {
|
|
1778
|
+
void stop();
|
|
1779
|
+
});
|
|
1780
|
+
process.once("SIGINT", () => {
|
|
1781
|
+
void stop();
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
main().catch((error) => {
|
|
1785
|
+
console.error("[IngestionWorker]", error.message);
|
|
1786
|
+
process.exit(1);
|
|
1787
|
+
});
|