curu-memory 0.2.0__py3-none-any.whl
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.
- curu_memory/__init__.py +3 -0
- curu_memory/__main__.py +1032 -0
- curu_memory/access.py +562 -0
- curu_memory/audit.py +63 -0
- curu_memory/auth.py +41 -0
- curu_memory/browser.py +396 -0
- curu_memory/config.py +308 -0
- curu_memory/embedders.py +118 -0
- curu_memory/errors.py +96 -0
- curu_memory/indexing.py +440 -0
- curu_memory/media.py +199 -0
- curu_memory/oidc.py +437 -0
- curu_memory/okf.py +271 -0
- curu_memory/ports.py +344 -0
- curu_memory/render.py +61 -0
- curu_memory/retrieval.py +570 -0
- curu_memory/server.py +272 -0
- curu_memory/store_sqlite.py +551 -0
- curu_memory-0.2.0.dist-info/METADATA +723 -0
- curu_memory-0.2.0.dist-info/RECORD +23 -0
- curu_memory-0.2.0.dist-info/WHEEL +4 -0
- curu_memory-0.2.0.dist-info/entry_points.txt +2 -0
- curu_memory-0.2.0.dist-info/licenses/LICENSE +21 -0
curu_memory/indexing.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""The index use case: read the concept files, report, and build the index.
|
|
2
|
+
|
|
3
|
+
`build_index` is the only path from the canonical files to the index (constitution,
|
|
4
|
+
article I). It writes nothing until each file is read and each vector is made, so a
|
|
5
|
+
failure keeps the old index.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from curu_memory.errors import EmbedderFailed, IndexRefused
|
|
17
|
+
from curu_memory.media import MediaFile, read_media
|
|
18
|
+
from curu_memory.okf import SUMMARY, ConceptFault, ParsedConcept, parse_concept, split_units
|
|
19
|
+
from curu_memory.ports import (
|
|
20
|
+
GLOBAL,
|
|
21
|
+
ConceptMedia,
|
|
22
|
+
Embedder,
|
|
23
|
+
IndexMeta,
|
|
24
|
+
IndexStore,
|
|
25
|
+
Link,
|
|
26
|
+
Prompts,
|
|
27
|
+
Unit,
|
|
28
|
+
parse_space,
|
|
29
|
+
)
|
|
30
|
+
from curu_memory.store_sqlite import SqliteEmbeddingCache
|
|
31
|
+
|
|
32
|
+
BATCH_SIZE = 32
|
|
33
|
+
#: Fewer media files than texts go in one request, because a file can be 10 MB.
|
|
34
|
+
MEDIA_BATCH_SIZE = 4
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class _PendingMedia:
|
|
39
|
+
"""One accepted image of a concept, before the embedder makes its vector."""
|
|
40
|
+
|
|
41
|
+
concept_id: str
|
|
42
|
+
concept_path: str
|
|
43
|
+
#: The path as the concept wrote it, for a warning.
|
|
44
|
+
written: str
|
|
45
|
+
#: The place of the image in the `media:` list, from 1.
|
|
46
|
+
number: int
|
|
47
|
+
slug: str
|
|
48
|
+
ordinal: int
|
|
49
|
+
media: MediaFile
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class IndexReport:
|
|
54
|
+
files_read: int
|
|
55
|
+
concepts_indexed: int
|
|
56
|
+
#: Each (path, reason) of a file that is not in the index.
|
|
57
|
+
skipped: tuple[tuple[str, str], ...]
|
|
58
|
+
#: Each (path, reason) of a problem that did not stop the file.
|
|
59
|
+
warnings: tuple[tuple[str, str], ...]
|
|
60
|
+
#: Each (source id, target id, kind) of a link to a missing concept.
|
|
61
|
+
broken_links: tuple[tuple[str, str, str], ...]
|
|
62
|
+
#: The model identifier of the embedder, or "none".
|
|
63
|
+
embedder: str
|
|
64
|
+
#: The media units in the new index: images and audio.
|
|
65
|
+
media_embedded: int = 0
|
|
66
|
+
#: The media references that are not in the index. Each one is also a warning.
|
|
67
|
+
media_skipped: int = 0
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, Any]:
|
|
70
|
+
return {
|
|
71
|
+
"files_read": self.files_read,
|
|
72
|
+
"concepts_indexed": self.concepts_indexed,
|
|
73
|
+
"skipped": [{"path": p, "reason": r} for p, r in self.skipped],
|
|
74
|
+
"warnings": [{"path": p, "reason": r} for p, r in self.warnings],
|
|
75
|
+
"broken_links": [
|
|
76
|
+
{"source_id": s, "target_id": t, "kind": k} for s, t, k in self.broken_links
|
|
77
|
+
],
|
|
78
|
+
"embedder": self.embedder,
|
|
79
|
+
"media_embedded": self.media_embedded,
|
|
80
|
+
"media_skipped": self.media_skipped,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def render(self) -> str:
|
|
84
|
+
lines = [
|
|
85
|
+
f"files read: {self.files_read}",
|
|
86
|
+
f"concepts indexed: {self.concepts_indexed}",
|
|
87
|
+
f"skipped: {len(self.skipped)}",
|
|
88
|
+
f"warnings: {len(self.warnings)}",
|
|
89
|
+
f"broken links: {len(self.broken_links)}",
|
|
90
|
+
f"embedder: {self.embedder}",
|
|
91
|
+
f"media embedded: {self.media_embedded}",
|
|
92
|
+
f"media skipped: {self.media_skipped}",
|
|
93
|
+
]
|
|
94
|
+
if self.skipped:
|
|
95
|
+
lines += ["", "Skipped:"] + [f" {p}: {r}" for p, r in self.skipped]
|
|
96
|
+
if self.warnings:
|
|
97
|
+
lines += ["", "Warnings:"] + [f" {p}: {r}" for p, r in self.warnings]
|
|
98
|
+
if self.broken_links:
|
|
99
|
+
lines += ["", "Broken links:"] + [
|
|
100
|
+
f" {s} -> {t} ({k})" for s, t, k in self.broken_links
|
|
101
|
+
]
|
|
102
|
+
return "\n".join(lines)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def concept_files(root: Path) -> list[tuple[str, Path]]:
|
|
106
|
+
"""Each (relative path, path) of a concept file, sorted by the relative path.
|
|
107
|
+
|
|
108
|
+
A file in a hidden directory, for example `.git` or `.obsidian`, is not a concept.
|
|
109
|
+
A file with the name `README.md` is not a concept.
|
|
110
|
+
"""
|
|
111
|
+
found = []
|
|
112
|
+
for path in root.rglob("*.md"):
|
|
113
|
+
relative = path.relative_to(root)
|
|
114
|
+
if any(part.startswith(".") for part in relative.parts):
|
|
115
|
+
continue
|
|
116
|
+
if path.name.lower() == "readme.md" or not path.is_file():
|
|
117
|
+
continue
|
|
118
|
+
found.append((relative.as_posix(), path))
|
|
119
|
+
return sorted(found)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_index(
|
|
123
|
+
concepts_dir: Path,
|
|
124
|
+
store: IndexStore,
|
|
125
|
+
embedder: Embedder | None = None,
|
|
126
|
+
*,
|
|
127
|
+
cache: SqliteEmbeddingCache | None = None,
|
|
128
|
+
tier_space: str = GLOBAL,
|
|
129
|
+
prompts: Prompts | None = None,
|
|
130
|
+
) -> IndexReport:
|
|
131
|
+
"""Index one directory into one space, under the lock of the data directory (ADR-016).
|
|
132
|
+
|
|
133
|
+
Raise `ValueError` for a bad space name, `IndexLocked` when another run holds the
|
|
134
|
+
lock and `IndexRefused` when the embedder model is not the model of another space.
|
|
135
|
+
"""
|
|
136
|
+
tier_space = parse_space(tier_space)
|
|
137
|
+
with store.index_lock():
|
|
138
|
+
others = [space for space in store.spaces() if space != tier_space]
|
|
139
|
+
_check_model(store, others, embedder)
|
|
140
|
+
taken = {cid: space for space in others for cid in store.concept_ids(space)}
|
|
141
|
+
return _build(concepts_dir, store, embedder, cache, tier_space, prompts, taken)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _check_model(store: IndexStore, others: Sequence[str], embedder: Embedder | None) -> None:
|
|
145
|
+
"""All spaces use one embedder model, so their vectors are comparable (ADR-016)."""
|
|
146
|
+
if embedder is None:
|
|
147
|
+
return
|
|
148
|
+
for space in others:
|
|
149
|
+
meta = store.meta(space)
|
|
150
|
+
model = meta.embedder_model if meta is not None else None
|
|
151
|
+
if model is not None and model != embedder.model:
|
|
152
|
+
raise IndexRefused(
|
|
153
|
+
f"The space {space} was indexed with the embedder {model!r}, but the embedder "
|
|
154
|
+
f"now is {embedder.model!r}. All spaces must use one embedder model. "
|
|
155
|
+
"Index each space again with one model."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _build(
|
|
160
|
+
concepts_dir: Path,
|
|
161
|
+
store: IndexStore,
|
|
162
|
+
embedder: Embedder | None,
|
|
163
|
+
cache: SqliteEmbeddingCache | None,
|
|
164
|
+
tier_space: str,
|
|
165
|
+
prompts: Prompts | None,
|
|
166
|
+
taken: dict[str, str],
|
|
167
|
+
) -> IndexReport:
|
|
168
|
+
files = concept_files(concepts_dir)
|
|
169
|
+
skipped: list[tuple[str, str]] = []
|
|
170
|
+
warnings: list[tuple[str, str]] = []
|
|
171
|
+
parsed: list[ParsedConcept] = []
|
|
172
|
+
for relative, path in files:
|
|
173
|
+
try:
|
|
174
|
+
item = parse_concept(relative, path.read_text(encoding="utf-8"), tier_space)
|
|
175
|
+
except ConceptFault as fault:
|
|
176
|
+
reason = f"line {fault.line}: {fault.reason}" if fault.line else fault.reason
|
|
177
|
+
skipped.append((relative, reason))
|
|
178
|
+
continue
|
|
179
|
+
except UnicodeDecodeError:
|
|
180
|
+
skipped.append((relative, "the file is not UTF-8 text"))
|
|
181
|
+
continue
|
|
182
|
+
parsed.append(item)
|
|
183
|
+
warnings.extend((relative, w) for w in item.warnings)
|
|
184
|
+
|
|
185
|
+
by_id: dict[str, list[ParsedConcept]] = defaultdict(list)
|
|
186
|
+
for item in parsed:
|
|
187
|
+
by_id[item.concept.id].append(item)
|
|
188
|
+
kept: list[ParsedConcept] = []
|
|
189
|
+
for identifier, items in sorted(by_id.items()):
|
|
190
|
+
if len(items) == 1:
|
|
191
|
+
kept.append(items[0])
|
|
192
|
+
continue
|
|
193
|
+
paths = [i.concept.path for i in items]
|
|
194
|
+
for item in items:
|
|
195
|
+
others = ", ".join(p for p in paths if p != item.concept.path)
|
|
196
|
+
skipped.append(
|
|
197
|
+
(
|
|
198
|
+
item.concept.path,
|
|
199
|
+
f"the identifier {identifier!r} is also used by {others}, "
|
|
200
|
+
"so neither file is indexed. Rename one of them.",
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# An identifier is unique in the instance, so each link has one target (ADR-016).
|
|
205
|
+
unique: list[ParsedConcept] = []
|
|
206
|
+
for item in kept:
|
|
207
|
+
space = taken.get(item.concept.id)
|
|
208
|
+
if space is None:
|
|
209
|
+
unique.append(item)
|
|
210
|
+
continue
|
|
211
|
+
skipped.append(
|
|
212
|
+
(
|
|
213
|
+
item.concept.path,
|
|
214
|
+
f"the identifier {item.concept.id!r} is in the space {space}. "
|
|
215
|
+
"An identifier must be unique in the instance, so rename one of them.",
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
kept = unique
|
|
219
|
+
|
|
220
|
+
# A link to a concept of another space is not broken. A read resolves it again over
|
|
221
|
+
# the readable spaces of the caller (ADR-016).
|
|
222
|
+
known = {item.concept.id for item in kept} | set(taken)
|
|
223
|
+
links = [
|
|
224
|
+
Link(item.concept.id, target, kind, target in known)
|
|
225
|
+
for item in kept
|
|
226
|
+
for target, kind in item.links
|
|
227
|
+
]
|
|
228
|
+
units = [unit for item in kept for unit in _units(item)]
|
|
229
|
+
titles = {item.concept.id: item.concept.title for item in kept}
|
|
230
|
+
units, meta = _embed(units, titles, embedder, cache, tier_space, prompts or Prompts())
|
|
231
|
+
|
|
232
|
+
paths = dict(files)
|
|
233
|
+
pending: list[_PendingMedia] = []
|
|
234
|
+
media_warnings: list[tuple[str, str]] = []
|
|
235
|
+
last_ordinal: dict[str, int] = defaultdict(int)
|
|
236
|
+
for unit in units:
|
|
237
|
+
last_ordinal[unit.concept_id] = max(last_ordinal[unit.concept_id], unit.ordinal)
|
|
238
|
+
for item in kept:
|
|
239
|
+
concept = item.concept
|
|
240
|
+
found, problems = _media(item, paths[concept.path], concepts_dir, last_ordinal[concept.id])
|
|
241
|
+
pending.extend(found)
|
|
242
|
+
media_warnings.extend(problems)
|
|
243
|
+
media_units, failures = _embed_media(pending, embedder, cache, meta.embedder_dimension)
|
|
244
|
+
media_warnings.extend(failures)
|
|
245
|
+
warnings.extend(media_warnings)
|
|
246
|
+
units = units + media_units
|
|
247
|
+
|
|
248
|
+
# Each accepted image is recorded with its bytes, with or without a vector (ADR-009).
|
|
249
|
+
media = [
|
|
250
|
+
ConceptMedia(p.concept_id, p.number, p.media.path, p.media.sha256, p.media.mime)
|
|
251
|
+
for p in pending
|
|
252
|
+
]
|
|
253
|
+
blobs = [p.media for p in pending]
|
|
254
|
+
store.build(meta, [item.concept for item in kept], units, links, media, blobs)
|
|
255
|
+
return IndexReport(
|
|
256
|
+
files_read=len(files),
|
|
257
|
+
concepts_indexed=len(kept),
|
|
258
|
+
skipped=tuple(sorted(skipped)),
|
|
259
|
+
warnings=tuple(sorted(warnings)),
|
|
260
|
+
broken_links=tuple(
|
|
261
|
+
sorted((lk.source_id, lk.target_id, lk.kind) for lk in links if not lk.resolved)
|
|
262
|
+
),
|
|
263
|
+
embedder=meta.embedder_model or "none",
|
|
264
|
+
media_embedded=len(media_units),
|
|
265
|
+
media_skipped=len(media_warnings),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _units(item: ParsedConcept) -> list[Unit]:
|
|
270
|
+
concept = item.concept
|
|
271
|
+
units = [Unit(concept.id, SUMMARY, 0, f"{concept.title}\n\n{concept.description}")]
|
|
272
|
+
for ordinal, (slug, text) in enumerate(split_units(concept.body), start=1):
|
|
273
|
+
units.append(Unit(concept.id, slug, ordinal, text))
|
|
274
|
+
return units
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _media(
|
|
278
|
+
item: ParsedConcept, path: Path, concepts_dir: Path, ordinal_base: int
|
|
279
|
+
) -> tuple[list[_PendingMedia], list[tuple[str, str]]]:
|
|
280
|
+
"""The accepted images of one concept, and a warning for each problem (ADR-007).
|
|
281
|
+
|
|
282
|
+
The slug is `image.<n>` or `audio.<n>`, from its place in the `media:` list, so it does
|
|
283
|
+
not change when an image before it is missing. A section slug has no `.`, so the
|
|
284
|
+
two never meet. Its ordinal comes after the text units.
|
|
285
|
+
"""
|
|
286
|
+
concept = item.concept
|
|
287
|
+
found: list[_PendingMedia] = []
|
|
288
|
+
problems: list[tuple[str, str]] = []
|
|
289
|
+
for number, written in enumerate(item.media, start=1):
|
|
290
|
+
files, refused = read_media(path, concepts_dir, [written])
|
|
291
|
+
problems.extend((concept.path, f"media {p.path!r}: {p.reason}") for p in refused)
|
|
292
|
+
for media in files:
|
|
293
|
+
found.append(
|
|
294
|
+
_PendingMedia(
|
|
295
|
+
concept.id,
|
|
296
|
+
concept.path,
|
|
297
|
+
written,
|
|
298
|
+
number,
|
|
299
|
+
f"{media.kind}.{number}",
|
|
300
|
+
ordinal_base + number,
|
|
301
|
+
media,
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
return found, problems
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _embed_media(
|
|
308
|
+
pending: list[_PendingMedia],
|
|
309
|
+
embedder: Embedder | None,
|
|
310
|
+
cache: SqliteEmbeddingCache | None,
|
|
311
|
+
dimension: int | None,
|
|
312
|
+
) -> tuple[list[Unit], list[tuple[str, str]]]:
|
|
313
|
+
"""The image units, and a warning for each image that has no vector.
|
|
314
|
+
|
|
315
|
+
The embedder gets each distinct file one time, with no prompt. The cache keys a
|
|
316
|
+
file by the model and `<kind>:<sha256>` (ADR-011). A failure gives a warning for
|
|
317
|
+
each file that fails alone, and does not stop the run (FR-006).
|
|
318
|
+
"""
|
|
319
|
+
if embedder is None:
|
|
320
|
+
reason = "no embedder is set, so a search by meaning cannot find the {kind}"
|
|
321
|
+
return [], [
|
|
322
|
+
(p.concept_path, f"media {p.written!r}: " + reason.format(kind=p.media.kind))
|
|
323
|
+
for p in pending
|
|
324
|
+
]
|
|
325
|
+
model = embedder.model
|
|
326
|
+
vectors: dict[str, tuple[float, ...]] = {}
|
|
327
|
+
failed: dict[str, str] = {}
|
|
328
|
+
missing: list[MediaFile] = []
|
|
329
|
+
for media in {p.media.sha256: p.media for p in pending}.values():
|
|
330
|
+
cached = cache.get(model, f"{media.kind}:{media.sha256}") if cache is not None else None
|
|
331
|
+
if cached is not None:
|
|
332
|
+
vectors[media.sha256] = cached
|
|
333
|
+
else:
|
|
334
|
+
missing.append(media)
|
|
335
|
+
made: list[tuple[MediaFile, list[float]]] = []
|
|
336
|
+
for start in range(0, len(missing), MEDIA_BATCH_SIZE):
|
|
337
|
+
batch = missing[start : start + MEDIA_BATCH_SIZE]
|
|
338
|
+
try:
|
|
339
|
+
made.extend(zip(batch, _call_media(embedder, batch), strict=True))
|
|
340
|
+
continue
|
|
341
|
+
except Exception: # noqa: S110 - each image of the request goes again, alone.
|
|
342
|
+
pass
|
|
343
|
+
# One bad image fails its whole request, so each image goes again alone (FR-006).
|
|
344
|
+
for media in batch:
|
|
345
|
+
try:
|
|
346
|
+
made.extend(zip([media], _call_media(embedder, [media]), strict=True))
|
|
347
|
+
except Exception as error:
|
|
348
|
+
detail = str(error) or type(error).__name__
|
|
349
|
+
failed[media.sha256] = f"the embedder did not embed the {media.kind} ({detail})"
|
|
350
|
+
for media, vector in made:
|
|
351
|
+
if dimension is not None and len(vector) != dimension:
|
|
352
|
+
failed[media.sha256] = (
|
|
353
|
+
f"the embedder gave an image vector of {len(vector)} values, "
|
|
354
|
+
f"not {dimension} as for text"
|
|
355
|
+
)
|
|
356
|
+
continue
|
|
357
|
+
vectors[media.sha256] = tuple(vector)
|
|
358
|
+
if cache is not None:
|
|
359
|
+
cache.put(model, f"{media.kind}:{media.sha256}", vector)
|
|
360
|
+
units: list[Unit] = []
|
|
361
|
+
warnings: list[tuple[str, str]] = []
|
|
362
|
+
for p in pending:
|
|
363
|
+
sha256 = p.media.sha256
|
|
364
|
+
if sha256 in failed:
|
|
365
|
+
warnings.append((p.concept_path, f"media {p.written!r}: {failed[sha256]}"))
|
|
366
|
+
continue
|
|
367
|
+
units.append(
|
|
368
|
+
Unit(
|
|
369
|
+
p.concept_id,
|
|
370
|
+
p.slug,
|
|
371
|
+
p.ordinal,
|
|
372
|
+
p.media.path,
|
|
373
|
+
vectors[sha256],
|
|
374
|
+
kind=p.media.kind,
|
|
375
|
+
media_path=p.media.path,
|
|
376
|
+
media_sha256=sha256,
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
return units, warnings
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _embed(
|
|
383
|
+
units: list[Unit],
|
|
384
|
+
titles: dict[str, str],
|
|
385
|
+
embedder: Embedder | None,
|
|
386
|
+
cache: SqliteEmbeddingCache | None,
|
|
387
|
+
tier_space: str,
|
|
388
|
+
prompts: Prompts,
|
|
389
|
+
) -> tuple[list[Unit], IndexMeta]:
|
|
390
|
+
count = len(titles)
|
|
391
|
+
if embedder is None:
|
|
392
|
+
return units, IndexMeta(tier_space, count, None, None)
|
|
393
|
+
model = embedder.model
|
|
394
|
+
# The embedder gets each unit in the document prompt of the model (ADR-003).
|
|
395
|
+
inputs = {
|
|
396
|
+
(u.concept_id, u.slug): prompts.for_document(titles[u.concept_id], u.text) for u in units
|
|
397
|
+
}
|
|
398
|
+
vectors: dict[str, tuple[float, ...]] = {}
|
|
399
|
+
missing: list[str] = []
|
|
400
|
+
for text in dict.fromkeys(inputs.values()):
|
|
401
|
+
cached = cache.get(model, text) if cache is not None else None
|
|
402
|
+
if cached is not None:
|
|
403
|
+
vectors[text] = cached
|
|
404
|
+
else:
|
|
405
|
+
missing.append(text)
|
|
406
|
+
for start in range(0, len(missing), BATCH_SIZE):
|
|
407
|
+
batch = missing[start : start + BATCH_SIZE]
|
|
408
|
+
made = _call(embedder, batch)
|
|
409
|
+
for text, vector in zip(batch, made, strict=True):
|
|
410
|
+
vectors[text] = tuple(vector)
|
|
411
|
+
if cache is not None:
|
|
412
|
+
cache.put(model, text, vector)
|
|
413
|
+
dimensions = {len(v) for v in vectors.values()}
|
|
414
|
+
if len(dimensions) > 1:
|
|
415
|
+
raise EmbedderFailed(f"it gave vectors of different sizes: {sorted(dimensions)}")
|
|
416
|
+
dimension = dimensions.pop() if dimensions else embedder.dimension
|
|
417
|
+
embedded = [
|
|
418
|
+
Unit(u.concept_id, u.slug, u.ordinal, u.text, vectors[inputs[(u.concept_id, u.slug)]])
|
|
419
|
+
for u in units
|
|
420
|
+
]
|
|
421
|
+
return embedded, IndexMeta(tier_space, count, model, dimension, prompts.fingerprint)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _call_media(embedder: Embedder, files: Sequence[MediaFile]) -> list[list[float]]:
|
|
425
|
+
made = embedder.embed_media(files)
|
|
426
|
+
if len(made) != len(files):
|
|
427
|
+
raise RuntimeError(f"it gave {len(made)} vectors for {len(files)} media files")
|
|
428
|
+
return made
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _call(embedder: Embedder, texts: Sequence[str]) -> list[list[float]]:
|
|
432
|
+
try:
|
|
433
|
+
made = embedder.embed(texts)
|
|
434
|
+
except EmbedderFailed:
|
|
435
|
+
raise
|
|
436
|
+
except Exception as error:
|
|
437
|
+
raise EmbedderFailed(str(error) or type(error).__name__) from error
|
|
438
|
+
if len(made) != len(texts):
|
|
439
|
+
raise EmbedderFailed(f"it gave {len(made)} vectors for {len(texts)} texts")
|
|
440
|
+
return made
|
curu_memory/media.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""The rules of the media files of a concept (ADR-007).
|
|
2
|
+
|
|
3
|
+
A concept references media with a front-matter list `media:` of paths relative to the
|
|
4
|
+
concept file. Each path must resolve inside the concept directory. The format comes from
|
|
5
|
+
the first bytes of the file, not from its name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import binascii
|
|
12
|
+
import hashlib
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from curu_memory.errors import InvalidArgument
|
|
18
|
+
|
|
19
|
+
MAX_BYTES = 10 * 1024 * 1024
|
|
20
|
+
UNKNOWN_FORMAT = "the file is not an image, audio or video file of a known format"
|
|
21
|
+
_SIGNATURES = (
|
|
22
|
+
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
|
23
|
+
(b"\xff\xd8\xff", "image/jpeg"),
|
|
24
|
+
(b"fLaC", "audio/flac"),
|
|
25
|
+
(b"ID3", "audio/mpeg"),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class MediaFile:
|
|
31
|
+
#: The path relative to the concept directory, with `/` as separator.
|
|
32
|
+
path: str
|
|
33
|
+
mime: str
|
|
34
|
+
sha256: str
|
|
35
|
+
data: bytes = field(repr=False)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def kind(self) -> str:
|
|
39
|
+
"""`image`, `audio` or `video`, from the MIME type."""
|
|
40
|
+
return self.mime.partition("/")[0]
|
|
41
|
+
|
|
42
|
+
def data_uri(self) -> str:
|
|
43
|
+
return f"data:{self.mime};base64," + base64.b64encode(self.data).decode()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class MediaProblem:
|
|
48
|
+
#: The path as the concept wrote it.
|
|
49
|
+
path: str
|
|
50
|
+
reason: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_media(
|
|
54
|
+
concept_file: Path, concepts_dir: Path, paths: Sequence[str]
|
|
55
|
+
) -> tuple[list[MediaFile], list[MediaProblem]]:
|
|
56
|
+
"""Read each media path of one concept. Give the accepted files and each problem."""
|
|
57
|
+
root = concepts_dir.resolve()
|
|
58
|
+
files: list[MediaFile] = []
|
|
59
|
+
problems: list[MediaProblem] = []
|
|
60
|
+
for written in paths:
|
|
61
|
+
if Path(written).is_absolute():
|
|
62
|
+
problems.append(MediaProblem(written, "the path is not relative to the concept file"))
|
|
63
|
+
continue
|
|
64
|
+
resolved = (concept_file.parent / written).resolve()
|
|
65
|
+
if not resolved.is_relative_to(root):
|
|
66
|
+
problems.append(MediaProblem(written, "the path is outside the concept directory"))
|
|
67
|
+
continue
|
|
68
|
+
if not resolved.exists():
|
|
69
|
+
problems.append(MediaProblem(written, "the file does not exist"))
|
|
70
|
+
continue
|
|
71
|
+
if not resolved.is_file():
|
|
72
|
+
problems.append(MediaProblem(written, "the path is not a file"))
|
|
73
|
+
continue
|
|
74
|
+
if resolved.stat().st_size > MAX_BYTES:
|
|
75
|
+
problems.append(MediaProblem(written, "the file is larger than 10 MB"))
|
|
76
|
+
continue
|
|
77
|
+
data = resolved.read_bytes()
|
|
78
|
+
mime = sniff_mime(data[:SNIFF_BYTES])
|
|
79
|
+
if mime is None:
|
|
80
|
+
problems.append(MediaProblem(written, UNKNOWN_FORMAT))
|
|
81
|
+
continue
|
|
82
|
+
files.append(
|
|
83
|
+
MediaFile(
|
|
84
|
+
path=resolved.relative_to(root).as_posix(),
|
|
85
|
+
mime=mime,
|
|
86
|
+
sha256=hashlib.sha256(data).hexdigest(),
|
|
87
|
+
data=data,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
return files, problems
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
#: The codec of the first packet of an Ogg audio file, at byte 28: Vorbis, Opus or FLAC.
|
|
94
|
+
#: A video-only Ogg file, for example Theora, is not audio.
|
|
95
|
+
_OGG_AUDIO = (b"\x01vorbis", b"OpusHead", b"\x7fFLAC")
|
|
96
|
+
#: The bytes that `sniff_mime` reads. The Ogg codec ends at byte 36.
|
|
97
|
+
SNIFF_BYTES = 64
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def sniff_mime(head: bytes) -> str | None:
|
|
101
|
+
"""The MIME type of a media file from its first bytes, or None."""
|
|
102
|
+
for signature, mime in _SIGNATURES:
|
|
103
|
+
if head.startswith(signature):
|
|
104
|
+
return mime
|
|
105
|
+
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
|
106
|
+
return "image/webp"
|
|
107
|
+
if head[:4] == b"RIFF" and head[8:12] == b"WAVE":
|
|
108
|
+
return "audio/wav"
|
|
109
|
+
if head[:4] == b"OggS" and head[28:36].startswith(_OGG_AUDIO):
|
|
110
|
+
return "audio/ogg"
|
|
111
|
+
if _is_mp3_frame(head):
|
|
112
|
+
return "audio/mpeg"
|
|
113
|
+
return _video_mime(head)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
#: The ISO media brands of a video. HEIF and AVIF images and M4A audio use `ftyp` too,
|
|
117
|
+
#: so a brand that is not in this set is not a video.
|
|
118
|
+
_MP4_BRANDS = frozenset(
|
|
119
|
+
{b"isom", b"iso2", b"iso3", b"iso4", b"iso5", b"iso6", b"mp41", b"mp42", b"avc1",
|
|
120
|
+
b"M4V ", b"dash", b"mmp4", b"3gp4", b"3gp5", b"3gp6", b"3g2a"}
|
|
121
|
+
) # fmt: skip
|
|
122
|
+
#: The EBML header of a Matroska or WebM file.
|
|
123
|
+
_EBML = b"\x1a\x45\xdf\xa3"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _video_mime(head: bytes) -> str | None:
|
|
127
|
+
"""The MIME type of an MP4, MOV, WebM or MKV video from its first bytes, or None."""
|
|
128
|
+
if head[4:8] == b"ftyp":
|
|
129
|
+
brand = head[8:12]
|
|
130
|
+
if brand == b"qt ":
|
|
131
|
+
return "video/quicktime"
|
|
132
|
+
return "video/mp4" if brand in _MP4_BRANDS else None
|
|
133
|
+
if head.startswith(_EBML):
|
|
134
|
+
# The DocType element (0x42 0x82) names the kind of the Matroska file.
|
|
135
|
+
if b"\x42\x82\x84webm" in head:
|
|
136
|
+
return "video/webm"
|
|
137
|
+
if b"\x42\x82\x88matroska" in head:
|
|
138
|
+
return "video/x-matroska"
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _is_mp3_frame(head: bytes) -> bool:
|
|
143
|
+
"""True for an MPEG audio frame header of Layer II or III, with no ID3 tag.
|
|
144
|
+
|
|
145
|
+
The header has 11 sync bits, a valid version, Layer II or III, a bitrate index from
|
|
146
|
+
1 to 14 and a valid sampling rate. So text, AAC and other data do not pass.
|
|
147
|
+
"""
|
|
148
|
+
if len(head) < 3 or head[0] != 0xFF or head[1] & 0xE0 != 0xE0:
|
|
149
|
+
return False
|
|
150
|
+
version = (head[1] >> 3) & 0b11
|
|
151
|
+
layer = (head[1] >> 1) & 0b11
|
|
152
|
+
bitrate = head[2] >> 4
|
|
153
|
+
rate = (head[2] >> 2) & 0b11
|
|
154
|
+
return version != 0b01 and layer in (0b01, 0b10) and bitrate not in (0, 15) and rate != 3
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
#: The most base64 characters that can hold MAX_BYTES, with some room for padding.
|
|
158
|
+
_MAX_BASE64 = (MAX_BYTES + 2) // 3 * 4 + 4
|
|
159
|
+
_TOO_LARGE = "The file is larger than 10 MB."
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def decode_media_argument(text: str) -> bytes:
|
|
163
|
+
"""The bytes of a media argument: base64 text, or a base64 `data:` URI (ADR-012).
|
|
164
|
+
|
|
165
|
+
The length is checked before the decode, so a large argument costs no memory.
|
|
166
|
+
"""
|
|
167
|
+
value = text.strip()
|
|
168
|
+
if value.startswith("data:"):
|
|
169
|
+
header, comma, value = value.partition(",")
|
|
170
|
+
if not comma or not header.endswith(";base64"):
|
|
171
|
+
raise _bad_media("The media is a data: URI with no base64 bytes.")
|
|
172
|
+
# base64 tools break the text into lines of 76 characters, with up to 2 more each.
|
|
173
|
+
if len(value) > _MAX_BASE64 + _MAX_BASE64 // 38 + 2:
|
|
174
|
+
raise _bad_media(_TOO_LARGE)
|
|
175
|
+
value = "".join(value.split())
|
|
176
|
+
if len(value) > _MAX_BASE64:
|
|
177
|
+
raise _bad_media(_TOO_LARGE)
|
|
178
|
+
try:
|
|
179
|
+
data = base64.b64decode(value, validate=True)
|
|
180
|
+
except (binascii.Error, ValueError):
|
|
181
|
+
# A character that is not ASCII gives ValueError, not binascii.Error.
|
|
182
|
+
data = b""
|
|
183
|
+
if not data:
|
|
184
|
+
raise _bad_media("The media is not base64 text or a data: URI.")
|
|
185
|
+
return data
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def read_query_media(data: bytes) -> MediaFile:
|
|
189
|
+
"""A media question from its bytes: an image, a clip or a video, as `read_media` takes."""
|
|
190
|
+
if len(data) > MAX_BYTES:
|
|
191
|
+
raise _bad_media(_TOO_LARGE)
|
|
192
|
+
mime = sniff_mime(data[:SNIFF_BYTES])
|
|
193
|
+
if mime is None:
|
|
194
|
+
raise _bad_media(f"{UNKNOWN_FORMAT.capitalize()}.")
|
|
195
|
+
return MediaFile("question", mime, hashlib.sha256(data).hexdigest(), data)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _bad_media(message: str) -> InvalidArgument:
|
|
199
|
+
return InvalidArgument("media", message)
|