videobook-engine 2.0.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.
- package/README.md +214 -0
- package/dist/artifacts.d.ts +20 -0
- package/dist/artifacts.d.ts.map +1 -0
- package/dist/artifacts.js +312 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/books.d.ts +7 -0
- package/dist/books.d.ts.map +1 -0
- package/dist/books.js +27 -0
- package/dist/books.js.map +1 -0
- package/dist/cas.d.ts +26 -0
- package/dist/cas.d.ts.map +1 -0
- package/dist/cas.js +139 -0
- package/dist/cas.js.map +1 -0
- package/dist/communications.d.ts +15 -0
- package/dist/communications.d.ts.map +1 -0
- package/dist/communications.js +139 -0
- package/dist/communications.js.map +1 -0
- package/dist/context.d.ts +42 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +197 -0
- package/dist/context.js.map +1 -0
- package/dist/domain.d.ts +32 -0
- package/dist/domain.d.ts.map +1 -0
- package/dist/domain.js +399 -0
- package/dist/domain.js.map +1 -0
- package/dist/engine-types.d.ts +548 -0
- package/dist/engine-types.d.ts.map +1 -0
- package/dist/engine-types.js +10 -0
- package/dist/engine-types.js.map +1 -0
- package/dist/engine.d.ts +213 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +130 -0
- package/dist/engine.js.map +1 -0
- package/dist/files.d.ts +28 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +476 -0
- package/dist/files.js.map +1 -0
- package/dist/history-types.d.ts +74 -0
- package/dist/history-types.d.ts.map +1 -0
- package/dist/history-types.js +2 -0
- package/dist/history-types.js.map +1 -0
- package/dist/history.d.ts +20 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +921 -0
- package/dist/history.js.map +1 -0
- package/dist/ids.d.ts +4 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +14 -0
- package/dist/ids.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/job-queue.d.ts +51 -0
- package/dist/job-queue.d.ts.map +1 -0
- package/dist/job-queue.js +443 -0
- package/dist/job-queue.js.map +1 -0
- package/dist/media.d.ts +7 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +60 -0
- package/dist/media.js.map +1 -0
- package/dist/metadata.d.ts +21 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +222 -0
- package/dist/metadata.js.map +1 -0
- package/dist/notebook/types.d.ts +51 -0
- package/dist/notebook/types.d.ts.map +1 -0
- package/dist/notebook/types.js +2 -0
- package/dist/notebook/types.js.map +1 -0
- package/dist/resolver.d.ts +15 -0
- package/dist/resolver.d.ts.map +1 -0
- package/dist/resolver.js +81 -0
- package/dist/resolver.js.map +1 -0
- package/dist/runtime-services.d.ts +65 -0
- package/dist/runtime-services.d.ts.map +1 -0
- package/dist/runtime-services.js +584 -0
- package/dist/runtime-services.js.map +1 -0
- package/dist/schema.d.ts +7 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +509 -0
- package/dist/schema.js.map +1 -0
- package/dist/similarity.d.ts +4 -0
- package/dist/similarity.d.ts.map +1 -0
- package/dist/similarity.js +1551 -0
- package/dist/similarity.js.map +1 -0
- package/dist/status.d.ts +9 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +231 -0
- package/dist/status.js.map +1 -0
- package/dist/storage.d.ts +7 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +129 -0
- package/dist/storage.js.map +1 -0
- package/dist/store.d.ts +55 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +378 -0
- package/dist/store.js.map +1 -0
- package/dist/timeline.d.ts +9 -0
- package/dist/timeline.d.ts.map +1 -0
- package/dist/timeline.js +236 -0
- package/dist/timeline.js.map +1 -0
- package/docs/engine-layout.md +489 -0
- package/package.json +55 -0
|
@@ -0,0 +1,1551 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { mkdtemp, mkdir, readdir, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { AutoProcessor, ClapAudioModelWithProjection, pipeline, RawImage, } from "@huggingface/transformers";
|
|
8
|
+
import sharp from "sharp";
|
|
9
|
+
import { Index, MetricKind, ScalarKind, } from "usearch";
|
|
10
|
+
import { err, } from "./engine-types.js";
|
|
11
|
+
import { resultOf, syncResultOf, } from "./context.js";
|
|
12
|
+
import { EngineFault } from "./store.js";
|
|
13
|
+
const DEFAULT_MODEL_ID = "Xenova/clip-vit-base-patch32";
|
|
14
|
+
const DEFAULT_MODEL_REVISION = "d15189d7028b43f1d3e65039190477f6af591c2a";
|
|
15
|
+
const DEFAULT_EMBEDDING_SPACE = "clip-vit-b32-q8-d15189d7028b43f1d3e65039190477f6af591c2a-v1";
|
|
16
|
+
const DEFAULT_DIMENSIONS = 512;
|
|
17
|
+
const DEFAULT_AUDIO_MODEL_ID = "Xenova/clap-htsat-unfused";
|
|
18
|
+
const DEFAULT_AUDIO_MODEL_REVISION = "c28f2883575e590e04d3146ff0713c2448d691ba";
|
|
19
|
+
const DEFAULT_AUDIO_EMBEDDING_SPACE = "clap-htsat-unfused-q8-c28f2883575e590e04d3146ff0713c2448d691ba-audio-v1";
|
|
20
|
+
const DEFAULT_AUDIO_DIMENSIONS = 512;
|
|
21
|
+
const DEFAULT_AUDIO_SAMPLE_RATE = 48_000;
|
|
22
|
+
const DEFAULT_AUDIO_MAX_SAMPLES = 480_000;
|
|
23
|
+
const DEFAULT_TEXT_MODEL_ID = "onnx-community/all-MiniLM-L6-v2-ONNX";
|
|
24
|
+
const DEFAULT_TEXT_MODEL_REVISION = "aff7a1dc4e8a1ea593e6ea21e95c22ef0a25966f";
|
|
25
|
+
const DEFAULT_TEXT_EMBEDDING_SPACE = "all-minilm-l6-v2-q4-aff7a1dc4e8a1ea593e6ea21e95c22ef0a25966f-text-v1";
|
|
26
|
+
const DEFAULT_TEXT_DIMENSIONS = 384;
|
|
27
|
+
const DEFAULT_TEXT_MAX_BYTES = 1024 * 1024;
|
|
28
|
+
const DEFAULT_TEXT_MAX_CHUNKS = 256;
|
|
29
|
+
const TEXT_CHUNK_TOKENS = 224;
|
|
30
|
+
const TEXT_OVERLAP_TOKENS = 32;
|
|
31
|
+
const TEXT_CHUNK_CHAR_WINDOW = 1600;
|
|
32
|
+
const TEXT_EXCERPT_LIMIT = 480;
|
|
33
|
+
const TEXT_ARTIFACT_KINDS = new Set([
|
|
34
|
+
"script",
|
|
35
|
+
"character",
|
|
36
|
+
"prompt",
|
|
37
|
+
"scene",
|
|
38
|
+
"final",
|
|
39
|
+
]);
|
|
40
|
+
export function createSimilarityApi(context) {
|
|
41
|
+
if (!context.config.similarity)
|
|
42
|
+
return disabledSimilarityApi();
|
|
43
|
+
return new LocalSimilarityApi(context, context.config.similarity);
|
|
44
|
+
}
|
|
45
|
+
class LocalSimilarityApi {
|
|
46
|
+
context;
|
|
47
|
+
provider;
|
|
48
|
+
audioProvider;
|
|
49
|
+
textProvider;
|
|
50
|
+
indexes = new Map();
|
|
51
|
+
constructor(context, config) {
|
|
52
|
+
this.context = context;
|
|
53
|
+
this.provider = config.provider ?? new LocalClipProvider(context, config);
|
|
54
|
+
this.audioProvider = config.audio
|
|
55
|
+
? config.audio.provider ??
|
|
56
|
+
new LocalClapAudioProvider(context, config, config.audio)
|
|
57
|
+
: null;
|
|
58
|
+
this.textProvider = config.text
|
|
59
|
+
? config.text.provider ?? new LocalTextProvider(context, config, config.text)
|
|
60
|
+
: null;
|
|
61
|
+
}
|
|
62
|
+
async prepare(options = {}) {
|
|
63
|
+
return resultOf(async () => {
|
|
64
|
+
const kinds = options.kind
|
|
65
|
+
? [options.kind]
|
|
66
|
+
: [
|
|
67
|
+
"image",
|
|
68
|
+
"video",
|
|
69
|
+
...(this.audioProvider ? ["audio"] : []),
|
|
70
|
+
...(this.textProvider ? ["text"] : []),
|
|
71
|
+
];
|
|
72
|
+
const spaces = {};
|
|
73
|
+
const preparedMedia = new Set();
|
|
74
|
+
for (const kind of kinds) {
|
|
75
|
+
if (kind === "text") {
|
|
76
|
+
const provider = this.requireTextProvider();
|
|
77
|
+
await provider.prepare();
|
|
78
|
+
spaces.text = provider.embeddingSpace;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const provider = this.providerFor(kind);
|
|
82
|
+
if (!preparedMedia.has(provider)) {
|
|
83
|
+
await provider.prepare();
|
|
84
|
+
preparedMedia.add(provider);
|
|
85
|
+
}
|
|
86
|
+
spaces[kind] = provider.embeddingSpace;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
embeddingSpace: this.provider.embeddingSpace,
|
|
91
|
+
embeddingSpaces: spaces,
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
async index(artifactReference, options = {}) {
|
|
96
|
+
return resultOf(async () => {
|
|
97
|
+
const artifact = this.context.artifactRow(artifactReference);
|
|
98
|
+
const kind = similarityKind(artifact);
|
|
99
|
+
if (kind === "text") {
|
|
100
|
+
return this.indexText(artifact, options);
|
|
101
|
+
}
|
|
102
|
+
return this.indexMedia(artifact, kind, options);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async rebuild(options = {}) {
|
|
106
|
+
return resultOf(async () => {
|
|
107
|
+
const allowedKinds = options.kind
|
|
108
|
+
? [options.kind]
|
|
109
|
+
: [
|
|
110
|
+
"image",
|
|
111
|
+
"video",
|
|
112
|
+
...(this.audioProvider ? ["audio"] : []),
|
|
113
|
+
...(this.textProvider ? ["text"] : []),
|
|
114
|
+
];
|
|
115
|
+
const artifactKinds = allowedKinds.flatMap((kind) => kind === "text" ? [...TEXT_ARTIFACT_KINDS] : [kind]);
|
|
116
|
+
const artifactRows = this.context.store.db
|
|
117
|
+
.prepare(`SELECT artifact_id, slug, kind, created_at
|
|
118
|
+
FROM artifacts
|
|
119
|
+
WHERE kind IN (${artifactKinds.map(() => "?").join(", ")})
|
|
120
|
+
ORDER BY created_at, artifact_id`)
|
|
121
|
+
.all(...artifactKinds);
|
|
122
|
+
const indexed = [];
|
|
123
|
+
for (const artifact of artifactRows) {
|
|
124
|
+
const kind = similarityKind(artifact);
|
|
125
|
+
if (!allowedKinds.includes(kind))
|
|
126
|
+
continue;
|
|
127
|
+
if (kind === "text" && !this.hasTextSource(artifact.artifact_id))
|
|
128
|
+
continue;
|
|
129
|
+
const result = await this.index(artifact.artifact_id, {
|
|
130
|
+
force: options.force,
|
|
131
|
+
});
|
|
132
|
+
if (!result.ok)
|
|
133
|
+
throw new EngineFault(result.error);
|
|
134
|
+
indexed.push(result.value);
|
|
135
|
+
}
|
|
136
|
+
return indexed;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
status(artifactReference) {
|
|
140
|
+
return syncResultOf(() => {
|
|
141
|
+
const artifact = this.context.artifactRow(artifactReference);
|
|
142
|
+
const kind = similarityKind(artifact);
|
|
143
|
+
if (kind === "text") {
|
|
144
|
+
const provider = this.requireTextProvider();
|
|
145
|
+
const row = this.textDocumentForArtifact(artifact.artifact_id, provider.embeddingSpace);
|
|
146
|
+
if (!row) {
|
|
147
|
+
return {
|
|
148
|
+
artifactId: artifact.artifact_id,
|
|
149
|
+
kind,
|
|
150
|
+
state: "not_indexed",
|
|
151
|
+
embeddingSpace: provider.embeddingSpace,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
artifactId: artifact.artifact_id,
|
|
156
|
+
kind,
|
|
157
|
+
state: "ready",
|
|
158
|
+
embeddingSpace: row.embedding_space,
|
|
159
|
+
objectHash: row.object_hash,
|
|
160
|
+
contentHash: row.content_hash,
|
|
161
|
+
chunkCount: row.chunk_count,
|
|
162
|
+
updatedAt: row.updated_at,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const provider = this.providerFor(kind);
|
|
166
|
+
const row = this.embeddingForArtifact(artifact.artifact_id, provider.embeddingSpace);
|
|
167
|
+
if (!row) {
|
|
168
|
+
return {
|
|
169
|
+
artifactId: artifact.artifact_id,
|
|
170
|
+
kind,
|
|
171
|
+
state: "not_indexed",
|
|
172
|
+
embeddingSpace: provider.embeddingSpace,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
artifactId: artifact.artifact_id,
|
|
177
|
+
kind,
|
|
178
|
+
state: "ready",
|
|
179
|
+
embeddingSpace: row.embedding_space,
|
|
180
|
+
objectHash: row.object_hash,
|
|
181
|
+
frameCount: row.frame_count,
|
|
182
|
+
updatedAt: row.updated_at,
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
stats() {
|
|
187
|
+
return syncResultOf(() => {
|
|
188
|
+
const rows = this.context.store.db
|
|
189
|
+
.prepare(`SELECT kind, COUNT(*) AS count
|
|
190
|
+
FROM runtime_similarity_embeddings
|
|
191
|
+
WHERE embedding_space=?
|
|
192
|
+
GROUP BY kind`)
|
|
193
|
+
.all(this.provider.embeddingSpace);
|
|
194
|
+
const spaces = {
|
|
195
|
+
image: this.provider.embeddingSpace,
|
|
196
|
+
video: this.provider.embeddingSpace,
|
|
197
|
+
};
|
|
198
|
+
let audioCount = 0;
|
|
199
|
+
if (this.audioProvider) {
|
|
200
|
+
spaces.audio = this.audioProvider.embeddingSpace;
|
|
201
|
+
const count = this.context.store.db
|
|
202
|
+
.prepare(`SELECT COUNT(*) AS count
|
|
203
|
+
FROM runtime_similarity_embeddings
|
|
204
|
+
WHERE kind='audio' AND embedding_space=?`)
|
|
205
|
+
.get(this.audioProvider.embeddingSpace);
|
|
206
|
+
audioCount = count.count;
|
|
207
|
+
}
|
|
208
|
+
let textCount = 0;
|
|
209
|
+
if (this.textProvider) {
|
|
210
|
+
spaces.text = this.textProvider.embeddingSpace;
|
|
211
|
+
const count = this.context.store.db
|
|
212
|
+
.prepare(`SELECT COUNT(*) AS count
|
|
213
|
+
FROM runtime_text_similarity_documents d
|
|
214
|
+
JOIN artifacts a ON a.artifact_id=d.artifact_id
|
|
215
|
+
WHERE d.embedding_space=?`)
|
|
216
|
+
.get(this.textProvider.embeddingSpace);
|
|
217
|
+
textCount = count.count;
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
embeddingSpace: this.provider.embeddingSpace,
|
|
221
|
+
embeddingSpaces: spaces,
|
|
222
|
+
imageCount: rows.find((row) => row.kind === "image")?.count ?? 0,
|
|
223
|
+
videoCount: rows.find((row) => row.kind === "video")?.count ?? 0,
|
|
224
|
+
audioCount,
|
|
225
|
+
textCount,
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
async findSimilar(artifactReference, options = {}) {
|
|
230
|
+
return resultOf(async () => {
|
|
231
|
+
const artifact = this.context.artifactRow(artifactReference);
|
|
232
|
+
const kind = similarityKind(artifact);
|
|
233
|
+
if (kind === "text") {
|
|
234
|
+
return this.findSimilarTextArtifact(artifact, options);
|
|
235
|
+
}
|
|
236
|
+
return this.findSimilarMedia(artifact, kind, options);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
async findSimilarText(query, options = {}) {
|
|
240
|
+
return resultOf(async () => {
|
|
241
|
+
const provider = this.requireTextProvider();
|
|
242
|
+
const normalizedQuery = normalizePlainText(query);
|
|
243
|
+
if (normalizedQuery.text.length === 0) {
|
|
244
|
+
throw new EngineFault({
|
|
245
|
+
code: "INVALID_INPUT",
|
|
246
|
+
message: "Text similarity queries must not be empty",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const maxBytes = checkedTextMaxBytes(this.context.config.similarity?.text);
|
|
250
|
+
if (Buffer.byteLength(normalizedQuery.text, "utf8") > maxBytes) {
|
|
251
|
+
throw new EngineFault({
|
|
252
|
+
code: "INVALID_INPUT",
|
|
253
|
+
message: `Text similarity queries cannot exceed ${maxBytes} bytes`,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
const chunks = await this.embedText(normalizedQuery.text);
|
|
257
|
+
return this.searchText(null, null, normalizedQuery.contentHash, chunks, options, provider);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
async indexMedia(artifact, kind, options) {
|
|
261
|
+
const provider = this.providerFor(kind);
|
|
262
|
+
const source = this.sourceFor(artifact, kind);
|
|
263
|
+
const existing = this.embeddingForArtifact(artifact.artifact_id, provider.embeddingSpace);
|
|
264
|
+
if (existing &&
|
|
265
|
+
existing.object_hash === source.object_hash &&
|
|
266
|
+
existing.dimensions === provider.dimensions &&
|
|
267
|
+
!options.force) {
|
|
268
|
+
this.addToCachedIndex(existing, vectorFromBlob(existing));
|
|
269
|
+
return this.indexResult(existing, true);
|
|
270
|
+
}
|
|
271
|
+
const reusable = !options.force
|
|
272
|
+
? this.embeddingForObject(source.object_hash, kind, provider.embeddingSpace, provider.dimensions)
|
|
273
|
+
: null;
|
|
274
|
+
const embedded = reusable
|
|
275
|
+
? {
|
|
276
|
+
vector: vectorFromBlob(reusable),
|
|
277
|
+
frameCount: reusable.frame_count,
|
|
278
|
+
reused: true,
|
|
279
|
+
}
|
|
280
|
+
: await this.embedMediaSource(source);
|
|
281
|
+
const row = this.context.store.runtime((now) => {
|
|
282
|
+
if (existing) {
|
|
283
|
+
this.context.store.db
|
|
284
|
+
.prepare(`UPDATE runtime_similarity_embeddings
|
|
285
|
+
SET kind=?, source_path=?, object_hash=?,
|
|
286
|
+
dimensions=?, vector_blob=?, frame_count=?, updated_at=?
|
|
287
|
+
WHERE id=?`)
|
|
288
|
+
.run(kind, source.path, source.object_hash, provider.dimensions, vectorToBlob(embedded.vector), embedded.frameCount, now, existing.id);
|
|
289
|
+
return this.embeddingById(existing.id);
|
|
290
|
+
}
|
|
291
|
+
const result = this.context.store.db
|
|
292
|
+
.prepare(`INSERT INTO runtime_similarity_embeddings(
|
|
293
|
+
artifact_id, kind, source_path, object_hash,
|
|
294
|
+
embedding_space, dimensions, vector_blob, frame_count, updated_at
|
|
295
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
296
|
+
.run(artifact.artifact_id, kind, source.path, source.object_hash, provider.embeddingSpace, provider.dimensions, vectorToBlob(embedded.vector), embedded.frameCount, now);
|
|
297
|
+
return this.embeddingById(Number(result.lastInsertRowid));
|
|
298
|
+
});
|
|
299
|
+
this.addToCachedIndex(row, embedded.vector);
|
|
300
|
+
return this.indexResult(row, embedded.reused);
|
|
301
|
+
}
|
|
302
|
+
async indexText(artifact, options) {
|
|
303
|
+
const provider = this.requireTextProvider();
|
|
304
|
+
const source = this.sourceFor(artifact, "text");
|
|
305
|
+
const normalizedSource = await this.readNormalizedText(source);
|
|
306
|
+
const existing = this.textDocumentForArtifact(artifact.artifact_id, provider.embeddingSpace);
|
|
307
|
+
if (existing &&
|
|
308
|
+
existing.object_hash === source.object_hash &&
|
|
309
|
+
existing.content_hash === normalizedSource.contentHash &&
|
|
310
|
+
existing.dimensions === provider.dimensions &&
|
|
311
|
+
!options.force) {
|
|
312
|
+
return this.textIndexResult(existing, true);
|
|
313
|
+
}
|
|
314
|
+
const reusable = !options.force
|
|
315
|
+
? this.textDocumentForObjectOrContent(source.object_hash, normalizedSource.contentHash, provider.embeddingSpace, provider.dimensions)
|
|
316
|
+
: null;
|
|
317
|
+
const embeddedChunks = reusable
|
|
318
|
+
? this.textChunksForDocument(reusable.id).map((chunk) => ({
|
|
319
|
+
startOffset: chunk.start_offset,
|
|
320
|
+
endOffset: chunk.end_offset,
|
|
321
|
+
vector: vectorFromBlob(chunk),
|
|
322
|
+
}))
|
|
323
|
+
: await this.embedText(normalizedSource.text);
|
|
324
|
+
const maxChunks = checkedTextMaxChunks(this.context.config.similarity?.text);
|
|
325
|
+
if (embeddedChunks.length > maxChunks) {
|
|
326
|
+
throw new EngineFault({
|
|
327
|
+
code: "INVALID_INPUT",
|
|
328
|
+
message: `Text source produces more than ${maxChunks} chunks`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
this.validateTextChunks(normalizedSource.text, embeddedChunks, provider.dimensions);
|
|
332
|
+
this.indexes.delete(indexKey("text", provider.embeddingSpace));
|
|
333
|
+
const row = this.context.store.runtime((now) => {
|
|
334
|
+
let documentId;
|
|
335
|
+
if (existing) {
|
|
336
|
+
documentId = existing.id;
|
|
337
|
+
this.context.store.db
|
|
338
|
+
.prepare(`UPDATE runtime_text_similarity_documents
|
|
339
|
+
SET source_path=?, object_hash=?, content_hash=?,
|
|
340
|
+
dimensions=?, chunk_count=?, updated_at=?
|
|
341
|
+
WHERE id=?`)
|
|
342
|
+
.run(source.path, source.object_hash, normalizedSource.contentHash, provider.dimensions, embeddedChunks.length, now, documentId);
|
|
343
|
+
this.context.store.db
|
|
344
|
+
.prepare("DELETE FROM runtime_text_similarity_chunks WHERE document_id=?")
|
|
345
|
+
.run(documentId);
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
const result = this.context.store.db
|
|
349
|
+
.prepare(`INSERT INTO runtime_text_similarity_documents(
|
|
350
|
+
artifact_id, source_path, object_hash, content_hash,
|
|
351
|
+
embedding_space, dimensions, chunk_count, updated_at
|
|
352
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
353
|
+
.run(artifact.artifact_id, source.path, source.object_hash, normalizedSource.contentHash, provider.embeddingSpace, provider.dimensions, embeddedChunks.length, now);
|
|
354
|
+
documentId = Number(result.lastInsertRowid);
|
|
355
|
+
}
|
|
356
|
+
const insertChunk = this.context.store.db.prepare(`INSERT INTO runtime_text_similarity_chunks(
|
|
357
|
+
document_id, artifact_id, embedding_space, chunk_index,
|
|
358
|
+
start_offset, end_offset, chunk_text, dimensions, vector_blob, updated_at
|
|
359
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
360
|
+
embeddedChunks.forEach((chunk, index) => {
|
|
361
|
+
insertChunk.run(documentId, artifact.artifact_id, provider.embeddingSpace, index, chunk.startOffset, chunk.endOffset, normalizedSource.text.slice(chunk.startOffset, chunk.endOffset), provider.dimensions, vectorToBlob(normalized(chunk.vector)), now);
|
|
362
|
+
});
|
|
363
|
+
return this.textDocumentById(documentId);
|
|
364
|
+
});
|
|
365
|
+
return this.textIndexResult(row, Boolean(reusable));
|
|
366
|
+
}
|
|
367
|
+
async findSimilarTextArtifact(artifact, options) {
|
|
368
|
+
const provider = this.requireTextProvider();
|
|
369
|
+
const query = this.textDocumentForArtifact(artifact.artifact_id, provider.embeddingSpace);
|
|
370
|
+
if (!query) {
|
|
371
|
+
throw new EngineFault({
|
|
372
|
+
code: "NOT_READY",
|
|
373
|
+
message: `Artifact is not indexed for similarity: ${artifact.slug}`,
|
|
374
|
+
details: { artifactId: artifact.artifact_id },
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
const queryChunks = this.textChunksForDocument(query.id).map((chunk) => ({
|
|
378
|
+
startOffset: chunk.start_offset,
|
|
379
|
+
endOffset: chunk.end_offset,
|
|
380
|
+
vector: vectorFromBlob(chunk),
|
|
381
|
+
}));
|
|
382
|
+
return this.searchText(artifact.artifact_id, query.object_hash, query.content_hash, queryChunks, options, provider);
|
|
383
|
+
}
|
|
384
|
+
async findSimilarMedia(artifact, kind, options) {
|
|
385
|
+
const limit = checkedLimit(options.limit);
|
|
386
|
+
const minScore = checkedMinScore(options.minScore);
|
|
387
|
+
const provider = this.providerFor(kind);
|
|
388
|
+
const query = this.embeddingForArtifact(artifact.artifact_id, provider.embeddingSpace);
|
|
389
|
+
if (!query) {
|
|
390
|
+
throw new EngineFault({
|
|
391
|
+
code: "NOT_READY",
|
|
392
|
+
message: `Artifact is not indexed for similarity: ${artifact.slug}`,
|
|
393
|
+
details: { artifactId: artifact.artifact_id },
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
const index = this.indexFor(kind, query.embedding_space, query.dimensions);
|
|
397
|
+
if (index.index.size() === 0)
|
|
398
|
+
return [];
|
|
399
|
+
const candidateCount = Math.min(index.index.size(), Math.max(20, limit * 5));
|
|
400
|
+
const nearest = index.index.search(vectorFromBlob(query), candidateCount, 0);
|
|
401
|
+
const ids = new Set();
|
|
402
|
+
for (const key of nearest.keys)
|
|
403
|
+
ids.add(Number(key));
|
|
404
|
+
for (const exact of this.exactObjectEmbeddings(kind, query.object_hash, query.embedding_space)) {
|
|
405
|
+
ids.add(exact.id);
|
|
406
|
+
}
|
|
407
|
+
const candidates = this.activeEmbeddingsByIds(kind, query.embedding_space, [...ids]);
|
|
408
|
+
const queryVector = vectorFromBlob(query);
|
|
409
|
+
return candidates
|
|
410
|
+
.filter((candidate) => options.includeSelf === true ||
|
|
411
|
+
candidate.artifact_id !== artifact.artifact_id)
|
|
412
|
+
.map((candidate) => {
|
|
413
|
+
const exactBytes = candidate.object_hash === query.object_hash;
|
|
414
|
+
const global = exactBytes
|
|
415
|
+
? 1
|
|
416
|
+
: cosine(queryVector, vectorFromBlob(candidate));
|
|
417
|
+
return {
|
|
418
|
+
artifactId: candidate.artifact_id,
|
|
419
|
+
slug: candidate.slug ?? candidate.artifact_id,
|
|
420
|
+
kind: candidate.kind,
|
|
421
|
+
score: global,
|
|
422
|
+
exactBytes,
|
|
423
|
+
embeddingSpace: candidate.embedding_space,
|
|
424
|
+
signals: { global },
|
|
425
|
+
};
|
|
426
|
+
})
|
|
427
|
+
.filter((match) => minScore === undefined || match.score >= minScore)
|
|
428
|
+
.sort((left, right) => right.score - left.score ||
|
|
429
|
+
left.artifactId.localeCompare(right.artifactId))
|
|
430
|
+
.slice(0, limit);
|
|
431
|
+
}
|
|
432
|
+
searchText(queryArtifactId, queryObjectHash, queryContentHash, queryChunks, options, provider) {
|
|
433
|
+
const limit = checkedLimit(options.limit);
|
|
434
|
+
const minScore = checkedMinScore(options.minScore);
|
|
435
|
+
if (queryChunks.length === 0)
|
|
436
|
+
return [];
|
|
437
|
+
const index = this.textIndexFor(provider.embeddingSpace, provider.dimensions);
|
|
438
|
+
if (index.index.size() === 0)
|
|
439
|
+
return [];
|
|
440
|
+
const candidateCount = Math.min(index.index.size(), Math.max(50, limit * 10));
|
|
441
|
+
const nearest = index.index.search(queryChunks.map((chunk) => normalized(chunk.vector)), candidateCount, 0);
|
|
442
|
+
const pairs = new Map();
|
|
443
|
+
for (let queryIndex = 0; queryIndex < queryChunks.length; queryIndex += 1) {
|
|
444
|
+
const matches = queryChunks.length === 1
|
|
445
|
+
? nearest
|
|
446
|
+
: nearest.get(queryIndex);
|
|
447
|
+
for (const key of matches.keys) {
|
|
448
|
+
const chunkId = Number(key);
|
|
449
|
+
const current = pairs.get(chunkId) ?? [];
|
|
450
|
+
current.push({ chunkId, queryChunkIndex: queryIndex });
|
|
451
|
+
pairs.set(chunkId, current);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const exactDocuments = new Map();
|
|
455
|
+
if (queryObjectHash) {
|
|
456
|
+
for (const document of this.textDocumentsForHash("object_hash", queryObjectHash, provider)) {
|
|
457
|
+
exactDocuments.set(document.id, {
|
|
458
|
+
exactBytes: true,
|
|
459
|
+
exactContent: document.content_hash === queryContentHash,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
for (const document of this.textDocumentsForHash("content_hash", queryContentHash, provider)) {
|
|
464
|
+
const current = exactDocuments.get(document.id);
|
|
465
|
+
exactDocuments.set(document.id, {
|
|
466
|
+
exactBytes: current?.exactBytes ?? false,
|
|
467
|
+
exactContent: true,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
for (const document of exactDocuments.keys()) {
|
|
471
|
+
const first = this.textChunksForDocument(document)[0];
|
|
472
|
+
if (first) {
|
|
473
|
+
const current = pairs.get(first.id) ?? [];
|
|
474
|
+
current.push({
|
|
475
|
+
chunkId: first.id,
|
|
476
|
+
queryChunkIndex: 0,
|
|
477
|
+
exactBytes: exactDocuments.get(document)?.exactBytes,
|
|
478
|
+
exactContent: exactDocuments.get(document)?.exactContent,
|
|
479
|
+
});
|
|
480
|
+
pairs.set(first.id, current);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const chunks = this.activeTextChunksByIds(provider.embeddingSpace, provider.dimensions, [...pairs.keys()]);
|
|
484
|
+
const best = new Map();
|
|
485
|
+
for (const chunk of chunks) {
|
|
486
|
+
for (const pair of pairs.get(chunk.id) ?? []) {
|
|
487
|
+
const queryChunk = queryChunks[pair.queryChunkIndex];
|
|
488
|
+
if (!queryChunk)
|
|
489
|
+
continue;
|
|
490
|
+
const exactBytes = pair.exactBytes === true ||
|
|
491
|
+
chunk.object_hash === queryObjectHash;
|
|
492
|
+
const exactContent = pair.exactContent === true ||
|
|
493
|
+
chunk.content_hash === queryContentHash;
|
|
494
|
+
const score = exactBytes || exactContent
|
|
495
|
+
? 1
|
|
496
|
+
: cosine(normalized(queryChunk.vector), vectorFromBlob(chunk));
|
|
497
|
+
const current = best.get(chunk.document_id);
|
|
498
|
+
if (!current || score > current.score) {
|
|
499
|
+
best.set(chunk.document_id, {
|
|
500
|
+
chunk,
|
|
501
|
+
queryChunkIndex: pair.queryChunkIndex,
|
|
502
|
+
score,
|
|
503
|
+
exactBytes,
|
|
504
|
+
exactContent,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return [...best.entries()]
|
|
510
|
+
.filter(([, match]) => options.includeSelf === true ||
|
|
511
|
+
queryArtifactId === null || match.chunk.artifact_id !== queryArtifactId)
|
|
512
|
+
.map(([, match]) => {
|
|
513
|
+
const queryChunk = queryChunks[match.queryChunkIndex];
|
|
514
|
+
return {
|
|
515
|
+
artifactId: match.chunk.artifact_id,
|
|
516
|
+
slug: match.chunk.slug ?? match.chunk.artifact_id,
|
|
517
|
+
kind: "text",
|
|
518
|
+
score: match.score,
|
|
519
|
+
exactBytes: match.exactBytes,
|
|
520
|
+
exactContent: match.exactContent,
|
|
521
|
+
embeddingSpace: match.chunk.embedding_space,
|
|
522
|
+
text: {
|
|
523
|
+
sourcePath: match.chunk.source_path ?? "original.txt",
|
|
524
|
+
chunkIndex: match.chunk.chunk_index,
|
|
525
|
+
startOffset: match.chunk.start_offset,
|
|
526
|
+
endOffset: match.chunk.end_offset,
|
|
527
|
+
excerpt: excerpt(match.chunk.chunk_text),
|
|
528
|
+
...(queryChunk
|
|
529
|
+
? {
|
|
530
|
+
queryStartOffset: queryChunk.startOffset,
|
|
531
|
+
queryEndOffset: queryChunk.endOffset,
|
|
532
|
+
}
|
|
533
|
+
: {}),
|
|
534
|
+
},
|
|
535
|
+
signals: { global: match.score },
|
|
536
|
+
};
|
|
537
|
+
})
|
|
538
|
+
.filter((match) => minScore === undefined || match.score >= minScore)
|
|
539
|
+
.sort((left, right) => right.score - left.score ||
|
|
540
|
+
left.artifactId.localeCompare(right.artifactId))
|
|
541
|
+
.slice(0, limit);
|
|
542
|
+
}
|
|
543
|
+
async embedMediaSource(source) {
|
|
544
|
+
const localPath = await this.context.objects.ensureLocalPath(source.object_hash);
|
|
545
|
+
if (source.kind === "audio") {
|
|
546
|
+
const provider = this.requireAudioProvider();
|
|
547
|
+
await provider.prepare();
|
|
548
|
+
return {
|
|
549
|
+
vector: normalized(await provider.embedAudio(localPath)),
|
|
550
|
+
frameCount: null,
|
|
551
|
+
reused: false,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
await this.provider.prepare();
|
|
555
|
+
if (source.kind === "image") {
|
|
556
|
+
return {
|
|
557
|
+
vector: normalized(await this.provider.embedImage(localPath)),
|
|
558
|
+
frameCount: null,
|
|
559
|
+
reused: false,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
const video = await this.provider.embedVideo(localPath);
|
|
563
|
+
return {
|
|
564
|
+
vector: normalized(video.vector),
|
|
565
|
+
frameCount: video.frameCount,
|
|
566
|
+
reused: false,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
async embedText(text) {
|
|
570
|
+
const provider = this.requireTextProvider();
|
|
571
|
+
await provider.prepare();
|
|
572
|
+
const chunks = await provider.embedText(text);
|
|
573
|
+
this.validateTextChunks(text, chunks, provider.dimensions);
|
|
574
|
+
const maxChunks = checkedTextMaxChunks(this.context.config.similarity?.text);
|
|
575
|
+
if (chunks.length > maxChunks) {
|
|
576
|
+
throw new EngineFault({
|
|
577
|
+
code: "INVALID_INPUT",
|
|
578
|
+
message: `Text source produces more than ${maxChunks} chunks`,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
return chunks.map((chunk) => ({
|
|
582
|
+
...chunk,
|
|
583
|
+
vector: normalized(chunk.vector),
|
|
584
|
+
}));
|
|
585
|
+
}
|
|
586
|
+
async readNormalizedText(source) {
|
|
587
|
+
const maxBytes = checkedTextMaxBytes(this.context.config.similarity?.text);
|
|
588
|
+
const localPath = await this.context.objects.ensureLocalPath(source.object_hash);
|
|
589
|
+
const bytes = await readFile(localPath);
|
|
590
|
+
if (bytes.byteLength > maxBytes) {
|
|
591
|
+
throw new EngineFault({
|
|
592
|
+
code: "INVALID_INPUT",
|
|
593
|
+
message: `Text source exceeds the ${maxBytes}-byte similarity limit`,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
let raw;
|
|
597
|
+
try {
|
|
598
|
+
raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
throw new EngineFault({
|
|
602
|
+
code: "INVALID_INPUT",
|
|
603
|
+
message: `Text source is not valid UTF-8: ${source.path}`,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
const extension = path.extname(source.path).toLowerCase();
|
|
607
|
+
const normalizedText = extension === ".json"
|
|
608
|
+
? normalizeJsonText(raw, source.path)
|
|
609
|
+
: normalizePlainText(raw);
|
|
610
|
+
if (normalizedText.text.length === 0) {
|
|
611
|
+
throw new EngineFault({
|
|
612
|
+
code: "INVALID_INPUT",
|
|
613
|
+
message: `Text source is empty: ${source.path}`,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
return normalizedText;
|
|
617
|
+
}
|
|
618
|
+
validateTextChunks(text, chunks, dimensions) {
|
|
619
|
+
if (chunks.length === 0) {
|
|
620
|
+
throw new EngineFault({
|
|
621
|
+
code: "INVALID_INPUT",
|
|
622
|
+
message: "Text provider returned no chunks",
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
let previousStart = -1;
|
|
626
|
+
for (const chunk of chunks) {
|
|
627
|
+
if (!Number.isInteger(chunk.startOffset) ||
|
|
628
|
+
!Number.isInteger(chunk.endOffset) ||
|
|
629
|
+
chunk.startOffset < 0 ||
|
|
630
|
+
chunk.endOffset <= chunk.startOffset ||
|
|
631
|
+
chunk.endOffset > text.length ||
|
|
632
|
+
chunk.startOffset < previousStart) {
|
|
633
|
+
throw new EngineFault({
|
|
634
|
+
code: "INVALID_INPUT",
|
|
635
|
+
message: "Text provider returned invalid chunk offsets",
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
if (chunk.vector.length !== dimensions) {
|
|
639
|
+
throw new EngineFault({
|
|
640
|
+
code: "INVALID_INPUT",
|
|
641
|
+
message: `Text provider returned ${chunk.vector.length} dimensions; expected ${dimensions}`,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
normalized(chunk.vector);
|
|
645
|
+
previousStart = chunk.startOffset;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
sourceFor(artifact, kind) {
|
|
649
|
+
const rows = this.context.store.db
|
|
650
|
+
.prepare(`SELECT f.artifact_id, f.path, f.object_hash, o.size_bytes,
|
|
651
|
+
f.mtime_ms, f.created_at
|
|
652
|
+
FROM artifact_files f
|
|
653
|
+
JOIN objects o ON o.object_hash=f.object_hash
|
|
654
|
+
WHERE f.artifact_id=?
|
|
655
|
+
ORDER BY f.path`)
|
|
656
|
+
.all(artifact.artifact_id);
|
|
657
|
+
const extension = kind === "image"
|
|
658
|
+
? "(?:png|jpe?g|webp)"
|
|
659
|
+
: kind === "video"
|
|
660
|
+
? "(?:mp4|mov|webm|mkv|avi)"
|
|
661
|
+
: kind === "audio"
|
|
662
|
+
? "(?:mp3|wav|ogg|flac|aac|m4a)"
|
|
663
|
+
: "(?:json|md|txt)";
|
|
664
|
+
const sources = rows.filter((row) => new RegExp(`(?:^|/)original\\.${extension}$`, "i").test(row.path));
|
|
665
|
+
if (sources.length === 0) {
|
|
666
|
+
throw new EngineFault({
|
|
667
|
+
code: "INVALID_INPUT",
|
|
668
|
+
message: `No supported original ${kind} file for ${artifact.slug}`,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
if (kind === "text" && sources.length > 1) {
|
|
672
|
+
throw new EngineFault({
|
|
673
|
+
code: "INVALID_INPUT",
|
|
674
|
+
message: `Multiple supported original ${kind} files for ${artifact.slug}`,
|
|
675
|
+
details: { paths: sources.map((source) => source.path) },
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
return { ...sources[0], kind };
|
|
679
|
+
}
|
|
680
|
+
hasTextSource(artifactId) {
|
|
681
|
+
const rows = this.context.store.db
|
|
682
|
+
.prepare("SELECT path FROM artifact_files WHERE artifact_id=?")
|
|
683
|
+
.all(artifactId);
|
|
684
|
+
return rows.some((row) => /(?:^|\/)original\.(?:json|md|txt)$/i.test(row.path));
|
|
685
|
+
}
|
|
686
|
+
embeddingForArtifact(artifactId, embeddingSpace) {
|
|
687
|
+
const row = this.context.store.db
|
|
688
|
+
.prepare(`SELECT id, artifact_id, kind, source_path, object_hash,
|
|
689
|
+
embedding_space, dimensions, vector_blob, frame_count, updated_at
|
|
690
|
+
FROM runtime_similarity_embeddings
|
|
691
|
+
WHERE artifact_id=? AND embedding_space=?`)
|
|
692
|
+
.get(artifactId, embeddingSpace);
|
|
693
|
+
return row ?? null;
|
|
694
|
+
}
|
|
695
|
+
embeddingForObject(objectHash, kind, embeddingSpace, dimensions) {
|
|
696
|
+
const row = this.context.store.db
|
|
697
|
+
.prepare(`SELECT id, artifact_id, kind, source_path, object_hash,
|
|
698
|
+
embedding_space, dimensions, vector_blob, frame_count, updated_at
|
|
699
|
+
FROM runtime_similarity_embeddings
|
|
700
|
+
WHERE object_hash=? AND kind=? AND embedding_space=? AND dimensions=?
|
|
701
|
+
ORDER BY updated_at DESC, id DESC LIMIT 1`)
|
|
702
|
+
.get(objectHash, kind, embeddingSpace, dimensions);
|
|
703
|
+
return row ?? null;
|
|
704
|
+
}
|
|
705
|
+
embeddingById(id) {
|
|
706
|
+
const row = this.context.store.db
|
|
707
|
+
.prepare(`SELECT id, artifact_id, kind, source_path, object_hash,
|
|
708
|
+
embedding_space, dimensions, vector_blob, frame_count, updated_at
|
|
709
|
+
FROM runtime_similarity_embeddings WHERE id=?`)
|
|
710
|
+
.get(id);
|
|
711
|
+
if (!row)
|
|
712
|
+
throw new Error(`Similarity embedding not found: ${id}`);
|
|
713
|
+
return row;
|
|
714
|
+
}
|
|
715
|
+
exactObjectEmbeddings(kind, objectHash, embeddingSpace) {
|
|
716
|
+
return this.context.store.db
|
|
717
|
+
.prepare(`SELECT id, artifact_id, kind, source_path, object_hash,
|
|
718
|
+
embedding_space, dimensions, vector_blob, frame_count, updated_at
|
|
719
|
+
FROM runtime_similarity_embeddings
|
|
720
|
+
WHERE kind=? AND object_hash=? AND embedding_space=?`)
|
|
721
|
+
.all(kind, objectHash, embeddingSpace);
|
|
722
|
+
}
|
|
723
|
+
activeEmbeddingsByIds(kind, embeddingSpace, ids) {
|
|
724
|
+
if (ids.length === 0)
|
|
725
|
+
return [];
|
|
726
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
727
|
+
return this.context.store.db
|
|
728
|
+
.prepare(`SELECT e.id, e.artifact_id, e.kind, e.source_path,
|
|
729
|
+
e.object_hash, e.embedding_space, e.dimensions, e.vector_blob,
|
|
730
|
+
e.frame_count, e.updated_at, a.slug
|
|
731
|
+
FROM runtime_similarity_embeddings e
|
|
732
|
+
JOIN artifacts a ON a.artifact_id=e.artifact_id
|
|
733
|
+
WHERE e.kind=? AND e.embedding_space=?
|
|
734
|
+
AND e.id IN (${placeholders})`)
|
|
735
|
+
.all(kind, embeddingSpace, ...ids);
|
|
736
|
+
}
|
|
737
|
+
indexFor(kind, embeddingSpace, dimensions) {
|
|
738
|
+
return this.cachedIndex(kind, embeddingSpace, dimensions, `SELECT e.id, e.artifact_id, e.kind, e.source_path,
|
|
739
|
+
e.object_hash, e.embedding_space, e.dimensions, e.vector_blob,
|
|
740
|
+
e.frame_count, e.updated_at
|
|
741
|
+
FROM runtime_similarity_embeddings e
|
|
742
|
+
JOIN artifacts a ON a.artifact_id=e.artifact_id
|
|
743
|
+
WHERE e.kind=? AND e.embedding_space=?
|
|
744
|
+
AND e.dimensions=?
|
|
745
|
+
ORDER BY e.id`);
|
|
746
|
+
}
|
|
747
|
+
textIndexFor(embeddingSpace, dimensions) {
|
|
748
|
+
const key = indexKey("text", embeddingSpace);
|
|
749
|
+
const current = this.indexes.get(key);
|
|
750
|
+
if (current)
|
|
751
|
+
return current;
|
|
752
|
+
const index = new Index({
|
|
753
|
+
dimensions,
|
|
754
|
+
metric: MetricKind.Cos,
|
|
755
|
+
quantization: ScalarKind.F16,
|
|
756
|
+
connectivity: 16,
|
|
757
|
+
expansion_add: 128,
|
|
758
|
+
expansion_search: 128,
|
|
759
|
+
multi: false,
|
|
760
|
+
});
|
|
761
|
+
const rows = this.context.store.db
|
|
762
|
+
.prepare(`SELECT c.id, c.document_id, c.artifact_id,
|
|
763
|
+
c.embedding_space, c.chunk_index, c.start_offset,
|
|
764
|
+
c.end_offset, c.chunk_text, c.dimensions, c.vector_blob,
|
|
765
|
+
c.updated_at
|
|
766
|
+
FROM runtime_text_similarity_chunks c
|
|
767
|
+
JOIN artifacts a ON a.artifact_id=c.artifact_id
|
|
768
|
+
WHERE c.embedding_space=?
|
|
769
|
+
AND c.dimensions=?
|
|
770
|
+
ORDER BY c.id`)
|
|
771
|
+
.all(embeddingSpace, dimensions);
|
|
772
|
+
for (const row of rows) {
|
|
773
|
+
index.add(BigInt(row.id), vectorFromBlob(row), 0);
|
|
774
|
+
}
|
|
775
|
+
const cached = { index, dimensions };
|
|
776
|
+
this.indexes.set(key, cached);
|
|
777
|
+
return cached;
|
|
778
|
+
}
|
|
779
|
+
cachedIndex(kind, embeddingSpace, dimensions, sql) {
|
|
780
|
+
const key = indexKey(kind, embeddingSpace);
|
|
781
|
+
const current = this.indexes.get(key);
|
|
782
|
+
if (current)
|
|
783
|
+
return current;
|
|
784
|
+
const index = new Index({
|
|
785
|
+
dimensions,
|
|
786
|
+
metric: MetricKind.Cos,
|
|
787
|
+
quantization: ScalarKind.F16,
|
|
788
|
+
connectivity: 16,
|
|
789
|
+
expansion_add: 128,
|
|
790
|
+
expansion_search: 128,
|
|
791
|
+
multi: false,
|
|
792
|
+
});
|
|
793
|
+
const rows = this.context.store.db
|
|
794
|
+
.prepare(sql)
|
|
795
|
+
.all(kind, embeddingSpace, dimensions);
|
|
796
|
+
for (const row of rows) {
|
|
797
|
+
index.add(BigInt(row.id), vectorFromBlob(row), 0);
|
|
798
|
+
}
|
|
799
|
+
const cached = { index, dimensions };
|
|
800
|
+
this.indexes.set(key, cached);
|
|
801
|
+
return cached;
|
|
802
|
+
}
|
|
803
|
+
addToCachedIndex(row, vector) {
|
|
804
|
+
const cached = this.indexes.get(indexKey(row.kind, row.embedding_space));
|
|
805
|
+
if (!cached)
|
|
806
|
+
return;
|
|
807
|
+
if (cached.dimensions !== vector.length) {
|
|
808
|
+
throw new Error("Similarity index dimension mismatch");
|
|
809
|
+
}
|
|
810
|
+
try {
|
|
811
|
+
cached.index.remove(BigInt(row.id));
|
|
812
|
+
}
|
|
813
|
+
catch {
|
|
814
|
+
// The row may not have been part of an index built before it existed.
|
|
815
|
+
}
|
|
816
|
+
cached.index.add(BigInt(row.id), vector, 0);
|
|
817
|
+
}
|
|
818
|
+
textDocumentForArtifact(artifactId, embeddingSpace) {
|
|
819
|
+
const row = this.context.store.db
|
|
820
|
+
.prepare(`SELECT id, artifact_id, source_path, object_hash,
|
|
821
|
+
content_hash, embedding_space, dimensions, chunk_count, updated_at
|
|
822
|
+
FROM runtime_text_similarity_documents
|
|
823
|
+
WHERE artifact_id=? AND embedding_space=?`)
|
|
824
|
+
.get(artifactId, embeddingSpace);
|
|
825
|
+
return row ?? null;
|
|
826
|
+
}
|
|
827
|
+
textDocumentById(id) {
|
|
828
|
+
const row = this.context.store.db
|
|
829
|
+
.prepare(`SELECT id, artifact_id, source_path, object_hash,
|
|
830
|
+
content_hash, embedding_space, dimensions, chunk_count, updated_at
|
|
831
|
+
FROM runtime_text_similarity_documents WHERE id=?`)
|
|
832
|
+
.get(id);
|
|
833
|
+
if (!row)
|
|
834
|
+
throw new Error(`Text similarity document not found: ${id}`);
|
|
835
|
+
return row;
|
|
836
|
+
}
|
|
837
|
+
textDocumentForObjectOrContent(objectHash, contentHash, embeddingSpace, dimensions) {
|
|
838
|
+
const row = this.context.store.db
|
|
839
|
+
.prepare(`SELECT id, artifact_id, source_path, object_hash,
|
|
840
|
+
content_hash, embedding_space, dimensions, chunk_count, updated_at
|
|
841
|
+
FROM runtime_text_similarity_documents
|
|
842
|
+
WHERE (object_hash=? OR content_hash=?)
|
|
843
|
+
AND embedding_space=? AND dimensions=?
|
|
844
|
+
ORDER BY updated_at DESC, id DESC LIMIT 1`)
|
|
845
|
+
.get(objectHash, contentHash, embeddingSpace, dimensions);
|
|
846
|
+
return row ?? null;
|
|
847
|
+
}
|
|
848
|
+
textDocumentsForHash(column, hash, provider) {
|
|
849
|
+
return this.context.store.db
|
|
850
|
+
.prepare(`SELECT id, artifact_id, source_path, object_hash,
|
|
851
|
+
content_hash, embedding_space, dimensions, chunk_count, updated_at
|
|
852
|
+
FROM runtime_text_similarity_documents
|
|
853
|
+
WHERE ${column}=? AND embedding_space=?
|
|
854
|
+
AND dimensions=?`)
|
|
855
|
+
.all(hash, provider.embeddingSpace, provider.dimensions);
|
|
856
|
+
}
|
|
857
|
+
textChunksForDocument(documentId) {
|
|
858
|
+
return this.context.store.db
|
|
859
|
+
.prepare(`SELECT id, document_id, artifact_id, embedding_space,
|
|
860
|
+
chunk_index, start_offset, end_offset, chunk_text, dimensions,
|
|
861
|
+
vector_blob, updated_at
|
|
862
|
+
FROM runtime_text_similarity_chunks
|
|
863
|
+
WHERE document_id=? ORDER BY chunk_index`)
|
|
864
|
+
.all(documentId);
|
|
865
|
+
}
|
|
866
|
+
activeTextChunksByIds(embeddingSpace, dimensions, ids) {
|
|
867
|
+
if (ids.length === 0)
|
|
868
|
+
return [];
|
|
869
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
870
|
+
return this.context.store.db
|
|
871
|
+
.prepare(`SELECT c.id, c.document_id, c.artifact_id,
|
|
872
|
+
c.embedding_space, c.chunk_index, c.start_offset,
|
|
873
|
+
c.end_offset, c.chunk_text, c.dimensions, c.vector_blob,
|
|
874
|
+
c.updated_at, d.source_path, d.object_hash, d.content_hash,
|
|
875
|
+
a.slug
|
|
876
|
+
FROM runtime_text_similarity_chunks c
|
|
877
|
+
JOIN runtime_text_similarity_documents d ON d.id=c.document_id
|
|
878
|
+
JOIN artifacts a ON a.artifact_id=c.artifact_id
|
|
879
|
+
WHERE c.embedding_space=? AND c.dimensions=?
|
|
880
|
+
AND c.id IN (${placeholders})`)
|
|
881
|
+
.all(embeddingSpace, dimensions, ...ids);
|
|
882
|
+
}
|
|
883
|
+
indexResult(row, reused) {
|
|
884
|
+
return {
|
|
885
|
+
artifactId: row.artifact_id,
|
|
886
|
+
kind: row.kind,
|
|
887
|
+
embeddingSpace: row.embedding_space,
|
|
888
|
+
frameCount: row.frame_count,
|
|
889
|
+
reused,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
textIndexResult(row, reused) {
|
|
893
|
+
return {
|
|
894
|
+
artifactId: row.artifact_id,
|
|
895
|
+
kind: "text",
|
|
896
|
+
embeddingSpace: row.embedding_space,
|
|
897
|
+
frameCount: null,
|
|
898
|
+
chunkCount: row.chunk_count,
|
|
899
|
+
reused,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
providerFor(kind) {
|
|
903
|
+
return kind === "audio" ? this.requireAudioProvider() : this.provider;
|
|
904
|
+
}
|
|
905
|
+
requireAudioProvider() {
|
|
906
|
+
if (!this.audioProvider) {
|
|
907
|
+
throw new EngineFault({
|
|
908
|
+
code: "FEATURE_UNAVAILABLE",
|
|
909
|
+
message: "Audio similarity is disabled; configure EngineConfig.similarity.audio",
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
return this.audioProvider;
|
|
913
|
+
}
|
|
914
|
+
requireTextProvider() {
|
|
915
|
+
if (!this.textProvider) {
|
|
916
|
+
throw new EngineFault({
|
|
917
|
+
code: "FEATURE_UNAVAILABLE",
|
|
918
|
+
message: "Text similarity is disabled; configure EngineConfig.similarity.text",
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
return this.textProvider;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
class LocalClipProvider {
|
|
925
|
+
context;
|
|
926
|
+
config;
|
|
927
|
+
embeddingSpace = DEFAULT_EMBEDDING_SPACE;
|
|
928
|
+
dimensions = DEFAULT_DIMENSIONS;
|
|
929
|
+
embedder = null;
|
|
930
|
+
constructor(context, config) {
|
|
931
|
+
this.context = context;
|
|
932
|
+
this.config = config;
|
|
933
|
+
}
|
|
934
|
+
async prepare() {
|
|
935
|
+
await this.loadEmbedder();
|
|
936
|
+
}
|
|
937
|
+
async embedImage(sourcePath) {
|
|
938
|
+
const vectors = await this.embedImages([sourcePath]);
|
|
939
|
+
const vector = vectors[0];
|
|
940
|
+
if (!vector)
|
|
941
|
+
throw new Error("Image embedder returned no vector");
|
|
942
|
+
return vector;
|
|
943
|
+
}
|
|
944
|
+
async embedVideo(sourcePath) {
|
|
945
|
+
const duration = await probeDuration(this.config.ffprobePath ?? "ffprobe", sourcePath);
|
|
946
|
+
const frameCount = Math.min(120, Math.max(1, Math.ceil(duration / 2)));
|
|
947
|
+
const framesDir = await mkdtemp(path.join(tmpdir(), "videobook-sim-"));
|
|
948
|
+
try {
|
|
949
|
+
const fps = `${frameCount}/${Math.max(duration, 0.001)}`;
|
|
950
|
+
await runCommand(this.config.ffmpegPath ?? "ffmpeg", [
|
|
951
|
+
"-hide_banner",
|
|
952
|
+
"-loglevel",
|
|
953
|
+
"error",
|
|
954
|
+
"-i",
|
|
955
|
+
sourcePath,
|
|
956
|
+
"-an",
|
|
957
|
+
"-vf",
|
|
958
|
+
`fps=${fps},scale=224:224:force_original_aspect_ratio=increase,crop=224:224`,
|
|
959
|
+
"-frames:v",
|
|
960
|
+
String(frameCount),
|
|
961
|
+
path.join(framesDir, "frame-%04d.png"),
|
|
962
|
+
]);
|
|
963
|
+
const frames = (await readdir(framesDir))
|
|
964
|
+
.filter((name) => name.endsWith(".png"))
|
|
965
|
+
.sort()
|
|
966
|
+
.map((name) => path.join(framesDir, name));
|
|
967
|
+
if (frames.length === 0) {
|
|
968
|
+
throw new EngineFault({
|
|
969
|
+
code: "INVALID_INPUT",
|
|
970
|
+
message: `No video frames could be decoded from ${sourcePath}`,
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
const vectors = [];
|
|
974
|
+
for (let offset = 0; offset < frames.length; offset += 8) {
|
|
975
|
+
vectors.push(...(await this.embedImages(frames.slice(offset, offset + 8))));
|
|
976
|
+
}
|
|
977
|
+
return { vector: normalizedCentroid(vectors), frameCount: vectors.length };
|
|
978
|
+
}
|
|
979
|
+
finally {
|
|
980
|
+
await rm(framesDir, { recursive: true, force: true }).catch(() => undefined);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
async loadEmbedder() {
|
|
984
|
+
if (this.embedder)
|
|
985
|
+
return this.embedder;
|
|
986
|
+
const dataDir = this.context.config.dataDir;
|
|
987
|
+
if (!dataDir)
|
|
988
|
+
throw new Error("Similarity requires a configured dataDir");
|
|
989
|
+
const cacheDir = this.config.modelCacheDir ?? path.join(dataDir, "similarity-models");
|
|
990
|
+
await mkdir(cacheDir, { recursive: true });
|
|
991
|
+
const modelId = this.config.modelId ?? DEFAULT_MODEL_ID;
|
|
992
|
+
try {
|
|
993
|
+
this.embedder = await pipeline("image-feature-extraction", modelId, {
|
|
994
|
+
dtype: "q8",
|
|
995
|
+
cache_dir: cacheDir,
|
|
996
|
+
local_files_only: this.config.allowModelDownload === false,
|
|
997
|
+
...(modelId === DEFAULT_MODEL_ID
|
|
998
|
+
? { revision: DEFAULT_MODEL_REVISION }
|
|
999
|
+
: {}),
|
|
1000
|
+
});
|
|
1001
|
+
return this.embedder;
|
|
1002
|
+
}
|
|
1003
|
+
catch (error) {
|
|
1004
|
+
throw new EngineFault({
|
|
1005
|
+
code: this.config.allowModelDownload === false ? "OFFLINE" : "FEATURE_UNAVAILABLE",
|
|
1006
|
+
message: `Unable to load local similarity model: ${errorMessage(error)}`,
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
async embedImages(sourcePaths) {
|
|
1011
|
+
const embedder = await this.loadEmbedder();
|
|
1012
|
+
const images = await Promise.all(sourcePaths.map(readNormalizedImage));
|
|
1013
|
+
const output = await embedder(images);
|
|
1014
|
+
const batch = output.dims[0];
|
|
1015
|
+
const dimensions = output.dims[1];
|
|
1016
|
+
if (batch !== sourcePaths.length || dimensions !== this.dimensions) {
|
|
1017
|
+
throw new Error(`Unexpected embedding shape: ${output.dims.join("x")}`);
|
|
1018
|
+
}
|
|
1019
|
+
const vectors = [];
|
|
1020
|
+
for (let index = 0; index < batch; index += 1) {
|
|
1021
|
+
const start = index * this.dimensions;
|
|
1022
|
+
vectors.push(normalized(output.data.slice(start, start + this.dimensions)));
|
|
1023
|
+
}
|
|
1024
|
+
return vectors;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
class LocalClapAudioProvider {
|
|
1028
|
+
context;
|
|
1029
|
+
sharedConfig;
|
|
1030
|
+
config;
|
|
1031
|
+
embeddingSpace;
|
|
1032
|
+
dimensions = DEFAULT_AUDIO_DIMENSIONS;
|
|
1033
|
+
processor = null;
|
|
1034
|
+
model = null;
|
|
1035
|
+
sampleRate = DEFAULT_AUDIO_SAMPLE_RATE;
|
|
1036
|
+
maxSamples = DEFAULT_AUDIO_MAX_SAMPLES;
|
|
1037
|
+
constructor(context, sharedConfig, config) {
|
|
1038
|
+
this.context = context;
|
|
1039
|
+
this.sharedConfig = sharedConfig;
|
|
1040
|
+
this.config = config;
|
|
1041
|
+
this.embeddingSpace =
|
|
1042
|
+
config.modelId && config.modelId !== DEFAULT_AUDIO_MODEL_ID
|
|
1043
|
+
? `audio-${config.modelId.replace(/[^a-zA-Z0-9]+/g, "-")}-v1`
|
|
1044
|
+
: DEFAULT_AUDIO_EMBEDDING_SPACE;
|
|
1045
|
+
}
|
|
1046
|
+
async prepare() {
|
|
1047
|
+
await this.loadModel();
|
|
1048
|
+
}
|
|
1049
|
+
async embedAudio(sourcePath) {
|
|
1050
|
+
const { processor, model } = await this.loadModel();
|
|
1051
|
+
const audio = await decodeAudioToMono(this.config.ffmpegPath ?? this.sharedConfig.ffmpegPath ?? "ffmpeg", sourcePath, this.sampleRate, this.maxSamples);
|
|
1052
|
+
const inputs = await processor(audio);
|
|
1053
|
+
const output = await model(inputs);
|
|
1054
|
+
const dimensions = output.audio_embeds.dims;
|
|
1055
|
+
if (dimensions.length !== 2 ||
|
|
1056
|
+
dimensions[0] !== 1 ||
|
|
1057
|
+
dimensions[1] !== this.dimensions ||
|
|
1058
|
+
output.audio_embeds.data.length !== this.dimensions) {
|
|
1059
|
+
throw new Error(`Unexpected audio embedding shape: ${dimensions.join("x")}`);
|
|
1060
|
+
}
|
|
1061
|
+
return output.audio_embeds.data.slice();
|
|
1062
|
+
}
|
|
1063
|
+
async loadModel() {
|
|
1064
|
+
if (this.processor && this.model) {
|
|
1065
|
+
return { processor: this.processor, model: this.model };
|
|
1066
|
+
}
|
|
1067
|
+
const dataDir = this.context.config.dataDir;
|
|
1068
|
+
if (!dataDir)
|
|
1069
|
+
throw new Error("Audio similarity requires a configured dataDir");
|
|
1070
|
+
const cacheDir = this.config.modelCacheDir ??
|
|
1071
|
+
this.sharedConfig.modelCacheDir ??
|
|
1072
|
+
path.join(dataDir, "similarity-models");
|
|
1073
|
+
await mkdir(cacheDir, { recursive: true });
|
|
1074
|
+
const modelId = this.config.modelId ?? DEFAULT_AUDIO_MODEL_ID;
|
|
1075
|
+
const allowDownload = this.config.allowModelDownload ??
|
|
1076
|
+
this.sharedConfig.allowModelDownload;
|
|
1077
|
+
const pinned = modelId === DEFAULT_AUDIO_MODEL_ID
|
|
1078
|
+
? { revision: DEFAULT_AUDIO_MODEL_REVISION }
|
|
1079
|
+
: {};
|
|
1080
|
+
try {
|
|
1081
|
+
const processor = await AutoProcessor.from_pretrained(modelId, {
|
|
1082
|
+
cache_dir: cacheDir,
|
|
1083
|
+
local_files_only: allowDownload === false,
|
|
1084
|
+
...pinned,
|
|
1085
|
+
});
|
|
1086
|
+
const model = await ClapAudioModelWithProjection.from_pretrained(modelId, {
|
|
1087
|
+
dtype: "q8",
|
|
1088
|
+
cache_dir: cacheDir,
|
|
1089
|
+
local_files_only: allowDownload === false,
|
|
1090
|
+
...pinned,
|
|
1091
|
+
});
|
|
1092
|
+
const featureConfig = processor.feature_extractor?.config;
|
|
1093
|
+
this.sampleRate = checkedAudioSampleRate(featureConfig?.sampling_rate);
|
|
1094
|
+
this.maxSamples = checkedAudioMaxSamples(featureConfig?.nb_max_samples, this.sampleRate);
|
|
1095
|
+
this.processor = processor;
|
|
1096
|
+
this.model = model;
|
|
1097
|
+
return { processor, model };
|
|
1098
|
+
}
|
|
1099
|
+
catch (error) {
|
|
1100
|
+
throw new EngineFault({
|
|
1101
|
+
code: allowDownload === false ? "OFFLINE" : "FEATURE_UNAVAILABLE",
|
|
1102
|
+
message: `Unable to load local audio similarity model: ${errorMessage(error)}`,
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
class LocalTextProvider {
|
|
1108
|
+
context;
|
|
1109
|
+
sharedConfig;
|
|
1110
|
+
config;
|
|
1111
|
+
embeddingSpace;
|
|
1112
|
+
dimensions = DEFAULT_TEXT_DIMENSIONS;
|
|
1113
|
+
embedder = null;
|
|
1114
|
+
constructor(context, sharedConfig, config) {
|
|
1115
|
+
this.context = context;
|
|
1116
|
+
this.sharedConfig = sharedConfig;
|
|
1117
|
+
this.config = config;
|
|
1118
|
+
this.embeddingSpace = config.modelId && config.modelId !== DEFAULT_TEXT_MODEL_ID
|
|
1119
|
+
? `text-${config.modelId.replace(/[^a-zA-Z0-9]+/g, "-")}-v1`
|
|
1120
|
+
: DEFAULT_TEXT_EMBEDDING_SPACE;
|
|
1121
|
+
}
|
|
1122
|
+
async prepare() {
|
|
1123
|
+
await this.loadEmbedder();
|
|
1124
|
+
}
|
|
1125
|
+
async embedText(text) {
|
|
1126
|
+
const embedder = await this.loadEmbedder();
|
|
1127
|
+
const ranges = makeTextRanges(text, embedder.tokenizer);
|
|
1128
|
+
const chunks = [];
|
|
1129
|
+
for (let offset = 0; offset < ranges.length; offset += 32) {
|
|
1130
|
+
const batchRanges = ranges.slice(offset, offset + 32);
|
|
1131
|
+
const output = await embedder(batchRanges.map((range) => text.slice(range.startOffset, range.endOffset)), { pooling: "mean", normalize: true });
|
|
1132
|
+
const batch = output.dims[0];
|
|
1133
|
+
const dimensions = output.dims[1];
|
|
1134
|
+
if (batch !== batchRanges.length || dimensions !== this.dimensions) {
|
|
1135
|
+
throw new Error(`Unexpected text embedding shape: ${output.dims.join("x")}`);
|
|
1136
|
+
}
|
|
1137
|
+
for (let index = 0; index < batch; index += 1) {
|
|
1138
|
+
const start = index * this.dimensions;
|
|
1139
|
+
chunks.push({
|
|
1140
|
+
startOffset: batchRanges[index].startOffset,
|
|
1141
|
+
endOffset: batchRanges[index].endOffset,
|
|
1142
|
+
vector: normalized(output.data.slice(start, start + this.dimensions)),
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
return chunks;
|
|
1147
|
+
}
|
|
1148
|
+
async loadEmbedder() {
|
|
1149
|
+
if (this.embedder)
|
|
1150
|
+
return this.embedder;
|
|
1151
|
+
const dataDir = this.context.config.dataDir;
|
|
1152
|
+
if (!dataDir)
|
|
1153
|
+
throw new Error("Text similarity requires a configured dataDir");
|
|
1154
|
+
const cacheDir = this.config.modelCacheDir ??
|
|
1155
|
+
this.sharedConfig.modelCacheDir ??
|
|
1156
|
+
path.join(dataDir, "similarity-models");
|
|
1157
|
+
await mkdir(cacheDir, { recursive: true });
|
|
1158
|
+
const modelId = this.config.modelId ?? DEFAULT_TEXT_MODEL_ID;
|
|
1159
|
+
const allowDownload = this.config.allowModelDownload ??
|
|
1160
|
+
this.sharedConfig.allowModelDownload;
|
|
1161
|
+
try {
|
|
1162
|
+
this.embedder = await pipeline("feature-extraction", modelId, {
|
|
1163
|
+
dtype: "q4",
|
|
1164
|
+
cache_dir: cacheDir,
|
|
1165
|
+
local_files_only: allowDownload === false,
|
|
1166
|
+
...(modelId === DEFAULT_TEXT_MODEL_ID
|
|
1167
|
+
? { revision: DEFAULT_TEXT_MODEL_REVISION }
|
|
1168
|
+
: {}),
|
|
1169
|
+
});
|
|
1170
|
+
return this.embedder;
|
|
1171
|
+
}
|
|
1172
|
+
catch (error) {
|
|
1173
|
+
throw new EngineFault({
|
|
1174
|
+
code: allowDownload === false ? "OFFLINE" : "FEATURE_UNAVAILABLE",
|
|
1175
|
+
message: `Unable to load local text similarity model: ${errorMessage(error)}`,
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
function disabledSimilarityApi() {
|
|
1181
|
+
const unavailable = () => err({
|
|
1182
|
+
code: "FEATURE_UNAVAILABLE",
|
|
1183
|
+
message: "Similarity is disabled; configure EngineConfig.similarity to enable it",
|
|
1184
|
+
});
|
|
1185
|
+
return {
|
|
1186
|
+
prepare: async () => unavailable(),
|
|
1187
|
+
index: async () => unavailable(),
|
|
1188
|
+
rebuild: async () => unavailable(),
|
|
1189
|
+
status: () => unavailable(),
|
|
1190
|
+
stats: () => unavailable(),
|
|
1191
|
+
findSimilar: async () => unavailable(),
|
|
1192
|
+
findSimilarText: async () => unavailable(),
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
function similarityKind(artifact) {
|
|
1196
|
+
if (artifact.kind === "image" ||
|
|
1197
|
+
artifact.kind === "video" ||
|
|
1198
|
+
artifact.kind === "audio") {
|
|
1199
|
+
return artifact.kind;
|
|
1200
|
+
}
|
|
1201
|
+
if (TEXT_ARTIFACT_KINDS.has(artifact.kind))
|
|
1202
|
+
return "text";
|
|
1203
|
+
throw new EngineFault({
|
|
1204
|
+
code: "INVALID_INPUT",
|
|
1205
|
+
message: `Similarity supports image, video, audio, and text artifacts, not ${artifact.kind}`,
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
async function readNormalizedImage(sourcePath) {
|
|
1209
|
+
const decoded = await sharp(sourcePath, { animated: false })
|
|
1210
|
+
.rotate()
|
|
1211
|
+
.toColourspace("srgb")
|
|
1212
|
+
.removeAlpha()
|
|
1213
|
+
.raw()
|
|
1214
|
+
.toBuffer({ resolveWithObject: true });
|
|
1215
|
+
if (decoded.info.width <= 0 || decoded.info.height <= 0) {
|
|
1216
|
+
throw new Error(`Invalid image dimensions: ${sourcePath}`);
|
|
1217
|
+
}
|
|
1218
|
+
if (decoded.info.channels !== 3) {
|
|
1219
|
+
throw new Error(`Expected RGB image after conversion: ${sourcePath}`);
|
|
1220
|
+
}
|
|
1221
|
+
return new RawImage(decoded.data, decoded.info.width, decoded.info.height, 3);
|
|
1222
|
+
}
|
|
1223
|
+
async function probeDuration(ffprobePath, sourcePath) {
|
|
1224
|
+
const result = await runCommand(ffprobePath, [
|
|
1225
|
+
"-v",
|
|
1226
|
+
"error",
|
|
1227
|
+
"-show_entries",
|
|
1228
|
+
"format=duration",
|
|
1229
|
+
"-of",
|
|
1230
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
1231
|
+
sourcePath,
|
|
1232
|
+
]);
|
|
1233
|
+
const duration = Number.parseFloat(result.stdout.trim());
|
|
1234
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
1235
|
+
throw new EngineFault({
|
|
1236
|
+
code: "INVALID_INPUT",
|
|
1237
|
+
message: `Unable to determine a positive duration for ${sourcePath}`,
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
return duration;
|
|
1241
|
+
}
|
|
1242
|
+
async function decodeAudioToMono(ffmpegPath, sourcePath, sampleRate, maxSamples) {
|
|
1243
|
+
const output = await runBinaryCommand(ffmpegPath, [
|
|
1244
|
+
"-hide_banner",
|
|
1245
|
+
"-loglevel",
|
|
1246
|
+
"error",
|
|
1247
|
+
"-nostdin",
|
|
1248
|
+
"-i",
|
|
1249
|
+
sourcePath,
|
|
1250
|
+
"-map",
|
|
1251
|
+
"0:a:0",
|
|
1252
|
+
"-vn",
|
|
1253
|
+
"-ac",
|
|
1254
|
+
"1",
|
|
1255
|
+
"-ar",
|
|
1256
|
+
String(sampleRate),
|
|
1257
|
+
"-t",
|
|
1258
|
+
String(maxSamples / sampleRate),
|
|
1259
|
+
"-f",
|
|
1260
|
+
"f32le",
|
|
1261
|
+
"-acodec",
|
|
1262
|
+
"pcm_f32le",
|
|
1263
|
+
"pipe:1",
|
|
1264
|
+
]);
|
|
1265
|
+
if (output.stdout.byteLength === 0) {
|
|
1266
|
+
throw new EngineFault({
|
|
1267
|
+
code: "INVALID_INPUT",
|
|
1268
|
+
message: `No audio samples could be decoded from ${sourcePath}`,
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
if (output.stdout.byteLength % Float32Array.BYTES_PER_ELEMENT !== 0) {
|
|
1272
|
+
throw new Error("FFmpeg returned an invalid PCM byte length");
|
|
1273
|
+
}
|
|
1274
|
+
const bytes = new Uint8Array(output.stdout.byteLength);
|
|
1275
|
+
bytes.set(output.stdout);
|
|
1276
|
+
const decoded = new Float32Array(bytes.buffer);
|
|
1277
|
+
return decoded.length > maxSamples
|
|
1278
|
+
? decoded.slice(0, maxSamples)
|
|
1279
|
+
: decoded;
|
|
1280
|
+
}
|
|
1281
|
+
async function runCommand(command, args) {
|
|
1282
|
+
const output = await runBinaryCommand(command, args);
|
|
1283
|
+
return {
|
|
1284
|
+
stdout: output.stdout.toString("utf8"),
|
|
1285
|
+
stderr: output.stderr,
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
function runBinaryCommand(command, args) {
|
|
1289
|
+
return new Promise((resolve, reject) => {
|
|
1290
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1291
|
+
const stdout = [];
|
|
1292
|
+
const stderr = [];
|
|
1293
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
1294
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
1295
|
+
child.once("error", (error) => {
|
|
1296
|
+
reject(new EngineFault({
|
|
1297
|
+
code: "FEATURE_UNAVAILABLE",
|
|
1298
|
+
message: `Unable to start ${command}: ${errorMessage(error)}`,
|
|
1299
|
+
}));
|
|
1300
|
+
});
|
|
1301
|
+
child.once("close", (code) => {
|
|
1302
|
+
const output = {
|
|
1303
|
+
stdout: Buffer.concat(stdout),
|
|
1304
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
1305
|
+
};
|
|
1306
|
+
if (code === 0) {
|
|
1307
|
+
resolve(output);
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
reject(new EngineFault({
|
|
1311
|
+
code: "INVALID_INPUT",
|
|
1312
|
+
message: `${command} exited with code ${code}: ${output.stderr.trim()}`,
|
|
1313
|
+
}));
|
|
1314
|
+
});
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
function normalizePlainText(raw) {
|
|
1318
|
+
const text = raw
|
|
1319
|
+
.replace(/^\uFEFF/, "")
|
|
1320
|
+
.normalize("NFC")
|
|
1321
|
+
.replace(/\r\n?/g, "\n")
|
|
1322
|
+
.replace(/[ \t]+$/gm, "")
|
|
1323
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
1324
|
+
.trim();
|
|
1325
|
+
return { text, contentHash: hashText(text) };
|
|
1326
|
+
}
|
|
1327
|
+
function normalizeJsonText(raw, sourcePath) {
|
|
1328
|
+
let value;
|
|
1329
|
+
try {
|
|
1330
|
+
value = JSON.parse(raw.replace(/^\uFEFF/, ""));
|
|
1331
|
+
}
|
|
1332
|
+
catch (error) {
|
|
1333
|
+
throw new EngineFault({
|
|
1334
|
+
code: "INVALID_INPUT",
|
|
1335
|
+
message: `Invalid JSON source ${sourcePath}: ${errorMessage(error)}`,
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
const lines = [];
|
|
1339
|
+
appendJsonLines(lines, "$", value, 0);
|
|
1340
|
+
return normalizePlainText(lines.join("\n"));
|
|
1341
|
+
}
|
|
1342
|
+
function appendJsonLines(lines, jsonPath, value, depth) {
|
|
1343
|
+
if (depth > 100) {
|
|
1344
|
+
throw new EngineFault({
|
|
1345
|
+
code: "INVALID_INPUT",
|
|
1346
|
+
message: "JSON source exceeds the maximum nesting depth",
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
if (Array.isArray(value)) {
|
|
1350
|
+
if (value.length === 0) {
|
|
1351
|
+
lines.push(`${jsonPath}: []`);
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
value.forEach((item, index) => appendJsonLines(lines, `${jsonPath}[${index}]`, item, depth + 1));
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
if (value !== null && typeof value === "object") {
|
|
1358
|
+
const entries = Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
|
|
1359
|
+
if (entries.length === 0) {
|
|
1360
|
+
lines.push(`${jsonPath}: {}`);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
for (const [key, item] of entries) {
|
|
1364
|
+
appendJsonLines(lines, `${jsonPath}.${key}`, item, depth + 1);
|
|
1365
|
+
}
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
lines.push(`${jsonPath}: ${JSON.stringify(value)}`);
|
|
1369
|
+
}
|
|
1370
|
+
function makeTextRanges(text, tokenizer) {
|
|
1371
|
+
const ranges = [];
|
|
1372
|
+
let startOffset = 0;
|
|
1373
|
+
while (startOffset < text.length) {
|
|
1374
|
+
let upper = Math.min(text.length, startOffset + TEXT_CHUNK_CHAR_WINDOW);
|
|
1375
|
+
let low = startOffset + 1;
|
|
1376
|
+
let high = upper;
|
|
1377
|
+
while (low < high) {
|
|
1378
|
+
const middle = Math.ceil((low + high) / 2);
|
|
1379
|
+
if (tokenCount(tokenizer, text.slice(startOffset, middle)) <= TEXT_CHUNK_TOKENS) {
|
|
1380
|
+
low = middle;
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
high = middle - 1;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
upper = low;
|
|
1387
|
+
const boundary = preferredBoundary(text, startOffset, upper);
|
|
1388
|
+
if (boundary > startOffset &&
|
|
1389
|
+
tokenCount(tokenizer, text.slice(startOffset, boundary)) <= TEXT_CHUNK_TOKENS) {
|
|
1390
|
+
upper = boundary;
|
|
1391
|
+
}
|
|
1392
|
+
ranges.push({ startOffset, endOffset: upper });
|
|
1393
|
+
if (upper >= text.length)
|
|
1394
|
+
break;
|
|
1395
|
+
const overlapStart = findOverlapStart(tokenizer, text, startOffset, upper);
|
|
1396
|
+
startOffset = overlapStart <= startOffset
|
|
1397
|
+
? upper
|
|
1398
|
+
: Math.max(startOffset + 1, Math.min(overlapStart, upper - 1));
|
|
1399
|
+
}
|
|
1400
|
+
return ranges;
|
|
1401
|
+
}
|
|
1402
|
+
function tokenCount(tokenizer, text) {
|
|
1403
|
+
return tokenizer.encode(text, { add_special_tokens: false }).length;
|
|
1404
|
+
}
|
|
1405
|
+
function preferredBoundary(text, start, end) {
|
|
1406
|
+
const floor = Math.max(start + 1, end - 180);
|
|
1407
|
+
for (let index = end; index >= floor; index -= 1) {
|
|
1408
|
+
const character = text[index - 1];
|
|
1409
|
+
if (character === "\n" || character === " " || character === "\t") {
|
|
1410
|
+
return index;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
return end;
|
|
1414
|
+
}
|
|
1415
|
+
function findOverlapStart(tokenizer, text, start, end) {
|
|
1416
|
+
let low = start;
|
|
1417
|
+
let high = end - 1;
|
|
1418
|
+
while (low < high) {
|
|
1419
|
+
const middle = Math.floor((low + high) / 2);
|
|
1420
|
+
if (tokenCount(tokenizer, text.slice(middle, end)) > TEXT_OVERLAP_TOKENS) {
|
|
1421
|
+
low = middle + 1;
|
|
1422
|
+
}
|
|
1423
|
+
else {
|
|
1424
|
+
high = middle;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
const boundary = preferredBoundary(text, low, end);
|
|
1428
|
+
return boundary > start && boundary < end ? boundary : low;
|
|
1429
|
+
}
|
|
1430
|
+
function hashText(text) {
|
|
1431
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
1432
|
+
}
|
|
1433
|
+
function normalized(vector) {
|
|
1434
|
+
const norm = Math.hypot(...vector);
|
|
1435
|
+
if (!Number.isFinite(norm) || norm === 0) {
|
|
1436
|
+
throw new Error("Embedding vector must have a finite non-zero norm");
|
|
1437
|
+
}
|
|
1438
|
+
const result = new Float32Array(vector.length);
|
|
1439
|
+
for (let index = 0; index < vector.length; index += 1) {
|
|
1440
|
+
result[index] = vector[index] / norm;
|
|
1441
|
+
}
|
|
1442
|
+
return result;
|
|
1443
|
+
}
|
|
1444
|
+
function normalizedCentroid(vectors) {
|
|
1445
|
+
const first = vectors[0];
|
|
1446
|
+
if (!first)
|
|
1447
|
+
throw new Error("Cannot pool an empty vector collection");
|
|
1448
|
+
const centroid = new Float32Array(first.length);
|
|
1449
|
+
for (const vector of vectors) {
|
|
1450
|
+
if (vector.length !== centroid.length) {
|
|
1451
|
+
throw new Error("Video frame embedding dimensions do not match");
|
|
1452
|
+
}
|
|
1453
|
+
for (let index = 0; index < vector.length; index += 1) {
|
|
1454
|
+
centroid[index] = (centroid[index] ?? 0) + vector[index];
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
return normalized(centroid);
|
|
1458
|
+
}
|
|
1459
|
+
function vectorToBlob(vector) {
|
|
1460
|
+
return Buffer.from(new Uint8Array(vector.buffer, vector.byteOffset, vector.byteLength));
|
|
1461
|
+
}
|
|
1462
|
+
function vectorFromBlob(row) {
|
|
1463
|
+
const bytes = row.vector_blob;
|
|
1464
|
+
if (bytes.byteLength !== row.dimensions * Float32Array.BYTES_PER_ELEMENT) {
|
|
1465
|
+
throw new Error("Stored similarity vector has an invalid byte length");
|
|
1466
|
+
}
|
|
1467
|
+
const copied = new Uint8Array(bytes.byteLength);
|
|
1468
|
+
copied.set(bytes);
|
|
1469
|
+
return new Float32Array(copied.buffer);
|
|
1470
|
+
}
|
|
1471
|
+
function cosine(left, right) {
|
|
1472
|
+
if (left.length !== right.length) {
|
|
1473
|
+
throw new Error("Cannot compare vectors with different dimensions");
|
|
1474
|
+
}
|
|
1475
|
+
let score = 0;
|
|
1476
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
1477
|
+
score += left[index] * right[index];
|
|
1478
|
+
}
|
|
1479
|
+
return Math.max(-1, Math.min(1, score));
|
|
1480
|
+
}
|
|
1481
|
+
function excerpt(text) {
|
|
1482
|
+
return text.length <= TEXT_EXCERPT_LIMIT
|
|
1483
|
+
? text
|
|
1484
|
+
: `${text.slice(0, TEXT_EXCERPT_LIMIT - 1)}…`;
|
|
1485
|
+
}
|
|
1486
|
+
function checkedLimit(value) {
|
|
1487
|
+
const limit = value ?? 20;
|
|
1488
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
1489
|
+
throw new EngineFault({
|
|
1490
|
+
code: "INVALID_INPUT",
|
|
1491
|
+
message: "Similarity limit must be an integer between 1 and 100",
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
return limit;
|
|
1495
|
+
}
|
|
1496
|
+
function checkedMinScore(value) {
|
|
1497
|
+
if (value === undefined)
|
|
1498
|
+
return undefined;
|
|
1499
|
+
if (!Number.isFinite(value) || value < -1 || value > 1) {
|
|
1500
|
+
throw new EngineFault({
|
|
1501
|
+
code: "INVALID_INPUT",
|
|
1502
|
+
message: "Similarity minScore must be between -1 and 1",
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
return value;
|
|
1506
|
+
}
|
|
1507
|
+
function checkedAudioSampleRate(value) {
|
|
1508
|
+
const sampleRate = value ?? DEFAULT_AUDIO_SAMPLE_RATE;
|
|
1509
|
+
if (!Number.isInteger(sampleRate) ||
|
|
1510
|
+
sampleRate < 8_000 ||
|
|
1511
|
+
sampleRate > 192_000) {
|
|
1512
|
+
throw new Error("Audio model sampling rate must be an integer between 8000 and 192000");
|
|
1513
|
+
}
|
|
1514
|
+
return sampleRate;
|
|
1515
|
+
}
|
|
1516
|
+
function checkedAudioMaxSamples(value, sampleRate) {
|
|
1517
|
+
const maxSamples = value ?? DEFAULT_AUDIO_MAX_SAMPLES;
|
|
1518
|
+
if (!Number.isInteger(maxSamples) ||
|
|
1519
|
+
maxSamples < sampleRate ||
|
|
1520
|
+
maxSamples > sampleRate * 60) {
|
|
1521
|
+
throw new Error("Audio model window must be between 1 and 60 seconds");
|
|
1522
|
+
}
|
|
1523
|
+
return maxSamples;
|
|
1524
|
+
}
|
|
1525
|
+
function checkedTextMaxBytes(config) {
|
|
1526
|
+
const value = config?.maxSourceBytes ?? DEFAULT_TEXT_MAX_BYTES;
|
|
1527
|
+
if (!Number.isInteger(value) || value < 1 || value > 64 * 1024 * 1024) {
|
|
1528
|
+
throw new EngineFault({
|
|
1529
|
+
code: "INVALID_INPUT",
|
|
1530
|
+
message: "Text maxSourceBytes must be an integer between 1 and 67108864",
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
return value;
|
|
1534
|
+
}
|
|
1535
|
+
function checkedTextMaxChunks(config) {
|
|
1536
|
+
const value = config?.maxChunks ?? DEFAULT_TEXT_MAX_CHUNKS;
|
|
1537
|
+
if (!Number.isInteger(value) || value < 1 || value > 2048) {
|
|
1538
|
+
throw new EngineFault({
|
|
1539
|
+
code: "INVALID_INPUT",
|
|
1540
|
+
message: "Text maxChunks must be an integer between 1 and 2048",
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
return value;
|
|
1544
|
+
}
|
|
1545
|
+
function indexKey(kind, embeddingSpace) {
|
|
1546
|
+
return `${kind}\u0000${embeddingSpace}`;
|
|
1547
|
+
}
|
|
1548
|
+
function errorMessage(error) {
|
|
1549
|
+
return error instanceof Error ? error.message : String(error);
|
|
1550
|
+
}
|
|
1551
|
+
//# sourceMappingURL=similarity.js.map
|