@arnilo/prism-rag 0.0.96 → 0.1.1

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,24 @@
1
+ import type { ResourceLoadContext, ResourceLoader } from "@arnilo/prism";
2
+ import type { DocumentLoader } from "./types.js";
3
+ export interface ResourceDocumentLoaderOptions {
4
+ readonly loader: ResourceLoader;
5
+ readonly context?: ResourceLoadContext;
6
+ }
7
+ export interface WebFetchDocument {
8
+ readonly citationId: string;
9
+ readonly provider: string;
10
+ readonly url: string;
11
+ readonly markdown: string;
12
+ readonly retrievedAt: string;
13
+ readonly untrusted: true;
14
+ }
15
+ export interface WebFetchAdapter {
16
+ fetch(url: string, options?: {
17
+ readonly signal?: AbortSignal;
18
+ }): Promise<WebFetchDocument>;
19
+ }
20
+ export interface WebFetchDocumentLoaderOptions {
21
+ readonly fetcher: WebFetchAdapter;
22
+ }
23
+ export declare function createResourceDocumentLoader(options: ResourceDocumentLoaderOptions): DocumentLoader;
24
+ export declare function createWebFetchDocumentLoader(options: WebFetchDocumentLoaderOptions): DocumentLoader;
@@ -0,0 +1,78 @@
1
+ import { RagLimitError, RagValidationError } from "./errors.js";
2
+ import { resolveRagLimits } from "./limits.js";
3
+ import { nonEmpty } from "./util.js";
4
+ export function createResourceDocumentLoader(options) {
5
+ return {
6
+ async load(uri, loadOptions = {}) {
7
+ nonEmpty(uri, "uri");
8
+ const context = { ...options.context, ...(loadOptions.signal ? { signal: loadOptions.signal } : {}) };
9
+ const resource = await options.loader.load(uri, context);
10
+ if (resource.text === undefined && resource.data === undefined)
11
+ throw new RagValidationError("resource has no text or data");
12
+ const document = Object.freeze({
13
+ uri: resource.uri,
14
+ ...(resource.mediaType ? { mediaType: resource.mediaType } : {}),
15
+ ...(resource.text !== undefined ? { text: resource.text } : { data: new Uint8Array(resource.data) }),
16
+ });
17
+ assertDocumentBytes(document, loadOptions.maxBytes);
18
+ return document;
19
+ },
20
+ };
21
+ }
22
+ export function createWebFetchDocumentLoader(options) {
23
+ return {
24
+ async load(uri, loadOptions = {}) {
25
+ const url = publicWebUrl(uri);
26
+ const document = await options.fetcher.fetch(url, { signal: loadOptions.signal });
27
+ publicWebUrl(document.url);
28
+ if (!document.untrusted || !document.markdown)
29
+ throw new RagValidationError("web fetcher returned an invalid untrusted document");
30
+ const loaded = Object.freeze({
31
+ uri: document.url,
32
+ sourceId: document.citationId,
33
+ mediaType: "text/markdown",
34
+ text: document.markdown,
35
+ metadata: {
36
+ web: {
37
+ citationId: document.citationId,
38
+ provider: document.provider,
39
+ url: document.url,
40
+ retrievedAt: document.retrievedAt,
41
+ },
42
+ untrusted: true,
43
+ },
44
+ });
45
+ assertDocumentBytes(loaded, loadOptions.maxBytes);
46
+ return loaded;
47
+ },
48
+ };
49
+ }
50
+ function assertDocumentBytes(document, maxBytes) {
51
+ const limit = resolveRagLimits({ maxDocumentBytes: maxBytes }).maxDocumentBytes;
52
+ const bytes = document.data ?? Buffer.from(document.text ?? "", "utf8");
53
+ if (bytes.byteLength > limit)
54
+ throw new RagLimitError(`document exceeds ${limit} bytes`);
55
+ }
56
+ function publicWebUrl(input) {
57
+ let url;
58
+ try {
59
+ url = new URL(input);
60
+ }
61
+ catch {
62
+ throw new RagValidationError("web document URI must be an absolute HTTP(S) URL");
63
+ }
64
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
65
+ if (!/^https?:$/u.test(url.protocol) || url.username || url.password || privateOrLocalHost(host)) {
66
+ throw new RagValidationError("web document URI must use a public hostname without credentials");
67
+ }
68
+ url.hash = "";
69
+ return url.toString();
70
+ }
71
+ function privateOrLocalHost(host) {
72
+ if (!host || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local"))
73
+ return true;
74
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(host) || host.includes(":"))
75
+ return true; // Require fetcher DNS policy for hostnames; never fetch IP literals.
76
+ return false;
77
+ }
78
+ //# sourceMappingURL=loaders.js.map
@@ -0,0 +1,5 @@
1
+ import type { Parser } from "./types.js";
2
+ export declare const textParser: Parser;
3
+ export declare const markdownParser: Parser;
4
+ export declare const htmlParser: Parser;
5
+ export declare const pdfParser: Parser;
@@ -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