@opengeni/documents 0.5.18 → 0.5.38

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,12 @@ import type {
12
12
  DocumentStatus,
13
13
  DocumentVisibility,
14
14
  FileAsset,
15
+ IndexedDocumentSummary,
16
+ KnowledgeBrowseResponse,
17
+ KnowledgeRecord,
18
+ KnowledgeSearchResponse,
15
19
  KnowledgeSourceKind,
20
+ ListIndexedDocumentsResponse,
16
21
  } from "@opengeni/contracts";
17
22
  import {
18
23
  requireFile,
@@ -25,9 +30,13 @@ import {
25
30
  } from "@opengeni/db";
26
31
  import * as schema from "@opengeni/db/schema";
27
32
  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";
33
+ import { createHash } from "node:crypto";
34
+ import { and, asc, desc, eq, gt, inArray, isNotNull, or, sql, type SQL } from "drizzle-orm";
35
+ import type OpenAI from "openai";
36
+ import { KNOWLEDGE_BROWSE_CURSOR_MAX_CHARS } from "@opengeni/contracts";
37
+ import { projectKnowledgeRecord } from "./knowledge-projection";
38
+
39
+ export { projectKnowledgeRecord } from "./knowledge-projection";
31
40
 
32
41
  export const DEFAULT_DOCUMENT_PARSER = "liteparse";
33
42
  export const DEFAULT_DOCUMENT_EMBEDDING_MODEL = "text-embedding-3-large";
@@ -39,6 +48,7 @@ export const DEFAULT_DOCUMENT_CURATION_MODEL = "gpt-4o-mini";
39
48
  // classify without paying for a full-document prompt on every drop.
40
49
  export const DOCUMENT_CURATION_MAX_INPUT_CHARS = 24_000;
41
50
  export const DOCUMENT_AUTHORITY_SUBJECT_MAX_BYTES = 1024;
51
+ export const DOCUMENT_INDEX_CHECKPOINT_MAX_CHARS = 1_024;
42
52
  // A base move is applied automatically only at or above this curator
43
53
  // confidence; below it the suggestion is surfaced for human review instead.
44
54
  export const DOCUMENT_CURATION_AUTO_FILE_CONFIDENCE = 0.75;
@@ -186,6 +196,28 @@ export type EffectiveDocumentSearchInput = Omit<DocumentSearchInput, "access"> &
186
196
  surface: "human" | "agent";
187
197
  };
188
198
 
199
+ export type ListEffectiveIndexedDocumentsInput = {
200
+ accountId: string;
201
+ workspaceId: string;
202
+ /** Immutable human subject accepted for the logical request/turn. */
203
+ initiatingSubjectId: string;
204
+ checkpoint?: string | undefined;
205
+ limit?: number | undefined;
206
+ };
207
+
208
+ export type EffectiveKnowledgeBrowseInput = {
209
+ accountId: string;
210
+ workspaceId: string;
211
+ /** Immutable human subject accepted for the logical request/turn. */
212
+ initiatingSubjectId: string;
213
+ /** Omit to browse top-level documents; pass a document record id for chunks. */
214
+ parentId?: string | undefined;
215
+ topic?: string | undefined;
216
+ sourceKinds?: KnowledgeSourceKind[] | undefined;
217
+ cursor?: string | undefined;
218
+ limit?: number | undefined;
219
+ };
220
+
189
221
  export type DocumentIndexHooks = {
190
222
  beforeEmbed?: (input: {
191
223
  accountId: string;
@@ -218,6 +250,7 @@ export class LiteParseDocumentParser implements DocumentParser {
218
250
 
219
251
  private async parseWithLiteParse(bytes: Uint8Array): Promise<string> {
220
252
  return await this.enqueueParse(async () => {
253
+ const { LiteParse } = await import("@llamaindex/liteparse");
221
254
  const parser = new LiteParse({ ocrEnabled: true, numWorkers: 1 });
222
255
  const result = await parser.parse(Buffer.from(bytes), true);
223
256
  const text = typeof result?.text === "string" ? result.text : "";
@@ -264,7 +297,7 @@ export class RecursiveTextChunker implements DocumentChunker {
264
297
  }
265
298
 
266
299
  export class OpenAIEmbeddingProvider implements DocumentEmbedder {
267
- private client: OpenAI | null = null;
300
+ private clientPromise: Promise<OpenAI> | null = null;
268
301
  private readonly apiKey: string | undefined;
269
302
  private readonly baseURL: string | undefined;
270
303
 
@@ -294,7 +327,9 @@ export class OpenAIEmbeddingProvider implements DocumentEmbedder {
294
327
  const out: number[][] = [];
295
328
  for (let start = 0; start < texts.length; start += 64) {
296
329
  const batch = texts.slice(start, start + 64);
297
- const response = await this.openai().embeddings.create({
330
+ const response = await (
331
+ await this.openai()
332
+ ).embeddings.create({
298
333
  model: this.model,
299
334
  input: batch,
300
335
  dimensions: this.dimensions,
@@ -314,17 +349,20 @@ export class OpenAIEmbeddingProvider implements DocumentEmbedder {
314
349
  return embedding;
315
350
  }
316
351
 
317
- private openai(): OpenAI {
352
+ private async openai(): Promise<OpenAI> {
318
353
  if (!this.apiKey) {
319
354
  throw new Error("OpenAI document embeddings require an API key");
320
355
  }
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;
356
+ this.clientPromise ??= import("openai").then(
357
+ ({ default: OpenAIClient }) =>
358
+ new OpenAIClient({
359
+ apiKey: this.apiKey,
360
+ ...(this.baseURL ? { baseURL: this.baseURL } : {}),
361
+ ...(this.defaultQuery ? { defaultQuery: this.defaultQuery } : {}),
362
+ ...(this.defaultHeaders ? { defaultHeaders: this.defaultHeaders } : {}),
363
+ }),
364
+ );
365
+ return await this.clientPromise;
328
366
  }
329
367
  }
330
368
 
@@ -410,7 +448,7 @@ const CURATION_SYSTEM_PROMPT = [
410
448
  ].join(" ");
411
449
 
412
450
  export class OpenAICurationProvider implements DocumentCurator {
413
- private client: OpenAI | null = null;
451
+ private clientPromise: Promise<OpenAI> | null = null;
414
452
  private readonly apiKey: string | undefined;
415
453
  private readonly baseURL: string | undefined;
416
454
  private readonly defaultHeaders: Record<string, string> | undefined;
@@ -432,7 +470,9 @@ export class OpenAICurationProvider implements DocumentCurator {
432
470
  }
433
471
 
434
472
  async curate(input: DocumentCurationInput): Promise<DocumentCurationOutcome> {
435
- const response = await this.openai().chat.completions.create({
473
+ const response = await (
474
+ await this.openai()
475
+ ).chat.completions.create({
436
476
  model: this.model,
437
477
  response_format: { type: "json_object" },
438
478
  messages: [
@@ -455,17 +495,20 @@ export class OpenAICurationProvider implements DocumentCurator {
455
495
  return parseCurationOutcome(raw, input.bases);
456
496
  }
457
497
 
458
- private openai(): OpenAI {
498
+ private async openai(): Promise<OpenAI> {
459
499
  if (!this.apiKey) {
460
500
  throw new Error("OpenAI document curation requires an API key");
461
501
  }
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;
502
+ this.clientPromise ??= import("openai").then(
503
+ ({ default: OpenAIClient }) =>
504
+ new OpenAIClient({
505
+ apiKey: this.apiKey,
506
+ ...(this.baseURL ? { baseURL: this.baseURL } : {}),
507
+ ...(this.defaultQuery ? { defaultQuery: this.defaultQuery } : {}),
508
+ ...(this.defaultHeaders ? { defaultHeaders: this.defaultHeaders } : {}),
509
+ }),
510
+ );
511
+ return await this.clientPromise;
469
512
  }
470
513
  }
471
514
 
@@ -897,6 +940,7 @@ export async function addDocumentToBase(
897
940
  organizationAuthorityGranted?: boolean | undefined;
898
941
  curationStatus?: DocumentCurationStatus | undefined;
899
942
  access?: DocumentAccessFilter | undefined;
943
+ knowledgeSourceIdentity?: string | null | undefined;
900
944
  },
901
945
  ): Promise<Document> {
902
946
  return await withRlsContext(
@@ -924,6 +968,10 @@ export async function addDocumentToBase(
924
968
  const base = await getDocumentBase(scopedDb, input.workspaceId, input.baseId);
925
969
  if (!base) throw new Error(`Document base not found: ${input.baseId}`);
926
970
  const file = await requireReadyFile(scopedDb, input.workspaceId, input.fileId);
971
+ const knowledgeSourceIdentity = cleanString(input.knowledgeSourceIdentity ?? null);
972
+ if (knowledgeSourceIdentity && knowledgeSourceIdentity.length > 512) {
973
+ throw new Error("knowledge source document identity exceeds 512 characters");
974
+ }
927
975
  const now = new Date();
928
976
  const [existing] = await scopedDb
929
977
  .select()
@@ -931,12 +979,19 @@ export async function addDocumentToBase(
931
979
  .where(
932
980
  and(
933
981
  eq(schema.documents.workspaceId, input.workspaceId),
934
- eq(schema.documents.baseId, input.baseId),
935
- eq(schema.documents.fileId, input.fileId),
982
+ ...(knowledgeSourceIdentity
983
+ ? [eq(schema.documents.knowledgeSourceIdentity, knowledgeSourceIdentity)]
984
+ : [
985
+ eq(schema.documents.baseId, input.baseId),
986
+ eq(schema.documents.fileId, input.fileId),
987
+ ]),
936
988
  ),
937
989
  )
938
990
  .limit(1);
939
991
  if (existing) {
992
+ if (knowledgeSourceIdentity && existing.fileId !== input.fileId) {
993
+ throw new Error("knowledge source document identity is bound to different content");
994
+ }
940
995
  if (!documentMatchesAccess(existing, input.workspaceId, input.access)) {
941
996
  throw new Error(`Document not found: ${existing.id}`);
942
997
  }
@@ -992,6 +1047,7 @@ export async function addDocumentToBase(
992
1047
  sourceCreatedAt: parseOptionalDate(input.sourceCreatedAt),
993
1048
  sourceUpdatedAt: parseOptionalDate(input.sourceUpdatedAt),
994
1049
  sourceVersion: cleanString(input.sourceVersion) ?? null,
1050
+ knowledgeSourceIdentity,
995
1051
  aclTags: cleanStringArray(input.aclTags),
996
1052
  authorityKind: authority.kind,
997
1053
  authorityWorkspaceId: authority.workspaceId,
@@ -1060,7 +1116,9 @@ export async function moveDocumentToBase(
1060
1116
  and(
1061
1117
  eq(schema.documents.workspaceId, input.workspaceId),
1062
1118
  eq(schema.documents.baseId, targetBaseId),
1063
- eq(schema.documents.fileId, row.fileId),
1119
+ ...(row.knowledgeSourceIdentity
1120
+ ? [eq(schema.documents.knowledgeSourceIdentity, row.knowledgeSourceIdentity)]
1121
+ : [eq(schema.documents.fileId, row.fileId)]),
1064
1122
  ),
1065
1123
  )
1066
1124
  .limit(1);
@@ -1178,6 +1236,151 @@ export async function listDocuments(
1178
1236
  });
1179
1237
  }
1180
1238
 
1239
+ /**
1240
+ * List newly ready documents in the same effective scope used by agent
1241
+ * retrieval. The opaque checkpoint is bound to the account, requesting
1242
+ * workspace, and immutable initiating subject, so it cannot be reused across
1243
+ * scheduled-task authority boundaries.
1244
+ */
1245
+ export async function listEffectiveIndexedDocuments(
1246
+ db: Database,
1247
+ input: ListEffectiveIndexedDocumentsInput,
1248
+ ): Promise<ListIndexedDocumentsResponse> {
1249
+ const initiatingSubjectId = canonicalEffectiveDocumentSubject(input.initiatingSubjectId);
1250
+ const limit = input.limit ?? 50;
1251
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
1252
+ throw new Error("indexed document list limit must be between 1 and 100");
1253
+ }
1254
+ const afterSequence = input.checkpoint
1255
+ ? decodeDocumentIndexCheckpoint(input.checkpoint, {
1256
+ accountId: input.accountId,
1257
+ workspaceId: input.workspaceId,
1258
+ initiatingSubjectId,
1259
+ })
1260
+ : 0n;
1261
+ const access: DocumentAccessFilter = {
1262
+ agentOnly: true,
1263
+ viewerSubjectId: initiatingSubjectId,
1264
+ };
1265
+ const rows = await withDocumentAccountRls(
1266
+ db,
1267
+ input.accountId,
1268
+ input.workspaceId,
1269
+ access,
1270
+ async (scopedDb) =>
1271
+ await scopedDb
1272
+ .select()
1273
+ .from(schema.documents)
1274
+ .where(
1275
+ and(
1276
+ eq(schema.documents.accountId, input.accountId),
1277
+ eq(schema.documents.status, "ready"),
1278
+ isNotNull(schema.documents.indexSequence),
1279
+ gt(schema.documents.indexSequence, afterSequence),
1280
+ ...documentAccessConditions(input.workspaceId, access),
1281
+ ),
1282
+ )
1283
+ .orderBy(asc(schema.documents.indexSequence))
1284
+ .limit(limit + 1),
1285
+ );
1286
+ const hasMore = rows.length > limit;
1287
+ const pageRows = rows.slice(0, limit);
1288
+ const nextSequence = pageRows.at(-1)?.indexSequence ?? afterSequence;
1289
+ if (nextSequence === null) {
1290
+ throw new Error("ready document is missing its index sequence");
1291
+ }
1292
+ return {
1293
+ documents: pageRows.map(mapIndexedDocumentSummary),
1294
+ nextCheckpoint: encodeDocumentIndexCheckpoint({
1295
+ accountId: input.accountId,
1296
+ workspaceId: input.workspaceId,
1297
+ initiatingSubjectId,
1298
+ sequence: nextSequence,
1299
+ }),
1300
+ hasMore,
1301
+ };
1302
+ }
1303
+
1304
+ export function encodeDocumentIndexCheckpoint(input: {
1305
+ accountId: string;
1306
+ workspaceId: string;
1307
+ initiatingSubjectId: string;
1308
+ sequence: bigint;
1309
+ }): string {
1310
+ if (input.sequence < 0n) throw new Error("document index checkpoint sequence is invalid");
1311
+ return Buffer.from(
1312
+ JSON.stringify({
1313
+ v: 1,
1314
+ s: documentIndexCheckpointScope(input),
1315
+ q: input.sequence.toString(),
1316
+ }),
1317
+ "utf8",
1318
+ ).toString("base64url");
1319
+ }
1320
+
1321
+ export function decodeDocumentIndexCheckpoint(
1322
+ value: string,
1323
+ scope: { accountId: string; workspaceId: string; initiatingSubjectId: string },
1324
+ ): bigint {
1325
+ try {
1326
+ if (!value || value.length > DOCUMENT_INDEX_CHECKPOINT_MAX_CHARS) {
1327
+ throw new Error("checkpoint length");
1328
+ }
1329
+ const bytes = Buffer.from(value, "base64url");
1330
+ if (bytes.toString("base64url") !== value) throw new Error("checkpoint encoding");
1331
+ const parsed = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
1332
+ if (
1333
+ Object.keys(parsed).sort().join(",") !== "q,s,v" ||
1334
+ parsed.v !== 1 ||
1335
+ typeof parsed.s !== "string" ||
1336
+ typeof parsed.q !== "string" ||
1337
+ !/^(0|[1-9][0-9]*)$/.test(parsed.q)
1338
+ ) {
1339
+ throw new Error("checkpoint payload");
1340
+ }
1341
+ if (parsed.s !== documentIndexCheckpointScope(scope)) {
1342
+ throw new Error("document index checkpoint belongs to a different workspace or subject");
1343
+ }
1344
+ return BigInt(parsed.q);
1345
+ } catch (error) {
1346
+ if (
1347
+ error instanceof Error &&
1348
+ error.message === "document index checkpoint belongs to a different workspace or subject"
1349
+ ) {
1350
+ throw error;
1351
+ }
1352
+ throw new Error("invalid document index checkpoint", { cause: error });
1353
+ }
1354
+ }
1355
+
1356
+ function documentIndexCheckpointScope(input: {
1357
+ accountId: string;
1358
+ workspaceId: string;
1359
+ initiatingSubjectId: string;
1360
+ }): string {
1361
+ return createHash("sha256")
1362
+ .update("opengeni:document-index-checkpoint:v1\0")
1363
+ .update(input.accountId)
1364
+ .update("\0")
1365
+ .update(input.workspaceId)
1366
+ .update("\0")
1367
+ .update(canonicalEffectiveDocumentSubject(input.initiatingSubjectId))
1368
+ .digest("hex");
1369
+ }
1370
+
1371
+ function canonicalEffectiveDocumentSubject(value: string): string {
1372
+ const subjectId = cleanString(value);
1373
+ if (!subjectId || subjectId !== value) {
1374
+ throw new Error("effective document retrieval requires an initiating subject");
1375
+ }
1376
+ if (new TextEncoder().encode(subjectId).byteLength > DOCUMENT_AUTHORITY_SUBJECT_MAX_BYTES) {
1377
+ throw new Error(
1378
+ `effective document initiating subject exceeds ${DOCUMENT_AUTHORITY_SUBJECT_MAX_BYTES} UTF-8 bytes`,
1379
+ );
1380
+ }
1381
+ return subjectId;
1382
+ }
1383
+
1181
1384
  export async function getDocument(
1182
1385
  db: Database,
1183
1386
  workspaceId: string,
@@ -1610,6 +1813,414 @@ export async function searchEffectiveDocuments(
1610
1813
  );
1611
1814
  }
1612
1815
 
1816
+ /**
1817
+ * Agent-facing Knowledge search over the existing Documents evidence store.
1818
+ * Search selects candidates only after authority filtering; this second read
1819
+ * rechecks every exact chunk before projecting it, so a record revoked between
1820
+ * ranking and response construction disappears rather than leaking stale data.
1821
+ */
1822
+ export async function searchEffectiveKnowledge(
1823
+ db: Database,
1824
+ input: EffectiveDocumentSearchInput,
1825
+ services: Pick<DocumentServices, "embedder"> = createDocumentServices(),
1826
+ ): Promise<KnowledgeSearchResponse> {
1827
+ const initiatingSubjectId = canonicalEffectiveDocumentSubject(input.initiatingSubjectId);
1828
+ const ranked = await searchEffectiveDocuments(
1829
+ db,
1830
+ { ...input, initiatingSubjectId, surface: "agent" },
1831
+ services,
1832
+ );
1833
+ if (ranked.length === 0) return { results: [] };
1834
+ const access: DocumentAccessFilter = { agentOnly: true, viewerSubjectId: initiatingSubjectId };
1835
+ const current = await withDocumentAccountRls(
1836
+ db,
1837
+ input.accountId,
1838
+ input.workspaceId,
1839
+ access,
1840
+ async (scopedDb) =>
1841
+ await scopedDb
1842
+ .select({ chunk: schema.documentChunks, document: schema.documents })
1843
+ .from(schema.documentChunks)
1844
+ .innerJoin(schema.documents, eq(schema.documentChunks.documentId, schema.documents.id))
1845
+ .where(
1846
+ and(
1847
+ eq(schema.documents.accountId, input.accountId),
1848
+ eq(schema.documentChunks.accountId, input.accountId),
1849
+ inArray(
1850
+ schema.documentChunks.id,
1851
+ ranked.map((result) => result.chunkId),
1852
+ ),
1853
+ eq(schema.documents.status, "ready"),
1854
+ ...documentAccessConditions(input.workspaceId, access),
1855
+ ),
1856
+ ),
1857
+ );
1858
+ const currentByChunkId = new Map(current.map((row) => [row.chunk.id, row]));
1859
+ return {
1860
+ results: ranked.flatMap((rankedResult) => {
1861
+ const row = currentByChunkId.get(rankedResult.chunkId);
1862
+ if (!row) return [];
1863
+ return [
1864
+ {
1865
+ record: knowledgeChunkRecord(row.document, row.chunk),
1866
+ retrieval: {
1867
+ score: rankedResult.score,
1868
+ matchType: rankedResult.matchType,
1869
+ vectorScore: rankedResult.vectorScore,
1870
+ keywordScore: rankedResult.keywordScore,
1871
+ },
1872
+ },
1873
+ ];
1874
+ }),
1875
+ };
1876
+ }
1877
+
1878
+ /** Fetch one stable Knowledge record with a fresh authorization check. */
1879
+ export async function getEffectiveKnowledgeRecord(
1880
+ db: Database,
1881
+ input: {
1882
+ accountId: string;
1883
+ workspaceId: string;
1884
+ initiatingSubjectId: string;
1885
+ id: string;
1886
+ },
1887
+ ): Promise<KnowledgeRecord | null> {
1888
+ const initiatingSubjectId = canonicalEffectiveDocumentSubject(input.initiatingSubjectId);
1889
+ const target = parseKnowledgeRecordId(input.id);
1890
+ const access: DocumentAccessFilter = { agentOnly: true, viewerSubjectId: initiatingSubjectId };
1891
+ return await withDocumentAccountRls(
1892
+ db,
1893
+ input.accountId,
1894
+ input.workspaceId,
1895
+ access,
1896
+ async (scopedDb) => {
1897
+ if (target.kind === "document") {
1898
+ const [document] = await scopedDb
1899
+ .select()
1900
+ .from(schema.documents)
1901
+ .where(
1902
+ and(
1903
+ eq(schema.documents.accountId, input.accountId),
1904
+ eq(schema.documents.id, target.id),
1905
+ eq(schema.documents.status, "ready"),
1906
+ ...documentAccessConditions(input.workspaceId, access),
1907
+ ),
1908
+ )
1909
+ .limit(1);
1910
+ return document ? knowledgeDocumentRecord(document) : null;
1911
+ }
1912
+ const [row] = await scopedDb
1913
+ .select({ chunk: schema.documentChunks, document: schema.documents })
1914
+ .from(schema.documentChunks)
1915
+ .innerJoin(schema.documents, eq(schema.documentChunks.documentId, schema.documents.id))
1916
+ .where(
1917
+ and(
1918
+ eq(schema.documents.accountId, input.accountId),
1919
+ eq(schema.documentChunks.accountId, input.accountId),
1920
+ eq(schema.documentChunks.id, target.id),
1921
+ eq(schema.documents.status, "ready"),
1922
+ ...documentAccessConditions(input.workspaceId, access),
1923
+ ),
1924
+ )
1925
+ .limit(1);
1926
+ return row ? knowledgeChunkRecord(row.document, row.chunk) : null;
1927
+ },
1928
+ );
1929
+ }
1930
+
1931
+ /**
1932
+ * Browse top-level authorized documents or the chunks of one authorized
1933
+ * document. The cursor is opaque and bound to the caller, workspace, parent,
1934
+ * and filters. It can change pagination position but can never widen scope.
1935
+ */
1936
+ export async function browseEffectiveKnowledge(
1937
+ db: Database,
1938
+ input: EffectiveKnowledgeBrowseInput,
1939
+ ): Promise<KnowledgeBrowseResponse> {
1940
+ const initiatingSubjectId = canonicalEffectiveDocumentSubject(input.initiatingSubjectId);
1941
+ const limit = input.limit ?? 20;
1942
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
1943
+ throw new Error("knowledge browse limit must be between 1 and 50");
1944
+ }
1945
+ const topic = input.topic === undefined ? null : (cleanString(input.topic) ?? null);
1946
+ if (input.topic !== undefined && (!topic || topic !== input.topic || topic.length > 256)) {
1947
+ throw new Error("knowledge browse topic is invalid");
1948
+ }
1949
+ const sourceKinds = [...new Set(input.sourceKinds ?? [])].sort();
1950
+ const parent = input.parentId ? parseKnowledgeRecordId(input.parentId) : null;
1951
+ if (parent?.kind === "document_chunk") {
1952
+ throw new Error("knowledge browse parent must be a document record");
1953
+ }
1954
+ if (parent && (topic || sourceKinds.length > 0)) {
1955
+ throw new Error("knowledge browse document contents do not accept topic/source filters");
1956
+ }
1957
+ const cursorScope = {
1958
+ accountId: input.accountId,
1959
+ workspaceId: input.workspaceId,
1960
+ initiatingSubjectId,
1961
+ parentId: parent ? `${parent.kind}:${parent.id}` : null,
1962
+ topic,
1963
+ sourceKinds,
1964
+ };
1965
+ const after = input.cursor ? decodeKnowledgeBrowseCursor(input.cursor, cursorScope) : 0n;
1966
+ if (parent && after > 2_147_483_648n) {
1967
+ throw new Error("invalid knowledge browse cursor");
1968
+ }
1969
+ const access: DocumentAccessFilter = { agentOnly: true, viewerSubjectId: initiatingSubjectId };
1970
+ return await withDocumentAccountRls(
1971
+ db,
1972
+ input.accountId,
1973
+ input.workspaceId,
1974
+ access,
1975
+ async (scopedDb) => {
1976
+ if (parent) {
1977
+ const [authorizedParent] = await scopedDb
1978
+ .select({ id: schema.documents.id })
1979
+ .from(schema.documents)
1980
+ .where(
1981
+ and(
1982
+ eq(schema.documents.accountId, input.accountId),
1983
+ eq(schema.documents.id, parent.id),
1984
+ eq(schema.documents.status, "ready"),
1985
+ ...documentAccessConditions(input.workspaceId, access),
1986
+ ),
1987
+ )
1988
+ .limit(1);
1989
+ if (!authorizedParent) return { records: [], nextCursor: null, hasMore: false };
1990
+ const rows = await scopedDb
1991
+ .select({ chunk: schema.documentChunks, document: schema.documents })
1992
+ .from(schema.documentChunks)
1993
+ .innerJoin(schema.documents, eq(schema.documentChunks.documentId, schema.documents.id))
1994
+ .where(
1995
+ and(
1996
+ eq(schema.documentChunks.accountId, input.accountId),
1997
+ eq(schema.documentChunks.documentId, parent.id),
1998
+ gt(schema.documentChunks.chunkIndex, Number(after) - 1),
1999
+ eq(schema.documents.status, "ready"),
2000
+ ...documentAccessConditions(input.workspaceId, access),
2001
+ ),
2002
+ )
2003
+ .orderBy(asc(schema.documentChunks.chunkIndex))
2004
+ .limit(limit + 1);
2005
+ const hasMore = rows.length > limit;
2006
+ const page = rows.slice(0, limit);
2007
+ const last = page.at(-1)?.chunk.chunkIndex;
2008
+ return {
2009
+ records: page.map((row) => knowledgeChunkRecord(row.document, row.chunk)),
2010
+ nextCursor:
2011
+ hasMore && last !== undefined
2012
+ ? encodeKnowledgeBrowseCursor(cursorScope, BigInt(last + 1))
2013
+ : null,
2014
+ hasMore,
2015
+ };
2016
+ }
2017
+
2018
+ const conditions: SQL[] = [
2019
+ eq(schema.documents.accountId, input.accountId),
2020
+ eq(schema.documents.status, "ready"),
2021
+ isNotNull(schema.documents.indexSequence),
2022
+ gt(schema.documents.indexSequence, after),
2023
+ ...documentAccessConditions(input.workspaceId, access),
2024
+ ];
2025
+ if (topic) conditions.push(sql`${schema.documents.topics} ? ${topic}`);
2026
+ if (sourceKinds.length > 0)
2027
+ conditions.push(inArray(schema.documents.sourceKind, sourceKinds));
2028
+ const rows = await scopedDb
2029
+ .select()
2030
+ .from(schema.documents)
2031
+ .where(and(...conditions))
2032
+ .orderBy(asc(schema.documents.indexSequence))
2033
+ .limit(limit + 1);
2034
+ const hasMore = rows.length > limit;
2035
+ const page = rows.slice(0, limit);
2036
+ const last = page.at(-1)?.indexSequence;
2037
+ return {
2038
+ records: page.map(knowledgeDocumentRecord),
2039
+ nextCursor:
2040
+ hasMore && last !== undefined && last !== null
2041
+ ? encodeKnowledgeBrowseCursor(cursorScope, last)
2042
+ : null,
2043
+ hasMore,
2044
+ };
2045
+ },
2046
+ );
2047
+ }
2048
+
2049
+ type KnowledgeBrowseCursorScope = {
2050
+ accountId: string;
2051
+ workspaceId: string;
2052
+ initiatingSubjectId: string;
2053
+ parentId: string | null;
2054
+ topic: string | null;
2055
+ sourceKinds: readonly string[];
2056
+ };
2057
+
2058
+ export function encodeKnowledgeBrowseCursor(
2059
+ scope: KnowledgeBrowseCursorScope,
2060
+ position: bigint,
2061
+ ): string {
2062
+ if (position < 0n) throw new Error("knowledge browse cursor position is invalid");
2063
+ return Buffer.from(
2064
+ JSON.stringify({ v: 1, s: knowledgeBrowseCursorScope(scope), q: position.toString() }),
2065
+ "utf8",
2066
+ ).toString("base64url");
2067
+ }
2068
+
2069
+ export function decodeKnowledgeBrowseCursor(
2070
+ value: string,
2071
+ scope: KnowledgeBrowseCursorScope,
2072
+ ): bigint {
2073
+ try {
2074
+ if (!value || value.length > KNOWLEDGE_BROWSE_CURSOR_MAX_CHARS) {
2075
+ throw new Error("cursor length");
2076
+ }
2077
+ const bytes = Buffer.from(value, "base64url");
2078
+ if (bytes.toString("base64url") !== value) throw new Error("cursor encoding");
2079
+ const parsed = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
2080
+ if (
2081
+ Object.keys(parsed).sort().join(",") !== "q,s,v" ||
2082
+ parsed.v !== 1 ||
2083
+ typeof parsed.s !== "string" ||
2084
+ typeof parsed.q !== "string" ||
2085
+ !/^(0|[1-9][0-9]*)$/.test(parsed.q)
2086
+ ) {
2087
+ throw new Error("cursor payload");
2088
+ }
2089
+ if (parsed.s !== knowledgeBrowseCursorScope(scope)) {
2090
+ throw new Error("knowledge browse cursor belongs to a different scope");
2091
+ }
2092
+ const position = BigInt(parsed.q);
2093
+ if (position > 9_223_372_036_854_775_807n) throw new Error("cursor position");
2094
+ return position;
2095
+ } catch (error) {
2096
+ if (
2097
+ error instanceof Error &&
2098
+ error.message === "knowledge browse cursor belongs to a different scope"
2099
+ ) {
2100
+ throw error;
2101
+ }
2102
+ throw new Error("invalid knowledge browse cursor", { cause: error });
2103
+ }
2104
+ }
2105
+
2106
+ function knowledgeBrowseCursorScope(scope: KnowledgeBrowseCursorScope): string {
2107
+ return createHash("sha256")
2108
+ .update("opengeni:knowledge-browse-cursor:v1\0")
2109
+ .update(scope.accountId)
2110
+ .update("\0")
2111
+ .update(scope.workspaceId)
2112
+ .update("\0")
2113
+ .update(canonicalEffectiveDocumentSubject(scope.initiatingSubjectId))
2114
+ .update("\0")
2115
+ .update(scope.parentId ?? "")
2116
+ .update("\0")
2117
+ .update(scope.topic ?? "")
2118
+ .update("\0")
2119
+ .update([...scope.sourceKinds].sort().join("\0"))
2120
+ .digest("hex");
2121
+ }
2122
+
2123
+ function parseKnowledgeRecordId(value: string): {
2124
+ kind: "document" | "document_chunk";
2125
+ id: string;
2126
+ } {
2127
+ const match =
2128
+ /^(document|document_chunk):([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/iu.exec(
2129
+ value,
2130
+ );
2131
+ if (!match) throw new Error("invalid knowledge record id");
2132
+ return { kind: match[1] as "document" | "document_chunk", id: match[2]!.toLowerCase() };
2133
+ }
2134
+
2135
+ function knowledgeDocumentRecord(document: typeof schema.documents.$inferSelect): KnowledgeRecord {
2136
+ if (!document.indexedAt) throw new Error(`Ready document is missing indexed_at: ${document.id}`);
2137
+ const projected = projectKnowledgeRecord({
2138
+ title: document.title,
2139
+ body: document.summary,
2140
+ summary: document.summary,
2141
+ topics: document.topics,
2142
+ metadata: { parser: document.parser, chunkCount: document.chunkCount },
2143
+ source: knowledgeSource(document),
2144
+ });
2145
+ return {
2146
+ id: `document:${document.id}`,
2147
+ kind: "document",
2148
+ title: projected.title,
2149
+ content: projected.content,
2150
+ authority: { kind: normalizeDocumentAuthorityKind(document.authorityKind) },
2151
+ provenance: {
2152
+ source: projected.source,
2153
+ indexedAt: document.indexedAt.toISOString(),
2154
+ },
2155
+ lifecycle: { state: "active", updatedAt: document.updatedAt.toISOString() },
2156
+ quality: knowledgeQuality(document),
2157
+ links: knowledgeSourceLinks(projected.source.uri),
2158
+ projection: projected.projection,
2159
+ };
2160
+ }
2161
+
2162
+ function knowledgeChunkRecord(
2163
+ document: typeof schema.documents.$inferSelect,
2164
+ chunk: typeof schema.documentChunks.$inferSelect,
2165
+ ): KnowledgeRecord {
2166
+ if (!document.indexedAt) throw new Error(`Ready document is missing indexed_at: ${document.id}`);
2167
+ const projected = projectKnowledgeRecord({
2168
+ title: document.title,
2169
+ body: chunk.text,
2170
+ summary: document.summary,
2171
+ topics: document.topics,
2172
+ metadata: { ...chunk.metadata, chunkIndex: chunk.chunkIndex },
2173
+ source: knowledgeSource(document),
2174
+ });
2175
+ return {
2176
+ id: `document_chunk:${chunk.id}`,
2177
+ kind: "document_chunk",
2178
+ title: projected.title,
2179
+ content: projected.content,
2180
+ authority: { kind: normalizeDocumentAuthorityKind(document.authorityKind) },
2181
+ provenance: {
2182
+ source: projected.source,
2183
+ indexedAt: document.indexedAt.toISOString(),
2184
+ },
2185
+ lifecycle: { state: "active", updatedAt: document.updatedAt.toISOString() },
2186
+ quality: knowledgeQuality(document),
2187
+ links: [
2188
+ { relation: "parent", target: { kind: "knowledge", id: `document:${document.id}` } },
2189
+ ...knowledgeSourceLinks(projected.source.uri),
2190
+ ],
2191
+ projection: projected.projection,
2192
+ };
2193
+ }
2194
+
2195
+ function knowledgeSource(document: typeof schema.documents.$inferSelect) {
2196
+ return {
2197
+ kind: normalizeKnowledgeSourceKind(document.sourceKind),
2198
+ uri: document.sourceUri,
2199
+ externalId: document.sourceExternalId,
2200
+ title: document.sourceTitle,
2201
+ author: document.sourceAuthor,
2202
+ createdAt: document.sourceCreatedAt?.toISOString() ?? null,
2203
+ updatedAt: document.sourceUpdatedAt?.toISOString() ?? null,
2204
+ version: document.sourceVersion,
2205
+ };
2206
+ }
2207
+
2208
+ function knowledgeQuality(
2209
+ document: typeof schema.documents.$inferSelect,
2210
+ ): KnowledgeRecord["quality"] {
2211
+ if (!document.indexedAt) throw new Error(`Ready document is missing indexed_at: ${document.id}`);
2212
+ return {
2213
+ trust: "sourced",
2214
+ freshnessAt: (document.sourceUpdatedAt ?? document.indexedAt).toISOString(),
2215
+ conflict: "not_evaluated",
2216
+ correction: "current_source_version",
2217
+ };
2218
+ }
2219
+
2220
+ function knowledgeSourceLinks(sourceUri: string | null): KnowledgeRecord["links"] {
2221
+ return sourceUri ? [{ relation: "source", target: { kind: "external", uri: sourceUri } }] : [];
2222
+ }
2223
+
1613
2224
  async function vectorSearchDocuments(
1614
2225
  db: Database,
1615
2226
  input: DocumentSearchInput,
@@ -2317,6 +2928,43 @@ function mapDocument(row: typeof schema.documents.$inferSelect): Document {
2317
2928
  };
2318
2929
  }
2319
2930
 
2931
+ function mapIndexedDocumentSummary(
2932
+ row: typeof schema.documents.$inferSelect,
2933
+ ): IndexedDocumentSummary {
2934
+ if (row.indexSequence === null || row.indexedAt === null) {
2935
+ throw new Error(`Ready document is missing index completion metadata: ${row.id}`);
2936
+ }
2937
+ return {
2938
+ id: row.id,
2939
+ title: row.title,
2940
+ parser: row.parser,
2941
+ chunkCount: row.chunkCount,
2942
+ indexedAt: row.indexedAt.toISOString(),
2943
+ summary: row.summary,
2944
+ topics: cleanStringArray(row.topics),
2945
+ source: {
2946
+ kind: normalizeKnowledgeSourceKind(row.sourceKind),
2947
+ uri: row.sourceUri,
2948
+ externalId: row.sourceExternalId,
2949
+ title: row.sourceTitle,
2950
+ author: row.sourceAuthor,
2951
+ createdAt: row.sourceCreatedAt?.toISOString() ?? null,
2952
+ updatedAt: row.sourceUpdatedAt?.toISOString() ?? null,
2953
+ version: row.sourceVersion,
2954
+ },
2955
+ provenance: {
2956
+ ingestionWorkspaceId: row.workspaceId,
2957
+ baseId: row.baseId,
2958
+ fileId: row.fileId,
2959
+ authorityKind: normalizeDocumentAuthorityKind(row.authorityKind),
2960
+ authorityWorkspaceId: row.authorityWorkspaceId,
2961
+ authoritySubjectId: row.authoritySubjectId,
2962
+ createdBy: row.createdBy,
2963
+ createdAt: row.createdAt.toISOString(),
2964
+ },
2965
+ };
2966
+ }
2967
+
2320
2968
  function normalizeDocumentAuthorityKind(value: string): DocumentAuthorityKind {
2321
2969
  switch (value) {
2322
2970
  case "organization":