@arnilo/prism-rag 0.0.14 → 0.0.16

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.
@@ -0,0 +1,101 @@
1
+ import { RagLimitError, RagValidationError } from "./errors.js";
2
+ import { resolveRagLimits } from "./limits.js";
3
+ import { assertNotAborted } from "./util.js";
4
+ export const textParser = { parse: (document, options) => parseText(document, options, ["text/plain"]) };
5
+ export const markdownParser = { parse: (document, options) => parseText(document, options, ["text/markdown", "text/x-markdown"]) };
6
+ export const htmlParser = {
7
+ parse: (document, options) => parseText(document, options, ["text/html", "application/xhtml+xml"], htmlToText),
8
+ };
9
+ export const pdfParser = { parse: parsePdf };
10
+ async function parseText(document, options = {}, mediaTypes, transform = (text) => text) {
11
+ assertMediaType(document, mediaTypes);
12
+ const { bytes, limits, started } = boundedDocument(document, options);
13
+ assertNotAborted(options.signal);
14
+ let text;
15
+ try {
16
+ text = document.text ?? new TextDecoder("utf-8", { fatal: true }).decode(bytes);
17
+ }
18
+ catch {
19
+ throw new RagValidationError("document is not valid UTF-8 text");
20
+ }
21
+ const parsed = transform(text);
22
+ assertParseTime(started, limits.maxParseMs);
23
+ assertNotAborted(options.signal);
24
+ return Object.freeze({ text: parsed, ...(document.metadata ? { metadata: document.metadata } : {}) });
25
+ }
26
+ async function parsePdf(document, options = {}) {
27
+ assertMediaType(document, ["application/pdf"]);
28
+ const { bytes, limits, started } = boundedDocument(document, options);
29
+ if (document.text !== undefined)
30
+ throw new RagValidationError("PDF parser requires binary document data");
31
+ const pdf = Buffer.from(bytes).toString("latin1");
32
+ if (!pdf.startsWith("%PDF-"))
33
+ throw new RagValidationError("document is not a PDF");
34
+ if (/\/Filter\s*\//u.test(pdf))
35
+ throw new RagValidationError("compressed PDFs require a host parser");
36
+ const pages = [...pdf.matchAll(/\/Type\s*\/Page\b/gu)].length;
37
+ if (pages > limits.maxPdfPages)
38
+ throw new RagLimitError(`PDF exceeds ${limits.maxPdfPages} pages`);
39
+ const blocks = pdf.match(/BT[\s\S]*?ET/gu) ?? [];
40
+ const text = blocks.flatMap(pdfBlockText).join("\n").trim();
41
+ if (!text)
42
+ throw new RagValidationError("PDF has no uncompressed text");
43
+ assertParseTime(started, limits.maxParseMs);
44
+ assertNotAborted(options.signal);
45
+ return Object.freeze({ text, ...(document.metadata ? { metadata: document.metadata } : {}) });
46
+ }
47
+ function boundedDocument(document, options) {
48
+ assertNotAborted(options.signal);
49
+ const limits = resolveRagLimits({
50
+ maxDocumentBytes: options.maxBytes,
51
+ maxParseMs: options.maxParseMs,
52
+ maxPdfPages: options.maxPages,
53
+ });
54
+ const bytes = document.data ?? Buffer.from(document.text ?? "", "utf8");
55
+ if (bytes.byteLength > limits.maxDocumentBytes)
56
+ throw new RagLimitError(`document exceeds ${limits.maxDocumentBytes} bytes`);
57
+ return { bytes, limits, started: Date.now() };
58
+ }
59
+ function assertMediaType(document, expected) {
60
+ if (document.mediaType && !expected.includes(document.mediaType.toLowerCase())) {
61
+ throw new RagValidationError(`parser does not accept ${document.mediaType}`);
62
+ }
63
+ }
64
+ function assertParseTime(started, maxParseMs) {
65
+ if (Date.now() - started > maxParseMs)
66
+ throw new RagLimitError(`document parsing exceeded ${maxParseMs}ms`);
67
+ }
68
+ function htmlToText(html) {
69
+ return decodeEntities(html
70
+ .replace(/<!--[\s\S]*?-->/gu, "")
71
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/giu, "")
72
+ .replace(/<(?:br|p|div|li|h[1-6]|tr)\b[^>]*>/giu, "\n")
73
+ .replace(/<[^>]*>/gu, " "))
74
+ .replace(/[ \t]{2,}/gu, " ")
75
+ .replace(/[ \t]+\n/gu, "\n")
76
+ .replace(/\n[ \t]+/gu, "\n")
77
+ .replace(/\n{3,}/gu, "\n\n")
78
+ .trim();
79
+ }
80
+ function decodeEntities(text) {
81
+ const entities = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'" };
82
+ return text.replace(/&([a-z]+|#39);/giu, (_match, name) => entities[name.toLowerCase()] ?? `&${name};`);
83
+ }
84
+ function pdfBlockText(block) {
85
+ const strings = [];
86
+ for (const match of block.matchAll(/\((?:\\.|[^\\)])*\)\s*(?:Tj|['"])/gu))
87
+ strings.push(decodePdfString(match[0].replace(/\s*(?:Tj|['"])$/u, "")));
88
+ for (const match of block.matchAll(/\[([\s\S]*?)\]\s*TJ/gu)) {
89
+ for (const value of match[1].matchAll(/\((?:\\.|[^\\)])*\)/gu))
90
+ strings.push(decodePdfString(value[0]));
91
+ }
92
+ return strings.filter(Boolean);
93
+ }
94
+ function decodePdfString(value) {
95
+ return value.slice(1, -1).replace(/\\([0-7]{1,3}|[nrtbf()\\])/gu, (_match, escape) => {
96
+ if (/^[0-7]+$/u.test(escape))
97
+ return String.fromCharCode(Number.parseInt(escape, 8));
98
+ return { n: "\n", r: "\r", t: "\t", b: "\b", f: "\f", "(": "(", ")": ")", "\\": "\\" }[escape];
99
+ });
100
+ }
101
+ //# sourceMappingURL=parsers.js.map
@@ -0,0 +1,11 @@
1
+ import { resolveRedactor } from "@arnilo/prism";
2
+ import type { RagHit, Reranker } from "./types.js";
3
+ export declare function rerankHits(query: string, hits: readonly RagHit[], options: {
4
+ readonly reranker: Reranker;
5
+ readonly maxBytes: number;
6
+ readonly maxMs: number;
7
+ readonly concurrency: number;
8
+ readonly signal?: AbortSignal;
9
+ readonly redactor?: Parameters<typeof resolveRedactor>[0];
10
+ readonly secrets?: Parameters<typeof resolveRedactor>[1];
11
+ }): Promise<readonly RagHit[]>;
package/dist/rerank.js ADDED
@@ -0,0 +1,62 @@
1
+ import { resolveRedactor } from "@arnilo/prism";
2
+ import { RagAbortError, RagLimitError, RagValidationError } from "./errors.js";
3
+ import { assertNotAborted, byteLength } from "./util.js";
4
+ const active = new WeakMap();
5
+ export async function rerankHits(query, hits, options) {
6
+ assertNotAborted(options.signal);
7
+ const redactor = resolveRedactor(options.redactor, options.secrets);
8
+ const safeQuery = redactor?.redact(query) ?? query;
9
+ const safeHits = hits.map((hit) => redactor?.redact(hit) ?? hit);
10
+ if (byteLength({ query: safeQuery, hits: safeHits }) > options.maxBytes) {
11
+ throw new RagLimitError(`reranker input exceeds ${options.maxBytes} bytes`);
12
+ }
13
+ const running = active.get(options.reranker) ?? 0;
14
+ if (running >= options.concurrency)
15
+ throw new RagLimitError(`reranker concurrency exceeds ${options.concurrency}`);
16
+ active.set(options.reranker, running + 1);
17
+ const release = () => {
18
+ const remaining = (active.get(options.reranker) ?? 1) - 1;
19
+ if (remaining)
20
+ active.set(options.reranker, remaining);
21
+ else
22
+ active.delete(options.reranker);
23
+ };
24
+ try {
25
+ const ordered = await boundedRerank((signal) => options.reranker.rerank({ query: safeQuery, hits: safeHits, signal }), (operation) => {
26
+ void operation.then(release, release);
27
+ }, options.maxMs, options.signal);
28
+ const originals = new Map(hits.map((hit) => [hit.id, hit]));
29
+ if (ordered.length !== hits.length || new Set(ordered.map((hit) => hit.id)).size !== hits.length) {
30
+ throw new RagValidationError("reranker must return each retrieved hit exactly once");
31
+ }
32
+ const output = ordered.map((hit) => originals.get(hit.id));
33
+ if (output.some((hit) => !hit))
34
+ throw new RagValidationError("reranker returned an unknown hit");
35
+ return Object.freeze(output);
36
+ }
37
+ catch (error) {
38
+ if (error instanceof RagAbortError || error instanceof RagLimitError || error instanceof RagValidationError)
39
+ throw error;
40
+ const message = error instanceof Error ? error.message : "reranker failed";
41
+ throw new RagValidationError(`reranker failed: ${redactor?.redact(message) ?? message}`);
42
+ }
43
+ }
44
+ async function boundedRerank(run, onStart, maxMs, signal) {
45
+ const controller = new AbortController();
46
+ const onAbort = () => controller.abort();
47
+ signal?.addEventListener("abort", onAbort, { once: true });
48
+ const timeout = setTimeout(() => controller.abort(), maxMs);
49
+ const aborted = new Promise((_, reject) => controller.signal.addEventListener("abort", () => {
50
+ reject(signal?.aborted ? new RagAbortError() : new RagLimitError(`reranker exceeded ${maxMs}ms`));
51
+ }, { once: true }));
52
+ const operation = Promise.resolve().then(() => run(controller.signal));
53
+ onStart(operation);
54
+ try {
55
+ return await Promise.race([operation, aborted]);
56
+ }
57
+ finally {
58
+ clearTimeout(timeout);
59
+ signal?.removeEventListener("abort", onAbort);
60
+ }
61
+ }
62
+ //# sourceMappingURL=rerank.js.map
package/dist/retrieve.js CHANGED
@@ -1,11 +1,13 @@
1
- import { HARD_CHUNK_SIZE_CAP, resolveRagLimits } from "./limits.js";
1
+ import { resolveRedactor } from "@arnilo/prism";
2
2
  import { RagScopeError, RagValidationError } from "./errors.js";
3
- import { assertBytes, assertNotAborted, assertScope, byteLength, isJsonObject, matchesFilter, nonEmpty, requireScope, requireSourceId, resolveRedactor, truncateUtf8, } from "./util.js";
3
+ import { HARD_CHUNK_SIZE_CAP, resolveRagLimits } from "./limits.js";
4
+ import { rerankHits } from "./rerank.js";
5
+ import { assertBytes, assertNotAborted, assertScope, byteLength, isJsonObject, matchesFilter, nonEmpty, requireScope, requireSourceId, truncateUtf8, } from "./util.js";
6
+ const RETRIEVED_CONTENT_TRUST = Object.freeze({ untrusted: true, inert: true, injectionCapable: true });
4
7
  export async function retrieveContext(query, options) {
5
8
  nonEmpty(query, "query");
6
- if (query.length > HARD_CHUNK_SIZE_CAP) {
9
+ if (query.length > HARD_CHUNK_SIZE_CAP)
7
10
  throw new RagValidationError(`query exceeds ${HARD_CHUNK_SIZE_CAP} characters`);
8
- }
9
11
  const scope = requireScope(options.scope);
10
12
  const limits = resolveRagLimits({
11
13
  topK: options.topK,
@@ -14,10 +16,13 @@ export async function retrieveContext(query, options) {
14
16
  maxContextTokens: options.maxContextTokens,
15
17
  maxMetadataBytes: options.maxMetadataBytes,
16
18
  maxVectorDimensions: options.maxVectorDimensions,
19
+ maxRerankBytes: options.maxRerankBytes,
20
+ maxRerankMs: options.maxRerankMs,
21
+ rerankConcurrency: options.rerankConcurrency,
17
22
  });
18
- if (!Number.isInteger(options.embedder.dimensions)
19
- || options.embedder.dimensions <= 0
20
- || options.embedder.dimensions > limits.maxVectorDimensions) {
23
+ if (!Number.isInteger(options.embedder.dimensions) ||
24
+ options.embedder.dimensions <= 0 ||
25
+ options.embedder.dimensions > limits.maxVectorDimensions) {
21
26
  throw new RagValidationError(`embedder dimensions must be an integer in 1..${limits.maxVectorDimensions}`);
22
27
  }
23
28
  if (options.filter)
@@ -27,10 +32,10 @@ export async function retrieveContext(query, options) {
27
32
  assertNotAborted(options.signal);
28
33
  const vectors = await options.embedder.embed([safeQuery], { signal: options.signal });
29
34
  const embedding = vectors[0];
30
- if (vectors.length !== 1
31
- || !embedding
32
- || embedding.length !== options.embedder.dimensions
33
- || embedding.some((value) => !Number.isFinite(value))) {
35
+ if (vectors.length !== 1 ||
36
+ !embedding ||
37
+ embedding.length !== options.embedder.dimensions ||
38
+ embedding.some((value) => !Number.isFinite(value))) {
34
39
  throw new RagValidationError("embedder returned invalid query vector");
35
40
  }
36
41
  const candidates = await options.store.query({
@@ -42,6 +47,26 @@ export async function retrieveContext(query, options) {
42
47
  signal: options.signal,
43
48
  });
44
49
  assertNotAborted(options.signal);
50
+ const retrievedAt = new Date().toISOString();
51
+ const retrieved = [];
52
+ for (const candidate of candidates.slice(0, limits.queryCandidates)) {
53
+ assertScope(scope, candidate);
54
+ const parsed = parseHit(candidate, retrieved.length, retrievedAt);
55
+ if (!matchesFilter(parsed.metadata, options.filter))
56
+ continue;
57
+ retrieved.push(Object.freeze(redactor?.redact(parsed) ?? parsed));
58
+ }
59
+ const ranked = options.reranker
60
+ ? await rerankHits(safeQuery, retrieved, {
61
+ reranker: options.reranker,
62
+ maxBytes: limits.maxRerankBytes,
63
+ maxMs: limits.maxRerankMs,
64
+ concurrency: limits.rerankConcurrency,
65
+ signal: options.signal,
66
+ redactor: options.redactor,
67
+ secrets: options.secrets,
68
+ })
69
+ : retrieved;
45
70
  const hits = [];
46
71
  const citations = [];
47
72
  const rendered = [];
@@ -49,15 +74,10 @@ export async function retrieveContext(query, options) {
49
74
  let usedBytes = 0;
50
75
  let usedChars = 0;
51
76
  let truncated = false;
52
- for (const candidate of candidates.slice(0, limits.queryCandidates)) {
53
- assertScope(scope, candidate);
54
- const parsed = parseHit(candidate);
55
- if (!matchesFilter(parsed.metadata, options.filter))
56
- continue;
77
+ for (const hit of ranked) {
57
78
  if (hits.length >= limits.topK)
58
79
  break;
59
- const safe = redactor?.redact(parsed) ?? parsed;
60
- const prefix = `[${parsed.citationId}] `;
80
+ const prefix = `[${hit.citationId}] `;
61
81
  const separator = rendered.length ? "\n\n" : "";
62
82
  const availableBytes = limits.maxResultBytes - usedBytes - byteLength(separator + prefix);
63
83
  const availableChars = maxChars - usedChars - separator.length - prefix.length;
@@ -65,61 +85,66 @@ export async function retrieveContext(query, options) {
65
85
  truncated = true;
66
86
  break;
67
87
  }
68
- let text = safe.text.slice(0, availableChars);
88
+ let text = hit.text.slice(0, availableChars);
69
89
  text = truncateUtf8(text, availableBytes);
70
90
  if (!text) {
71
91
  truncated = true;
72
92
  break;
73
93
  }
74
- if (text.length < safe.text.length)
94
+ if (text.length < hit.text.length)
75
95
  truncated = true;
76
- const hit = Object.freeze({ ...safe, text });
96
+ const renderedHit = Object.freeze({ ...hit, text });
77
97
  const citation = Object.freeze({
78
- id: hit.citationId,
79
- sourceId: hit.sourceId,
80
- chunkId: hit.id,
81
- ...(hit.metadata ? { metadata: hit.metadata } : {}),
98
+ id: renderedHit.citationId,
99
+ sourceId: renderedHit.sourceId,
100
+ chunkId: renderedHit.id,
101
+ provenance: renderedHit.provenance,
102
+ trust: renderedHit.trust,
103
+ ...(renderedHit.metadata ? { metadata: renderedHit.metadata } : {}),
82
104
  });
83
105
  const block = `${separator}${prefix}${text}`;
84
106
  rendered.push(block);
85
107
  usedBytes += byteLength(block);
86
108
  usedChars += block.length;
87
- hits.push(hit);
109
+ hits.push(renderedHit);
88
110
  citations.push(citation);
89
111
  if (truncated)
90
112
  break;
91
113
  }
92
114
  return Object.freeze({
93
115
  query: safeQuery,
116
+ trust: RETRIEVED_CONTENT_TRUST,
94
117
  text: rendered.join(""),
95
118
  hits: Object.freeze(hits),
96
119
  citations: Object.freeze(citations),
97
120
  truncated,
98
121
  });
99
122
  }
100
- function parseHit(hit) {
123
+ function parseHit(hit, retrievalRank, retrievedAt) {
101
124
  const metadata = hit.metadata;
102
125
  const rag = metadata?._rag;
103
126
  if (!isJsonObject(rag))
104
127
  throw new RagScopeError("vector hit is missing RAG source metadata");
105
128
  const sourceId = requireSourceId(rag.sourceId);
106
129
  const citationId = nonEmpty(rag.citationId, "metadata._rag.citationId");
107
- if (!Number.isInteger(rag.chunkIndex)
108
- || Number(rag.chunkIndex) < 0
109
- || !Number.isInteger(rag.start)
110
- || Number(rag.start) < 0
111
- || !Number.isInteger(rag.end)
112
- || Number(rag.end) < Number(rag.start)
113
- || !Number.isFinite(hit.score)) {
130
+ if (!Number.isInteger(rag.chunkIndex) ||
131
+ Number(rag.chunkIndex) < 0 ||
132
+ !Number.isInteger(rag.start) ||
133
+ Number(rag.start) < 0 ||
134
+ !Number.isInteger(rag.end) ||
135
+ Number(rag.end) < Number(rag.start) ||
136
+ !Number.isFinite(hit.score)) {
114
137
  throw new RagValidationError("vector hit has invalid RAG offsets");
115
138
  }
116
- if (hit.id !== citationId || !citationId.startsWith(`${sourceId}#`)) {
139
+ if (hit.id !== citationId || !citationId.startsWith(`${sourceId}#`))
117
140
  throw new RagValidationError("vector hit has inconsistent citation identity");
118
- }
119
141
  const userMetadata = {};
120
142
  for (const [key, value] of Object.entries(metadata ?? {}))
121
143
  if (key !== "_rag")
122
144
  userMetadata[key] = value;
145
+ const web = isJsonObject(userMetadata.web) ? userMetadata.web : undefined;
146
+ const provider = typeof web?.provider === "string" && web.provider.trim() ? web.provider : "host";
147
+ const provenance = Object.freeze({ sourceId, chunkId: hit.id, citationId, provider, retrieval: "vector", retrievedAt });
123
148
  return {
124
149
  id: hit.id,
125
150
  citationId,
@@ -129,6 +154,9 @@ function parseHit(hit) {
129
154
  end: rag.end,
130
155
  text: hit.text,
131
156
  score: hit.score,
157
+ retrievalRank,
158
+ provenance,
159
+ trust: RETRIEVED_CONTENT_TRUST,
132
160
  ...(Object.keys(userMetadata).length ? { metadata: userMetadata } : {}),
133
161
  };
134
162
  }
@@ -0,0 +1,9 @@
1
+ import type { DeleteSourceOptions, ReplaceDocumentOptions, ReplaceSourceOptions } from "./types.js";
2
+ export interface SourceMutationResult {
3
+ readonly sourceId: string;
4
+ readonly deleted: number;
5
+ readonly indexed: number;
6
+ }
7
+ export declare function replaceSource(options: ReplaceSourceOptions): Promise<SourceMutationResult>;
8
+ export declare function deleteSource(options: DeleteSourceOptions): Promise<SourceMutationResult>;
9
+ export declare function replaceDocument(options: ReplaceDocumentOptions): Promise<SourceMutationResult>;
@@ -0,0 +1,118 @@
1
+ import { resolveRedactor } from "@arnilo/prism";
2
+ import { chunkText } from "./chunk.js";
3
+ import { RagScopeError, RagValidationError } from "./errors.js";
4
+ import { indexChunkBatches } from "./indexing.js";
5
+ import { ingestionStatus } from "./ingestion-status.js";
6
+ import { assertNotAborted, byteLength, requireScope, requireSourceId } from "./util.js";
7
+ export async function replaceSource(options) {
8
+ const sourceId = requireSourceId(options.sourceId);
9
+ const scope = requireScope(options.scope);
10
+ assertTransactionalStore(options.store);
11
+ if (options.chunks.some((chunk) => chunk.sourceId !== sourceId)) {
12
+ throw new RagValidationError("replaceSource chunks must all belong to sourceId");
13
+ }
14
+ const redactor = resolveRedactor(options.redactor, options.secrets);
15
+ const totalBytes = options.chunks.reduce((total, chunk) => total + byteLength(redactor?.redact(chunk.text) ?? chunk.text), 0);
16
+ const setStatus = async (state, error) => {
17
+ if (!options.statusStore)
18
+ return;
19
+ const message = error instanceof Error ? error.message : error === undefined ? undefined : "source replacement failed";
20
+ await options.statusStore.set(ingestionStatus(scope, sourceId, state, state === "indexed" ? totalBytes : 0, state === "indexed" ? options.chunks.length : 0, message ? (redactor?.redact(message) ?? message) : undefined));
21
+ };
22
+ await setStatus("pending");
23
+ try {
24
+ const staged = [];
25
+ const indexed = await indexChunkBatches({ ...options, statusStore: undefined }, async (records) => {
26
+ staged.push(...records);
27
+ });
28
+ assertNotAborted(options.signal);
29
+ const result = await options.store.transaction(async (store) => {
30
+ const previous = await sourceRecords(store, sourceId, scope, options.signal);
31
+ assertNotAborted(options.signal);
32
+ if (previous.length) {
33
+ await store.delete({ tenantId: scope.tenantId, resourceId: scope.resourceId, threadId: scope.corpusId, ids: previous.map((record) => record.id) }, { signal: options.signal });
34
+ }
35
+ if (staged.length)
36
+ await store.upsert(staged, { signal: options.signal });
37
+ return Object.freeze({ sourceId, deleted: previous.length, indexed: indexed.indexed });
38
+ }, { signal: options.signal });
39
+ await setStatus("indexed");
40
+ return result;
41
+ }
42
+ catch (error) {
43
+ await setStatus("failed", error);
44
+ throw error;
45
+ }
46
+ }
47
+ export async function deleteSource(options) {
48
+ const sourceId = requireSourceId(options.sourceId);
49
+ const scope = requireScope(options.scope);
50
+ assertSourceStore(options.store);
51
+ const records = await sourceRecords(options.store, sourceId, scope, options.signal);
52
+ assertNotAborted(options.signal);
53
+ const deleted = records.length
54
+ ? await options.store.delete({ tenantId: scope.tenantId, resourceId: scope.resourceId, threadId: scope.corpusId, ids: records.map((record) => record.id) }, { signal: options.signal })
55
+ : 0;
56
+ await options.statusStore?.delete(scope, sourceId);
57
+ return Object.freeze({ sourceId, deleted, indexed: 0 });
58
+ }
59
+ export async function replaceDocument(options) {
60
+ const loaded = await options.loader.load(options.uri, {
61
+ ...options.loaderOptions,
62
+ signal: options.signal ?? options.loaderOptions?.signal,
63
+ });
64
+ assertNotAborted(options.signal);
65
+ const parsed = await options.parser.parse(loaded, {
66
+ ...options.parserOptions,
67
+ signal: options.signal ?? options.parserOptions?.signal,
68
+ });
69
+ assertNotAborted(options.signal);
70
+ const sourceId = options.sourceId ?? loaded.sourceId;
71
+ if (!sourceId)
72
+ throw new RagValidationError("replaceDocument requires sourceId when its loader does not provide one");
73
+ const { metadata, ...chunk } = options.chunk ?? {};
74
+ const mergedMetadata = mergeMetadata(parsed.metadata, metadata);
75
+ const chunks = (options.chunker ?? chunkText)(parsed.text, {
76
+ ...chunk,
77
+ sourceId,
78
+ ...(mergedMetadata ? { metadata: mergedMetadata } : {}),
79
+ });
80
+ return replaceSource({
81
+ ...options,
82
+ sourceId,
83
+ chunks,
84
+ });
85
+ }
86
+ function mergeMetadata(parser, supplied) {
87
+ if (!parser && !supplied)
88
+ return undefined;
89
+ return { ...supplied, ...parser }; // Loader trust metadata wins over caller-supplied metadata.
90
+ }
91
+ async function sourceRecords(store, sourceId, scope, signal) {
92
+ assertNotAborted(signal);
93
+ const records = await store.getBySource({ tenantId: scope.tenantId, resourceId: scope.resourceId, threadId: scope.corpusId }, sourceId, {
94
+ signal,
95
+ });
96
+ for (const record of records) {
97
+ if (record.tenantId !== scope.tenantId || record.resourceId !== scope.resourceId || record.threadId !== scope.corpusId) {
98
+ throw new RagScopeError("source lookup crossed tenant/resource/corpus boundary");
99
+ }
100
+ const rag = record.metadata?._rag;
101
+ if (typeof rag !== "object" || rag === null || Array.isArray(rag) || rag.sourceId !== sourceId) {
102
+ throw new RagScopeError("source lookup returned a different source");
103
+ }
104
+ }
105
+ return records;
106
+ }
107
+ function assertSourceStore(store) {
108
+ if (!store || typeof store !== "object" || typeof store.getBySource !== "function") {
109
+ throw new RagValidationError("source deletion requires a scoped source-aware vector store");
110
+ }
111
+ }
112
+ function assertTransactionalStore(store) {
113
+ if (!store || typeof store !== "object" || typeof store.transaction !== "function") {
114
+ throw new RagValidationError("atomic source replacement requires a transactional vector store");
115
+ }
116
+ assertSourceStore(store);
117
+ }
118
+ //# sourceMappingURL=sources.js.map
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ContextProvider, JsonObject, Message, SecretRedactor } from "@arnilo/prism";
2
- import type { Embedder, VectorStore } from "@arnilo/prism-memory";
2
+ import type { Embedder, MemoryVectorRecord, VectorStore } from "@arnilo/prism-memory";
3
3
  export interface RagScope {
4
4
  readonly tenantId: string;
5
5
  readonly resourceId: string;
@@ -23,6 +23,80 @@ export interface ChunkOptions {
23
23
  readonly maxDocumentChars?: number;
24
24
  readonly maxChunks?: number;
25
25
  }
26
+ export interface LoadedDocument {
27
+ readonly uri: string;
28
+ readonly sourceId?: string;
29
+ readonly mediaType?: string;
30
+ readonly text?: string;
31
+ readonly data?: Uint8Array;
32
+ readonly metadata?: JsonObject;
33
+ }
34
+ export interface ParsedDocument {
35
+ readonly text: string;
36
+ readonly metadata?: JsonObject;
37
+ }
38
+ export interface DocumentLoadOptions {
39
+ readonly signal?: AbortSignal;
40
+ readonly maxBytes?: number;
41
+ }
42
+ export interface DocumentParseOptions extends DocumentLoadOptions {
43
+ readonly maxParseMs?: number;
44
+ readonly maxPages?: number;
45
+ }
46
+ export interface DocumentLoader {
47
+ load(uri: string, options?: DocumentLoadOptions): Promise<LoadedDocument>;
48
+ }
49
+ export interface Parser {
50
+ parse(document: LoadedDocument, options?: DocumentParseOptions): Promise<ParsedDocument>;
51
+ }
52
+ export type Chunker = (text: string, options: ChunkOptions) => readonly RagChunk[];
53
+ export interface SourceVectorStore extends VectorStore {
54
+ getBySource(scope: {
55
+ readonly tenantId: string;
56
+ readonly resourceId: string;
57
+ readonly threadId: string;
58
+ }, sourceId: string, options?: {
59
+ readonly signal?: AbortSignal;
60
+ }): Promise<readonly MemoryVectorRecord[]>;
61
+ }
62
+ export interface TransactionalVectorStore extends SourceVectorStore {
63
+ transaction<T>(operation: (store: SourceVectorStore) => Promise<T>, options?: {
64
+ readonly signal?: AbortSignal;
65
+ }): Promise<T>;
66
+ }
67
+ export type IngestionState = "pending" | "indexed" | "failed" | "partial";
68
+ export interface IngestionStatus {
69
+ readonly sourceId: string;
70
+ readonly scope: RagScope;
71
+ readonly state: IngestionState;
72
+ readonly bytes: number;
73
+ readonly chunks: number;
74
+ readonly error?: string;
75
+ readonly updatedAt: string;
76
+ }
77
+ export interface IngestionStatusStore {
78
+ set(status: IngestionStatus, options?: {
79
+ readonly signal?: AbortSignal;
80
+ }): Promise<void>;
81
+ delete(scope: RagScope, sourceId: string, options?: {
82
+ readonly signal?: AbortSignal;
83
+ }): Promise<void>;
84
+ list(scope: RagScope, options: {
85
+ readonly limit: number;
86
+ readonly cursor?: string;
87
+ readonly signal?: AbortSignal;
88
+ }): Promise<{
89
+ readonly entries: readonly IngestionStatus[];
90
+ readonly nextCursor?: string;
91
+ }>;
92
+ }
93
+ export interface IngestionStatusQuery {
94
+ readonly store: IngestionStatusStore;
95
+ readonly scope: RagScope;
96
+ readonly limit?: number;
97
+ readonly cursor?: string;
98
+ readonly signal?: AbortSignal;
99
+ }
26
100
  export interface IndexChunksOptions {
27
101
  readonly chunks: readonly RagChunk[];
28
102
  readonly embedder: Embedder;
@@ -35,20 +109,68 @@ export interface IndexChunksOptions {
35
109
  readonly maxMetadataBytes?: number;
36
110
  readonly redactor?: SecretRedactor;
37
111
  readonly secrets?: readonly (string | undefined)[];
112
+ readonly statusStore?: IngestionStatusStore;
38
113
  readonly signal?: AbortSignal;
39
114
  }
40
115
  export interface IndexChunksResult {
41
116
  readonly indexed: number;
42
117
  readonly sourceIds: readonly string[];
43
118
  }
119
+ export interface ReplaceSourceOptions extends Omit<IndexChunksOptions, "chunks" | "store"> {
120
+ readonly sourceId: string;
121
+ readonly chunks: readonly RagChunk[];
122
+ readonly store: TransactionalVectorStore;
123
+ }
124
+ export interface DeleteSourceOptions {
125
+ readonly sourceId: string;
126
+ readonly store: SourceVectorStore;
127
+ readonly scope: RagScope;
128
+ readonly statusStore?: IngestionStatusStore;
129
+ readonly signal?: AbortSignal;
130
+ }
131
+ export interface ReplaceDocumentOptions extends Omit<ReplaceSourceOptions, "sourceId" | "chunks"> {
132
+ readonly uri: string;
133
+ readonly sourceId?: string;
134
+ readonly loader: DocumentLoader;
135
+ readonly parser: Parser;
136
+ readonly chunker?: Chunker;
137
+ readonly chunk?: Omit<ChunkOptions, "sourceId">;
138
+ readonly loaderOptions?: DocumentLoadOptions;
139
+ readonly parserOptions?: DocumentParseOptions;
140
+ }
141
+ export interface RagProvenance {
142
+ readonly sourceId: string;
143
+ readonly chunkId: string;
144
+ readonly citationId: string;
145
+ readonly provider: string;
146
+ readonly retrieval: "vector";
147
+ readonly retrievedAt: string;
148
+ }
149
+ export interface RagContentTrust {
150
+ readonly untrusted: true;
151
+ readonly inert: true;
152
+ readonly injectionCapable: true;
153
+ }
44
154
  export interface RagCitation {
45
155
  readonly id: string;
46
156
  readonly sourceId: string;
47
157
  readonly chunkId: string;
158
+ readonly provenance: RagProvenance;
159
+ readonly trust: RagContentTrust;
48
160
  readonly metadata?: JsonObject;
49
161
  }
50
162
  export interface RagHit extends RagChunk {
51
163
  readonly score: number;
164
+ readonly retrievalRank: number;
165
+ readonly provenance: RagProvenance;
166
+ readonly trust: RagContentTrust;
167
+ }
168
+ export interface Reranker {
169
+ rerank(input: {
170
+ readonly query: string;
171
+ readonly hits: readonly RagHit[];
172
+ readonly signal?: AbortSignal;
173
+ }): Promise<readonly RagHit[]>;
52
174
  }
53
175
  export interface RetrieveContextOptions {
54
176
  readonly embedder: Embedder;
@@ -61,12 +183,17 @@ export interface RetrieveContextOptions {
61
183
  readonly maxContextTokens?: number;
62
184
  readonly maxMetadataBytes?: number;
63
185
  readonly maxVectorDimensions?: number;
186
+ readonly reranker?: Reranker;
187
+ readonly maxRerankBytes?: number;
188
+ readonly maxRerankMs?: number;
189
+ readonly rerankConcurrency?: number;
64
190
  readonly redactor?: SecretRedactor;
65
191
  readonly secrets?: readonly (string | undefined)[];
66
192
  readonly signal?: AbortSignal;
67
193
  }
68
194
  export interface RagContextResult {
69
195
  readonly query: string;
196
+ readonly trust: RagContentTrust;
70
197
  readonly text: string;
71
198
  readonly hits: readonly RagHit[];
72
199
  readonly citations: readonly RagCitation[];