@opengeni/documents 0.2.30 → 0.2.36
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/index.d.ts +120 -6
- package/dist/index.js +416 -17
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/index.ts +660 -10
package/dist/index.js
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
import { requireFile, withRlsContext, withWorkspaceRls } from "@opengeni/db";
|
|
3
3
|
import * as schema from "@opengeni/db/schema";
|
|
4
4
|
import { LiteParse } from "@llamaindex/liteparse";
|
|
5
|
-
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
|
|
5
|
+
import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm";
|
|
6
6
|
import OpenAI from "openai";
|
|
7
7
|
var DEFAULT_DOCUMENT_PARSER = "liteparse";
|
|
8
8
|
var DEFAULT_DOCUMENT_EMBEDDING_MODEL = "text-embedding-3-large";
|
|
9
9
|
var DEFAULT_DOCUMENT_EMBEDDING_DIMENSIONS = 3072;
|
|
10
10
|
var DEFAULT_DOCUMENT_CHUNK_SIZE = 1200;
|
|
11
11
|
var DEFAULT_DOCUMENT_CHUNK_OVERLAP = 160;
|
|
12
|
+
var DEFAULT_DOCUMENT_CURATION_MODEL = "gpt-4o-mini";
|
|
13
|
+
var DOCUMENT_CURATION_MAX_INPUT_CHARS = 24e3;
|
|
14
|
+
var DOCUMENT_CURATION_AUTO_FILE_CONFIDENCE = 0.75;
|
|
15
|
+
var DEFAULT_BASE_NAME = "Default";
|
|
16
|
+
var DEFAULT_BASE_DESCRIPTION = "Default base for dropped files and notes.";
|
|
12
17
|
var LiteParseDocumentParser = class {
|
|
13
18
|
name = DEFAULT_DOCUMENT_PARSER;
|
|
14
19
|
parseQueue = Promise.resolve();
|
|
@@ -132,6 +137,123 @@ var DeterministicEmbeddingProvider = class {
|
|
|
132
137
|
return deterministicEmbedding(text, this.dimensions);
|
|
133
138
|
}
|
|
134
139
|
};
|
|
140
|
+
function heuristicCuration(input, contentType = "application/octet-stream") {
|
|
141
|
+
const firstLine = input.text.split("\n").map((line) => line.replace(/^[#>\s*-]+/, "").trim()).find((line) => line.length >= 3);
|
|
142
|
+
const title = (firstLine ?? input.title).slice(0, 120).trim() || input.title;
|
|
143
|
+
const summaryWindow = input.text.replace(/\s+/g, " ").trim().slice(0, 360);
|
|
144
|
+
const summary = summaryWindow.length === 360 ? `${summaryWindow.slice(0, 357)}...` : summaryWindow;
|
|
145
|
+
return {
|
|
146
|
+
title,
|
|
147
|
+
summary: summary || null,
|
|
148
|
+
sourceKind: heuristicSourceKind(input.filename, contentType),
|
|
149
|
+
topics: [],
|
|
150
|
+
targetBaseId: null,
|
|
151
|
+
confidence: 0,
|
|
152
|
+
reason: null
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function heuristicSourceKind(rawFilename, rawContentType) {
|
|
156
|
+
const filename = rawFilename.toLowerCase();
|
|
157
|
+
const contentType = rawContentType.toLowerCase();
|
|
158
|
+
if (filename.endsWith(".eml") || contentType === "message/rfc822") return "email";
|
|
159
|
+
if (filename.endsWith(".vtt") || filename.endsWith(".srt") || filename.includes("transcript")) {
|
|
160
|
+
return "meeting_transcript";
|
|
161
|
+
}
|
|
162
|
+
if (contentType === "text/html") return "web";
|
|
163
|
+
if (contentType === "application/pdf" || filename.endsWith(".docx") || filename.endsWith(".md")) {
|
|
164
|
+
return "document";
|
|
165
|
+
}
|
|
166
|
+
return "manual_upload";
|
|
167
|
+
}
|
|
168
|
+
var HeuristicCurationProvider = class {
|
|
169
|
+
model = "heuristic";
|
|
170
|
+
async curate(input) {
|
|
171
|
+
return heuristicCuration(input);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
var CURATION_SYSTEM_PROMPT = [
|
|
175
|
+
"You organize a team knowledge base. Given the beginning of a dropped document",
|
|
176
|
+
"and the list of existing collections (bases), return STRICT JSON with keys:",
|
|
177
|
+
'"title" (concise, specific, <= 120 chars, no filename extensions),',
|
|
178
|
+
'"summary" (2-3 sentences, plain prose, what the document is and why it matters),',
|
|
179
|
+
'"sourceKind" (one of: manual_upload, meeting_transcript, repository, email, chat, document, web, other),',
|
|
180
|
+
'"topics" (3-6 short lowercase tags),',
|
|
181
|
+
'"targetBaseId" (the id of the best-fitting existing base, or null if none fits),',
|
|
182
|
+
'"confidence" (0..1 \u2014 how sure you are the document belongs in targetBaseId),',
|
|
183
|
+
'"reason" (one sentence explaining the filing choice).',
|
|
184
|
+
"Only pick a targetBaseId from the provided list. If the document fits no base",
|
|
185
|
+
"well, return targetBaseId null and confidence 0. Respond with JSON only."
|
|
186
|
+
].join(" ");
|
|
187
|
+
var OpenAICurationProvider = class {
|
|
188
|
+
client = null;
|
|
189
|
+
apiKey;
|
|
190
|
+
baseURL;
|
|
191
|
+
defaultHeaders;
|
|
192
|
+
defaultQuery;
|
|
193
|
+
model;
|
|
194
|
+
constructor(args) {
|
|
195
|
+
this.apiKey = args.apiKey ?? process.env.OPENAI_API_KEY;
|
|
196
|
+
this.baseURL = args.baseURL;
|
|
197
|
+
this.defaultHeaders = args.defaultHeaders;
|
|
198
|
+
this.defaultQuery = args.defaultQuery;
|
|
199
|
+
this.model = args.model ?? DEFAULT_DOCUMENT_CURATION_MODEL;
|
|
200
|
+
}
|
|
201
|
+
async curate(input) {
|
|
202
|
+
const response = await this.openai().chat.completions.create({
|
|
203
|
+
model: this.model,
|
|
204
|
+
response_format: { type: "json_object" },
|
|
205
|
+
messages: [
|
|
206
|
+
{ role: "system", content: CURATION_SYSTEM_PROMPT },
|
|
207
|
+
{
|
|
208
|
+
role: "user",
|
|
209
|
+
content: JSON.stringify({
|
|
210
|
+
filename: input.filename,
|
|
211
|
+
currentTitle: input.title,
|
|
212
|
+
bases: input.bases,
|
|
213
|
+
text: input.text
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
]
|
|
217
|
+
});
|
|
218
|
+
const raw = response.choices[0]?.message?.content;
|
|
219
|
+
if (!raw) {
|
|
220
|
+
throw new Error("curation model returned no content");
|
|
221
|
+
}
|
|
222
|
+
return parseCurationOutcome(raw, input.bases);
|
|
223
|
+
}
|
|
224
|
+
openai() {
|
|
225
|
+
if (!this.apiKey) {
|
|
226
|
+
throw new Error("OpenAI document curation requires an API key");
|
|
227
|
+
}
|
|
228
|
+
this.client ??= new OpenAI({
|
|
229
|
+
apiKey: this.apiKey,
|
|
230
|
+
...this.baseURL ? { baseURL: this.baseURL } : {},
|
|
231
|
+
...this.defaultQuery ? { defaultQuery: this.defaultQuery } : {},
|
|
232
|
+
...this.defaultHeaders ? { defaultHeaders: this.defaultHeaders } : {}
|
|
233
|
+
});
|
|
234
|
+
return this.client;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
function parseCurationOutcome(raw, bases) {
|
|
238
|
+
const parsed = JSON.parse(raw);
|
|
239
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
240
|
+
throw new Error("curation model returned non-object JSON");
|
|
241
|
+
}
|
|
242
|
+
const record = parsed;
|
|
243
|
+
const knownBase = bases.find((base) => base.id === record.targetBaseId);
|
|
244
|
+
const confidence = typeof record.confidence === "number" && Number.isFinite(record.confidence) ? Math.min(1, Math.max(0, record.confidence)) : 0;
|
|
245
|
+
return {
|
|
246
|
+
title: cleanString(typeof record.title === "string" ? record.title.slice(0, 200) : null) ?? null,
|
|
247
|
+
summary: cleanString(typeof record.summary === "string" ? record.summary.slice(0, 2e3) : null) ?? null,
|
|
248
|
+
sourceKind: typeof record.sourceKind === "string" ? normalizeKnowledgeSourceKind(record.sourceKind) : null,
|
|
249
|
+
topics: cleanStringArray(
|
|
250
|
+
Array.isArray(record.topics) ? record.topics.filter((topic) => typeof topic === "string").slice(0, 8) : []
|
|
251
|
+
).map((topic) => topic.toLowerCase().slice(0, 60)),
|
|
252
|
+
targetBaseId: knownBase?.id ?? null,
|
|
253
|
+
confidence: knownBase ? confidence : 0,
|
|
254
|
+
reason: cleanString(typeof record.reason === "string" ? record.reason.slice(0, 500) : null) ?? null
|
|
255
|
+
};
|
|
256
|
+
}
|
|
135
257
|
function createDocumentServices(settings, overrides = {}) {
|
|
136
258
|
const dimensions = settings?.documentEmbeddingDimensions ?? DEFAULT_DOCUMENT_EMBEDDING_DIMENSIONS;
|
|
137
259
|
const openAIEmbeddingConfig = documentOpenAIEmbeddingConfig(settings);
|
|
@@ -145,9 +267,23 @@ function createDocumentServices(settings, overrides = {}) {
|
|
|
145
267
|
...openAIEmbeddingConfig,
|
|
146
268
|
model: settings?.documentEmbeddingModel ?? DEFAULT_DOCUMENT_EMBEDDING_MODEL,
|
|
147
269
|
dimensions
|
|
148
|
-
}))
|
|
270
|
+
})),
|
|
271
|
+
curator: overrides.curator ?? createDocumentCurator(settings)
|
|
149
272
|
};
|
|
150
273
|
}
|
|
274
|
+
function createDocumentCurator(settings) {
|
|
275
|
+
const provider = settings?.documentCurationProvider ?? "openai";
|
|
276
|
+
if (provider === "none") return void 0;
|
|
277
|
+
if (provider === "heuristic") return new HeuristicCurationProvider();
|
|
278
|
+
const embeddingConfig = documentOpenAIEmbeddingConfig(settings);
|
|
279
|
+
return new OpenAICurationProvider({
|
|
280
|
+
apiKey: settings?.documentCurationApiKey ?? embeddingConfig.apiKey,
|
|
281
|
+
baseURL: settings?.documentCurationBaseUrl ?? embeddingConfig.baseURL,
|
|
282
|
+
defaultHeaders: embeddingConfig.defaultHeaders,
|
|
283
|
+
defaultQuery: embeddingConfig.defaultQuery,
|
|
284
|
+
model: settings?.documentCurationModel ?? DEFAULT_DOCUMENT_CURATION_MODEL
|
|
285
|
+
});
|
|
286
|
+
}
|
|
151
287
|
function documentOpenAIEmbeddingConfig(settings) {
|
|
152
288
|
if (!settings) return {};
|
|
153
289
|
if (settings.documentEmbeddingApiKey || settings.documentEmbeddingBaseUrl) {
|
|
@@ -215,6 +351,27 @@ async function getDocumentBase(db, workspaceId, baseId) {
|
|
|
215
351
|
return row ? mapDocumentBase(row) : null;
|
|
216
352
|
});
|
|
217
353
|
}
|
|
354
|
+
async function ensureDefaultBase(db, input) {
|
|
355
|
+
return await withRlsContext(
|
|
356
|
+
db,
|
|
357
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
358
|
+
async (scopedDb) => await scopedDb.transaction(async (tx) => {
|
|
359
|
+
const defaultName = sql`lower(btrim(${schema.documentBases.name})) = ${DEFAULT_BASE_NAME.toLowerCase()}`;
|
|
360
|
+
const [existing] = await tx.select().from(schema.documentBases).where(and(eq(schema.documentBases.workspaceId, input.workspaceId), defaultName)).limit(1);
|
|
361
|
+
if (existing) return mapDocumentBase(existing);
|
|
362
|
+
const [inserted] = await tx.insert(schema.documentBases).values({
|
|
363
|
+
accountId: input.accountId,
|
|
364
|
+
workspaceId: input.workspaceId,
|
|
365
|
+
name: DEFAULT_BASE_NAME,
|
|
366
|
+
description: DEFAULT_BASE_DESCRIPTION
|
|
367
|
+
}).onConflictDoNothing().returning();
|
|
368
|
+
if (inserted) return mapDocumentBase(inserted);
|
|
369
|
+
const [raced] = await tx.select().from(schema.documentBases).where(and(eq(schema.documentBases.workspaceId, input.workspaceId), defaultName)).limit(1);
|
|
370
|
+
if (raced) return mapDocumentBase(raced);
|
|
371
|
+
throw new Error("Failed to create Default document base");
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
}
|
|
218
375
|
async function addDocumentToBase(db, input) {
|
|
219
376
|
return await withRlsContext(
|
|
220
377
|
db,
|
|
@@ -223,6 +380,9 @@ async function addDocumentToBase(db, input) {
|
|
|
223
380
|
const base = await getDocumentBase(scopedDb, input.workspaceId, input.baseId);
|
|
224
381
|
if (!base) throw new Error(`Document base not found: ${input.baseId}`);
|
|
225
382
|
const file = await requireReadyFile(scopedDb, input.workspaceId, input.fileId);
|
|
383
|
+
if (input.visibility === "private" && !cleanString(input.createdBy ?? null)) {
|
|
384
|
+
throw new Error("private documents require a creating subject");
|
|
385
|
+
}
|
|
226
386
|
const now = /* @__PURE__ */ new Date();
|
|
227
387
|
const [existing] = await scopedDb.select().from(schema.documents).where(
|
|
228
388
|
and(
|
|
@@ -232,6 +392,9 @@ async function addDocumentToBase(db, input) {
|
|
|
232
392
|
)
|
|
233
393
|
).limit(1);
|
|
234
394
|
if (existing) {
|
|
395
|
+
if (!documentMatchesAccess(existing, input.access)) {
|
|
396
|
+
throw new Error(`Document not found: ${existing.id}`);
|
|
397
|
+
}
|
|
235
398
|
const [updated] = await scopedDb.update(schema.documents).set({
|
|
236
399
|
title: cleanString(input.title) ?? cleanString(input.sourceTitle) ?? existing.title,
|
|
237
400
|
...input.sourceKind !== void 0 ? { sourceKind: input.sourceKind } : {},
|
|
@@ -247,7 +410,8 @@ async function addDocumentToBase(db, input) {
|
|
|
247
410
|
}).where(
|
|
248
411
|
and(
|
|
249
412
|
eq(schema.documents.workspaceId, input.workspaceId),
|
|
250
|
-
eq(schema.documents.id, existing.id)
|
|
413
|
+
eq(schema.documents.id, existing.id),
|
|
414
|
+
...documentAccessConditions(input.access)
|
|
251
415
|
)
|
|
252
416
|
).returning();
|
|
253
417
|
return mapDocument(updated ?? existing);
|
|
@@ -269,6 +433,10 @@ async function addDocumentToBase(db, input) {
|
|
|
269
433
|
sourceUpdatedAt: parseOptionalDate(input.sourceUpdatedAt),
|
|
270
434
|
sourceVersion: cleanString(input.sourceVersion) ?? null,
|
|
271
435
|
aclTags: cleanStringArray(input.aclTags),
|
|
436
|
+
visibility: input.visibility ?? "workspace",
|
|
437
|
+
agentAccess: input.agentAccess ?? true,
|
|
438
|
+
createdBy: input.createdBy ?? null,
|
|
439
|
+
curationStatus: input.curationStatus ?? "none",
|
|
272
440
|
updatedAt: now
|
|
273
441
|
}).returning();
|
|
274
442
|
if (!row) throw new Error("Failed to create document");
|
|
@@ -276,6 +444,65 @@ async function addDocumentToBase(db, input) {
|
|
|
276
444
|
}
|
|
277
445
|
);
|
|
278
446
|
}
|
|
447
|
+
async function moveDocumentToBase(db, input) {
|
|
448
|
+
return await withRlsContext(
|
|
449
|
+
db,
|
|
450
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
451
|
+
async (scopedDb) => {
|
|
452
|
+
const [row] = await scopedDb.select().from(schema.documents).where(
|
|
453
|
+
and(
|
|
454
|
+
eq(schema.documents.workspaceId, input.workspaceId),
|
|
455
|
+
eq(schema.documents.id, input.documentId),
|
|
456
|
+
...documentAccessConditions(input.access)
|
|
457
|
+
)
|
|
458
|
+
).limit(1);
|
|
459
|
+
if (!row) throw new Error(`Document not found: ${input.documentId}`);
|
|
460
|
+
const suggestion = row.curation?.suggestedBaseId;
|
|
461
|
+
const targetBaseId = input.targetBaseId ?? suggestion;
|
|
462
|
+
if (!targetBaseId) {
|
|
463
|
+
throw new Error("document has no suggested base; pass targetBaseId");
|
|
464
|
+
}
|
|
465
|
+
if (targetBaseId === row.baseId) return mapDocument(row);
|
|
466
|
+
const base = await getDocumentBase(scopedDb, input.workspaceId, targetBaseId);
|
|
467
|
+
if (!base) throw new Error(`Document base not found: ${targetBaseId}`);
|
|
468
|
+
const [conflict] = await scopedDb.select({ id: schema.documents.id }).from(schema.documents).where(
|
|
469
|
+
and(
|
|
470
|
+
eq(schema.documents.workspaceId, input.workspaceId),
|
|
471
|
+
eq(schema.documents.baseId, targetBaseId),
|
|
472
|
+
eq(schema.documents.fileId, row.fileId)
|
|
473
|
+
)
|
|
474
|
+
).limit(1);
|
|
475
|
+
if (conflict) {
|
|
476
|
+
throw new Error("a document for this file already exists in the target base");
|
|
477
|
+
}
|
|
478
|
+
const now = /* @__PURE__ */ new Date();
|
|
479
|
+
const moved = await scopedDb.transaction(async (tx) => {
|
|
480
|
+
const [updated] = await tx.update(schema.documents).set({
|
|
481
|
+
baseId: targetBaseId,
|
|
482
|
+
...row.curationStatus === "suggested" || row.curationStatus === "pending" ? { curationStatus: "auto_filed" } : {},
|
|
483
|
+
updatedAt: now
|
|
484
|
+
}).where(
|
|
485
|
+
and(
|
|
486
|
+
eq(schema.documents.workspaceId, input.workspaceId),
|
|
487
|
+
eq(schema.documents.id, input.documentId),
|
|
488
|
+
...documentAccessConditions(input.access)
|
|
489
|
+
)
|
|
490
|
+
).returning();
|
|
491
|
+
if (updated) {
|
|
492
|
+
await tx.update(schema.documentChunks).set({ baseId: targetBaseId }).where(
|
|
493
|
+
and(
|
|
494
|
+
eq(schema.documentChunks.workspaceId, input.workspaceId),
|
|
495
|
+
eq(schema.documentChunks.documentId, input.documentId)
|
|
496
|
+
)
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
return updated;
|
|
500
|
+
});
|
|
501
|
+
if (!moved) throw new Error(`Document not found: ${input.documentId}`);
|
|
502
|
+
return mapDocument(moved);
|
|
503
|
+
}
|
|
504
|
+
);
|
|
505
|
+
}
|
|
279
506
|
async function deleteDocumentFromBase(db, input) {
|
|
280
507
|
await withRlsContext(
|
|
281
508
|
db,
|
|
@@ -284,7 +511,8 @@ async function deleteDocumentFromBase(db, input) {
|
|
|
284
511
|
const [document] = await scopedDb.select().from(schema.documents).where(
|
|
285
512
|
and(
|
|
286
513
|
eq(schema.documents.workspaceId, input.workspaceId),
|
|
287
|
-
eq(schema.documents.id, input.documentId)
|
|
514
|
+
eq(schema.documents.id, input.documentId),
|
|
515
|
+
...documentAccessConditions(input.access)
|
|
288
516
|
)
|
|
289
517
|
).limit(1);
|
|
290
518
|
if (!document) {
|
|
@@ -296,50 +524,64 @@ async function deleteDocumentFromBase(db, input) {
|
|
|
296
524
|
await scopedDb.delete(schema.documents).where(
|
|
297
525
|
and(
|
|
298
526
|
eq(schema.documents.workspaceId, input.workspaceId),
|
|
299
|
-
eq(schema.documents.id, input.documentId)
|
|
527
|
+
eq(schema.documents.id, input.documentId),
|
|
528
|
+
...documentAccessConditions(input.access)
|
|
300
529
|
)
|
|
301
530
|
);
|
|
302
531
|
}
|
|
303
532
|
);
|
|
304
533
|
}
|
|
305
|
-
async function listDocuments(db, workspaceId, baseId) {
|
|
534
|
+
async function listDocuments(db, workspaceId, baseId, access) {
|
|
306
535
|
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
307
536
|
const rows = await scopedDb.select().from(schema.documents).where(
|
|
308
|
-
and(
|
|
537
|
+
and(
|
|
538
|
+
eq(schema.documents.workspaceId, workspaceId),
|
|
539
|
+
eq(schema.documents.baseId, baseId),
|
|
540
|
+
...documentAccessConditions(access)
|
|
541
|
+
)
|
|
309
542
|
).orderBy(asc(schema.documents.createdAt));
|
|
310
543
|
return rows.map(mapDocument);
|
|
311
544
|
});
|
|
312
545
|
}
|
|
313
|
-
async function getDocument(db, workspaceId, documentId) {
|
|
546
|
+
async function getDocument(db, workspaceId, documentId, access) {
|
|
314
547
|
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
315
548
|
const [row] = await scopedDb.select().from(schema.documents).where(
|
|
316
|
-
and(
|
|
549
|
+
and(
|
|
550
|
+
eq(schema.documents.workspaceId, workspaceId),
|
|
551
|
+
eq(schema.documents.id, documentId),
|
|
552
|
+
...documentAccessConditions(access)
|
|
553
|
+
)
|
|
317
554
|
).limit(1);
|
|
318
555
|
return row ? mapDocument(row) : null;
|
|
319
556
|
});
|
|
320
557
|
}
|
|
321
|
-
async function queueDocumentForReindex(db, workspaceId, documentId) {
|
|
558
|
+
async function queueDocumentForReindex(db, workspaceId, documentId, access) {
|
|
322
559
|
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
323
560
|
const [row] = await scopedDb.update(schema.documents).set({
|
|
324
561
|
status: "queued",
|
|
325
562
|
error: null,
|
|
326
563
|
updatedAt: /* @__PURE__ */ new Date()
|
|
327
564
|
}).where(
|
|
328
|
-
and(
|
|
565
|
+
and(
|
|
566
|
+
eq(schema.documents.workspaceId, workspaceId),
|
|
567
|
+
eq(schema.documents.id, documentId),
|
|
568
|
+
...documentAccessConditions(access)
|
|
569
|
+
)
|
|
329
570
|
).returning();
|
|
330
571
|
if (!row) throw new Error(`Document not found: ${documentId}`);
|
|
331
572
|
return mapDocument(row);
|
|
332
573
|
});
|
|
333
574
|
}
|
|
334
575
|
async function indexDocumentNow(db, objectStorage, workspaceId, documentId, services = createDocumentServices(), hooks = {}) {
|
|
335
|
-
const [
|
|
576
|
+
const [loadedDocument] = await withWorkspaceRls(
|
|
336
577
|
db,
|
|
337
578
|
workspaceId,
|
|
338
579
|
async (scopedDb) => await scopedDb.select().from(schema.documents).where(
|
|
339
580
|
and(eq(schema.documents.workspaceId, workspaceId), eq(schema.documents.id, documentId))
|
|
340
581
|
).limit(1)
|
|
341
582
|
);
|
|
342
|
-
if (!
|
|
583
|
+
if (!loadedDocument) throw new Error(`Document not found: ${documentId}`);
|
|
584
|
+
let document = loadedDocument;
|
|
343
585
|
const file = await requireReadyFile(db, workspaceId, document.fileId);
|
|
344
586
|
await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
345
587
|
await scopedDb.update(schema.documents).set({
|
|
@@ -354,6 +596,9 @@ async function indexDocumentNow(db, objectStorage, workspaceId, documentId, serv
|
|
|
354
596
|
try {
|
|
355
597
|
const bytes = await objectStorage.getFileBytes(file);
|
|
356
598
|
const parsed = await services.parser.parse(bytes, file);
|
|
599
|
+
if (document.curationStatus === "pending") {
|
|
600
|
+
document = await curateDroppedDocument(db, services, document, parsed, file);
|
|
601
|
+
}
|
|
357
602
|
const chunks = services.chunker.chunk(parsed, file);
|
|
358
603
|
await hooks.beforeEmbed?.({
|
|
359
604
|
accountId: document.accountId,
|
|
@@ -438,10 +683,104 @@ async function indexDocumentNow(db, objectStorage, workspaceId, documentId, serv
|
|
|
438
683
|
if (!failed) throw error;
|
|
439
684
|
return mapDocument(failed);
|
|
440
685
|
}
|
|
441
|
-
const updated = await getDocument(db, workspaceId, documentId
|
|
686
|
+
const updated = await getDocument(db, workspaceId, documentId, {
|
|
687
|
+
viewerSubjectId: document.createdBy
|
|
688
|
+
});
|
|
442
689
|
if (!updated) throw new Error(`Document disappeared after indexing: ${documentId}`);
|
|
443
690
|
return updated;
|
|
444
691
|
}
|
|
692
|
+
async function curateDroppedDocument(db, services, document, parsed, file) {
|
|
693
|
+
if (!services.curator) {
|
|
694
|
+
const [updated2] = await withWorkspaceRls(
|
|
695
|
+
db,
|
|
696
|
+
document.workspaceId,
|
|
697
|
+
async (scopedDb) => await scopedDb.update(schema.documents).set({
|
|
698
|
+
curationStatus: "none",
|
|
699
|
+
summary: null,
|
|
700
|
+
topics: [],
|
|
701
|
+
curation: null,
|
|
702
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
703
|
+
}).where(
|
|
704
|
+
and(
|
|
705
|
+
eq(schema.documents.workspaceId, document.workspaceId),
|
|
706
|
+
eq(schema.documents.id, document.id)
|
|
707
|
+
)
|
|
708
|
+
).returning()
|
|
709
|
+
);
|
|
710
|
+
return updated2 ?? document;
|
|
711
|
+
}
|
|
712
|
+
const bases = await listDocumentBases(db, document.workspaceId);
|
|
713
|
+
const candidates = bases.filter((base) => base.id !== document.baseId).map((base) => ({ id: base.id, name: base.name, description: base.description }));
|
|
714
|
+
const input = {
|
|
715
|
+
text: parsed.text.slice(0, DOCUMENT_CURATION_MAX_INPUT_CHARS),
|
|
716
|
+
filename: file.filename,
|
|
717
|
+
title: document.title,
|
|
718
|
+
bases: candidates
|
|
719
|
+
};
|
|
720
|
+
let outcome;
|
|
721
|
+
let model;
|
|
722
|
+
let failure = null;
|
|
723
|
+
try {
|
|
724
|
+
outcome = await services.curator.curate(input);
|
|
725
|
+
model = services.curator.model;
|
|
726
|
+
} catch (error) {
|
|
727
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
728
|
+
console.warn("document curation failed; applying heuristic fallback", {
|
|
729
|
+
workspaceId: document.workspaceId,
|
|
730
|
+
documentId: document.id,
|
|
731
|
+
error: failure
|
|
732
|
+
});
|
|
733
|
+
outcome = heuristicCuration(input, file.contentType);
|
|
734
|
+
model = "heuristic";
|
|
735
|
+
}
|
|
736
|
+
const suggestedBase = candidates.find((base) => base.id === outcome.targetBaseId) ?? null;
|
|
737
|
+
let moveToBaseId = null;
|
|
738
|
+
if (suggestedBase && failure === null && outcome.confidence >= DOCUMENT_CURATION_AUTO_FILE_CONFIDENCE) {
|
|
739
|
+
const conflict = await withWorkspaceRls(
|
|
740
|
+
db,
|
|
741
|
+
document.workspaceId,
|
|
742
|
+
async (scopedDb) => await scopedDb.select({ id: schema.documents.id }).from(schema.documents).where(
|
|
743
|
+
and(
|
|
744
|
+
eq(schema.documents.workspaceId, document.workspaceId),
|
|
745
|
+
eq(schema.documents.baseId, suggestedBase.id),
|
|
746
|
+
eq(schema.documents.fileId, document.fileId)
|
|
747
|
+
)
|
|
748
|
+
).limit(1)
|
|
749
|
+
);
|
|
750
|
+
if (conflict.length === 0) {
|
|
751
|
+
moveToBaseId = suggestedBase.id;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
const curation = {
|
|
755
|
+
suggestedBaseId: suggestedBase?.id ?? null,
|
|
756
|
+
suggestedBaseName: suggestedBase?.name ?? null,
|
|
757
|
+
confidence: outcome.confidence,
|
|
758
|
+
reason: failure ? `curation failed (${failure}); heuristic fallback applied` : outcome.reason,
|
|
759
|
+
originalTitle: document.title,
|
|
760
|
+
model
|
|
761
|
+
};
|
|
762
|
+
const curationStatus = failure ? "failed" : moveToBaseId ? "auto_filed" : "suggested";
|
|
763
|
+
const [updated] = await withWorkspaceRls(
|
|
764
|
+
db,
|
|
765
|
+
document.workspaceId,
|
|
766
|
+
async (scopedDb) => await scopedDb.update(schema.documents).set({
|
|
767
|
+
title: outcome.title ?? document.title,
|
|
768
|
+
summary: outcome.summary,
|
|
769
|
+
topics: outcome.topics,
|
|
770
|
+
...outcome.sourceKind ? { sourceKind: outcome.sourceKind } : {},
|
|
771
|
+
...moveToBaseId ? { baseId: moveToBaseId } : {},
|
|
772
|
+
curationStatus,
|
|
773
|
+
curation,
|
|
774
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
775
|
+
}).where(
|
|
776
|
+
and(
|
|
777
|
+
eq(schema.documents.workspaceId, document.workspaceId),
|
|
778
|
+
eq(schema.documents.id, document.id)
|
|
779
|
+
)
|
|
780
|
+
).returning()
|
|
781
|
+
);
|
|
782
|
+
return updated ?? document;
|
|
783
|
+
}
|
|
445
784
|
async function searchDocuments(db, input, services = createDocumentServices()) {
|
|
446
785
|
const mode = input.mode ?? "hybrid";
|
|
447
786
|
const limit = Math.min(Math.max(input.limit ?? 5, 1), 50);
|
|
@@ -539,7 +878,7 @@ async function keywordSearchDocuments(db, input, limit) {
|
|
|
539
878
|
keywordScore: normalizeKeywordScore(Number(row.rank))
|
|
540
879
|
}));
|
|
541
880
|
}
|
|
542
|
-
async function getDocumentChunk(db, workspaceId, chunkId) {
|
|
881
|
+
async function getDocumentChunk(db, workspaceId, chunkId, access) {
|
|
543
882
|
const [row] = await withWorkspaceRls(
|
|
544
883
|
db,
|
|
545
884
|
workspaceId,
|
|
@@ -565,7 +904,8 @@ async function getDocumentChunk(db, workspaceId, chunkId) {
|
|
|
565
904
|
and(
|
|
566
905
|
eq(schema.documentChunks.workspaceId, workspaceId),
|
|
567
906
|
eq(schema.documentChunks.id, chunkId),
|
|
568
|
-
eq(schema.documents.status, "ready")
|
|
907
|
+
eq(schema.documents.status, "ready"),
|
|
908
|
+
...documentAccessConditions(access)
|
|
569
909
|
)
|
|
570
910
|
).limit(1)
|
|
571
911
|
);
|
|
@@ -578,10 +918,36 @@ async function getDocumentChunk(db, workspaceId, chunkId) {
|
|
|
578
918
|
keywordScore: null
|
|
579
919
|
};
|
|
580
920
|
}
|
|
921
|
+
function documentAccessConditions(access) {
|
|
922
|
+
if (access?.agentOnly) {
|
|
923
|
+
const viewer2 = access.viewerSubjectId;
|
|
924
|
+
const visibility = viewer2 ? or(eq(schema.documents.visibility, "workspace"), eq(schema.documents.createdBy, viewer2)) : eq(schema.documents.visibility, "workspace");
|
|
925
|
+
return [eq(schema.documents.agentAccess, true), ...visibility ? [visibility] : []];
|
|
926
|
+
}
|
|
927
|
+
const viewer = access?.viewerSubjectId;
|
|
928
|
+
if (viewer) {
|
|
929
|
+
const condition = or(
|
|
930
|
+
eq(schema.documents.visibility, "workspace"),
|
|
931
|
+
eq(schema.documents.createdBy, viewer)
|
|
932
|
+
);
|
|
933
|
+
return condition ? [condition] : [];
|
|
934
|
+
}
|
|
935
|
+
return [eq(schema.documents.visibility, "workspace")];
|
|
936
|
+
}
|
|
937
|
+
function documentMatchesAccess(document, access) {
|
|
938
|
+
if (access?.agentOnly) {
|
|
939
|
+
return document.agentAccess && (document.visibility !== "private" || !!access.viewerSubjectId && document.createdBy === access.viewerSubjectId);
|
|
940
|
+
}
|
|
941
|
+
return canViewDocument(document, access?.viewerSubjectId);
|
|
942
|
+
}
|
|
943
|
+
function canViewDocument(document, viewerSubjectId) {
|
|
944
|
+
return document.visibility !== "private" || !!viewerSubjectId && document.createdBy === viewerSubjectId;
|
|
945
|
+
}
|
|
581
946
|
function documentSearchConditions(input, embeddingModel) {
|
|
582
947
|
const conditions = [
|
|
583
948
|
eq(schema.documents.status, "ready"),
|
|
584
|
-
eq(schema.documentChunks.workspaceId, input.workspaceId)
|
|
949
|
+
eq(schema.documentChunks.workspaceId, input.workspaceId),
|
|
950
|
+
...documentAccessConditions(input.access)
|
|
585
951
|
];
|
|
586
952
|
if (embeddingModel) {
|
|
587
953
|
conditions.push(eq(schema.documentChunks.embeddingModel, embeddingModel));
|
|
@@ -822,33 +1188,66 @@ function mapDocument(row) {
|
|
|
822
1188
|
sourceUpdatedAt: row.sourceUpdatedAt?.toISOString() ?? null,
|
|
823
1189
|
sourceVersion: row.sourceVersion,
|
|
824
1190
|
aclTags: cleanStringArray(row.aclTags),
|
|
1191
|
+
visibility: normalizeDocumentVisibility(row.visibility),
|
|
1192
|
+
createdBy: row.createdBy,
|
|
1193
|
+
agentAccess: row.agentAccess,
|
|
1194
|
+
summary: row.summary,
|
|
1195
|
+
topics: cleanStringArray(row.topics),
|
|
1196
|
+
curationStatus: normalizeDocumentCurationStatus(row.curationStatus),
|
|
1197
|
+
curation: row.curation ?? null,
|
|
825
1198
|
createdAt: row.createdAt.toISOString(),
|
|
826
1199
|
updatedAt: row.updatedAt.toISOString()
|
|
827
1200
|
};
|
|
828
1201
|
}
|
|
1202
|
+
function normalizeDocumentVisibility(value) {
|
|
1203
|
+
return value === "private" ? "private" : "workspace";
|
|
1204
|
+
}
|
|
1205
|
+
function normalizeDocumentCurationStatus(value) {
|
|
1206
|
+
switch (value) {
|
|
1207
|
+
case "pending":
|
|
1208
|
+
case "suggested":
|
|
1209
|
+
case "auto_filed":
|
|
1210
|
+
case "failed":
|
|
1211
|
+
return value;
|
|
1212
|
+
default:
|
|
1213
|
+
return "none";
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
829
1216
|
export {
|
|
1217
|
+
DEFAULT_BASE_DESCRIPTION,
|
|
1218
|
+
DEFAULT_BASE_NAME,
|
|
830
1219
|
DEFAULT_DOCUMENT_CHUNK_OVERLAP,
|
|
831
1220
|
DEFAULT_DOCUMENT_CHUNK_SIZE,
|
|
1221
|
+
DEFAULT_DOCUMENT_CURATION_MODEL,
|
|
832
1222
|
DEFAULT_DOCUMENT_EMBEDDING_DIMENSIONS,
|
|
833
1223
|
DEFAULT_DOCUMENT_EMBEDDING_MODEL,
|
|
834
1224
|
DEFAULT_DOCUMENT_PARSER,
|
|
1225
|
+
DOCUMENT_CURATION_AUTO_FILE_CONFIDENCE,
|
|
1226
|
+
DOCUMENT_CURATION_MAX_INPUT_CHARS,
|
|
835
1227
|
DeterministicEmbeddingProvider,
|
|
1228
|
+
HeuristicCurationProvider,
|
|
836
1229
|
LiteParseDocumentParser,
|
|
1230
|
+
OpenAICurationProvider,
|
|
837
1231
|
OpenAIEmbeddingProvider,
|
|
838
1232
|
RecursiveTextChunker,
|
|
839
1233
|
addDocumentToBase,
|
|
1234
|
+
canViewDocument,
|
|
840
1235
|
chunkText,
|
|
841
1236
|
createDocumentBase,
|
|
842
1237
|
createDocumentServices,
|
|
843
1238
|
deleteDocumentFromBase,
|
|
844
1239
|
deterministicEmbedding,
|
|
845
1240
|
documentOpenAIEmbeddingConfig,
|
|
1241
|
+
ensureDefaultBase,
|
|
846
1242
|
getDocument,
|
|
847
1243
|
getDocumentBase,
|
|
848
1244
|
getDocumentChunk,
|
|
1245
|
+
heuristicCuration,
|
|
849
1246
|
indexDocumentNow,
|
|
850
1247
|
listDocumentBases,
|
|
851
1248
|
listDocuments,
|
|
1249
|
+
moveDocumentToBase,
|
|
1250
|
+
parseCurationOutcome,
|
|
852
1251
|
parseDocumentBytes,
|
|
853
1252
|
queueDocumentForReindex,
|
|
854
1253
|
searchDocuments
|