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/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."}