virtus 0.0.3__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.
virtus/journal.py ADDED
@@ -0,0 +1,244 @@
1
+ """Where threads keep their history: the only state a conversation has, so it outlives the process and resumes.
2
+
3
+ A thread holds no messages of its own; it reads its history from the journal before each run and writes
4
+ it back after every completed step. `MemoryJournal` is the default and lives as long as the agent;
5
+ `JsonJournal` puts the same thing on disk. The history is all the framework needs to continue: tool calls
6
+ never sit without their results in it, and rewrites made by middleware are already applied. State that
7
+ lives elsewhere (a `Capabilities` instance, a kernel) is not covered; what needs to survive goes to disk
8
+ by its owner, or is rebuilt from the history.
9
+ """
10
+
11
+ import shutil
12
+ from base64 import b64decode, b64encode
13
+ from collections.abc import Sequence
14
+ from pathlib import Path
15
+ from typing import Annotated, Literal, Protocol, runtime_checkable
16
+ from urllib.parse import quote, unquote
17
+
18
+ from pydantic import BaseModel, Field, TypeAdapter
19
+
20
+ from virtus.message import (
21
+ AssistantMessage,
22
+ Audio,
23
+ AudioType,
24
+ File,
25
+ FileType,
26
+ Image,
27
+ ImageType,
28
+ Message,
29
+ ToolCall,
30
+ ToolResult,
31
+ Usage,
32
+ UserMessage,
33
+ )
34
+
35
+
36
+ @runtime_checkable
37
+ class Journal(Protocol):
38
+ """`path` is `(agent name, id)` for a root thread; a thread opened by delegation appends the agent name
39
+ and the conversation name to its parent's path. `delete` removes the thread and every thread under it;
40
+ `children` lists the (agent name, conversation name) pairs directly under a thread.
41
+ """
42
+
43
+ def read(self, path: Sequence[str]) -> tuple[Message, ...] | None: ...
44
+
45
+ def write(self, path: Sequence[str], history: Sequence[Message]) -> None: ...
46
+
47
+ def delete(self, path: Sequence[str]) -> None: ...
48
+
49
+ def children(self, path: Sequence[str]) -> Sequence[tuple[str, str]]: ...
50
+
51
+
52
+ class MemoryJournal(Journal):
53
+ """Threads kept in a dict, for as long as the journal is referenced."""
54
+
55
+ def __init__(self) -> None:
56
+ self._threads: dict[tuple[str, ...], tuple[Message, ...]] = {}
57
+
58
+ def read(self, path: Sequence[str]) -> tuple[Message, ...] | None:
59
+ return self._threads.get(tuple(path))
60
+
61
+ def write(self, path: Sequence[str], history: Sequence[Message]) -> None:
62
+ self._threads[tuple(path)] = tuple(history)
63
+
64
+ def delete(self, path: Sequence[str]) -> None:
65
+ prefix = tuple(path)
66
+ for key in [k for k in self._threads if k[: len(prefix)] == prefix]:
67
+ del self._threads[key]
68
+
69
+ def children(self, path: Sequence[str]) -> tuple[tuple[str, str], ...]:
70
+ prefix = tuple(path)
71
+ return tuple((k[-2], k[-1]) for k in self._threads if len(k) == len(prefix) + 2 and k[: len(prefix)] == prefix)
72
+
73
+
74
+ class JsonJournal(Journal):
75
+ """One JSON file per thread, `directory/<agent>/<id>.json`; children live in a folder named after their parent."""
76
+
77
+ def __init__(self, directory: str | Path) -> None:
78
+ self.directory = Path(directory)
79
+
80
+ def read(self, path: Sequence[str]) -> tuple[Message, ...] | None:
81
+ file = self._file(path)
82
+ if not file.exists():
83
+ return None
84
+ return tuple(map(_message, _RECORD.validate_json(file.read_bytes()).messages))
85
+
86
+ def write(self, path: Sequence[str], history: Sequence[Message]) -> None:
87
+ file = self._file(path)
88
+ file.parent.mkdir(parents=True, exist_ok=True)
89
+ record = _Record(messages=[_entry(m) for m in history])
90
+ temporary = file.with_suffix(".json.tmp")
91
+ temporary.write_bytes(record.model_dump_json(indent=1).encode())
92
+ temporary.replace(file)
93
+
94
+ def delete(self, path: Sequence[str]) -> None:
95
+ self._file(path).unlink(missing_ok=True)
96
+ shutil.rmtree(self._file(path).with_suffix(""), ignore_errors=True)
97
+
98
+ def children(self, path: Sequence[str]) -> tuple[tuple[str, str], ...]:
99
+ folder = self._file(path).with_suffix("")
100
+ if not folder.is_dir():
101
+ return ()
102
+ return tuple(
103
+ (unquote(agent.name), unquote(file.stem))
104
+ for agent in sorted(folder.iterdir())
105
+ if agent.is_dir()
106
+ for file in sorted(agent.glob("*.json"))
107
+ )
108
+
109
+ def _file(self, path: Sequence[str]) -> Path:
110
+ """Each segment becomes one file-system name; percent-encoding keeps `/` and `..` out of it."""
111
+ segments = [quote(segment, safe="").replace(".", "%2E") for segment in path]
112
+ return self.directory.joinpath(*segments).with_suffix(".json")
113
+
114
+
115
+ class _Image(BaseModel):
116
+ media_type: ImageType
117
+ data: str
118
+
119
+ @classmethod
120
+ def of(cls, image: Image) -> "_Image":
121
+ return cls(media_type=image.media_type, data=b64encode(image.data).decode())
122
+
123
+ def image(self) -> Image:
124
+ return Image(b64decode(self.data), self.media_type)
125
+
126
+
127
+ class _Audio(BaseModel):
128
+ media_type: AudioType
129
+ data: str
130
+
131
+ @classmethod
132
+ def of(cls, audio: Audio) -> "_Audio":
133
+ return cls(media_type=audio.media_type, data=b64encode(audio.data).decode())
134
+
135
+ def audio(self) -> Audio:
136
+ return Audio(b64decode(self.data), self.media_type)
137
+
138
+
139
+ class _File(BaseModel):
140
+ media_type: FileType
141
+ name: str
142
+ data: str
143
+ text: str | None = None
144
+
145
+ @classmethod
146
+ def of(cls, file: File) -> "_File":
147
+ return cls(media_type=file.media_type, name=file.name, data=b64encode(file.data).decode(), text=file.text)
148
+
149
+ def file(self) -> File:
150
+ return File(b64decode(self.data), self.media_type, self.name, self.text)
151
+
152
+
153
+ class _User(BaseModel):
154
+ role: Literal["user"] = "user"
155
+ content: str
156
+ images: list[_Image] = []
157
+ audio: list[_Audio] = []
158
+ files: list[_File] = []
159
+
160
+
161
+ class _Call(BaseModel):
162
+ id: str
163
+ name: str
164
+ arguments: str
165
+
166
+
167
+ class _Usage(BaseModel):
168
+ input: int
169
+ output: int
170
+
171
+
172
+ class _Assistant(BaseModel):
173
+ role: Literal["assistant"] = "assistant"
174
+ content: str
175
+ tool_calls: list[_Call] = []
176
+ images: list[_Image] = []
177
+ audio: _Audio | None = None
178
+ usage: _Usage | None = None
179
+
180
+
181
+ class _Tool(BaseModel):
182
+ role: Literal["tool"] = "tool"
183
+ call_id: str
184
+ name: str
185
+ content: str
186
+ is_error: bool = False
187
+ images: list[_Image] = []
188
+ audio: list[_Audio] = []
189
+ files: list[_File] = []
190
+ tools: list[str] = []
191
+
192
+
193
+ type _Entry = Annotated[_User | _Assistant | _Tool, Field(discriminator="role")]
194
+
195
+
196
+ class _Record(BaseModel):
197
+ version: Literal[1] = 1
198
+ messages: list[_Entry]
199
+
200
+
201
+ _RECORD = TypeAdapter(_Record)
202
+
203
+
204
+ def _entry(message: Message) -> _User | _Assistant | _Tool:
205
+ match message:
206
+ case UserMessage(content=content, images=images, audio=audio, files=files):
207
+ return _User(content=content, images=_images(images), audio=_clips(audio), files=_files(files))
208
+ case AssistantMessage(content=content, tool_calls=calls, images=images, audio=audio, usage=usage):
209
+ tool_calls = [_Call(id=c.id, name=c.name, arguments=c.arguments) for c in calls]
210
+ spoken = _Audio.of(audio) if audio else None
211
+ used = _Usage(input=usage.input, output=usage.output) if usage else None
212
+ return _Assistant(content=content, tool_calls=tool_calls, images=_images(images), audio=spoken, usage=used)
213
+ case ToolResult(call_id=call_id, name=name, content=content, is_error=is_error, images=images, audio=audio):
214
+ entry = _Tool(call_id=call_id, name=name, content=content, is_error=is_error, tools=list(message.tools))
215
+ attached = {"images": _images(images), "audio": _clips(audio), "files": _files(message.files)}
216
+ return entry.model_copy(update=attached)
217
+
218
+
219
+ def _message(entry: _User | _Assistant | _Tool) -> Message:
220
+ match entry:
221
+ case _User(content=content, images=images, audio=audio, files=files):
222
+ media = tuple(i.image() for i in images), tuple(a.audio() for a in audio), tuple(f.file() for f in files)
223
+ return UserMessage(content, *media)
224
+ case _Assistant(content=content, tool_calls=calls, images=images, audio=audio, usage=usage):
225
+ tool_calls = tuple(ToolCall(c.id, c.name, c.arguments) for c in calls)
226
+ spoken = audio.audio() if audio else None
227
+ used = Usage(usage.input, usage.output) if usage else None
228
+ return AssistantMessage(content, tool_calls, tuple(i.image() for i in images), spoken, used)
229
+ case _Tool(call_id=call_id, name=name, content=content, is_error=is_error, images=images, audio=audio):
230
+ files = tuple(f.file() for f in entry.files)
231
+ media = tuple(i.image() for i in images), tuple(a.audio() for a in audio), files
232
+ return ToolResult(call_id, name, content, is_error, *media, tuple(entry.tools))
233
+
234
+
235
+ def _files(files: Sequence[File]) -> list[_File]:
236
+ return [_File.of(f) for f in files]
237
+
238
+
239
+ def _images(images: Sequence[Image]) -> list[_Image]:
240
+ return [_Image.of(i) for i in images]
241
+
242
+
243
+ def _clips(audio: Sequence[Audio]) -> list[_Audio]:
244
+ return [_Audio.of(a) for a in audio]
virtus/knowledge.py ADDED
@@ -0,0 +1,401 @@
1
+ """Documents on disk the agent can consult, retrieved in the cheapest way their volume allows.
2
+
3
+ While everything fits the token budget, it all goes into the system prompt. Past that, only an
4
+ index of ids and titles goes in and the agent reads entries on demand. Past what the index
5
+ allows, the agent searches first and then reads: BM25 with no dependencies, or by meaning when
6
+ an `Embedder` is given. Search and reading go by passages, so a long document (a PDF, say) is
7
+ found by the part that matters and read a part at a time.
8
+
9
+ `knowledge(...)` is read-only material the developer provides; `memory(...)` is the same store
10
+ with tools for the agent to write and delete entries, so it persists across conversations.
11
+ """
12
+
13
+ import hashlib
14
+ import json
15
+ import math
16
+ import re
17
+ from collections import Counter
18
+ from collections.abc import AsyncIterator, Iterator, Sequence
19
+ from dataclasses import dataclass, replace
20
+ from datetime import UTC, datetime
21
+ from functools import lru_cache
22
+ from pathlib import Path
23
+ from typing import TypedDict, Unpack
24
+
25
+ from pydantic import TypeAdapter
26
+
27
+ from virtus.message import Event
28
+ from virtus.middleware import Middleware, Next
29
+ from virtus.pdfs import Reader
30
+ from virtus.provider import Embedder
31
+ from virtus.request import Request
32
+ from virtus.tool import Tool, tool
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Document:
37
+ id: str
38
+ """Path relative to the root it was found under, e.g. `guides/setup.md`."""
39
+ title: str
40
+ content: str
41
+ path: Path
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Passage:
46
+ """Part of a document, at most the store's `passage` characters long and overlapping the next by `overlap`."""
47
+
48
+ id: str
49
+ """The document's id, with `#n` after it when the document has more than one passage."""
50
+ document: Document
51
+ number: int
52
+ """1-based, out of `total`."""
53
+ total: int
54
+ content: str
55
+
56
+
57
+ class Store:
58
+ """Markdown, text and PDF files under `roots`, read again on every access so edits show up at once.
59
+
60
+ A `.md` file may start with a frontmatter block of `key: value` lines between `---` fences;
61
+ its `title` names the document. Otherwise the first heading does, and failing that the file name.
62
+ A PDF is its pages' text (OCR where a page has none, extra `pdf`), extracted once per version of the file.
63
+
64
+ Search and reading go by passages of at most `passage` characters, cut where a paragraph or else a word
65
+ ends; each starts `overlap` characters before the previous one ended, so a sentence across a cut is whole
66
+ in one of them.
67
+
68
+ A PDF's text comes from `reader`; the last `cache` PDFs read are kept, each until its file changes.
69
+
70
+ With an `embedder`, `search` ranks by meaning. The vectors are kept in a file named `index` in each
71
+ root's directory, keyed by a hash of the passage, so only new or changed passages are embedded again;
72
+ vectors of another size than the embedder now produces are embedded again too. Without one, it ranks by
73
+ BM25 with `k1` and `b`.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ *roots: Path,
79
+ suffixes: Sequence[str] = (".md", ".txt", ".pdf"),
80
+ embedder: Embedder | None = None,
81
+ passage: int = 2_000,
82
+ overlap: int = 200,
83
+ index: str = ".embeddings.json",
84
+ reader: Reader = Reader(),
85
+ cache: int = 256,
86
+ k1: float = 1.5,
87
+ b: float = 0.75,
88
+ ) -> None:
89
+ if not 0 <= overlap < passage // 2:
90
+ raise ValueError(f"overlap must be at least 0 and under half the passage, got {overlap} of {passage}")
91
+ self.roots = roots
92
+ self.suffixes = tuple(suffixes)
93
+ self.embedder = embedder
94
+ self.passage = passage
95
+ self.overlap = overlap
96
+ self.index = index
97
+ self.k1 = k1
98
+ self.b = b
99
+
100
+ def extract(file: Path, modified: int, size: int) -> str:
101
+ """Keyed by modification time and size too, so a changed file is extracted again."""
102
+ pages = reader.pages(file.read_bytes())
103
+ return "\n\n".join(f"[page {n}]\n{text}" for n, text in enumerate(pages, 1))
104
+
105
+ self._pdf = lru_cache(maxsize=cache)(extract)
106
+
107
+ def documents(self) -> tuple[Document, ...]:
108
+ def under(root: Path) -> Iterator[Document]:
109
+ if root.is_file():
110
+ yield self.load(root, root.name)
111
+ elif root.is_dir():
112
+ for file in sorted(root.rglob("*")):
113
+ if file.suffix in self.suffixes:
114
+ try:
115
+ yield self.load(file, str(file.relative_to(root)))
116
+ except (FileNotFoundError, IsADirectoryError):
117
+ continue # deleted, or a directory named like a file, while listing
118
+
119
+ return tuple(d for root in self.roots for d in under(root))
120
+
121
+ def passages(self) -> tuple[Passage, ...]:
122
+ return tuple(p for d in self.documents() for p in _passages(d, self.passage, self.overlap))
123
+
124
+ def read(self, id: str) -> Document | None:
125
+ return next((d for d in self.documents() if d.id == id), None)
126
+
127
+ def load(self, file: Path, id: str) -> Document:
128
+ """A heading that names a markdown document is not repeated in its content; a PDF's pages are marked."""
129
+ if file.suffix == ".pdf":
130
+ stat = file.stat()
131
+ return Document(id, file.stem, self._pdf(file, stat.st_mtime_ns, stat.st_size), file)
132
+ if file.suffix != ".md":
133
+ return Document(id, file.stem, file.read_text().strip(), file)
134
+
135
+ frontmatter, body = _split(file.read_text())
136
+ if title := frontmatter.get("title"):
137
+ return Document(id, title, body.strip(), file)
138
+ if heading := re.search(r"^#\s+(.+)$", body, re.MULTILINE):
139
+ return Document(
140
+ id, heading.group(1).strip(), (body[: heading.start()] + body[heading.end() :]).strip(), file
141
+ )
142
+ return Document(id, file.stem, body.strip(), file)
143
+
144
+ async def search(self, query: str, limit: int = 5) -> list[Passage]:
145
+ """The `limit` passages nearest the query: by cosine with an embedder, else by BM25 over title and text."""
146
+ passages = self.passages()
147
+ if self.embedder is None:
148
+ return _bm25(passages, query, limit, self.k1, self.b)
149
+
150
+ [vector] = await self.embedder.embed([query], "query")
151
+ vectors = [v for root in self.roots for v in await self._vectors(root, len(vector))]
152
+ ranked = sorted(zip(map(lambda v: _cosine(vector, v), vectors), passages, strict=True), key=lambda p: -p[0])
153
+ return [passage for _, passage in ranked[:limit]]
154
+
155
+ async def _vectors(self, root: Path, size: int) -> list[list[float]]:
156
+ """The vectors of the passages under `root`, from its index where the text hasn't changed."""
157
+ assert self.embedder is not None
158
+ passages = [p for p in self.passages() if p.document.path == root or p.document.path.is_relative_to(root)]
159
+ index = (root if root.is_dir() else root.parent) / self.index
160
+ cached = _INDEX.validate_json(index.read_bytes()) if index.exists() else {}
161
+ keys = [_digest(p) for p in passages]
162
+ kept = {k: v for k, v in cached.items() if k in keys and len(v) == size}
163
+
164
+ missing = [(k, p) for k, p in zip(keys, passages, strict=True) if k not in kept]
165
+ if missing:
166
+ fresh = await self.embedder.embed([_text(p) for _, p in missing], "document")
167
+ kept |= {k: v for (k, _), v in zip(missing, fresh, strict=True)}
168
+ if kept != cached:
169
+ index.parent.mkdir(parents=True, exist_ok=True)
170
+ index.write_text(json.dumps(kept))
171
+ return [kept[k] for k in keys]
172
+
173
+
174
+ class Options(TypedDict, total=False):
175
+ """The `Store` settings `knowledge` and `memory` pass on."""
176
+
177
+ suffixes: Sequence[str]
178
+ embedder: Embedder | None
179
+ passage: int
180
+ overlap: int
181
+ index: str
182
+ reader: Reader
183
+ cache: int
184
+ k1: float
185
+ b: float
186
+
187
+
188
+ KNOWLEDGE = "Reference material you can rely on."
189
+ MEMORY = "Notes you kept from earlier conversations. Save new ones with {name}_remember."
190
+
191
+
192
+ def knowledge(
193
+ *paths: str | Path,
194
+ name: str = "knowledge",
195
+ intro: str = KNOWLEDGE,
196
+ max_tokens: int = 4_000,
197
+ chars_per_token: int = 4,
198
+ hits: int = 5,
199
+ snippet: int = 160,
200
+ **options: Unpack[Options],
201
+ ) -> Middleware:
202
+ """Let the agent consult `.md`, `.txt` and `.pdf` files, or whole directories of them, read-only.
203
+
204
+ `name` prefixes the tools and the section in the system prompt, so two knowledge bases can
205
+ coexist; `intro` opens that section. `max_tokens` (estimated as `chars_per_token` characters each)
206
+ is how much of the prompt the content, or failing that the index, may take before the agent has to
207
+ read or search instead. A search lists `hits` passages, each with its first `snippet` characters.
208
+ `options` go to the `Store`: an `embedder` makes the search semantic, `passage` and `overlap` size
209
+ the parts it goes by.
210
+ """
211
+ return _consult(
212
+ Store(*map(Path, paths), **options),
213
+ name,
214
+ intro,
215
+ max_tokens,
216
+ chars_per_token,
217
+ hits,
218
+ snippet,
219
+ manage=(),
220
+ )
221
+
222
+
223
+ def memory(
224
+ path: str | Path,
225
+ *,
226
+ name: str = "memory",
227
+ intro: str = MEMORY,
228
+ max_tokens: int = 4_000,
229
+ chars_per_token: int = 4,
230
+ hits: int = 5,
231
+ snippet: int = 160,
232
+ **options: Unpack[Options],
233
+ ) -> Middleware:
234
+ """Give the agent a directory of markdown notes it writes and reads back in later conversations.
235
+
236
+ Each note is a file named after its title, with the title and creation time in frontmatter.
237
+ Notes are plain files, so they can be read and edited by hand. `{name}` in `intro` is the name;
238
+ the other arguments are those of `knowledge`.
239
+ """
240
+ root = Path(path)
241
+ store = Store(root, **options)
242
+
243
+ def remember(title: str, content: str) -> str:
244
+ """Save something worth knowing in later conversations. The same title overwrites the note."""
245
+ root.mkdir(parents=True, exist_ok=True)
246
+ file = root / f"{_slug(title)}.md"
247
+ created = datetime.now(UTC).isoformat(timespec="seconds")
248
+ file.write_text(f"---\ntitle: {title}\ncreated: {created}\n---\n\n{content.strip()}\n")
249
+ return f"saved as {file.name}"
250
+
251
+ def forget(id: str) -> str:
252
+ """Delete a note by id."""
253
+ if (document := store.read(id)) is None:
254
+ raise FileNotFoundError(f"no note {id!r}")
255
+ document.path.unlink()
256
+ return f"deleted {id}"
257
+
258
+ return _consult(
259
+ store,
260
+ name,
261
+ intro.format(name=name),
262
+ max_tokens,
263
+ chars_per_token,
264
+ hits,
265
+ snippet,
266
+ manage=(tool(remember, name=f"{name}_remember"), tool(forget, name=f"{name}_forget")),
267
+ )
268
+
269
+
270
+ def _consult(
271
+ store: Store,
272
+ name: str,
273
+ intro: str,
274
+ max_tokens: int,
275
+ chars_per_token: int,
276
+ hits: int,
277
+ snippet: int,
278
+ *,
279
+ manage: tuple[Tool, ...],
280
+ ) -> Middleware:
281
+ def read(id: str) -> str:
282
+ """A long entry is read a passage at a time: its id alone gives the first, `id#n` the n-th."""
283
+ passages = {p.id: p for p in store.passages()}
284
+ if (passage := passages.get(id) or passages.get(f"{id}#1")) is None:
285
+ raise FileNotFoundError(f"no entry {id!r}")
286
+ text = f"# {passage.document.title}\n\n{passage.content}"
287
+ if passage.total == 1:
288
+ return text
289
+ following = f"; next is {passage.document.id}#{passage.number + 1}" if passage.number < passage.total else ""
290
+ return f"{text}\n\n[passage {passage.number} of {passage.total}{following}]"
291
+
292
+ async def search(query: str) -> str:
293
+ found = await store.search(query, hits)
294
+ lines = (f"- {p.id}: {p.document.title}\n {_snippet(p.content, snippet)}" for p in found)
295
+ return "\n".join(lines) or "No matches."
296
+
297
+ how = "by meaning" if store.embedder else "by keywords"
298
+ reading = f"Read one entry of {name} by id; a long one a passage (`id#n`) at a time."
299
+ reader = tool(read, name=f"{name}_read", description=reading)
300
+ searcher = tool(search, name=f"{name}_search", description=f"Search {name} {how}; returns ids to read.")
301
+
302
+ async def consult(request: Request, next: Next) -> AsyncIterator[Event]:
303
+ documents = store.documents()
304
+ full = "\n\n".join(f"### {d.title} ({d.id})\n{d.content}" for d in documents)
305
+ index = "\n".join(f"- {d.id}: {d.title}" for d in documents)
306
+
307
+ if len(full) // chars_per_token <= max_tokens:
308
+ body, tools = full or "(nothing yet)", manage
309
+ elif len(index) // chars_per_token <= max_tokens:
310
+ body, tools = f"{index}\n\nRead an entry with {name}_read.", (reader, *manage)
311
+ else:
312
+ body = f"{len(documents)} entries. Find the relevant ones with {name}_search, then {name}_read them."
313
+ tools = (searcher, reader, *manage)
314
+
315
+ section = f"## {name}\n\n{intro}\n\n{body}"
316
+ system = "\n\n".join(part for part in (request.system, section) if part)
317
+
318
+ async for event in next(replace(request, system=system, tools=(*request.tools, *tools))):
319
+ yield event
320
+
321
+ return consult
322
+
323
+
324
+ def _passages(document: Document, size: int, overlap: int) -> list[Passage]:
325
+ """Cut where a paragraph, or else a word, ends in the second half of the window; the next passage
326
+ starts at a word `overlap` characters before the cut."""
327
+ text, cuts, start = document.content, [], 0
328
+ while len(text) - start > size:
329
+ window = text[start : start + size]
330
+ cut = window.rfind("\n\n", size // 2)
331
+ cut = cut if cut != -1 else window.rfind(" ", size // 2)
332
+ end = start + (cut if cut != -1 else size)
333
+ cuts.append((start, end))
334
+ space = text.find(" ", end - overlap, end)
335
+ start = space + 1 if space != -1 else end - overlap
336
+ cuts.append((start, len(text)))
337
+ total = len(cuts)
338
+ return [
339
+ Passage(document.id if total == 1 else f"{document.id}#{n}", document, n, total, text[a:b].strip())
340
+ for n, (a, b) in enumerate(cuts, 1)
341
+ ]
342
+
343
+
344
+ def _text(passage: Passage) -> str:
345
+ return f"{passage.document.title}\n\n{passage.content}"
346
+
347
+
348
+ def _bm25(passages: Sequence[Passage], query: str, limit: int, k1: float, b: float) -> list[Passage]:
349
+ """Rank passages by BM25 over title and text; only positive scores count."""
350
+ bags = [Counter(_tokens(_text(p))) for p in passages]
351
+ total = len(passages)
352
+ average = sum(sum(bag.values()) for bag in bags) / total if total else 0.0
353
+
354
+ def idf(term: str) -> float:
355
+ containing = sum(term in bag for bag in bags)
356
+ return math.log(1 + (total - containing + 0.5) / (containing + 0.5))
357
+
358
+ def score(bag: Counter[str]) -> float:
359
+ length = sum(bag.values())
360
+ return sum(
361
+ idf(term) * bag[term] * (k1 + 1) / (bag[term] + k1 * (1 - b + b * length / average))
362
+ for term in set(_tokens(query))
363
+ if term in bag
364
+ )
365
+
366
+ ranked = sorted(zip(map(score, bags), passages, strict=True), key=lambda pair: -pair[0])
367
+ return [passage for value, passage in ranked[:limit] if value > 0]
368
+
369
+
370
+ def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
371
+ norms = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
372
+ return sum(x * y for x, y in zip(a, b, strict=True)) / norms if norms else 0.0
373
+
374
+
375
+ def _digest(passage: Passage) -> str:
376
+ return hashlib.sha256(_text(passage).encode()).hexdigest()
377
+
378
+
379
+ _INDEX = TypeAdapter(dict[str, list[float]])
380
+
381
+ _FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n?", re.DOTALL)
382
+
383
+
384
+ def _split(text: str) -> tuple[dict[str, str], str]:
385
+ if (match := _FRONTMATTER.match(text)) is None:
386
+ return {}, text
387
+ pairs = (line.partition(":") for line in match.group(1).splitlines() if ":" in line)
388
+ return {key.strip(): value.strip().strip("'\"") for key, _, value in pairs}, text[match.end() :]
389
+
390
+
391
+ def _tokens(text: str) -> list[str]:
392
+ return re.findall(r"\w+", text.lower())
393
+
394
+
395
+ def _slug(title: str) -> str:
396
+ return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "note"
397
+
398
+
399
+ def _snippet(content: str, length: int) -> str:
400
+ collapsed = " ".join(content.split())
401
+ return collapsed if len(collapsed) <= length else f"{collapsed[:length].rstrip()}…"