@opengeni/documents 0.5.18 → 0.5.32

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 CHANGED
@@ -12,7 +12,9 @@ import type {
12
12
  DocumentStatus,
13
13
  DocumentVisibility,
14
14
  FileAsset,
15
+ IndexedDocumentSummary,
15
16
  KnowledgeSourceKind,
17
+ ListIndexedDocumentsResponse,
16
18
  } from "@opengeni/contracts";
17
19
  import {
18
20
  requireFile,
@@ -25,9 +27,9 @@ import {
25
27
  } from "@opengeni/db";
26
28
  import * as schema from "@opengeni/db/schema";
27
29
  import type { ObjectStorage } from "@opengeni/storage";
28
- import { LiteParse } from "@llamaindex/liteparse";
29
- import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm";
30
- import OpenAI from "openai";
30
+ import { createHash } from "node:crypto";
31
+ import { and, asc, desc, eq, gt, inArray, isNotNull, or, sql, type SQL } from "drizzle-orm";
32
+ import type OpenAI from "openai";
31
33
 
32
34
  export const DEFAULT_DOCUMENT_PARSER = "liteparse";
33
35
  export const DEFAULT_DOCUMENT_EMBEDDING_MODEL = "text-embedding-3-large";
@@ -39,6 +41,7 @@ export const DEFAULT_DOCUMENT_CURATION_MODEL = "gpt-4o-mini";
39
41
  // classify without paying for a full-document prompt on every drop.
40
42
  export const DOCUMENT_CURATION_MAX_INPUT_CHARS = 24_000;
41
43
  export const DOCUMENT_AUTHORITY_SUBJECT_MAX_BYTES = 1024;
44
+ export const DOCUMENT_INDEX_CHECKPOINT_MAX_CHARS = 1_024;
42
45
  // A base move is applied automatically only at or above this curator
43
46
  // confidence; below it the suggestion is surfaced for human review instead.
44
47
  export const DOCUMENT_CURATION_AUTO_FILE_CONFIDENCE = 0.75;
@@ -186,6 +189,15 @@ export type EffectiveDocumentSearchInput = Omit<DocumentSearchInput, "access"> &
186
189
  surface: "human" | "agent";
187
190
  };
188
191
 
192
+ export type ListEffectiveIndexedDocumentsInput = {
193
+ accountId: string;
194
+ workspaceId: string;
195
+ /** Immutable human subject accepted for the logical request/turn. */
196
+ initiatingSubjectId: string;
197
+ checkpoint?: string | undefined;
198
+ limit?: number | undefined;
199
+ };
200
+
189
201
  export type DocumentIndexHooks = {
190
202
  beforeEmbed?: (input: {
191
203
  accountId: string;
@@ -218,6 +230,7 @@ export class LiteParseDocumentParser implements DocumentParser {
218
230
 
219
231
  private async parseWithLiteParse(bytes: Uint8Array): Promise<string> {
220
232
  return await this.enqueueParse(async () => {
233
+ const { LiteParse } = await import("@llamaindex/liteparse");
221
234
  const parser = new LiteParse({ ocrEnabled: true, numWorkers: 1 });
222
235
  const result = await parser.parse(Buffer.from(bytes), true);
223
236
  const text = typeof result?.text === "string" ? result.text : "";
@@ -264,7 +277,7 @@ export class RecursiveTextChunker implements DocumentChunker {
264
277
  }
265
278
 
266
279
  export class OpenAIEmbeddingProvider implements DocumentEmbedder {
267
- private client: OpenAI | null = null;
280
+ private clientPromise: Promise<OpenAI> | null = null;
268
281
  private readonly apiKey: string | undefined;
269
282
  private readonly baseURL: string | undefined;
270
283
 
@@ -294,7 +307,9 @@ export class OpenAIEmbeddingProvider implements DocumentEmbedder {
294
307
  const out: number[][] = [];
295
308
  for (let start = 0; start < texts.length; start += 64) {
296
309
  const batch = texts.slice(start, start + 64);
297
- const response = await this.openai().embeddings.create({
310
+ const response = await (
311
+ await this.openai()
312
+ ).embeddings.create({
298
313
  model: this.model,
299
314
  input: batch,
300
315
  dimensions: this.dimensions,
@@ -314,17 +329,20 @@ export class OpenAIEmbeddingProvider implements DocumentEmbedder {
314
329
  return embedding;
315
330
  }
316
331
 
317
- private openai(): OpenAI {
332
+ private async openai(): Promise<OpenAI> {
318
333
  if (!this.apiKey) {
319
334
  throw new Error("OpenAI document embeddings require an API key");
320
335
  }
321
- this.client ??= new OpenAI({
322
- apiKey: this.apiKey,
323
- ...(this.baseURL ? { baseURL: this.baseURL } : {}),
324
- ...(this.defaultQuery ? { defaultQuery: this.defaultQuery } : {}),
325
- ...(this.defaultHeaders ? { defaultHeaders: this.defaultHeaders } : {}),
326
- });
327
- return this.client;
336
+ this.clientPromise ??= import("openai").then(
337
+ ({ default: OpenAIClient }) =>
338
+ new OpenAIClient({
339
+ apiKey: this.apiKey,
340
+ ...(this.baseURL ? { baseURL: this.baseURL } : {}),
341
+ ...(this.defaultQuery ? { defaultQuery: this.defaultQuery } : {}),
342
+ ...(this.defaultHeaders ? { defaultHeaders: this.defaultHeaders } : {}),
343
+ }),
344
+ );
345
+ return await this.clientPromise;
328
346
  }
329
347
  }
330
348
 
@@ -410,7 +428,7 @@ const CURATION_SYSTEM_PROMPT = [
410
428
  ].join(" ");
411
429
 
412
430
  export class OpenAICurationProvider implements DocumentCurator {
413
- private client: OpenAI | null = null;
431
+ private clientPromise: Promise<OpenAI> | null = null;
414
432
  private readonly apiKey: string | undefined;
415
433
  private readonly baseURL: string | undefined;
416
434
  private readonly defaultHeaders: Record<string, string> | undefined;
@@ -432,7 +450,9 @@ export class OpenAICurationProvider implements DocumentCurator {
432
450
  }
433
451
 
434
452
  async curate(input: DocumentCurationInput): Promise<DocumentCurationOutcome> {
435
- const response = await this.openai().chat.completions.create({
453
+ const response = await (
454
+ await this.openai()
455
+ ).chat.completions.create({
436
456
  model: this.model,
437
457
  response_format: { type: "json_object" },
438
458
  messages: [
@@ -455,17 +475,20 @@ export class OpenAICurationProvider implements DocumentCurator {
455
475
  return parseCurationOutcome(raw, input.bases);
456
476
  }
457
477
 
458
- private openai(): OpenAI {
478
+ private async openai(): Promise<OpenAI> {
459
479
  if (!this.apiKey) {
460
480
  throw new Error("OpenAI document curation requires an API key");
461
481
  }
462
- this.client ??= new OpenAI({
463
- apiKey: this.apiKey,
464
- ...(this.baseURL ? { baseURL: this.baseURL } : {}),
465
- ...(this.defaultQuery ? { defaultQuery: this.defaultQuery } : {}),
466
- ...(this.defaultHeaders ? { defaultHeaders: this.defaultHeaders } : {}),
467
- });
468
- return this.client;
482
+ this.clientPromise ??= import("openai").then(
483
+ ({ default: OpenAIClient }) =>
484
+ new OpenAIClient({
485
+ apiKey: this.apiKey,
486
+ ...(this.baseURL ? { baseURL: this.baseURL } : {}),
487
+ ...(this.defaultQuery ? { defaultQuery: this.defaultQuery } : {}),
488
+ ...(this.defaultHeaders ? { defaultHeaders: this.defaultHeaders } : {}),
489
+ }),
490
+ );
491
+ return await this.clientPromise;
469
492
  }
470
493
  }
471
494
 
@@ -897,6 +920,7 @@ export async function addDocumentToBase(
897
920
  organizationAuthorityGranted?: boolean | undefined;
898
921
  curationStatus?: DocumentCurationStatus | undefined;
899
922
  access?: DocumentAccessFilter | undefined;
923
+ knowledgeSourceIdentity?: string | null | undefined;
900
924
  },
901
925
  ): Promise<Document> {
902
926
  return await withRlsContext(
@@ -924,6 +948,10 @@ export async function addDocumentToBase(
924
948
  const base = await getDocumentBase(scopedDb, input.workspaceId, input.baseId);
925
949
  if (!base) throw new Error(`Document base not found: ${input.baseId}`);
926
950
  const file = await requireReadyFile(scopedDb, input.workspaceId, input.fileId);
951
+ const knowledgeSourceIdentity = cleanString(input.knowledgeSourceIdentity ?? null);
952
+ if (knowledgeSourceIdentity && knowledgeSourceIdentity.length > 512) {
953
+ throw new Error("knowledge source document identity exceeds 512 characters");
954
+ }
927
955
  const now = new Date();
928
956
  const [existing] = await scopedDb
929
957
  .select()
@@ -931,12 +959,19 @@ export async function addDocumentToBase(
931
959
  .where(
932
960
  and(
933
961
  eq(schema.documents.workspaceId, input.workspaceId),
934
- eq(schema.documents.baseId, input.baseId),
935
- eq(schema.documents.fileId, input.fileId),
962
+ ...(knowledgeSourceIdentity
963
+ ? [eq(schema.documents.knowledgeSourceIdentity, knowledgeSourceIdentity)]
964
+ : [
965
+ eq(schema.documents.baseId, input.baseId),
966
+ eq(schema.documents.fileId, input.fileId),
967
+ ]),
936
968
  ),
937
969
  )
938
970
  .limit(1);
939
971
  if (existing) {
972
+ if (knowledgeSourceIdentity && existing.fileId !== input.fileId) {
973
+ throw new Error("knowledge source document identity is bound to different content");
974
+ }
940
975
  if (!documentMatchesAccess(existing, input.workspaceId, input.access)) {
941
976
  throw new Error(`Document not found: ${existing.id}`);
942
977
  }
@@ -992,6 +1027,7 @@ export async function addDocumentToBase(
992
1027
  sourceCreatedAt: parseOptionalDate(input.sourceCreatedAt),
993
1028
  sourceUpdatedAt: parseOptionalDate(input.sourceUpdatedAt),
994
1029
  sourceVersion: cleanString(input.sourceVersion) ?? null,
1030
+ knowledgeSourceIdentity,
995
1031
  aclTags: cleanStringArray(input.aclTags),
996
1032
  authorityKind: authority.kind,
997
1033
  authorityWorkspaceId: authority.workspaceId,
@@ -1060,7 +1096,9 @@ export async function moveDocumentToBase(
1060
1096
  and(
1061
1097
  eq(schema.documents.workspaceId, input.workspaceId),
1062
1098
  eq(schema.documents.baseId, targetBaseId),
1063
- eq(schema.documents.fileId, row.fileId),
1099
+ ...(row.knowledgeSourceIdentity
1100
+ ? [eq(schema.documents.knowledgeSourceIdentity, row.knowledgeSourceIdentity)]
1101
+ : [eq(schema.documents.fileId, row.fileId)]),
1064
1102
  ),
1065
1103
  )
1066
1104
  .limit(1);
@@ -1178,6 +1216,151 @@ export async function listDocuments(
1178
1216
  });
1179
1217
  }
1180
1218
 
1219
+ /**
1220
+ * List newly ready documents in the same effective scope used by agent
1221
+ * retrieval. The opaque checkpoint is bound to the account, requesting
1222
+ * workspace, and immutable initiating subject, so it cannot be reused across
1223
+ * scheduled-task authority boundaries.
1224
+ */
1225
+ export async function listEffectiveIndexedDocuments(
1226
+ db: Database,
1227
+ input: ListEffectiveIndexedDocumentsInput,
1228
+ ): Promise<ListIndexedDocumentsResponse> {
1229
+ const initiatingSubjectId = canonicalEffectiveDocumentSubject(input.initiatingSubjectId);
1230
+ const limit = input.limit ?? 50;
1231
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
1232
+ throw new Error("indexed document list limit must be between 1 and 100");
1233
+ }
1234
+ const afterSequence = input.checkpoint
1235
+ ? decodeDocumentIndexCheckpoint(input.checkpoint, {
1236
+ accountId: input.accountId,
1237
+ workspaceId: input.workspaceId,
1238
+ initiatingSubjectId,
1239
+ })
1240
+ : 0n;
1241
+ const access: DocumentAccessFilter = {
1242
+ agentOnly: true,
1243
+ viewerSubjectId: initiatingSubjectId,
1244
+ };
1245
+ const rows = await withDocumentAccountRls(
1246
+ db,
1247
+ input.accountId,
1248
+ input.workspaceId,
1249
+ access,
1250
+ async (scopedDb) =>
1251
+ await scopedDb
1252
+ .select()
1253
+ .from(schema.documents)
1254
+ .where(
1255
+ and(
1256
+ eq(schema.documents.accountId, input.accountId),
1257
+ eq(schema.documents.status, "ready"),
1258
+ isNotNull(schema.documents.indexSequence),
1259
+ gt(schema.documents.indexSequence, afterSequence),
1260
+ ...documentAccessConditions(input.workspaceId, access),
1261
+ ),
1262
+ )
1263
+ .orderBy(asc(schema.documents.indexSequence))
1264
+ .limit(limit + 1),
1265
+ );
1266
+ const hasMore = rows.length > limit;
1267
+ const pageRows = rows.slice(0, limit);
1268
+ const nextSequence = pageRows.at(-1)?.indexSequence ?? afterSequence;
1269
+ if (nextSequence === null) {
1270
+ throw new Error("ready document is missing its index sequence");
1271
+ }
1272
+ return {
1273
+ documents: pageRows.map(mapIndexedDocumentSummary),
1274
+ nextCheckpoint: encodeDocumentIndexCheckpoint({
1275
+ accountId: input.accountId,
1276
+ workspaceId: input.workspaceId,
1277
+ initiatingSubjectId,
1278
+ sequence: nextSequence,
1279
+ }),
1280
+ hasMore,
1281
+ };
1282
+ }
1283
+
1284
+ export function encodeDocumentIndexCheckpoint(input: {
1285
+ accountId: string;
1286
+ workspaceId: string;
1287
+ initiatingSubjectId: string;
1288
+ sequence: bigint;
1289
+ }): string {
1290
+ if (input.sequence < 0n) throw new Error("document index checkpoint sequence is invalid");
1291
+ return Buffer.from(
1292
+ JSON.stringify({
1293
+ v: 1,
1294
+ s: documentIndexCheckpointScope(input),
1295
+ q: input.sequence.toString(),
1296
+ }),
1297
+ "utf8",
1298
+ ).toString("base64url");
1299
+ }
1300
+
1301
+ export function decodeDocumentIndexCheckpoint(
1302
+ value: string,
1303
+ scope: { accountId: string; workspaceId: string; initiatingSubjectId: string },
1304
+ ): bigint {
1305
+ try {
1306
+ if (!value || value.length > DOCUMENT_INDEX_CHECKPOINT_MAX_CHARS) {
1307
+ throw new Error("checkpoint length");
1308
+ }
1309
+ const bytes = Buffer.from(value, "base64url");
1310
+ if (bytes.toString("base64url") !== value) throw new Error("checkpoint encoding");
1311
+ const parsed = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
1312
+ if (
1313
+ Object.keys(parsed).sort().join(",") !== "q,s,v" ||
1314
+ parsed.v !== 1 ||
1315
+ typeof parsed.s !== "string" ||
1316
+ typeof parsed.q !== "string" ||
1317
+ !/^(0|[1-9][0-9]*)$/.test(parsed.q)
1318
+ ) {
1319
+ throw new Error("checkpoint payload");
1320
+ }
1321
+ if (parsed.s !== documentIndexCheckpointScope(scope)) {
1322
+ throw new Error("document index checkpoint belongs to a different workspace or subject");
1323
+ }
1324
+ return BigInt(parsed.q);
1325
+ } catch (error) {
1326
+ if (
1327
+ error instanceof Error &&
1328
+ error.message === "document index checkpoint belongs to a different workspace or subject"
1329
+ ) {
1330
+ throw error;
1331
+ }
1332
+ throw new Error("invalid document index checkpoint", { cause: error });
1333
+ }
1334
+ }
1335
+
1336
+ function documentIndexCheckpointScope(input: {
1337
+ accountId: string;
1338
+ workspaceId: string;
1339
+ initiatingSubjectId: string;
1340
+ }): string {
1341
+ return createHash("sha256")
1342
+ .update("opengeni:document-index-checkpoint:v1\0")
1343
+ .update(input.accountId)
1344
+ .update("\0")
1345
+ .update(input.workspaceId)
1346
+ .update("\0")
1347
+ .update(canonicalEffectiveDocumentSubject(input.initiatingSubjectId))
1348
+ .digest("hex");
1349
+ }
1350
+
1351
+ function canonicalEffectiveDocumentSubject(value: string): string {
1352
+ const subjectId = cleanString(value);
1353
+ if (!subjectId || subjectId !== value) {
1354
+ throw new Error("effective document retrieval requires an initiating subject");
1355
+ }
1356
+ if (new TextEncoder().encode(subjectId).byteLength > DOCUMENT_AUTHORITY_SUBJECT_MAX_BYTES) {
1357
+ throw new Error(
1358
+ `effective document initiating subject exceeds ${DOCUMENT_AUTHORITY_SUBJECT_MAX_BYTES} UTF-8 bytes`,
1359
+ );
1360
+ }
1361
+ return subjectId;
1362
+ }
1363
+
1181
1364
  export async function getDocument(
1182
1365
  db: Database,
1183
1366
  workspaceId: string,
@@ -2317,6 +2500,43 @@ function mapDocument(row: typeof schema.documents.$inferSelect): Document {
2317
2500
  };
2318
2501
  }
2319
2502
 
2503
+ function mapIndexedDocumentSummary(
2504
+ row: typeof schema.documents.$inferSelect,
2505
+ ): IndexedDocumentSummary {
2506
+ if (row.indexSequence === null || row.indexedAt === null) {
2507
+ throw new Error(`Ready document is missing index completion metadata: ${row.id}`);
2508
+ }
2509
+ return {
2510
+ id: row.id,
2511
+ title: row.title,
2512
+ parser: row.parser,
2513
+ chunkCount: row.chunkCount,
2514
+ indexedAt: row.indexedAt.toISOString(),
2515
+ summary: row.summary,
2516
+ topics: cleanStringArray(row.topics),
2517
+ source: {
2518
+ kind: normalizeKnowledgeSourceKind(row.sourceKind),
2519
+ uri: row.sourceUri,
2520
+ externalId: row.sourceExternalId,
2521
+ title: row.sourceTitle,
2522
+ author: row.sourceAuthor,
2523
+ createdAt: row.sourceCreatedAt?.toISOString() ?? null,
2524
+ updatedAt: row.sourceUpdatedAt?.toISOString() ?? null,
2525
+ version: row.sourceVersion,
2526
+ },
2527
+ provenance: {
2528
+ ingestionWorkspaceId: row.workspaceId,
2529
+ baseId: row.baseId,
2530
+ fileId: row.fileId,
2531
+ authorityKind: normalizeDocumentAuthorityKind(row.authorityKind),
2532
+ authorityWorkspaceId: row.authorityWorkspaceId,
2533
+ authoritySubjectId: row.authoritySubjectId,
2534
+ createdBy: row.createdBy,
2535
+ createdAt: row.createdAt.toISOString(),
2536
+ },
2537
+ };
2538
+ }
2539
+
2320
2540
  function normalizeDocumentAuthorityKind(value: string): DocumentAuthorityKind {
2321
2541
  switch (value) {
2322
2542
  case "organization":