@opengeni/documents 0.2.31 → 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/src/index.ts
CHANGED
|
@@ -4,8 +4,11 @@ import type {
|
|
|
4
4
|
CreateDocumentBaseRequest,
|
|
5
5
|
Document,
|
|
6
6
|
DocumentBase,
|
|
7
|
+
DocumentCuration,
|
|
8
|
+
DocumentCurationStatus,
|
|
7
9
|
DocumentSearchMode,
|
|
8
10
|
DocumentSearchResult,
|
|
11
|
+
DocumentVisibility,
|
|
9
12
|
FileAsset,
|
|
10
13
|
KnowledgeSourceKind,
|
|
11
14
|
} from "@opengeni/contracts";
|
|
@@ -13,7 +16,7 @@ import { requireFile, withRlsContext, withWorkspaceRls, type Database } from "@o
|
|
|
13
16
|
import * as schema from "@opengeni/db/schema";
|
|
14
17
|
import type { ObjectStorage } from "@opengeni/storage";
|
|
15
18
|
import { LiteParse } from "@llamaindex/liteparse";
|
|
16
|
-
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm";
|
|
19
|
+
import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm";
|
|
17
20
|
import OpenAI from "openai";
|
|
18
21
|
|
|
19
22
|
export const DEFAULT_DOCUMENT_PARSER = "liteparse";
|
|
@@ -21,6 +24,17 @@ export const DEFAULT_DOCUMENT_EMBEDDING_MODEL = "text-embedding-3-large";
|
|
|
21
24
|
export const DEFAULT_DOCUMENT_EMBEDDING_DIMENSIONS = 3072;
|
|
22
25
|
export const DEFAULT_DOCUMENT_CHUNK_SIZE = 1200;
|
|
23
26
|
export const DEFAULT_DOCUMENT_CHUNK_OVERLAP = 160;
|
|
27
|
+
export const DEFAULT_DOCUMENT_CURATION_MODEL = "gpt-4o-mini";
|
|
28
|
+
// Curator input is a preview, not the whole document — enough to name and
|
|
29
|
+
// classify without paying for a full-document prompt on every drop.
|
|
30
|
+
export const DOCUMENT_CURATION_MAX_INPUT_CHARS = 24_000;
|
|
31
|
+
// A base move is applied automatically only at or above this curator
|
|
32
|
+
// confidence; below it the suggestion is surfaced for human review instead.
|
|
33
|
+
export const DOCUMENT_CURATION_AUTO_FILE_CONFIDENCE = 0.75;
|
|
34
|
+
// The per-workspace default base: where knowledge drops land (created on
|
|
35
|
+
// first drop) and stay unless configured curation files them into a topical base.
|
|
36
|
+
export const DEFAULT_BASE_NAME = "Default";
|
|
37
|
+
export const DEFAULT_BASE_DESCRIPTION = "Default base for dropped files and notes.";
|
|
24
38
|
|
|
25
39
|
export type ParsedDocument = {
|
|
26
40
|
text: string;
|
|
@@ -48,10 +62,59 @@ export type DocumentEmbedder = {
|
|
|
48
62
|
embedQuery: (text: string) => Promise<number[]>;
|
|
49
63
|
};
|
|
50
64
|
|
|
65
|
+
export type DocumentCurationCandidateBase = {
|
|
66
|
+
id: string;
|
|
67
|
+
name: string;
|
|
68
|
+
description: string | null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type DocumentCurationInput = {
|
|
72
|
+
/** Parsed document text, clipped to DOCUMENT_CURATION_MAX_INPUT_CHARS. */
|
|
73
|
+
text: string;
|
|
74
|
+
filename: string;
|
|
75
|
+
/** Current (usually filename-derived) title. */
|
|
76
|
+
title: string;
|
|
77
|
+
/** Candidate bases the document could be filed into (never its current base). */
|
|
78
|
+
bases: DocumentCurationCandidateBase[];
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type DocumentCurationOutcome = {
|
|
82
|
+
title: string | null;
|
|
83
|
+
summary: string | null;
|
|
84
|
+
sourceKind: KnowledgeSourceKind | null;
|
|
85
|
+
topics: string[];
|
|
86
|
+
targetBaseId: string | null;
|
|
87
|
+
confidence: number;
|
|
88
|
+
reason: string | null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type DocumentCurator = {
|
|
92
|
+
model: string;
|
|
93
|
+
curate: (input: DocumentCurationInput) => Promise<DocumentCurationOutcome>;
|
|
94
|
+
};
|
|
95
|
+
|
|
51
96
|
export type DocumentServices = {
|
|
52
97
|
parser: DocumentParser;
|
|
53
98
|
chunker: DocumentChunker;
|
|
54
99
|
embedder: DocumentEmbedder;
|
|
100
|
+
/** Optional: names/summarizes/categorizes dropped documents during indexing. */
|
|
101
|
+
curator?: DocumentCurator | undefined;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Read-scoping for document queries. Fail-closed: when a caller supplies no
|
|
106
|
+
* filter, private documents are invisible (only their creator may see them,
|
|
107
|
+
* and only by passing their subject id).
|
|
108
|
+
*/
|
|
109
|
+
export type DocumentAccessFilter = {
|
|
110
|
+
/** Grant subject id of the human viewer; null/undefined hides private docs. */
|
|
111
|
+
viewerSubjectId?: string | null | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* Agent retrieval surface: only agent-enabled documents. Workspace-visible
|
|
114
|
+
* documents are available to every agent; private documents are available
|
|
115
|
+
* only when the agent carries the creating subject as its viewer subject.
|
|
116
|
+
*/
|
|
117
|
+
agentOnly?: boolean | undefined;
|
|
55
118
|
};
|
|
56
119
|
|
|
57
120
|
export type DocumentSearchInput = {
|
|
@@ -62,6 +125,7 @@ export type DocumentSearchInput = {
|
|
|
62
125
|
mode?: DocumentSearchMode | undefined;
|
|
63
126
|
sourceKinds?: KnowledgeSourceKind[] | undefined;
|
|
64
127
|
aclTags?: string[] | undefined;
|
|
128
|
+
access?: DocumentAccessFilter | undefined;
|
|
65
129
|
};
|
|
66
130
|
|
|
67
131
|
export type DocumentIndexHooks = {
|
|
@@ -221,6 +285,169 @@ export class DeterministicEmbeddingProvider implements DocumentEmbedder {
|
|
|
221
285
|
}
|
|
222
286
|
}
|
|
223
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Deterministic no-network curation: first meaningful line becomes the title,
|
|
290
|
+
* the opening text becomes the summary, and the kind is guessed from the
|
|
291
|
+
* filename/content type. Never proposes a base move (confidence 0). Used as
|
|
292
|
+
* the `heuristic` provider and as the in-pipeline fallback when the LLM
|
|
293
|
+
* curator fails — a drop must always end up named and summarized.
|
|
294
|
+
*/
|
|
295
|
+
export function heuristicCuration(
|
|
296
|
+
input: DocumentCurationInput,
|
|
297
|
+
contentType = "application/octet-stream",
|
|
298
|
+
): DocumentCurationOutcome {
|
|
299
|
+
const firstLine = input.text
|
|
300
|
+
.split("\n")
|
|
301
|
+
.map((line) => line.replace(/^[#>\s*-]+/, "").trim())
|
|
302
|
+
.find((line) => line.length >= 3);
|
|
303
|
+
const title = (firstLine ?? input.title).slice(0, 120).trim() || input.title;
|
|
304
|
+
const summaryWindow = input.text.replace(/\s+/g, " ").trim().slice(0, 360);
|
|
305
|
+
const summary =
|
|
306
|
+
summaryWindow.length === 360 ? `${summaryWindow.slice(0, 357)}...` : summaryWindow;
|
|
307
|
+
return {
|
|
308
|
+
title,
|
|
309
|
+
summary: summary || null,
|
|
310
|
+
sourceKind: heuristicSourceKind(input.filename, contentType),
|
|
311
|
+
topics: [],
|
|
312
|
+
targetBaseId: null,
|
|
313
|
+
confidence: 0,
|
|
314
|
+
reason: null,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function heuristicSourceKind(rawFilename: string, rawContentType: string): KnowledgeSourceKind {
|
|
319
|
+
const filename = rawFilename.toLowerCase();
|
|
320
|
+
const contentType = rawContentType.toLowerCase();
|
|
321
|
+
if (filename.endsWith(".eml") || contentType === "message/rfc822") return "email";
|
|
322
|
+
if (filename.endsWith(".vtt") || filename.endsWith(".srt") || filename.includes("transcript")) {
|
|
323
|
+
return "meeting_transcript";
|
|
324
|
+
}
|
|
325
|
+
if (contentType === "text/html") return "web";
|
|
326
|
+
if (contentType === "application/pdf" || filename.endsWith(".docx") || filename.endsWith(".md")) {
|
|
327
|
+
return "document";
|
|
328
|
+
}
|
|
329
|
+
return "manual_upload";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export class HeuristicCurationProvider implements DocumentCurator {
|
|
333
|
+
readonly model = "heuristic";
|
|
334
|
+
|
|
335
|
+
async curate(input: DocumentCurationInput): Promise<DocumentCurationOutcome> {
|
|
336
|
+
return heuristicCuration(input);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const CURATION_SYSTEM_PROMPT = [
|
|
341
|
+
"You organize a team knowledge base. Given the beginning of a dropped document",
|
|
342
|
+
"and the list of existing collections (bases), return STRICT JSON with keys:",
|
|
343
|
+
'"title" (concise, specific, <= 120 chars, no filename extensions),',
|
|
344
|
+
'"summary" (2-3 sentences, plain prose, what the document is and why it matters),',
|
|
345
|
+
'"sourceKind" (one of: manual_upload, meeting_transcript, repository, email, chat, document, web, other),',
|
|
346
|
+
'"topics" (3-6 short lowercase tags),',
|
|
347
|
+
'"targetBaseId" (the id of the best-fitting existing base, or null if none fits),',
|
|
348
|
+
'"confidence" (0..1 — how sure you are the document belongs in targetBaseId),',
|
|
349
|
+
'"reason" (one sentence explaining the filing choice).',
|
|
350
|
+
"Only pick a targetBaseId from the provided list. If the document fits no base",
|
|
351
|
+
"well, return targetBaseId null and confidence 0. Respond with JSON only.",
|
|
352
|
+
].join(" ");
|
|
353
|
+
|
|
354
|
+
export class OpenAICurationProvider implements DocumentCurator {
|
|
355
|
+
private client: OpenAI | null = null;
|
|
356
|
+
private readonly apiKey: string | undefined;
|
|
357
|
+
private readonly baseURL: string | undefined;
|
|
358
|
+
private readonly defaultHeaders: Record<string, string> | undefined;
|
|
359
|
+
private readonly defaultQuery: Record<string, string> | undefined;
|
|
360
|
+
readonly model: string;
|
|
361
|
+
|
|
362
|
+
constructor(args: {
|
|
363
|
+
apiKey?: string | undefined;
|
|
364
|
+
baseURL?: string | undefined;
|
|
365
|
+
defaultHeaders?: Record<string, string> | undefined;
|
|
366
|
+
defaultQuery?: Record<string, string> | undefined;
|
|
367
|
+
model?: string | undefined;
|
|
368
|
+
}) {
|
|
369
|
+
this.apiKey = args.apiKey ?? process.env.OPENAI_API_KEY;
|
|
370
|
+
this.baseURL = args.baseURL;
|
|
371
|
+
this.defaultHeaders = args.defaultHeaders;
|
|
372
|
+
this.defaultQuery = args.defaultQuery;
|
|
373
|
+
this.model = args.model ?? DEFAULT_DOCUMENT_CURATION_MODEL;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async curate(input: DocumentCurationInput): Promise<DocumentCurationOutcome> {
|
|
377
|
+
const response = await this.openai().chat.completions.create({
|
|
378
|
+
model: this.model,
|
|
379
|
+
response_format: { type: "json_object" },
|
|
380
|
+
messages: [
|
|
381
|
+
{ role: "system", content: CURATION_SYSTEM_PROMPT },
|
|
382
|
+
{
|
|
383
|
+
role: "user",
|
|
384
|
+
content: JSON.stringify({
|
|
385
|
+
filename: input.filename,
|
|
386
|
+
currentTitle: input.title,
|
|
387
|
+
bases: input.bases,
|
|
388
|
+
text: input.text,
|
|
389
|
+
}),
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
});
|
|
393
|
+
const raw = response.choices[0]?.message?.content;
|
|
394
|
+
if (!raw) {
|
|
395
|
+
throw new Error("curation model returned no content");
|
|
396
|
+
}
|
|
397
|
+
return parseCurationOutcome(raw, input.bases);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private openai(): OpenAI {
|
|
401
|
+
if (!this.apiKey) {
|
|
402
|
+
throw new Error("OpenAI document curation requires an API key");
|
|
403
|
+
}
|
|
404
|
+
this.client ??= new OpenAI({
|
|
405
|
+
apiKey: this.apiKey,
|
|
406
|
+
...(this.baseURL ? { baseURL: this.baseURL } : {}),
|
|
407
|
+
...(this.defaultQuery ? { defaultQuery: this.defaultQuery } : {}),
|
|
408
|
+
...(this.defaultHeaders ? { defaultHeaders: this.defaultHeaders } : {}),
|
|
409
|
+
});
|
|
410
|
+
return this.client;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Parse + clamp model output; a targetBaseId outside the candidate list is dropped. */
|
|
415
|
+
export function parseCurationOutcome(
|
|
416
|
+
raw: string,
|
|
417
|
+
bases: DocumentCurationCandidateBase[],
|
|
418
|
+
): DocumentCurationOutcome {
|
|
419
|
+
const parsed: unknown = JSON.parse(raw);
|
|
420
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
421
|
+
throw new Error("curation model returned non-object JSON");
|
|
422
|
+
}
|
|
423
|
+
const record = parsed as Record<string, unknown>;
|
|
424
|
+
const knownBase = bases.find((base) => base.id === record.targetBaseId);
|
|
425
|
+
const confidence =
|
|
426
|
+
typeof record.confidence === "number" && Number.isFinite(record.confidence)
|
|
427
|
+
? Math.min(1, Math.max(0, record.confidence))
|
|
428
|
+
: 0;
|
|
429
|
+
return {
|
|
430
|
+
title:
|
|
431
|
+
cleanString(typeof record.title === "string" ? record.title.slice(0, 200) : null) ?? null,
|
|
432
|
+
summary:
|
|
433
|
+
cleanString(typeof record.summary === "string" ? record.summary.slice(0, 2000) : null) ??
|
|
434
|
+
null,
|
|
435
|
+
sourceKind:
|
|
436
|
+
typeof record.sourceKind === "string"
|
|
437
|
+
? normalizeKnowledgeSourceKind(record.sourceKind)
|
|
438
|
+
: null,
|
|
439
|
+
topics: cleanStringArray(
|
|
440
|
+
Array.isArray(record.topics)
|
|
441
|
+
? record.topics.filter((topic): topic is string => typeof topic === "string").slice(0, 8)
|
|
442
|
+
: [],
|
|
443
|
+
).map((topic) => topic.toLowerCase().slice(0, 60)),
|
|
444
|
+
targetBaseId: knownBase?.id ?? null,
|
|
445
|
+
confidence: knownBase ? confidence : 0,
|
|
446
|
+
reason:
|
|
447
|
+
cleanString(typeof record.reason === "string" ? record.reason.slice(0, 500) : null) ?? null,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
224
451
|
export function createDocumentServices(
|
|
225
452
|
settings?: Settings,
|
|
226
453
|
overrides: Partial<DocumentServices> = {},
|
|
@@ -244,9 +471,24 @@ export function createDocumentServices(
|
|
|
244
471
|
model: settings?.documentEmbeddingModel ?? DEFAULT_DOCUMENT_EMBEDDING_MODEL,
|
|
245
472
|
dimensions,
|
|
246
473
|
})),
|
|
474
|
+
curator: overrides.curator ?? createDocumentCurator(settings),
|
|
247
475
|
};
|
|
248
476
|
}
|
|
249
477
|
|
|
478
|
+
function createDocumentCurator(settings?: Settings): DocumentCurator | undefined {
|
|
479
|
+
const provider = settings?.documentCurationProvider ?? "openai";
|
|
480
|
+
if (provider === "none") return undefined;
|
|
481
|
+
if (provider === "heuristic") return new HeuristicCurationProvider();
|
|
482
|
+
const embeddingConfig = documentOpenAIEmbeddingConfig(settings);
|
|
483
|
+
return new OpenAICurationProvider({
|
|
484
|
+
apiKey: settings?.documentCurationApiKey ?? embeddingConfig.apiKey,
|
|
485
|
+
baseURL: settings?.documentCurationBaseUrl ?? embeddingConfig.baseURL,
|
|
486
|
+
defaultHeaders: embeddingConfig.defaultHeaders,
|
|
487
|
+
defaultQuery: embeddingConfig.defaultQuery,
|
|
488
|
+
model: settings?.documentCurationModel ?? DEFAULT_DOCUMENT_CURATION_MODEL,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
250
492
|
export function documentOpenAIEmbeddingConfig(settings?: Settings): {
|
|
251
493
|
apiKey?: string | undefined;
|
|
252
494
|
baseURL?: string | undefined;
|
|
@@ -354,9 +596,65 @@ export async function getDocumentBase(
|
|
|
354
596
|
});
|
|
355
597
|
}
|
|
356
598
|
|
|
599
|
+
/**
|
|
600
|
+
* Find-or-create the workspace's Default base — where knowledge drops land
|
|
601
|
+
* before configured curation files them elsewhere. Matched by name
|
|
602
|
+
* (case-insensitive) so a user-created "Default" is adopted rather than
|
|
603
|
+
* duplicated.
|
|
604
|
+
*/
|
|
605
|
+
export async function ensureDefaultBase(
|
|
606
|
+
db: Database,
|
|
607
|
+
input: { accountId: string; workspaceId: string },
|
|
608
|
+
): Promise<DocumentBase> {
|
|
609
|
+
return await withRlsContext(
|
|
610
|
+
db,
|
|
611
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
612
|
+
async (scopedDb) =>
|
|
613
|
+
await scopedDb.transaction(async (tx) => {
|
|
614
|
+
const defaultName = sql`lower(btrim(${schema.documentBases.name})) = ${DEFAULT_BASE_NAME.toLowerCase()}`;
|
|
615
|
+
const [existing] = await tx
|
|
616
|
+
.select()
|
|
617
|
+
.from(schema.documentBases)
|
|
618
|
+
.where(and(eq(schema.documentBases.workspaceId, input.workspaceId), defaultName))
|
|
619
|
+
.limit(1);
|
|
620
|
+
if (existing) return mapDocumentBase(existing);
|
|
621
|
+
|
|
622
|
+
// The partial unique index is the serialization point for concurrent
|
|
623
|
+
// first drops. A losing insert re-reads the winner instead of
|
|
624
|
+
// surfacing a duplicate-base error.
|
|
625
|
+
const [inserted] = await tx
|
|
626
|
+
.insert(schema.documentBases)
|
|
627
|
+
.values({
|
|
628
|
+
accountId: input.accountId,
|
|
629
|
+
workspaceId: input.workspaceId,
|
|
630
|
+
name: DEFAULT_BASE_NAME,
|
|
631
|
+
description: DEFAULT_BASE_DESCRIPTION,
|
|
632
|
+
})
|
|
633
|
+
.onConflictDoNothing()
|
|
634
|
+
.returning();
|
|
635
|
+
if (inserted) return mapDocumentBase(inserted);
|
|
636
|
+
|
|
637
|
+
const [raced] = await tx
|
|
638
|
+
.select()
|
|
639
|
+
.from(schema.documentBases)
|
|
640
|
+
.where(and(eq(schema.documentBases.workspaceId, input.workspaceId), defaultName))
|
|
641
|
+
.limit(1);
|
|
642
|
+
if (raced) return mapDocumentBase(raced);
|
|
643
|
+
throw new Error("Failed to create Default document base");
|
|
644
|
+
}),
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
357
648
|
export async function addDocumentToBase(
|
|
358
649
|
db: Database,
|
|
359
|
-
input: AddDocumentRequest & {
|
|
650
|
+
input: AddDocumentRequest & {
|
|
651
|
+
accountId: string;
|
|
652
|
+
workspaceId: string;
|
|
653
|
+
baseId: string;
|
|
654
|
+
createdBy?: string | null | undefined;
|
|
655
|
+
curationStatus?: DocumentCurationStatus | undefined;
|
|
656
|
+
access?: DocumentAccessFilter | undefined;
|
|
657
|
+
},
|
|
360
658
|
): Promise<Document> {
|
|
361
659
|
return await withRlsContext(
|
|
362
660
|
db,
|
|
@@ -365,6 +663,9 @@ export async function addDocumentToBase(
|
|
|
365
663
|
const base = await getDocumentBase(scopedDb, input.workspaceId, input.baseId);
|
|
366
664
|
if (!base) throw new Error(`Document base not found: ${input.baseId}`);
|
|
367
665
|
const file = await requireReadyFile(scopedDb, input.workspaceId, input.fileId);
|
|
666
|
+
if (input.visibility === "private" && !cleanString(input.createdBy ?? null)) {
|
|
667
|
+
throw new Error("private documents require a creating subject");
|
|
668
|
+
}
|
|
368
669
|
const now = new Date();
|
|
369
670
|
const [existing] = await scopedDb
|
|
370
671
|
.select()
|
|
@@ -378,9 +679,14 @@ export async function addDocumentToBase(
|
|
|
378
679
|
)
|
|
379
680
|
.limit(1);
|
|
380
681
|
if (existing) {
|
|
682
|
+
if (!documentMatchesAccess(existing, input.access)) {
|
|
683
|
+
throw new Error(`Document not found: ${existing.id}`);
|
|
684
|
+
}
|
|
381
685
|
// Idempotent re-add: refresh caller-supplied source metadata on the
|
|
382
686
|
// existing row instead of silently discarding it (aclTags especially —
|
|
383
|
-
// a re-add that tightens tags must not be a no-op).
|
|
687
|
+
// a re-add that tightens tags must not be a no-op). Access policy is
|
|
688
|
+
// deliberately inherited: re-adding a known file is not an implicit
|
|
689
|
+
// visibility/agent-policy update that another manager can exploit.
|
|
384
690
|
const [updated] = await scopedDb
|
|
385
691
|
.update(schema.documents)
|
|
386
692
|
.set({
|
|
@@ -400,6 +706,7 @@ export async function addDocumentToBase(
|
|
|
400
706
|
and(
|
|
401
707
|
eq(schema.documents.workspaceId, input.workspaceId),
|
|
402
708
|
eq(schema.documents.id, existing.id),
|
|
709
|
+
...documentAccessConditions(input.access),
|
|
403
710
|
),
|
|
404
711
|
)
|
|
405
712
|
.returning();
|
|
@@ -424,6 +731,10 @@ export async function addDocumentToBase(
|
|
|
424
731
|
sourceUpdatedAt: parseOptionalDate(input.sourceUpdatedAt),
|
|
425
732
|
sourceVersion: cleanString(input.sourceVersion) ?? null,
|
|
426
733
|
aclTags: cleanStringArray(input.aclTags),
|
|
734
|
+
visibility: input.visibility ?? "workspace",
|
|
735
|
+
agentAccess: input.agentAccess ?? true,
|
|
736
|
+
createdBy: input.createdBy ?? null,
|
|
737
|
+
curationStatus: input.curationStatus ?? "none",
|
|
427
738
|
updatedAt: now,
|
|
428
739
|
})
|
|
429
740
|
.returning();
|
|
@@ -433,9 +744,107 @@ export async function addDocumentToBase(
|
|
|
433
744
|
);
|
|
434
745
|
}
|
|
435
746
|
|
|
747
|
+
/**
|
|
748
|
+
* Move a document and its indexed chunks to another base. With no explicit
|
|
749
|
+
* target, applies the stored curation suggestion. A 'suggested' or 'pending'
|
|
750
|
+
* document that gets moved counts as filed.
|
|
751
|
+
*/
|
|
752
|
+
export async function moveDocumentToBase(
|
|
753
|
+
db: Database,
|
|
754
|
+
input: {
|
|
755
|
+
accountId: string;
|
|
756
|
+
workspaceId: string;
|
|
757
|
+
documentId: string;
|
|
758
|
+
targetBaseId?: string | null | undefined;
|
|
759
|
+
access?: DocumentAccessFilter | undefined;
|
|
760
|
+
},
|
|
761
|
+
): Promise<Document> {
|
|
762
|
+
return await withRlsContext(
|
|
763
|
+
db,
|
|
764
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
765
|
+
async (scopedDb) => {
|
|
766
|
+
const [row] = await scopedDb
|
|
767
|
+
.select()
|
|
768
|
+
.from(schema.documents)
|
|
769
|
+
.where(
|
|
770
|
+
and(
|
|
771
|
+
eq(schema.documents.workspaceId, input.workspaceId),
|
|
772
|
+
eq(schema.documents.id, input.documentId),
|
|
773
|
+
...documentAccessConditions(input.access),
|
|
774
|
+
),
|
|
775
|
+
)
|
|
776
|
+
.limit(1);
|
|
777
|
+
if (!row) throw new Error(`Document not found: ${input.documentId}`);
|
|
778
|
+
const suggestion = (row.curation as { suggestedBaseId?: string | null } | null)
|
|
779
|
+
?.suggestedBaseId;
|
|
780
|
+
const targetBaseId = input.targetBaseId ?? suggestion;
|
|
781
|
+
if (!targetBaseId) {
|
|
782
|
+
throw new Error("document has no suggested base; pass targetBaseId");
|
|
783
|
+
}
|
|
784
|
+
if (targetBaseId === row.baseId) return mapDocument(row);
|
|
785
|
+
const base = await getDocumentBase(scopedDb, input.workspaceId, targetBaseId);
|
|
786
|
+
if (!base) throw new Error(`Document base not found: ${targetBaseId}`);
|
|
787
|
+
const [conflict] = await scopedDb
|
|
788
|
+
.select({ id: schema.documents.id })
|
|
789
|
+
.from(schema.documents)
|
|
790
|
+
.where(
|
|
791
|
+
and(
|
|
792
|
+
eq(schema.documents.workspaceId, input.workspaceId),
|
|
793
|
+
eq(schema.documents.baseId, targetBaseId),
|
|
794
|
+
eq(schema.documents.fileId, row.fileId),
|
|
795
|
+
),
|
|
796
|
+
)
|
|
797
|
+
.limit(1);
|
|
798
|
+
if (conflict) {
|
|
799
|
+
throw new Error("a document for this file already exists in the target base");
|
|
800
|
+
}
|
|
801
|
+
const now = new Date();
|
|
802
|
+
const moved = await scopedDb.transaction(async (tx) => {
|
|
803
|
+
const [updated] = await tx
|
|
804
|
+
.update(schema.documents)
|
|
805
|
+
.set({
|
|
806
|
+
baseId: targetBaseId,
|
|
807
|
+
...(row.curationStatus === "suggested" || row.curationStatus === "pending"
|
|
808
|
+
? { curationStatus: "auto_filed" }
|
|
809
|
+
: {}),
|
|
810
|
+
updatedAt: now,
|
|
811
|
+
})
|
|
812
|
+
.where(
|
|
813
|
+
and(
|
|
814
|
+
eq(schema.documents.workspaceId, input.workspaceId),
|
|
815
|
+
eq(schema.documents.id, input.documentId),
|
|
816
|
+
...documentAccessConditions(input.access),
|
|
817
|
+
),
|
|
818
|
+
)
|
|
819
|
+
.returning();
|
|
820
|
+
if (updated) {
|
|
821
|
+
await tx
|
|
822
|
+
.update(schema.documentChunks)
|
|
823
|
+
.set({ baseId: targetBaseId })
|
|
824
|
+
.where(
|
|
825
|
+
and(
|
|
826
|
+
eq(schema.documentChunks.workspaceId, input.workspaceId),
|
|
827
|
+
eq(schema.documentChunks.documentId, input.documentId),
|
|
828
|
+
),
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
return updated;
|
|
832
|
+
});
|
|
833
|
+
if (!moved) throw new Error(`Document not found: ${input.documentId}`);
|
|
834
|
+
return mapDocument(moved);
|
|
835
|
+
},
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
436
839
|
export async function deleteDocumentFromBase(
|
|
437
840
|
db: Database,
|
|
438
|
-
input: {
|
|
841
|
+
input: {
|
|
842
|
+
accountId: string;
|
|
843
|
+
workspaceId: string;
|
|
844
|
+
baseId: string;
|
|
845
|
+
documentId: string;
|
|
846
|
+
access?: DocumentAccessFilter | undefined;
|
|
847
|
+
},
|
|
439
848
|
): Promise<void> {
|
|
440
849
|
await withRlsContext(
|
|
441
850
|
db,
|
|
@@ -448,6 +857,7 @@ export async function deleteDocumentFromBase(
|
|
|
448
857
|
and(
|
|
449
858
|
eq(schema.documents.workspaceId, input.workspaceId),
|
|
450
859
|
eq(schema.documents.id, input.documentId),
|
|
860
|
+
...documentAccessConditions(input.access),
|
|
451
861
|
),
|
|
452
862
|
)
|
|
453
863
|
.limit(1);
|
|
@@ -463,6 +873,7 @@ export async function deleteDocumentFromBase(
|
|
|
463
873
|
and(
|
|
464
874
|
eq(schema.documents.workspaceId, input.workspaceId),
|
|
465
875
|
eq(schema.documents.id, input.documentId),
|
|
876
|
+
...documentAccessConditions(input.access),
|
|
466
877
|
),
|
|
467
878
|
);
|
|
468
879
|
},
|
|
@@ -473,13 +884,18 @@ export async function listDocuments(
|
|
|
473
884
|
db: Database,
|
|
474
885
|
workspaceId: string,
|
|
475
886
|
baseId: string,
|
|
887
|
+
access?: DocumentAccessFilter,
|
|
476
888
|
): Promise<Document[]> {
|
|
477
889
|
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
478
890
|
const rows = await scopedDb
|
|
479
891
|
.select()
|
|
480
892
|
.from(schema.documents)
|
|
481
893
|
.where(
|
|
482
|
-
and(
|
|
894
|
+
and(
|
|
895
|
+
eq(schema.documents.workspaceId, workspaceId),
|
|
896
|
+
eq(schema.documents.baseId, baseId),
|
|
897
|
+
...documentAccessConditions(access),
|
|
898
|
+
),
|
|
483
899
|
)
|
|
484
900
|
.orderBy(asc(schema.documents.createdAt));
|
|
485
901
|
return rows.map(mapDocument);
|
|
@@ -490,13 +906,18 @@ export async function getDocument(
|
|
|
490
906
|
db: Database,
|
|
491
907
|
workspaceId: string,
|
|
492
908
|
documentId: string,
|
|
909
|
+
access?: DocumentAccessFilter,
|
|
493
910
|
): Promise<Document | null> {
|
|
494
911
|
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
495
912
|
const [row] = await scopedDb
|
|
496
913
|
.select()
|
|
497
914
|
.from(schema.documents)
|
|
498
915
|
.where(
|
|
499
|
-
and(
|
|
916
|
+
and(
|
|
917
|
+
eq(schema.documents.workspaceId, workspaceId),
|
|
918
|
+
eq(schema.documents.id, documentId),
|
|
919
|
+
...documentAccessConditions(access),
|
|
920
|
+
),
|
|
500
921
|
)
|
|
501
922
|
.limit(1);
|
|
502
923
|
return row ? mapDocument(row) : null;
|
|
@@ -507,6 +928,7 @@ export async function queueDocumentForReindex(
|
|
|
507
928
|
db: Database,
|
|
508
929
|
workspaceId: string,
|
|
509
930
|
documentId: string,
|
|
931
|
+
access?: DocumentAccessFilter,
|
|
510
932
|
): Promise<Document> {
|
|
511
933
|
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
512
934
|
const [row] = await scopedDb
|
|
@@ -517,7 +939,11 @@ export async function queueDocumentForReindex(
|
|
|
517
939
|
updatedAt: new Date(),
|
|
518
940
|
})
|
|
519
941
|
.where(
|
|
520
|
-
and(
|
|
942
|
+
and(
|
|
943
|
+
eq(schema.documents.workspaceId, workspaceId),
|
|
944
|
+
eq(schema.documents.id, documentId),
|
|
945
|
+
...documentAccessConditions(access),
|
|
946
|
+
),
|
|
521
947
|
)
|
|
522
948
|
.returning();
|
|
523
949
|
if (!row) throw new Error(`Document not found: ${documentId}`);
|
|
@@ -533,7 +959,7 @@ export async function indexDocumentNow(
|
|
|
533
959
|
services: DocumentServices = createDocumentServices(),
|
|
534
960
|
hooks: DocumentIndexHooks = {},
|
|
535
961
|
): Promise<Document> {
|
|
536
|
-
const [
|
|
962
|
+
const [loadedDocument] = await withWorkspaceRls(
|
|
537
963
|
db,
|
|
538
964
|
workspaceId,
|
|
539
965
|
async (scopedDb) =>
|
|
@@ -545,7 +971,8 @@ export async function indexDocumentNow(
|
|
|
545
971
|
)
|
|
546
972
|
.limit(1),
|
|
547
973
|
);
|
|
548
|
-
if (!
|
|
974
|
+
if (!loadedDocument) throw new Error(`Document not found: ${documentId}`);
|
|
975
|
+
let document: DocumentRow = loadedDocument;
|
|
549
976
|
const file = await requireReadyFile(db, workspaceId, document.fileId);
|
|
550
977
|
await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
551
978
|
await scopedDb
|
|
@@ -563,6 +990,13 @@ export async function indexDocumentNow(
|
|
|
563
990
|
try {
|
|
564
991
|
const bytes = await objectStorage.getFileBytes(file);
|
|
565
992
|
const parsed = await services.parser.parse(bytes, file);
|
|
993
|
+
// Knowledge drops (curationStatus 'pending') are curated between parse and
|
|
994
|
+
// chunking when a provider is enabled, so chunk metadata and base placement
|
|
995
|
+
// reflect the curated truth. Disabled curation leaves caller metadata intact;
|
|
996
|
+
// enabled-provider failures remain fail-soft through the heuristic fallback.
|
|
997
|
+
if (document.curationStatus === "pending") {
|
|
998
|
+
document = await curateDroppedDocument(db, services, document, parsed, file);
|
|
999
|
+
}
|
|
566
1000
|
const chunks = services.chunker.chunk(parsed, file);
|
|
567
1001
|
await hooks.beforeEmbed?.({
|
|
568
1002
|
accountId: document.accountId,
|
|
@@ -658,11 +1092,147 @@ export async function indexDocumentNow(
|
|
|
658
1092
|
if (!failed) throw error;
|
|
659
1093
|
return mapDocument(failed);
|
|
660
1094
|
}
|
|
661
|
-
|
|
1095
|
+
// Internal indexing must be able to return a private document to the caller
|
|
1096
|
+
// that created/queued it. Public reads remain fail-closed when no subject is
|
|
1097
|
+
// supplied; the creator subject is the document's frozen access principal.
|
|
1098
|
+
const updated = await getDocument(db, workspaceId, documentId, {
|
|
1099
|
+
viewerSubjectId: document.createdBy,
|
|
1100
|
+
});
|
|
662
1101
|
if (!updated) throw new Error(`Document disappeared after indexing: ${documentId}`);
|
|
663
1102
|
return updated;
|
|
664
1103
|
}
|
|
665
1104
|
|
|
1105
|
+
type DocumentRow = typeof schema.documents.$inferSelect;
|
|
1106
|
+
|
|
1107
|
+
async function curateDroppedDocument(
|
|
1108
|
+
db: Database,
|
|
1109
|
+
services: DocumentServices,
|
|
1110
|
+
document: DocumentRow,
|
|
1111
|
+
parsed: ParsedDocument,
|
|
1112
|
+
file: FileAsset,
|
|
1113
|
+
): Promise<DocumentRow> {
|
|
1114
|
+
// `none` is an explicit disabled-curation policy. Do not silently replace
|
|
1115
|
+
// it with heuristics: indexing still proceeds, but the drop remains an
|
|
1116
|
+
// ordinary uncured document with its caller-supplied title and metadata.
|
|
1117
|
+
if (!services.curator) {
|
|
1118
|
+
const [updated] = await withWorkspaceRls(
|
|
1119
|
+
db,
|
|
1120
|
+
document.workspaceId,
|
|
1121
|
+
async (scopedDb) =>
|
|
1122
|
+
await scopedDb
|
|
1123
|
+
.update(schema.documents)
|
|
1124
|
+
.set({
|
|
1125
|
+
curationStatus: "none",
|
|
1126
|
+
summary: null,
|
|
1127
|
+
topics: [],
|
|
1128
|
+
curation: null,
|
|
1129
|
+
updatedAt: new Date(),
|
|
1130
|
+
})
|
|
1131
|
+
.where(
|
|
1132
|
+
and(
|
|
1133
|
+
eq(schema.documents.workspaceId, document.workspaceId),
|
|
1134
|
+
eq(schema.documents.id, document.id),
|
|
1135
|
+
),
|
|
1136
|
+
)
|
|
1137
|
+
.returning(),
|
|
1138
|
+
);
|
|
1139
|
+
return updated ?? document;
|
|
1140
|
+
}
|
|
1141
|
+
const bases = await listDocumentBases(db, document.workspaceId);
|
|
1142
|
+
const candidates: DocumentCurationCandidateBase[] = bases
|
|
1143
|
+
.filter((base) => base.id !== document.baseId)
|
|
1144
|
+
.map((base) => ({ id: base.id, name: base.name, description: base.description }));
|
|
1145
|
+
const input: DocumentCurationInput = {
|
|
1146
|
+
text: parsed.text.slice(0, DOCUMENT_CURATION_MAX_INPUT_CHARS),
|
|
1147
|
+
filename: file.filename,
|
|
1148
|
+
title: document.title,
|
|
1149
|
+
bases: candidates,
|
|
1150
|
+
};
|
|
1151
|
+
let outcome: DocumentCurationOutcome;
|
|
1152
|
+
let model: string;
|
|
1153
|
+
let failure: string | null = null;
|
|
1154
|
+
try {
|
|
1155
|
+
outcome = await services.curator.curate(input);
|
|
1156
|
+
model = services.curator.model;
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
1159
|
+
console.warn("document curation failed; applying heuristic fallback", {
|
|
1160
|
+
workspaceId: document.workspaceId,
|
|
1161
|
+
documentId: document.id,
|
|
1162
|
+
error: failure,
|
|
1163
|
+
});
|
|
1164
|
+
outcome = heuristicCuration(input, file.contentType);
|
|
1165
|
+
model = "heuristic";
|
|
1166
|
+
}
|
|
1167
|
+
const suggestedBase = candidates.find((base) => base.id === outcome.targetBaseId) ?? null;
|
|
1168
|
+
let moveToBaseId: string | null = null;
|
|
1169
|
+
if (
|
|
1170
|
+
suggestedBase &&
|
|
1171
|
+
failure === null &&
|
|
1172
|
+
outcome.confidence >= DOCUMENT_CURATION_AUTO_FILE_CONFIDENCE
|
|
1173
|
+
) {
|
|
1174
|
+
// The (workspace, base, file) unique index means a same-file twin already
|
|
1175
|
+
// in the target base blocks the move; keep it as a suggestion instead.
|
|
1176
|
+
const conflict = await withWorkspaceRls(
|
|
1177
|
+
db,
|
|
1178
|
+
document.workspaceId,
|
|
1179
|
+
async (scopedDb) =>
|
|
1180
|
+
await scopedDb
|
|
1181
|
+
.select({ id: schema.documents.id })
|
|
1182
|
+
.from(schema.documents)
|
|
1183
|
+
.where(
|
|
1184
|
+
and(
|
|
1185
|
+
eq(schema.documents.workspaceId, document.workspaceId),
|
|
1186
|
+
eq(schema.documents.baseId, suggestedBase.id),
|
|
1187
|
+
eq(schema.documents.fileId, document.fileId),
|
|
1188
|
+
),
|
|
1189
|
+
)
|
|
1190
|
+
.limit(1),
|
|
1191
|
+
);
|
|
1192
|
+
if (conflict.length === 0) {
|
|
1193
|
+
moveToBaseId = suggestedBase.id;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
const curation: DocumentCuration = {
|
|
1197
|
+
suggestedBaseId: suggestedBase?.id ?? null,
|
|
1198
|
+
suggestedBaseName: suggestedBase?.name ?? null,
|
|
1199
|
+
confidence: outcome.confidence,
|
|
1200
|
+
reason: failure ? `curation failed (${failure}); heuristic fallback applied` : outcome.reason,
|
|
1201
|
+
originalTitle: document.title,
|
|
1202
|
+
model,
|
|
1203
|
+
};
|
|
1204
|
+
const curationStatus: DocumentCurationStatus = failure
|
|
1205
|
+
? "failed"
|
|
1206
|
+
: moveToBaseId
|
|
1207
|
+
? "auto_filed"
|
|
1208
|
+
: "suggested";
|
|
1209
|
+
const [updated] = await withWorkspaceRls(
|
|
1210
|
+
db,
|
|
1211
|
+
document.workspaceId,
|
|
1212
|
+
async (scopedDb) =>
|
|
1213
|
+
await scopedDb
|
|
1214
|
+
.update(schema.documents)
|
|
1215
|
+
.set({
|
|
1216
|
+
title: outcome.title ?? document.title,
|
|
1217
|
+
summary: outcome.summary,
|
|
1218
|
+
topics: outcome.topics,
|
|
1219
|
+
...(outcome.sourceKind ? { sourceKind: outcome.sourceKind } : {}),
|
|
1220
|
+
...(moveToBaseId ? { baseId: moveToBaseId } : {}),
|
|
1221
|
+
curationStatus,
|
|
1222
|
+
curation,
|
|
1223
|
+
updatedAt: new Date(),
|
|
1224
|
+
})
|
|
1225
|
+
.where(
|
|
1226
|
+
and(
|
|
1227
|
+
eq(schema.documents.workspaceId, document.workspaceId),
|
|
1228
|
+
eq(schema.documents.id, document.id),
|
|
1229
|
+
),
|
|
1230
|
+
)
|
|
1231
|
+
.returning(),
|
|
1232
|
+
);
|
|
1233
|
+
return updated ?? document;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
666
1236
|
export async function searchDocuments(
|
|
667
1237
|
db: Database,
|
|
668
1238
|
input: DocumentSearchInput,
|
|
@@ -794,6 +1364,7 @@ export async function getDocumentChunk(
|
|
|
794
1364
|
db: Database,
|
|
795
1365
|
workspaceId: string,
|
|
796
1366
|
chunkId: string,
|
|
1367
|
+
access?: DocumentAccessFilter,
|
|
797
1368
|
): Promise<DocumentSearchResult | null> {
|
|
798
1369
|
const [row] = await withWorkspaceRls(
|
|
799
1370
|
db,
|
|
@@ -826,6 +1397,7 @@ export async function getDocumentChunk(
|
|
|
826
1397
|
eq(schema.documentChunks.workspaceId, workspaceId),
|
|
827
1398
|
eq(schema.documentChunks.id, chunkId),
|
|
828
1399
|
eq(schema.documents.status, "ready"),
|
|
1400
|
+
...documentAccessConditions(access),
|
|
829
1401
|
),
|
|
830
1402
|
)
|
|
831
1403
|
.limit(1),
|
|
@@ -849,10 +1421,65 @@ type CombinedSearchRow = SearchRowBase & {
|
|
|
849
1421
|
keywordScore: number | null;
|
|
850
1422
|
};
|
|
851
1423
|
|
|
1424
|
+
/**
|
|
1425
|
+
* Visibility/agent scoping shared by every document read path. Fail-closed:
|
|
1426
|
+
* with no filter supplied, private documents are invisible.
|
|
1427
|
+
*/
|
|
1428
|
+
function documentAccessConditions(access: DocumentAccessFilter | undefined): SQL[] {
|
|
1429
|
+
if (access?.agentOnly) {
|
|
1430
|
+
const viewer = access.viewerSubjectId;
|
|
1431
|
+
const visibility = viewer
|
|
1432
|
+
? or(eq(schema.documents.visibility, "workspace"), eq(schema.documents.createdBy, viewer))
|
|
1433
|
+
: eq(schema.documents.visibility, "workspace");
|
|
1434
|
+
return [eq(schema.documents.agentAccess, true), ...(visibility ? [visibility] : [])];
|
|
1435
|
+
}
|
|
1436
|
+
const viewer = access?.viewerSubjectId;
|
|
1437
|
+
if (viewer) {
|
|
1438
|
+
const condition = or(
|
|
1439
|
+
eq(schema.documents.visibility, "workspace"),
|
|
1440
|
+
eq(schema.documents.createdBy, viewer),
|
|
1441
|
+
);
|
|
1442
|
+
return condition ? [condition] : [];
|
|
1443
|
+
}
|
|
1444
|
+
return [eq(schema.documents.visibility, "workspace")];
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function documentMatchesAccess(
|
|
1448
|
+
document: Pick<DocumentAccessRecord, "visibility" | "createdBy" | "agentAccess">,
|
|
1449
|
+
access: DocumentAccessFilter | undefined,
|
|
1450
|
+
): boolean {
|
|
1451
|
+
if (access?.agentOnly) {
|
|
1452
|
+
return (
|
|
1453
|
+
document.agentAccess &&
|
|
1454
|
+
(document.visibility !== "private" ||
|
|
1455
|
+
(!!access.viewerSubjectId && document.createdBy === access.viewerSubjectId))
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
return canViewDocument(document, access?.viewerSubjectId);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/** Whether a single already-fetched document is readable by this human viewer. */
|
|
1462
|
+
export function canViewDocument(
|
|
1463
|
+
document: Pick<DocumentAccessRecord, "visibility" | "createdBy">,
|
|
1464
|
+
viewerSubjectId: string | null | undefined,
|
|
1465
|
+
): boolean {
|
|
1466
|
+
return (
|
|
1467
|
+
document.visibility !== "private" ||
|
|
1468
|
+
(!!viewerSubjectId && document.createdBy === viewerSubjectId)
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
type DocumentAccessRecord = {
|
|
1473
|
+
visibility: string;
|
|
1474
|
+
createdBy: string | null;
|
|
1475
|
+
agentAccess: boolean;
|
|
1476
|
+
};
|
|
1477
|
+
|
|
852
1478
|
function documentSearchConditions(input: DocumentSearchInput, embeddingModel?: string): SQL[] {
|
|
853
1479
|
const conditions: SQL[] = [
|
|
854
1480
|
eq(schema.documents.status, "ready"),
|
|
855
1481
|
eq(schema.documentChunks.workspaceId, input.workspaceId),
|
|
1482
|
+
...documentAccessConditions(input.access),
|
|
856
1483
|
];
|
|
857
1484
|
if (embeddingModel) {
|
|
858
1485
|
conditions.push(eq(schema.documentChunks.embeddingModel, embeddingModel));
|
|
@@ -1195,7 +1822,30 @@ function mapDocument(row: typeof schema.documents.$inferSelect): Document {
|
|
|
1195
1822
|
sourceUpdatedAt: row.sourceUpdatedAt?.toISOString() ?? null,
|
|
1196
1823
|
sourceVersion: row.sourceVersion,
|
|
1197
1824
|
aclTags: cleanStringArray(row.aclTags),
|
|
1825
|
+
visibility: normalizeDocumentVisibility(row.visibility),
|
|
1826
|
+
createdBy: row.createdBy,
|
|
1827
|
+
agentAccess: row.agentAccess,
|
|
1828
|
+
summary: row.summary,
|
|
1829
|
+
topics: cleanStringArray(row.topics),
|
|
1830
|
+
curationStatus: normalizeDocumentCurationStatus(row.curationStatus),
|
|
1831
|
+
curation: (row.curation as DocumentCuration | null) ?? null,
|
|
1198
1832
|
createdAt: row.createdAt.toISOString(),
|
|
1199
1833
|
updatedAt: row.updatedAt.toISOString(),
|
|
1200
1834
|
};
|
|
1201
1835
|
}
|
|
1836
|
+
|
|
1837
|
+
function normalizeDocumentVisibility(value: string): DocumentVisibility {
|
|
1838
|
+
return value === "private" ? "private" : "workspace";
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
function normalizeDocumentCurationStatus(value: string): DocumentCurationStatus {
|
|
1842
|
+
switch (value) {
|
|
1843
|
+
case "pending":
|
|
1844
|
+
case "suggested":
|
|
1845
|
+
case "auto_filed":
|
|
1846
|
+
case "failed":
|
|
1847
|
+
return value;
|
|
1848
|
+
default:
|
|
1849
|
+
return "none";
|
|
1850
|
+
}
|
|
1851
|
+
}
|