@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/dist/atlassian.d.ts +78 -0
- package/dist/atlassian.js +140 -0
- package/dist/atlassian.js.map +1 -0
- package/dist/google-drive.d.ts +2 -0
- package/dist/google-drive.js +12 -1
- package/dist/google-drive.js.map +1 -1
- package/dist/index.d.ts +74 -4
- package/dist/index.js +645 -27
- package/dist/index.js.map +1 -1
- package/dist/knowledge-projection.d.ts +14 -0
- package/package.json +10 -6
- package/src/atlassian.ts +250 -0
- package/src/google-drive.ts +21 -1
- package/src/index.ts +674 -26
- package/src/knowledge-projection.ts +214 -0
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 {
|
|
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
|
|
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
|
|
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.
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
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
|
|
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.
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
935
|
-
|
|
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
|
-
|
|
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":
|