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/okf.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Parse and validate one OKF v0.2 concept file, with the rules of the curu index.
|
|
2
|
+
|
|
3
|
+
The rules are the rules of curu (research R-3), so that each file that curu accepts
|
|
4
|
+
also indexes here. Only the `description` field is required. A link is a Markdown
|
|
5
|
+
link to a sibling `.md` file, with a title that is one of the eight link kinds.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime
|
|
11
|
+
import hashlib
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import PurePosixPath
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
from curu_memory.ports import GLOBAL, Concept
|
|
20
|
+
|
|
21
|
+
#: The closed vocabulary of link kinds, the same as in curu. Each kind says something
|
|
22
|
+
#: specific, so there is no general "related" kind.
|
|
23
|
+
LINK_KINDS: tuple[str, ...] = (
|
|
24
|
+
"USES_COMPONENT",
|
|
25
|
+
"HAS_TURBO_VARIANT",
|
|
26
|
+
"SAME_LINEAGE_AS",
|
|
27
|
+
"SHARES_CLIP_TYPE_LABEL_WITH",
|
|
28
|
+
"EVIDENCED_BY",
|
|
29
|
+
"SHARES_GUARD_CONVENTION_WITH",
|
|
30
|
+
"DISTINCT_FAILURE_CLASS_FROM",
|
|
31
|
+
"RETRACTS_CLAIM_IN",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
UNTYPED = "Untyped"
|
|
35
|
+
|
|
36
|
+
_MARKDOWN_LINK = re.compile(r'\[[^\]]+\]\(\s*(?P<target>[^\s)]+)(?:\s+"(?P<kind>[^"]*)")?\s*\)')
|
|
37
|
+
_WIKI_LINK = re.compile(r"\[\[(?P<target>[^\[\]\n]+)\]\]")
|
|
38
|
+
_FENCE = re.compile(r"^ {0,3}(```+|~~~+)")
|
|
39
|
+
_INLINE_CODE = re.compile(r"(`+)(?:(?!\1).)+?\1")
|
|
40
|
+
_HEADING = re.compile(r"^## +(?P<title>.+?)\s*#*\s*$")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ConceptFault(ValueError):
|
|
44
|
+
"""A concept file that is not valid OKF. The index command skips it and reports it."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, path: str, reason: str, line: int | None = None) -> None:
|
|
47
|
+
where = f"{path}:{line}" if line is not None else path
|
|
48
|
+
super().__init__(f"{where}: {reason}")
|
|
49
|
+
self.path = path
|
|
50
|
+
self.reason = reason
|
|
51
|
+
self.line = line
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ParsedConcept:
|
|
56
|
+
concept: Concept
|
|
57
|
+
#: Each (target identifier, kind), in the order of the body. The first link wins.
|
|
58
|
+
links: tuple[tuple[str, str], ...]
|
|
59
|
+
#: Problems that do not stop the index, for example a missing type.
|
|
60
|
+
warnings: tuple[str, ...]
|
|
61
|
+
#: The `media:` paths, relative to the concept file (ADR-007).
|
|
62
|
+
media: tuple[str, ...] = ()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def concept_id(relative_path: str) -> str:
|
|
66
|
+
return PurePosixPath(relative_path).stem
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_concept(relative_path: str, text: str, tier_space: str = GLOBAL) -> ParsedConcept:
|
|
70
|
+
"""Parse the text of one concept file. Raise `ConceptFault` when it is not valid."""
|
|
71
|
+
path = PurePosixPath(relative_path).as_posix()
|
|
72
|
+
front, body, body_line = _split_front_matter(path, text)
|
|
73
|
+
identifier = concept_id(path)
|
|
74
|
+
|
|
75
|
+
description = front.get("description")
|
|
76
|
+
if not description or not str(description).strip():
|
|
77
|
+
raise ConceptFault(path, "the front matter has no 'description', which is required")
|
|
78
|
+
|
|
79
|
+
warnings: list[str] = []
|
|
80
|
+
kind = front.get("type")
|
|
81
|
+
if not kind or not str(kind).strip():
|
|
82
|
+
warnings.append(f"the front matter has no 'type', so the type is {UNTYPED}")
|
|
83
|
+
kind = UNTYPED
|
|
84
|
+
title = front.get("title")
|
|
85
|
+
status = front.get("status")
|
|
86
|
+
|
|
87
|
+
concept = Concept(
|
|
88
|
+
id=identifier,
|
|
89
|
+
tier_space=tier_space,
|
|
90
|
+
path=path,
|
|
91
|
+
type=str(kind).strip(),
|
|
92
|
+
title=str(title).strip() if title else identifier,
|
|
93
|
+
description=str(description).strip(),
|
|
94
|
+
tags=_tags(front.get("tags")),
|
|
95
|
+
status=str(status) if status is not None else None,
|
|
96
|
+
sources=_sources(front.get("sources")),
|
|
97
|
+
body=body,
|
|
98
|
+
content_sha256=hashlib.sha256(text.encode()).hexdigest(),
|
|
99
|
+
)
|
|
100
|
+
media = front.get("media")
|
|
101
|
+
if media is None:
|
|
102
|
+
media = []
|
|
103
|
+
elif not isinstance(media, list) or not all(isinstance(item, str) for item in media):
|
|
104
|
+
warnings.append("the front-matter key 'media' is not a list of paths, so it is ignored")
|
|
105
|
+
media = []
|
|
106
|
+
return ParsedConcept(
|
|
107
|
+
concept=concept,
|
|
108
|
+
links=_links(path, body, body_line),
|
|
109
|
+
warnings=tuple(warnings),
|
|
110
|
+
media=tuple(media),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
#: The slug of the unit of the title and the description.
|
|
115
|
+
SUMMARY = "summary"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def split_units(body: str) -> list[tuple[str, str]]:
|
|
119
|
+
"""Split a body at its `##` headings. Give each (slug, text) in order (research R-4).
|
|
120
|
+
|
|
121
|
+
The text before the first heading is the unit `overview`. A heading in a fenced
|
|
122
|
+
code block does not split. A repeated slug gets a number: `notes`, `notes-2`.
|
|
123
|
+
A slug is never the slug of another unit of the concept. The index uses `summary`
|
|
124
|
+
for the title and the description, so a `## Summary` heading is `summary-2`.
|
|
125
|
+
"""
|
|
126
|
+
sections: list[tuple[str | None, list[str]]] = [(None, [])]
|
|
127
|
+
fence: str | None = None
|
|
128
|
+
for line in body.splitlines():
|
|
129
|
+
opened = _FENCE.match(line)
|
|
130
|
+
if fence is None and opened:
|
|
131
|
+
fence = opened.group(1)
|
|
132
|
+
elif fence is not None and line.strip().startswith(fence[0] * len(fence)):
|
|
133
|
+
fence = None
|
|
134
|
+
elif fence is None and (heading := _HEADING.match(line)):
|
|
135
|
+
sections.append((heading.group("title"), []))
|
|
136
|
+
sections[-1][1].append(line)
|
|
137
|
+
|
|
138
|
+
units: list[tuple[str, str]] = []
|
|
139
|
+
used = {SUMMARY}
|
|
140
|
+
for title, lines in sections:
|
|
141
|
+
text = "\n".join(lines).strip()
|
|
142
|
+
if not text:
|
|
143
|
+
continue
|
|
144
|
+
base = "overview" if title is None else _slug(title)
|
|
145
|
+
slug, number = base, 1
|
|
146
|
+
while slug in used:
|
|
147
|
+
number += 1
|
|
148
|
+
slug = f"{base}-{number}"
|
|
149
|
+
used.add(slug)
|
|
150
|
+
units.append((slug, text))
|
|
151
|
+
return units
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _split_front_matter(path: str, text: str) -> tuple[dict[str, Any], str, int]:
|
|
155
|
+
"""Give the front matter, the body and the file line where the body starts."""
|
|
156
|
+
lines = text.splitlines(keepends=True)
|
|
157
|
+
if not lines or lines[0].rstrip("\r\n") != "---":
|
|
158
|
+
raise ConceptFault(path, "the file has no front matter between two '---' lines")
|
|
159
|
+
for index in range(1, len(lines)):
|
|
160
|
+
if lines[index].rstrip("\r\n") == "---":
|
|
161
|
+
break
|
|
162
|
+
else:
|
|
163
|
+
raise ConceptFault(path, "the front matter has no closing '---' line")
|
|
164
|
+
source = "".join(lines[1:index])
|
|
165
|
+
try:
|
|
166
|
+
front = yaml.safe_load(source)
|
|
167
|
+
except yaml.YAMLError as error:
|
|
168
|
+
mark = getattr(error, "problem_mark", None)
|
|
169
|
+
line = mark.line + 2 if mark is not None else None
|
|
170
|
+
problem = getattr(error, "problem", None) or "it cannot be read"
|
|
171
|
+
raise ConceptFault(path, f"the front matter is not valid YAML: {problem}", line) from None
|
|
172
|
+
if front is None:
|
|
173
|
+
front = {}
|
|
174
|
+
if not isinstance(front, dict):
|
|
175
|
+
raise ConceptFault(path, "the front matter must be a YAML mapping of fields")
|
|
176
|
+
return front, "".join(lines[index + 1 :]), index + 2
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _links(path: str, body: str, body_line: int) -> tuple[tuple[str, str], ...]:
|
|
180
|
+
unread = _WIKI_LINK.search(_mask_code(body))
|
|
181
|
+
if unread is not None:
|
|
182
|
+
target = unread.group("target").strip()
|
|
183
|
+
raise ConceptFault(
|
|
184
|
+
path,
|
|
185
|
+
f"the body has the wiki-link [[{target}]], which is not a Markdown link. "
|
|
186
|
+
f'Write [text](./{target}.md "KIND") with one of: {", ".join(LINK_KINDS)}',
|
|
187
|
+
_line_of(body, unread.start(), body_line),
|
|
188
|
+
)
|
|
189
|
+
links: list[tuple[str, str]] = []
|
|
190
|
+
seen: set[str] = set()
|
|
191
|
+
for match in _MARKDOWN_LINK.finditer(body):
|
|
192
|
+
target = match.group("target").strip()
|
|
193
|
+
if target.startswith(("http://", "https://")) or not target.endswith(".md"):
|
|
194
|
+
continue
|
|
195
|
+
target_id = PurePosixPath(target).stem
|
|
196
|
+
kind = match.group("kind")
|
|
197
|
+
line = _line_of(body, match.start(), body_line)
|
|
198
|
+
if kind is None:
|
|
199
|
+
raise ConceptFault(
|
|
200
|
+
path,
|
|
201
|
+
f'the link to {target!r} has no kind. Write [text]({target} "KIND") '
|
|
202
|
+
f"with one of: {', '.join(LINK_KINDS)}",
|
|
203
|
+
line,
|
|
204
|
+
)
|
|
205
|
+
if kind not in LINK_KINDS:
|
|
206
|
+
raise ConceptFault(
|
|
207
|
+
path,
|
|
208
|
+
f"the link to {target!r} has the unknown kind {kind!r}. "
|
|
209
|
+
f"Use one of: {', '.join(LINK_KINDS)}",
|
|
210
|
+
line,
|
|
211
|
+
)
|
|
212
|
+
if target_id in seen:
|
|
213
|
+
continue
|
|
214
|
+
seen.add(target_id)
|
|
215
|
+
links.append((target_id, kind))
|
|
216
|
+
return tuple(links)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _mask_code(body: str) -> str:
|
|
220
|
+
"""Blank fenced code blocks and inline code spans, but keep the line breaks."""
|
|
221
|
+
masked: list[str] = []
|
|
222
|
+
fence: str | None = None
|
|
223
|
+
for line in body.splitlines(keepends=True):
|
|
224
|
+
opened = _FENCE.match(line)
|
|
225
|
+
if fence is None and opened:
|
|
226
|
+
fence = opened.group(1)
|
|
227
|
+
masked.append(_blank(line))
|
|
228
|
+
elif fence is not None:
|
|
229
|
+
if line.strip().startswith(fence[0] * len(fence)):
|
|
230
|
+
fence = None
|
|
231
|
+
masked.append(_blank(line))
|
|
232
|
+
else:
|
|
233
|
+
masked.append(_INLINE_CODE.sub(lambda m: _blank(m.group(0)), line))
|
|
234
|
+
return "".join(masked)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _blank(text: str) -> str:
|
|
238
|
+
return "".join(c if c in "\r\n" else " " for c in text)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _line_of(body: str, offset: int, body_line: int) -> int:
|
|
242
|
+
return body_line + body.count("\n", 0, offset)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _slug(title: str) -> str:
|
|
246
|
+
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "section"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _tags(value: Any) -> tuple[str, ...]:
|
|
250
|
+
if value is None:
|
|
251
|
+
return ()
|
|
252
|
+
if isinstance(value, list | tuple):
|
|
253
|
+
return tuple(str(tag) for tag in value)
|
|
254
|
+
return (str(value),)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _sources(value: Any) -> tuple[dict[str, Any], ...]:
|
|
258
|
+
if not isinstance(value, list):
|
|
259
|
+
return ()
|
|
260
|
+
return tuple(_plain(item) for item in value if isinstance(item, dict))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _plain(value: Any) -> Any:
|
|
264
|
+
"""Make YAML values plain JSON values: a date becomes ISO text."""
|
|
265
|
+
if isinstance(value, dict):
|
|
266
|
+
return {str(k): _plain(v) for k, v in value.items()}
|
|
267
|
+
if isinstance(value, list):
|
|
268
|
+
return [_plain(v) for v in value]
|
|
269
|
+
if isinstance(value, datetime.date | datetime.datetime):
|
|
270
|
+
return value.isoformat()
|
|
271
|
+
return value
|
curu_memory/ports.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""The records of the index and the two ports: `IndexStore` and `Embedder`.
|
|
2
|
+
|
|
3
|
+
The application depends on these protocols, not on SQLite or HTTP (constitution,
|
|
4
|
+
article VII). Each read method of `IndexStore` takes the tier spaces that the caller
|
|
5
|
+
can read, so the filter is part of each query (ADR-001).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from contextlib import AbstractContextManager
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
|
|
16
|
+
from curu_memory.media import MediaFile
|
|
17
|
+
|
|
18
|
+
GLOBAL = "global"
|
|
19
|
+
#: A space name: `global`, `team/<slug>` or `personal/<handle>` (ADR-016).
|
|
20
|
+
_SPACE = re.compile(r"(?:team|personal)/[a-z][a-z0-9-]{0,39}")
|
|
21
|
+
#: Version 2 adds the kind of a unit, and the path and SHA-256 of an image (ADR-007).
|
|
22
|
+
#: Version 3 adds the concept media and the media bytes (ADR-009).
|
|
23
|
+
SCHEMA_VERSION = 3
|
|
24
|
+
TEXT = "text"
|
|
25
|
+
IMAGE = "image"
|
|
26
|
+
AUDIO = "audio"
|
|
27
|
+
VIDEO = "video"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_space(text: str) -> str:
|
|
31
|
+
"""The space name in `text`, or `ValueError` with the reason (ADR-016)."""
|
|
32
|
+
if text == GLOBAL or _SPACE.fullmatch(text):
|
|
33
|
+
return text
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"the space {text!r} is not valid. Give global, team/<slug> or personal/<handle>. "
|
|
36
|
+
"A slug or a handle has 1 to 40 characters from a to z, 0 to 9 and -, first a letter."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def space_file_name(space: str) -> str:
|
|
41
|
+
"""The index file name of a space. `/` becomes `--`, so `team/look` is `team--look.sqlite`."""
|
|
42
|
+
return parse_space(space).replace("/", "--") + ".sqlite"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def space_of_file_name(name: str) -> str | None:
|
|
46
|
+
"""The space of an index file name, or None for each other file."""
|
|
47
|
+
if not name.endswith(".sqlite"):
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
return parse_space(name.removesuffix(".sqlite").replace("--", "/", 1))
|
|
51
|
+
except ValueError:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class IndexLocked(Exception):
|
|
56
|
+
"""Another index run holds the lock of the data directory."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Concept:
|
|
61
|
+
id: str
|
|
62
|
+
tier_space: str
|
|
63
|
+
path: str
|
|
64
|
+
type: str
|
|
65
|
+
title: str
|
|
66
|
+
description: str
|
|
67
|
+
tags: tuple[str, ...]
|
|
68
|
+
status: str | None
|
|
69
|
+
sources: tuple[dict[str, Any], ...]
|
|
70
|
+
body: str
|
|
71
|
+
content_sha256: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Unit:
|
|
76
|
+
concept_id: str
|
|
77
|
+
slug: str
|
|
78
|
+
ordinal: int
|
|
79
|
+
#: The text of a text unit, or the media path of an image unit.
|
|
80
|
+
text: str
|
|
81
|
+
vector: tuple[float, ...] = field(default=(), repr=False)
|
|
82
|
+
#: `text` or `image`.
|
|
83
|
+
kind: str = TEXT
|
|
84
|
+
#: The path of the image, relative to the concept directory. None for text.
|
|
85
|
+
media_path: str | None = None
|
|
86
|
+
#: The SHA-256 of the image bytes. None for text.
|
|
87
|
+
media_sha256: str | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class ConceptMedia:
|
|
92
|
+
"""One accepted image of a concept, with or without a vector (ADR-009)."""
|
|
93
|
+
|
|
94
|
+
concept_id: str
|
|
95
|
+
#: The place of the image in the `media:` list, from 1.
|
|
96
|
+
number: int
|
|
97
|
+
#: The path relative to the concept directory.
|
|
98
|
+
path: str
|
|
99
|
+
sha256: str
|
|
100
|
+
mime: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class Link:
|
|
105
|
+
source_id: str
|
|
106
|
+
target_id: str
|
|
107
|
+
kind: str
|
|
108
|
+
resolved: bool
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class IndexMeta:
|
|
113
|
+
tier_space: str
|
|
114
|
+
concept_count: int
|
|
115
|
+
embedder_model: str | None
|
|
116
|
+
embedder_dimension: int | None
|
|
117
|
+
#: The fingerprint of the prompts of the embeddings, or None without embeddings.
|
|
118
|
+
embedder_prompts: str | None = None
|
|
119
|
+
schema_version: int = SCHEMA_VERSION
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class Prompts:
|
|
124
|
+
"""The task prompts that put a query or a document into the form that a model needs.
|
|
125
|
+
|
|
126
|
+
`{text}` is the text. `{title}` is the title of the concept, in a document prompt only.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
query: str = "{text}"
|
|
130
|
+
document: str = "{text}"
|
|
131
|
+
|
|
132
|
+
def for_query(self, text: str) -> str:
|
|
133
|
+
return self.query.replace("{text}", text)
|
|
134
|
+
|
|
135
|
+
def for_document(self, title: str, text: str) -> str:
|
|
136
|
+
# The text goes in last, so that braces in the text stay as they are.
|
|
137
|
+
return self.document.replace("{title}", title).replace("{text}", text)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def fingerprint(self) -> str:
|
|
141
|
+
return f"query={self.query}|document={self.document}"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
#: The retrieval prompts of the EmbeddingGemma model card (ADR-003).
|
|
145
|
+
EMBEDDINGGEMMA = Prompts(
|
|
146
|
+
query="task: search result | query: {text}",
|
|
147
|
+
document="title: {title} | text: {text}",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def prompts_for(model: str) -> Prompts:
|
|
152
|
+
"""The default prompts for a model name: EmbeddingGemma prompts, or no prompts."""
|
|
153
|
+
return EMBEDDINGGEMMA if _is_embeddinggemma(model) else Prompts()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
#: The lowest cosine similarity that counts as a match of meaning, for a model with no
|
|
157
|
+
#: measured limit.
|
|
158
|
+
DEFAULT_MIN_SIMILARITY = 0.35
|
|
159
|
+
#: The measured limit of EmbeddingGemma 2 (ADR-005). Unrelated questions reach about
|
|
160
|
+
#: 0.67, and known answers start at about 0.70.
|
|
161
|
+
EMBEDDINGGEMMA_MIN_SIMILARITY = 0.68
|
|
162
|
+
#: The measured limit of EmbeddingGemma 2 for a match between words and an image
|
|
163
|
+
#: (ADR-008). Known images scored from 0.741. Wrong images and unrelated questions
|
|
164
|
+
#: reached 0.655.
|
|
165
|
+
EMBEDDINGGEMMA_MIN_IMAGE_SIMILARITY = 0.68
|
|
166
|
+
#: The measured limits of EmbeddingGemma 2 when the question is an image (ADR-010).
|
|
167
|
+
EMBEDDINGGEMMA_MIN_IMAGE_TO_IMAGE_SIMILARITY = 0.89
|
|
168
|
+
EMBEDDINGGEMMA_MIN_IMAGE_TO_TEXT_SIMILARITY = 0.70
|
|
169
|
+
#: The measured limit of EmbeddingGemma 2 for words and an audio clip (ADR-011).
|
|
170
|
+
EMBEDDINGGEMMA_MIN_AUDIO_SIMILARITY = 0.68
|
|
171
|
+
#: The measured limit of EmbeddingGemma 2 for words and a video (ADR-013).
|
|
172
|
+
EMBEDDINGGEMMA_MIN_VIDEO_SIMILARITY = 0.71
|
|
173
|
+
#: The measured limits of EmbeddingGemma 2 when the question is a video (ADR-014).
|
|
174
|
+
EMBEDDINGGEMMA_MIN_VIDEO_TO_VIDEO_SIMILARITY = 0.92
|
|
175
|
+
EMBEDDINGGEMMA_MIN_VIDEO_TO_TEXT_SIMILARITY = 0.73
|
|
176
|
+
#: The measured limits of EmbeddingGemma 2 when the question is a clip (ADR-012).
|
|
177
|
+
EMBEDDINGGEMMA_MIN_AUDIO_TO_AUDIO_SIMILARITY = 0.87
|
|
178
|
+
EMBEDDINGGEMMA_MIN_AUDIO_TO_TEXT_SIMILARITY = 0.70
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def min_similarity_for(model: str) -> float:
|
|
182
|
+
"""The default similarity limit for a model name."""
|
|
183
|
+
return EMBEDDINGGEMMA_MIN_SIMILARITY if _is_embeddinggemma(model) else DEFAULT_MIN_SIMILARITY
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def min_image_similarity_for(model: str) -> float:
|
|
187
|
+
"""The default similarity limit of an image unit for a model name."""
|
|
188
|
+
if _is_embeddinggemma(model):
|
|
189
|
+
return EMBEDDINGGEMMA_MIN_IMAGE_SIMILARITY
|
|
190
|
+
return DEFAULT_MIN_SIMILARITY
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def min_audio_similarity_for(model: str) -> float:
|
|
194
|
+
"""The default limit when a question in words meets an audio unit."""
|
|
195
|
+
if _is_embeddinggemma(model):
|
|
196
|
+
return EMBEDDINGGEMMA_MIN_AUDIO_SIMILARITY
|
|
197
|
+
return DEFAULT_MIN_SIMILARITY
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def min_video_similarity_for(model: str) -> float:
|
|
201
|
+
"""The default limit when a question in words meets a video unit."""
|
|
202
|
+
if _is_embeddinggemma(model):
|
|
203
|
+
return EMBEDDINGGEMMA_MIN_VIDEO_SIMILARITY
|
|
204
|
+
return DEFAULT_MIN_SIMILARITY
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def min_video_to_video_similarity_for(model: str) -> float:
|
|
208
|
+
"""The default limit when a video question meets a video unit."""
|
|
209
|
+
if _is_embeddinggemma(model):
|
|
210
|
+
return EMBEDDINGGEMMA_MIN_VIDEO_TO_VIDEO_SIMILARITY
|
|
211
|
+
return DEFAULT_MIN_SIMILARITY
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def min_video_to_text_similarity_for(model: str) -> float:
|
|
215
|
+
"""The default limit when a video question meets a text unit."""
|
|
216
|
+
if _is_embeddinggemma(model):
|
|
217
|
+
return EMBEDDINGGEMMA_MIN_VIDEO_TO_TEXT_SIMILARITY
|
|
218
|
+
return DEFAULT_MIN_SIMILARITY
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def min_audio_to_audio_similarity_for(model: str) -> float:
|
|
222
|
+
"""The default limit when a clip question meets an audio unit."""
|
|
223
|
+
if _is_embeddinggemma(model):
|
|
224
|
+
return EMBEDDINGGEMMA_MIN_AUDIO_TO_AUDIO_SIMILARITY
|
|
225
|
+
return DEFAULT_MIN_SIMILARITY
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def min_audio_to_text_similarity_for(model: str) -> float:
|
|
229
|
+
"""The default limit when a clip question meets a text unit."""
|
|
230
|
+
if _is_embeddinggemma(model):
|
|
231
|
+
return EMBEDDINGGEMMA_MIN_AUDIO_TO_TEXT_SIMILARITY
|
|
232
|
+
return DEFAULT_MIN_SIMILARITY
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def min_image_to_image_similarity_for(model: str) -> float:
|
|
236
|
+
"""The default limit when an image question meets an image unit."""
|
|
237
|
+
if _is_embeddinggemma(model):
|
|
238
|
+
return EMBEDDINGGEMMA_MIN_IMAGE_TO_IMAGE_SIMILARITY
|
|
239
|
+
return DEFAULT_MIN_SIMILARITY
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def min_image_to_text_similarity_for(model: str) -> float:
|
|
243
|
+
"""The default limit when an image question meets a text unit."""
|
|
244
|
+
if _is_embeddinggemma(model):
|
|
245
|
+
return EMBEDDINGGEMMA_MIN_IMAGE_TO_TEXT_SIMILARITY
|
|
246
|
+
return DEFAULT_MIN_SIMILARITY
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _is_embeddinggemma(model: str) -> bool:
|
|
250
|
+
return "embeddinggemma" in model.lower()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass(frozen=True)
|
|
254
|
+
class Filters:
|
|
255
|
+
"""Optional limits on a search: any of `types`, and all of `tags`."""
|
|
256
|
+
|
|
257
|
+
types: tuple[str, ...] = ()
|
|
258
|
+
tags: tuple[str, ...] = ()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class Embedder(Protocol):
|
|
262
|
+
"""Turns texts into vectors. An adapter never loads model weights in this process."""
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def model(self) -> str: ...
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def dimension(self) -> int | None: ...
|
|
269
|
+
|
|
270
|
+
def embed(self, texts: Sequence[str]) -> list[list[float]]: ...
|
|
271
|
+
|
|
272
|
+
def embed_media(self, files: Sequence[MediaFile]) -> list[list[float]]:
|
|
273
|
+
"""One vector for each image, audio or video file, in the space of the text, with no prompt.
|
|
274
|
+
|
|
275
|
+
Raise an error when the embedder cannot embed a file (ADR-007, ADR-011).
|
|
276
|
+
"""
|
|
277
|
+
...
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class IndexStore(Protocol):
|
|
281
|
+
"""The derived index. Only `build` writes, and it replaces a tier space atomically."""
|
|
282
|
+
|
|
283
|
+
def build(
|
|
284
|
+
self,
|
|
285
|
+
meta: IndexMeta,
|
|
286
|
+
concepts: Sequence[Concept],
|
|
287
|
+
units: Sequence[Unit],
|
|
288
|
+
links: Sequence[Link],
|
|
289
|
+
media: Sequence[ConceptMedia] = (),
|
|
290
|
+
blobs: Sequence[MediaFile] = (),
|
|
291
|
+
) -> None:
|
|
292
|
+
"""Replace the tier space. `blobs` holds the bytes; each SHA-256 is kept one time."""
|
|
293
|
+
...
|
|
294
|
+
|
|
295
|
+
def meta(self, tier_space: str) -> IndexMeta | None: ...
|
|
296
|
+
|
|
297
|
+
def spaces(self) -> tuple[str, ...]:
|
|
298
|
+
"""Each space that has an index, in name order."""
|
|
299
|
+
...
|
|
300
|
+
|
|
301
|
+
def concept_ids(self, tier_space: str) -> set[str]:
|
|
302
|
+
"""The concept identifiers of one space. A space with no index has none."""
|
|
303
|
+
...
|
|
304
|
+
|
|
305
|
+
def index_lock(self) -> AbstractContextManager[None]:
|
|
306
|
+
"""Hold the lock of an index run, or raise `IndexLocked`."""
|
|
307
|
+
...
|
|
308
|
+
|
|
309
|
+
def get(self, spaces: Sequence[str], concept_id: str) -> Concept | None: ...
|
|
310
|
+
|
|
311
|
+
def word_search(
|
|
312
|
+
self, spaces: Sequence[str], words: Sequence[str], filters: Filters, limit: int
|
|
313
|
+
) -> list[str]:
|
|
314
|
+
"""The identifiers that match any of the words, best first."""
|
|
315
|
+
...
|
|
316
|
+
|
|
317
|
+
def unit_vectors(
|
|
318
|
+
self, spaces: Sequence[str], filters: Filters
|
|
319
|
+
) -> list[tuple[str, str, str, tuple[float, ...]]]:
|
|
320
|
+
"""Each (concept id, unit slug, unit kind, vector) that has a vector.
|
|
321
|
+
|
|
322
|
+
Each unit of an index of schema version 1 has the kind `text`.
|
|
323
|
+
"""
|
|
324
|
+
...
|
|
325
|
+
|
|
326
|
+
def links_from(self, spaces: Sequence[str], concept_id: str) -> list[Link]:
|
|
327
|
+
"""The outgoing links. A target resolves only when a readable space holds it."""
|
|
328
|
+
...
|
|
329
|
+
|
|
330
|
+
def media_of(self, spaces: Sequence[str], concept_id: str) -> list[ConceptMedia]:
|
|
331
|
+
"""The images of a concept, in list order. An index before schema 3 has none."""
|
|
332
|
+
...
|
|
333
|
+
|
|
334
|
+
def media_bytes(self, spaces: Sequence[str], sha256: str) -> tuple[str, bytes] | None:
|
|
335
|
+
"""The MIME type and the bytes of an image, or None."""
|
|
336
|
+
...
|
|
337
|
+
|
|
338
|
+
def links_to(self, spaces: Sequence[str], concept_id: str) -> list[Link]: ...
|
|
339
|
+
|
|
340
|
+
def titles(self, spaces: Sequence[str], concept_ids: Sequence[str]) -> dict[str, str]: ...
|
|
341
|
+
|
|
342
|
+
def canonical_dump(self, tier_space: str) -> str:
|
|
343
|
+
"""A text of all the rows in key order. Equal dumps mean identical indexes."""
|
|
344
|
+
...
|
curu_memory/render.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Markdown to safe HTML for the read-only browser (ADR-004, research R-2).
|
|
2
|
+
|
|
3
|
+
A concept body comes from persons and agents, so its HTML must run nothing:
|
|
4
|
+
|
|
5
|
+
- Raw HTML in a body shows as text (`html: False`).
|
|
6
|
+
- The link check of markdown-it-py refuses `javascript:`, `vbscript:`, `file:` and `data:`.
|
|
7
|
+
- A link to the `.md` file of a known concept goes to the page of that concept.
|
|
8
|
+
- A link to a missing concept keeps its text and loses its address.
|
|
9
|
+
- An external link gets `rel="noopener noreferrer"`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Set
|
|
15
|
+
from pathlib import PurePosixPath
|
|
16
|
+
from urllib.parse import quote, urlsplit
|
|
17
|
+
|
|
18
|
+
from markdown_it import MarkdownIt
|
|
19
|
+
from markdown_it.rules_core import StateCore
|
|
20
|
+
from markdown_it.token import Token
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render_markdown(body: str, known_ids: Set[str]) -> str:
|
|
24
|
+
"""The safe HTML of one concept body."""
|
|
25
|
+
parser = MarkdownIt("commonmark", {"html": False})
|
|
26
|
+
parser.core.ruler.push("concept_links", lambda state: _rewrite_links(state, known_ids))
|
|
27
|
+
return parser.render(body)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def concept_href(concept_id: str) -> str:
|
|
31
|
+
return "/concepts/" + quote(concept_id, safe="._-")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _rewrite_links(state: StateCore, known_ids: Set[str]) -> None:
|
|
35
|
+
for block in state.tokens:
|
|
36
|
+
if block.type != "inline" or not block.children:
|
|
37
|
+
continue
|
|
38
|
+
opened: list[Token] = []
|
|
39
|
+
for token in block.children:
|
|
40
|
+
if token.type == "link_open":
|
|
41
|
+
_rewrite_open(token, known_ids)
|
|
42
|
+
opened.append(token)
|
|
43
|
+
elif token.type == "link_close" and opened:
|
|
44
|
+
token.tag = opened.pop().tag
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _rewrite_open(token: Token, known_ids: Set[str]) -> None:
|
|
48
|
+
href = str(token.attrGet("href") or "")
|
|
49
|
+
parts = urlsplit(href)
|
|
50
|
+
if parts.scheme or parts.netloc:
|
|
51
|
+
token.attrSet("rel", "noopener noreferrer")
|
|
52
|
+
return
|
|
53
|
+
if not parts.path.endswith(".md"):
|
|
54
|
+
return
|
|
55
|
+
concept_id = PurePosixPath(parts.path).stem
|
|
56
|
+
if concept_id in known_ids:
|
|
57
|
+
token.attrSet("href", concept_href(concept_id))
|
|
58
|
+
return
|
|
59
|
+
# A link to a missing concept: keep the text, and give no address.
|
|
60
|
+
token.tag = "span"
|
|
61
|
+
token.attrs = {"class": "broken", "title": f"No concept {concept_id!r} is in the index."}
|