@opengeni/documents 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,667 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import type { AddDocumentRequest, CreateDocumentBaseRequest, Document, DocumentBase, DocumentSearchResult, FileAsset } from "@opengeni/contracts";
3
+ import { requireFile, withRlsContext, withWorkspaceRls, type Database } from "@opengeni/db";
4
+ import * as schema from "@opengeni/db/schema";
5
+ import type { ObjectStorage } from "@opengeni/storage";
6
+ import { LiteParse } from "@llamaindex/liteparse";
7
+ import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
8
+ import OpenAI from "openai";
9
+
10
+ export const DEFAULT_DOCUMENT_PARSER = "liteparse";
11
+ export const DEFAULT_DOCUMENT_EMBEDDING_MODEL = "text-embedding-3-large";
12
+ export const DEFAULT_DOCUMENT_EMBEDDING_DIMENSIONS = 3072;
13
+ export const DEFAULT_DOCUMENT_CHUNK_SIZE = 1200;
14
+ export const DEFAULT_DOCUMENT_CHUNK_OVERLAP = 160;
15
+
16
+ export type ParsedDocument = {
17
+ text: string;
18
+ metadata?: Record<string, unknown>;
19
+ };
20
+
21
+ export type DocumentChunk = {
22
+ text: string;
23
+ metadata: Record<string, unknown>;
24
+ };
25
+
26
+ export type DocumentParser = {
27
+ name: string;
28
+ parse: (bytes: Uint8Array, file: FileAsset) => Promise<ParsedDocument>;
29
+ };
30
+
31
+ export type DocumentChunker = {
32
+ chunk: (parsed: ParsedDocument, file: FileAsset) => DocumentChunk[];
33
+ };
34
+
35
+ export type DocumentEmbedder = {
36
+ model: string;
37
+ dimensions: number;
38
+ embedMany: (texts: string[]) => Promise<number[][]>;
39
+ embedQuery: (text: string) => Promise<number[]>;
40
+ };
41
+
42
+ export type DocumentServices = {
43
+ parser: DocumentParser;
44
+ chunker: DocumentChunker;
45
+ embedder: DocumentEmbedder;
46
+ };
47
+
48
+ export type DocumentIndexHooks = {
49
+ beforeEmbed?: (input: {
50
+ accountId: string;
51
+ workspaceId: string;
52
+ documentId: string;
53
+ chunkCount: number;
54
+ }) => Promise<void>;
55
+ };
56
+
57
+ export class LiteParseDocumentParser implements DocumentParser {
58
+ readonly name = DEFAULT_DOCUMENT_PARSER;
59
+ private parseQueue: Promise<void> = Promise.resolve();
60
+
61
+ async parse(bytes: Uint8Array, file: FileAsset): Promise<ParsedDocument> {
62
+ const text = isTextLike(file)
63
+ ? Buffer.from(bytes).toString("utf8").replace(/\0/g, " ").trim()
64
+ : await this.parseWithLiteParse(bytes);
65
+ if (!text.trim()) {
66
+ throw new Error(`Parsed document is empty: ${file.filename}`);
67
+ }
68
+ return {
69
+ text: text.trim(),
70
+ metadata: {
71
+ parser: this.name,
72
+ filename: file.filename,
73
+ contentType: file.contentType,
74
+ },
75
+ };
76
+ }
77
+
78
+ private async parseWithLiteParse(bytes: Uint8Array): Promise<string> {
79
+ return await this.enqueueParse(async () => {
80
+ const parser = new LiteParse({ ocrEnabled: true, numWorkers: 1 });
81
+ const result = await parser.parse(Buffer.from(bytes), true);
82
+ const text = typeof result?.text === "string" ? result.text : "";
83
+ return text.replace(/\0/g, " ").trim();
84
+ });
85
+ }
86
+
87
+ private async enqueueParse<T>(task: () => Promise<T>): Promise<T> {
88
+ const previous = this.parseQueue;
89
+ let release: () => void = () => undefined;
90
+ this.parseQueue = new Promise<void>((resolve) => {
91
+ release = resolve;
92
+ });
93
+ await previous.catch(() => undefined);
94
+ try {
95
+ return await task();
96
+ } finally {
97
+ release();
98
+ }
99
+ }
100
+ }
101
+
102
+ export class RecursiveTextChunker implements DocumentChunker {
103
+ constructor(
104
+ private readonly maxChars = DEFAULT_DOCUMENT_CHUNK_SIZE,
105
+ private readonly overlapChars = DEFAULT_DOCUMENT_CHUNK_OVERLAP,
106
+ ) {
107
+ if (overlapChars >= maxChars) {
108
+ throw new Error("document chunk overlap must be smaller than chunk size");
109
+ }
110
+ }
111
+
112
+ chunk(parsed: ParsedDocument, file: FileAsset): DocumentChunk[] {
113
+ return chunkText(parsed.text, this.maxChars, this.overlapChars).map((text, index) => ({
114
+ text,
115
+ metadata: {
116
+ ...parsed.metadata,
117
+ filename: file.filename,
118
+ contentType: file.contentType,
119
+ chunkIndex: index,
120
+ },
121
+ }));
122
+ }
123
+ }
124
+
125
+ export class OpenAIEmbeddingProvider implements DocumentEmbedder {
126
+ private client: OpenAI | null = null;
127
+ private readonly apiKey: string | undefined;
128
+ private readonly baseURL: string | undefined;
129
+
130
+ constructor(args: {
131
+ apiKey?: string | undefined;
132
+ baseURL?: string | undefined;
133
+ defaultHeaders?: Record<string, string> | undefined;
134
+ defaultQuery?: Record<string, string> | undefined;
135
+ model?: string | undefined;
136
+ dimensions?: number | undefined;
137
+ }) {
138
+ this.apiKey = args.apiKey ?? process.env.OPENAI_API_KEY;
139
+ this.baseURL = args.baseURL;
140
+ this.defaultHeaders = args.defaultHeaders;
141
+ this.defaultQuery = args.defaultQuery;
142
+ this.model = args.model ?? DEFAULT_DOCUMENT_EMBEDDING_MODEL;
143
+ this.dimensions = args.dimensions ?? DEFAULT_DOCUMENT_EMBEDDING_DIMENSIONS;
144
+ }
145
+
146
+ readonly model: string;
147
+ readonly dimensions: number;
148
+ private readonly defaultHeaders: Record<string, string> | undefined;
149
+ private readonly defaultQuery: Record<string, string> | undefined;
150
+
151
+ async embedMany(texts: string[]): Promise<number[][]> {
152
+ if (texts.length === 0) return [];
153
+ const out: number[][] = [];
154
+ for (let start = 0; start < texts.length; start += 64) {
155
+ const batch = texts.slice(start, start + 64);
156
+ const response = await this.openai().embeddings.create({
157
+ model: this.model,
158
+ input: batch,
159
+ dimensions: this.dimensions,
160
+ });
161
+ for (const item of response.data) {
162
+ out.push(validateEmbedding(item.embedding, this.dimensions, this.model));
163
+ }
164
+ }
165
+ return out;
166
+ }
167
+
168
+ async embedQuery(text: string): Promise<number[]> {
169
+ const [embedding] = await this.embedMany([text]);
170
+ if (!embedding) {
171
+ throw new Error("Embedding provider returned no query embedding");
172
+ }
173
+ return embedding;
174
+ }
175
+
176
+ private openai(): OpenAI {
177
+ if (!this.apiKey) {
178
+ throw new Error("OpenAI document embeddings require an API key");
179
+ }
180
+ this.client ??= new OpenAI({
181
+ apiKey: this.apiKey,
182
+ ...(this.baseURL ? { baseURL: this.baseURL } : {}),
183
+ ...(this.defaultQuery ? { defaultQuery: this.defaultQuery } : {}),
184
+ ...(this.defaultHeaders ? { defaultHeaders: this.defaultHeaders } : {}),
185
+ });
186
+ return this.client;
187
+ }
188
+ }
189
+
190
+ export class DeterministicEmbeddingProvider implements DocumentEmbedder {
191
+ constructor(
192
+ readonly dimensions = DEFAULT_DOCUMENT_EMBEDDING_DIMENSIONS,
193
+ readonly model = `deterministic-local-${dimensions}`,
194
+ ) {}
195
+
196
+ async embedMany(texts: string[]): Promise<number[][]> {
197
+ return texts.map((text) => deterministicEmbedding(text, this.dimensions));
198
+ }
199
+
200
+ async embedQuery(text: string): Promise<number[]> {
201
+ return deterministicEmbedding(text, this.dimensions);
202
+ }
203
+ }
204
+
205
+ export function createDocumentServices(settings?: Settings, overrides: Partial<DocumentServices> = {}): DocumentServices {
206
+ const dimensions = settings?.documentEmbeddingDimensions ?? DEFAULT_DOCUMENT_EMBEDDING_DIMENSIONS;
207
+ const openAIEmbeddingConfig = documentOpenAIEmbeddingConfig(settings);
208
+ return {
209
+ parser: overrides.parser ?? new LiteParseDocumentParser(),
210
+ chunker: overrides.chunker ?? new RecursiveTextChunker(
211
+ settings?.documentChunkSize ?? DEFAULT_DOCUMENT_CHUNK_SIZE,
212
+ settings?.documentChunkOverlap ?? DEFAULT_DOCUMENT_CHUNK_OVERLAP,
213
+ ),
214
+ embedder: overrides.embedder ?? (
215
+ settings?.documentEmbeddingProvider === "deterministic"
216
+ ? new DeterministicEmbeddingProvider(dimensions, settings.documentEmbeddingModel)
217
+ : new OpenAIEmbeddingProvider({
218
+ ...openAIEmbeddingConfig,
219
+ model: settings?.documentEmbeddingModel ?? DEFAULT_DOCUMENT_EMBEDDING_MODEL,
220
+ dimensions,
221
+ })
222
+ ),
223
+ };
224
+ }
225
+
226
+ export function documentOpenAIEmbeddingConfig(settings?: Settings): {
227
+ apiKey?: string | undefined;
228
+ baseURL?: string | undefined;
229
+ defaultHeaders?: Record<string, string> | undefined;
230
+ defaultQuery?: Record<string, string> | undefined;
231
+ } {
232
+ if (!settings) return {};
233
+ if (settings.documentEmbeddingApiKey || settings.documentEmbeddingBaseUrl) {
234
+ return {
235
+ apiKey: settings.documentEmbeddingApiKey ?? settings.openaiApiKey ?? settings.azureOpenaiApiKey,
236
+ baseURL: settings.documentEmbeddingBaseUrl ?? settings.openaiBaseUrl ?? settings.azureOpenaiBaseUrl,
237
+ };
238
+ }
239
+ if (settings.openaiProvider === "azure") {
240
+ const baseURL = settings.azureOpenaiBaseUrl ?? azureDeploymentBaseUrl(settings);
241
+ return {
242
+ apiKey: settings.azureOpenaiApiKey ?? settings.azureOpenaiAdToken ?? "azure-ad-token",
243
+ baseURL,
244
+ defaultQuery: azureOpenAIDefaultQuery(settings, baseURL),
245
+ defaultHeaders: settings.azureOpenaiAdToken && !settings.azureOpenaiApiKey
246
+ ? { Authorization: `Bearer ${settings.azureOpenaiAdToken}` }
247
+ : undefined,
248
+ };
249
+ }
250
+ return {
251
+ apiKey: settings.openaiApiKey,
252
+ baseURL: settings.openaiBaseUrl,
253
+ };
254
+ }
255
+
256
+ function azureDeploymentBaseUrl(settings: Settings): string {
257
+ const endpoint = settings.azureOpenaiEndpoint?.replace(/\/+$/, "");
258
+ if (!endpoint || !settings.azureOpenaiDeployment) {
259
+ throw new Error("Azure OpenAI endpoint/deployment settings are incomplete");
260
+ }
261
+ return `${endpoint}/openai/deployments/${settings.azureOpenaiDeployment}`;
262
+ }
263
+
264
+ function azureOpenAIDefaultQuery(
265
+ settings: Pick<Settings, "azureOpenaiApiVersion">,
266
+ baseURL: string,
267
+ ): Record<string, string> | undefined {
268
+ if (!settings.azureOpenaiApiVersion) return undefined;
269
+ const normalized = baseURL.replace(/\/+$/, "").toLowerCase();
270
+ if (normalized.endsWith("/openai/v1")) {
271
+ return undefined;
272
+ }
273
+ return { "api-version": settings.azureOpenaiApiVersion };
274
+ }
275
+
276
+ export async function createDocumentBase(db: Database, input: CreateDocumentBaseRequest & { accountId: string; workspaceId: string }): Promise<DocumentBase> {
277
+ return await withRlsContext(db, { accountId: input.accountId, workspaceId: input.workspaceId }, async (scopedDb) => {
278
+ const [row] = await scopedDb.insert(schema.documentBases).values({
279
+ accountId: input.accountId,
280
+ workspaceId: input.workspaceId,
281
+ name: input.name.trim(),
282
+ description: input.description?.trim() || null,
283
+ }).returning();
284
+ if (!row) throw new Error("Failed to create document base");
285
+ return mapDocumentBase(row);
286
+ });
287
+ }
288
+
289
+ export async function listDocumentBases(db: Database, workspaceId: string): Promise<DocumentBase[]> {
290
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
291
+ const rows = await scopedDb.select().from(schema.documentBases)
292
+ .where(eq(schema.documentBases.workspaceId, workspaceId))
293
+ .orderBy(desc(schema.documentBases.createdAt));
294
+ return rows.map(mapDocumentBase);
295
+ });
296
+ }
297
+
298
+ export async function getDocumentBase(db: Database, workspaceId: string, baseId: string): Promise<DocumentBase | null> {
299
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
300
+ const [row] = await scopedDb.select().from(schema.documentBases).where(and(eq(schema.documentBases.workspaceId, workspaceId), eq(schema.documentBases.id, baseId))).limit(1);
301
+ return row ? mapDocumentBase(row) : null;
302
+ });
303
+ }
304
+
305
+ export async function addDocumentToBase(db: Database, input: AddDocumentRequest & { accountId: string; workspaceId: string; baseId: string }): Promise<Document> {
306
+ return await withRlsContext(db, { accountId: input.accountId, workspaceId: input.workspaceId }, async (scopedDb) => {
307
+ const base = await getDocumentBase(scopedDb, input.workspaceId, input.baseId);
308
+ if (!base) throw new Error(`Document base not found: ${input.baseId}`);
309
+ const file = await requireReadyFile(scopedDb, input.workspaceId, input.fileId);
310
+ const now = new Date();
311
+ const [existing] = await scopedDb.select().from(schema.documents)
312
+ .where(and(eq(schema.documents.workspaceId, input.workspaceId), eq(schema.documents.baseId, input.baseId), eq(schema.documents.fileId, input.fileId)))
313
+ .limit(1);
314
+ if (existing) {
315
+ return mapDocument(existing);
316
+ }
317
+ const [row] = await scopedDb.insert(schema.documents).values({
318
+ accountId: input.accountId,
319
+ workspaceId: input.workspaceId,
320
+ baseId: input.baseId,
321
+ fileId: input.fileId,
322
+ status: "queued",
323
+ title: file.filename,
324
+ parser: DEFAULT_DOCUMENT_PARSER,
325
+ updatedAt: now,
326
+ }).returning();
327
+ if (!row) throw new Error("Failed to create document");
328
+ return mapDocument(row);
329
+ });
330
+ }
331
+
332
+ export async function deleteDocumentFromBase(
333
+ db: Database,
334
+ input: { accountId: string; workspaceId: string; baseId: string; documentId: string },
335
+ ): Promise<void> {
336
+ await withRlsContext(db, { accountId: input.accountId, workspaceId: input.workspaceId }, async (scopedDb) => {
337
+ const [document] = await scopedDb.select().from(schema.documents)
338
+ .where(and(eq(schema.documents.workspaceId, input.workspaceId), eq(schema.documents.id, input.documentId)))
339
+ .limit(1);
340
+ if (!document) {
341
+ throw new Error(`Document not found: ${input.documentId}`);
342
+ }
343
+ if (document.baseId !== input.baseId) {
344
+ throw new Error(`Document not found: ${input.documentId}`);
345
+ }
346
+ await scopedDb.delete(schema.documents)
347
+ .where(and(eq(schema.documents.workspaceId, input.workspaceId), eq(schema.documents.id, input.documentId)));
348
+ });
349
+ }
350
+
351
+ export async function listDocuments(db: Database, workspaceId: string, baseId: string): Promise<Document[]> {
352
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
353
+ const rows = await scopedDb.select().from(schema.documents)
354
+ .where(and(eq(schema.documents.workspaceId, workspaceId), eq(schema.documents.baseId, baseId)))
355
+ .orderBy(asc(schema.documents.createdAt));
356
+ return rows.map(mapDocument);
357
+ });
358
+ }
359
+
360
+ export async function getDocument(db: Database, workspaceId: string, documentId: string): Promise<Document | null> {
361
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
362
+ const [row] = await scopedDb.select().from(schema.documents).where(and(eq(schema.documents.workspaceId, workspaceId), eq(schema.documents.id, documentId))).limit(1);
363
+ return row ? mapDocument(row) : null;
364
+ });
365
+ }
366
+
367
+ export async function queueDocumentForReindex(db: Database, workspaceId: string, documentId: string): Promise<Document> {
368
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
369
+ const [row] = await scopedDb.update(schema.documents).set({
370
+ status: "queued",
371
+ error: null,
372
+ updatedAt: new Date(),
373
+ }).where(and(eq(schema.documents.workspaceId, workspaceId), eq(schema.documents.id, documentId))).returning();
374
+ if (!row) throw new Error(`Document not found: ${documentId}`);
375
+ return mapDocument(row);
376
+ });
377
+ }
378
+
379
+ export async function indexDocumentNow(
380
+ db: Database,
381
+ objectStorage: ObjectStorage,
382
+ workspaceId: string,
383
+ documentId: string,
384
+ services: DocumentServices = createDocumentServices(),
385
+ hooks: DocumentIndexHooks = {},
386
+ ): Promise<Document> {
387
+ const [document] = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
388
+ await scopedDb.select().from(schema.documents).where(and(eq(schema.documents.workspaceId, workspaceId), eq(schema.documents.id, documentId))).limit(1)
389
+ );
390
+ if (!document) throw new Error(`Document not found: ${documentId}`);
391
+ const file = await requireReadyFile(db, workspaceId, document.fileId);
392
+ await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
393
+ await scopedDb.update(schema.documents).set({
394
+ status: "indexing",
395
+ parser: services.parser.name,
396
+ error: null,
397
+ updatedAt: new Date(),
398
+ }).where(and(eq(schema.documents.workspaceId, workspaceId), eq(schema.documents.id, documentId)));
399
+ });
400
+ try {
401
+ const bytes = await objectStorage.getFileBytes(file);
402
+ const parsed = await services.parser.parse(bytes, file);
403
+ const chunks = services.chunker.chunk(parsed, file);
404
+ await hooks.beforeEmbed?.({
405
+ accountId: document.accountId,
406
+ workspaceId: document.workspaceId,
407
+ documentId,
408
+ chunkCount: chunks.length,
409
+ });
410
+ const embeddings = await services.embedder.embedMany(chunks.map((chunk) => chunk.text));
411
+ if (embeddings.length !== chunks.length) {
412
+ throw new Error(`Embedding provider returned ${embeddings.length} embeddings for ${chunks.length} chunks`);
413
+ }
414
+ await withWorkspaceRls(db, workspaceId, async (scopedDb) => await scopedDb.transaction(async (tx) => {
415
+ await tx.delete(schema.documentChunks).where(and(eq(schema.documentChunks.workspaceId, workspaceId), eq(schema.documentChunks.documentId, documentId)));
416
+ if (chunks.length > 0) {
417
+ await tx.insert(schema.documentChunks).values(chunks.map((chunk, index) => ({
418
+ accountId: document.accountId,
419
+ workspaceId: document.workspaceId,
420
+ documentId,
421
+ baseId: document.baseId,
422
+ fileId: file.id,
423
+ chunkIndex: index,
424
+ text: chunk.text,
425
+ metadata: chunk.metadata,
426
+ embedding: validateEmbedding(embeddings[index] ?? [], services.embedder.dimensions, services.embedder.model),
427
+ embeddingModel: services.embedder.model,
428
+ })));
429
+ }
430
+ await tx.update(schema.documents).set({
431
+ status: "ready",
432
+ parser: services.parser.name,
433
+ chunkCount: chunks.length,
434
+ error: null,
435
+ updatedAt: new Date(),
436
+ }).where(and(eq(schema.documents.workspaceId, workspaceId), eq(schema.documents.id, documentId)));
437
+ }));
438
+ } catch (error) {
439
+ const [failed] = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
440
+ await scopedDb.update(schema.documents).set({
441
+ status: "failed",
442
+ error: error instanceof Error ? error.message : String(error),
443
+ updatedAt: new Date(),
444
+ }).where(and(eq(schema.documents.workspaceId, workspaceId), eq(schema.documents.id, documentId))).returning()
445
+ );
446
+ if (!failed) throw error;
447
+ return mapDocument(failed);
448
+ }
449
+ const updated = await getDocument(db, workspaceId, documentId);
450
+ if (!updated) throw new Error(`Document disappeared after indexing: ${documentId}`);
451
+ return updated;
452
+ }
453
+
454
+ export async function searchDocuments(
455
+ db: Database,
456
+ input: { workspaceId: string; query: string; baseIds?: string[]; limit?: number },
457
+ services: Pick<DocumentServices, "embedder"> = createDocumentServices(),
458
+ ): Promise<DocumentSearchResult[]> {
459
+ const limit = Math.min(Math.max(input.limit ?? 5, 1), 20);
460
+ const queryEmbedding = await services.embedder.embedQuery(input.query);
461
+ validateEmbedding(queryEmbedding, services.embedder.dimensions, services.embedder.model);
462
+ const distance = sql<number>`${schema.documentChunks.embedding} <=> ${vectorLiteral(queryEmbedding)}::vector`;
463
+ const rows = await withWorkspaceRls(db, input.workspaceId, async (scopedDb) =>
464
+ await scopedDb.select({
465
+ chunkId: schema.documentChunks.id,
466
+ documentId: schema.documentChunks.documentId,
467
+ baseId: schema.documentChunks.baseId,
468
+ fileId: schema.documentChunks.fileId,
469
+ title: schema.documents.title,
470
+ text: schema.documentChunks.text,
471
+ chunkIndex: schema.documentChunks.chunkIndex,
472
+ metadata: schema.documentChunks.metadata,
473
+ distance,
474
+ }).from(schema.documentChunks)
475
+ .innerJoin(schema.documents, eq(schema.documentChunks.documentId, schema.documents.id))
476
+ .where(and(
477
+ eq(schema.documents.status, "ready"),
478
+ eq(schema.documentChunks.workspaceId, input.workspaceId),
479
+ eq(schema.documentChunks.embeddingModel, services.embedder.model),
480
+ input.baseIds && input.baseIds.length > 0 ? inArray(schema.documentChunks.baseId, input.baseIds) : undefined,
481
+ ))
482
+ .orderBy(distance)
483
+ .limit(limit)
484
+ );
485
+ return rows.map((row) => ({
486
+ chunkId: row.chunkId,
487
+ workspaceId: input.workspaceId,
488
+ documentId: row.documentId,
489
+ baseId: row.baseId,
490
+ fileId: row.fileId,
491
+ title: row.title,
492
+ text: row.text,
493
+ score: 1 / (1 + Number(row.distance)),
494
+ chunkIndex: row.chunkIndex,
495
+ metadata: row.metadata,
496
+ }));
497
+ }
498
+
499
+ export async function getDocumentChunk(db: Database, workspaceId: string, chunkId: string): Promise<DocumentSearchResult | null> {
500
+ const [row] = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
501
+ await scopedDb.select({
502
+ chunkId: schema.documentChunks.id,
503
+ documentId: schema.documentChunks.documentId,
504
+ baseId: schema.documentChunks.baseId,
505
+ fileId: schema.documentChunks.fileId,
506
+ title: schema.documents.title,
507
+ text: schema.documentChunks.text,
508
+ chunkIndex: schema.documentChunks.chunkIndex,
509
+ metadata: schema.documentChunks.metadata,
510
+ }).from(schema.documentChunks)
511
+ .innerJoin(schema.documents, eq(schema.documentChunks.documentId, schema.documents.id))
512
+ .where(and(eq(schema.documentChunks.workspaceId, workspaceId), eq(schema.documentChunks.id, chunkId), eq(schema.documents.status, "ready")))
513
+ .limit(1)
514
+ );
515
+ if (!row) return null;
516
+ return {
517
+ chunkId: row.chunkId,
518
+ workspaceId,
519
+ documentId: row.documentId,
520
+ baseId: row.baseId,
521
+ fileId: row.fileId,
522
+ title: row.title,
523
+ text: row.text,
524
+ score: 1,
525
+ chunkIndex: row.chunkIndex,
526
+ metadata: row.metadata,
527
+ };
528
+ }
529
+
530
+ export async function parseDocumentBytes(bytes: Uint8Array, file: FileAsset, parser: DocumentParser = new LiteParseDocumentParser()): Promise<ParsedDocument> {
531
+ return await parser.parse(bytes, file);
532
+ }
533
+
534
+ export function chunkText(text: string, maxChars = DEFAULT_DOCUMENT_CHUNK_SIZE, overlapChars = DEFAULT_DOCUMENT_CHUNK_OVERLAP): string[] {
535
+ if (overlapChars >= maxChars) {
536
+ throw new Error("chunk overlap must be smaller than chunk size");
537
+ }
538
+ const normalized = text.replace(/\r\n/g, "\n").replace(/[ \t]+/g, " ").trim();
539
+ if (!normalized) return [];
540
+ const paragraphs = normalized.split(/\n{2,}/).map((part) => part.replace(/\s+/g, " ").trim()).filter(Boolean);
541
+ const chunks: string[] = [];
542
+ let current = "";
543
+ for (const paragraph of paragraphs.length > 0 ? paragraphs : [normalized.replace(/\s+/g, " ")]) {
544
+ for (const part of splitOversizedText(paragraph, maxChars)) {
545
+ if (!current) {
546
+ current = part;
547
+ } else if (current.length + 1 + part.length <= maxChars) {
548
+ current = `${current} ${part}`;
549
+ } else {
550
+ chunks.push(current);
551
+ current = withOverlap(current, overlapChars, part, maxChars);
552
+ }
553
+ }
554
+ }
555
+ if (current) chunks.push(current);
556
+ return chunks.map((chunk) => chunk.trim()).filter(Boolean);
557
+ }
558
+
559
+ export function deterministicEmbedding(text: string, dimensions = DEFAULT_DOCUMENT_EMBEDDING_DIMENSIONS): number[] {
560
+ const values = new Array(dimensions).fill(0);
561
+ const tokens = text.toLowerCase().match(/[\p{L}\p{N}_-]+/gu) ?? [];
562
+ for (const token of tokens) {
563
+ let hash = 2166136261;
564
+ for (const char of token) {
565
+ hash ^= char.codePointAt(0) ?? 0;
566
+ hash = Math.imul(hash, 16777619);
567
+ }
568
+ values[Math.abs(hash) % dimensions] += 1;
569
+ }
570
+ const norm = Math.hypot(...values) || 1;
571
+ return values.map((value) => Number((value / norm).toFixed(6)));
572
+ }
573
+
574
+ async function requireReadyFile(db: Database, workspaceId: string, fileId: string): Promise<FileAsset> {
575
+ const file = await requireFile(db, workspaceId, fileId);
576
+ if (file.status !== "ready") {
577
+ throw new Error(`File ${fileId} is ${file.status}`);
578
+ }
579
+ return file;
580
+ }
581
+
582
+ function validateEmbedding(values: number[], dimensions: number, model: string): number[] {
583
+ if (values.length !== dimensions) {
584
+ throw new Error(`Embedding model ${model} returned ${values.length} dimensions; expected ${dimensions}`);
585
+ }
586
+ if (values.some((value) => !Number.isFinite(value))) {
587
+ throw new Error(`Embedding model ${model} returned non-finite values`);
588
+ }
589
+ return values;
590
+ }
591
+
592
+ function isTextLike(file: FileAsset): boolean {
593
+ const contentType = file.contentType.toLowerCase();
594
+ const filename = file.filename.toLowerCase();
595
+ return contentType.startsWith("text/")
596
+ || contentType === "application/json"
597
+ || contentType === "application/xml"
598
+ || contentType === "application/x-yaml"
599
+ || filename.endsWith(".md")
600
+ || filename.endsWith(".markdown")
601
+ || filename.endsWith(".json")
602
+ || filename.endsWith(".yaml")
603
+ || filename.endsWith(".yml")
604
+ || filename.endsWith(".csv")
605
+ || filename.endsWith(".tsv")
606
+ || filename.endsWith(".xml");
607
+ }
608
+
609
+ function splitOversizedText(text: string, maxChars: number): string[] {
610
+ if (text.length <= maxChars) return [text];
611
+ const out: string[] = [];
612
+ let remaining = text;
613
+ while (remaining.length > maxChars) {
614
+ const window = remaining.slice(0, maxChars + 1);
615
+ const breakAt = Math.max(
616
+ window.lastIndexOf(". "),
617
+ window.lastIndexOf("? "),
618
+ window.lastIndexOf("! "),
619
+ window.lastIndexOf("; "),
620
+ window.lastIndexOf(", "),
621
+ window.lastIndexOf(" "),
622
+ );
623
+ const end = breakAt > Math.floor(maxChars * 0.5) ? breakAt + 1 : maxChars;
624
+ out.push(remaining.slice(0, end).trim());
625
+ remaining = remaining.slice(end).trim();
626
+ }
627
+ if (remaining) out.push(remaining);
628
+ return out;
629
+ }
630
+
631
+ function withOverlap(previous: string, overlapChars: number, next: string, maxChars: number): string {
632
+ if (overlapChars <= 0) return next;
633
+ const overlap = previous.slice(Math.max(0, previous.length - overlapChars)).replace(/^\S+\s+/, "").trim();
634
+ const candidate = overlap ? `${overlap} ${next}` : next;
635
+ return candidate.length <= maxChars ? candidate : next;
636
+ }
637
+
638
+ function vectorLiteral(values: number[]): string {
639
+ return `[${values.join(",")}]`;
640
+ }
641
+
642
+ function mapDocumentBase(row: typeof schema.documentBases.$inferSelect): DocumentBase {
643
+ return {
644
+ id: row.id,
645
+ workspaceId: row.workspaceId,
646
+ name: row.name,
647
+ description: row.description,
648
+ createdAt: row.createdAt.toISOString(),
649
+ updatedAt: row.updatedAt.toISOString(),
650
+ };
651
+ }
652
+
653
+ function mapDocument(row: typeof schema.documents.$inferSelect): Document {
654
+ return {
655
+ id: row.id,
656
+ workspaceId: row.workspaceId,
657
+ baseId: row.baseId,
658
+ fileId: row.fileId,
659
+ status: row.status as Document["status"],
660
+ title: row.title,
661
+ parser: row.parser,
662
+ chunkCount: row.chunkCount,
663
+ error: row.error,
664
+ createdAt: row.createdAt.toISOString(),
665
+ updatedAt: row.updatedAt.toISOString(),
666
+ };
667
+ }