hotmemory 0.1.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.
- hotmemory/__init__.py +26 -0
- hotmemory/_chunks.py +184 -0
- hotmemory/_rules.py +196 -0
- hotmemory/_writer.py +115 -0
- hotmemory/contract.py +645 -0
- hotmemory/filter.py +108 -0
- hotmemory/hotdata.py +1099 -0
- hotmemory/memory.py +267 -0
- hotmemory/openai.py +68 -0
- hotmemory/py.typed +0 -0
- hotmemory/record.py +145 -0
- hotmemory/store.py +178 -0
- hotmemory-0.1.0.dist-info/METADATA +263 -0
- hotmemory-0.1.0.dist-info/RECORD +16 -0
- hotmemory-0.1.0.dist-info/WHEEL +4 -0
- hotmemory-0.1.0.dist-info/licenses/LICENSE +21 -0
hotmemory/contract.py
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"""The memory contract: `Memory`, the operations an agent calls, over one `Store`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from dataclasses import dataclass, field, replace
|
|
9
|
+
from datetime import UTC, datetime, timedelta
|
|
10
|
+
from typing import Literal, TypeAlias
|
|
11
|
+
|
|
12
|
+
from hotmemory._chunks import chunk_document, chunk_text
|
|
13
|
+
from hotmemory._rules import (
|
|
14
|
+
check_prefix,
|
|
15
|
+
is_duplicate,
|
|
16
|
+
is_forgotten,
|
|
17
|
+
newest_first,
|
|
18
|
+
next_revision,
|
|
19
|
+
under_prefix,
|
|
20
|
+
)
|
|
21
|
+
from hotmemory._writer import check_count
|
|
22
|
+
from hotmemory.filter import Filter
|
|
23
|
+
from hotmemory.memory import utc_now
|
|
24
|
+
from hotmemory.record import (
|
|
25
|
+
KINDS,
|
|
26
|
+
JSONValue,
|
|
27
|
+
Kind,
|
|
28
|
+
Record,
|
|
29
|
+
check_key,
|
|
30
|
+
check_namespace,
|
|
31
|
+
normalize,
|
|
32
|
+
record_id,
|
|
33
|
+
)
|
|
34
|
+
from hotmemory.store import Clock, Hit, Store
|
|
35
|
+
|
|
36
|
+
SUBJECT_LENGTH = 64
|
|
37
|
+
HASH_LENGTH = 16
|
|
38
|
+
DEFAULT_BUDGET = 2000
|
|
39
|
+
DEFAULT_CHUNK_CHARS = 2000
|
|
40
|
+
FORGET_SCAN = 10_000
|
|
41
|
+
PROFILE_LIMIT = 100
|
|
42
|
+
COUNT_LIMIT = 1000
|
|
43
|
+
_NOT_IN_KEY = re.compile(r"[^A-Za-z0-9_-]")
|
|
44
|
+
_CHUNK_ID = re.compile(r"^(.+)-([0-9]{4})@([0-9]+)$")
|
|
45
|
+
RANGE_MARK = "\u2026"
|
|
46
|
+
RECALLED_KINDS: tuple[Kind, ...] = ("fact", "profile", "procedure")
|
|
47
|
+
EPISODES_LINE = "episodes:"
|
|
48
|
+
Episodes: TypeAlias = Literal["cited", "search"]
|
|
49
|
+
"""Which episodes `recall` adds after the facts: those the facts cite, or a search over them."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, kw_only=True)
|
|
53
|
+
class Fact:
|
|
54
|
+
"""One structured fact for `Memory.remember`: `kind`, `content`, and optional fields.
|
|
55
|
+
|
|
56
|
+
The fields are those of `Record` that a caller sets. The store checks them on write.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
kind: Kind
|
|
60
|
+
content: str
|
|
61
|
+
subject: str = ""
|
|
62
|
+
cues: tuple[str, ...] = ()
|
|
63
|
+
payload: dict[str, JSONValue] = field(default_factory=dict)
|
|
64
|
+
tags: tuple[str, ...] = ()
|
|
65
|
+
sources: tuple[str, ...] = ()
|
|
66
|
+
observed_at: datetime | None = None
|
|
67
|
+
valid_from: datetime | None = None
|
|
68
|
+
valid_until: datetime | None = None
|
|
69
|
+
forget_after: datetime | None = None
|
|
70
|
+
forget_reason: str = ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
Extractor: TypeAlias = Callable[[str, datetime | None, list[Record]], Sequence[Fact]]
|
|
74
|
+
"""Takes the text, `observed_at`, and the current records, and returns the facts to remember."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_valid_at(record: Record, when: datetime) -> bool:
|
|
78
|
+
"""Return True if `record` is valid at `when` by the as-of rule.
|
|
79
|
+
|
|
80
|
+
`valid_from` is null or at most `when`, and `valid_until` is null or after `when`.
|
|
81
|
+
"""
|
|
82
|
+
starts = record.valid_from is None or record.valid_from <= when
|
|
83
|
+
ends = record.valid_until is None or record.valid_until > when
|
|
84
|
+
return starts and ends
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_line(record: Record) -> str:
|
|
88
|
+
"""Return the line of `record` in a rendered block.
|
|
89
|
+
|
|
90
|
+
The line is `- <content> [sources: a, b] [valid: <from> to <until>]`. Each run of
|
|
91
|
+
whitespace in the content becomes one space. A null `valid_from` shows as `unknown`,
|
|
92
|
+
a null `valid_until` as `now`, and no sources as `none`. A source that is the id of a
|
|
93
|
+
record in the namespace of `record` shows as `<key>@<revision>`. A run of two or more
|
|
94
|
+
such sources, next to each other, with keys `<prefix>-<n>` of one prefix, n in four
|
|
95
|
+
digits counting up by 1, and one revision, shows as `<prefix>-<first>…<last>@<revision>`.
|
|
96
|
+
"""
|
|
97
|
+
content = " ".join(record.content.split())
|
|
98
|
+
sources = ", ".join(_shown_sources(record.sources, record.namespace))
|
|
99
|
+
sources = sources or "none"
|
|
100
|
+
start = _time(record.valid_from, "unknown")
|
|
101
|
+
end = _time(record.valid_until, "now")
|
|
102
|
+
return f"- {content} [sources: {sources}] [valid: {start} to {end}]"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def render_episode(record: Record) -> str:
|
|
106
|
+
"""Return the part of a rendered block for the episode `record`.
|
|
107
|
+
|
|
108
|
+
The part is a header `## <key>@<revision> [observed: <observed_at>]`, a newline, and
|
|
109
|
+
the content with its line breaks. A null `observed_at` shows as `unknown`.
|
|
110
|
+
"""
|
|
111
|
+
observed = _time(record.observed_at, "unknown")
|
|
112
|
+
return f"## {record.key}@{record.revision} [observed: {observed}]\n{record.content}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def fit_lines(lines: Sequence[str], budget: int) -> int:
|
|
116
|
+
"""Return how many of `lines`, from the first, fit in `budget` characters with newlines.
|
|
117
|
+
|
|
118
|
+
The count stops at the first line that would pass the budget.
|
|
119
|
+
"""
|
|
120
|
+
used = 0
|
|
121
|
+
for count, line in enumerate(lines):
|
|
122
|
+
used += len(line) + (1 if count else 0)
|
|
123
|
+
if used > budget:
|
|
124
|
+
return count
|
|
125
|
+
return len(lines)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def derive_key(subject: str, content: str) -> str:
|
|
129
|
+
"""Return the key of a fact with `subject` and `content`.
|
|
130
|
+
|
|
131
|
+
Each character of `subject` outside `[A-Za-z0-9_-]` becomes `-`, and the result keeps
|
|
132
|
+
its first 64 characters. An empty subject becomes `fact`. Then come `-` and the first
|
|
133
|
+
16 hex characters of the SHA-256 of the normalized content.
|
|
134
|
+
"""
|
|
135
|
+
label = _NOT_IN_KEY.sub("-", subject)[:SUBJECT_LENGTH] or "fact"
|
|
136
|
+
digest = hashlib.sha256(normalize(content).encode()).hexdigest()[:HASH_LENGTH]
|
|
137
|
+
return f"{label}-{digest}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Memory:
|
|
141
|
+
"""The memory contract over one `Store`. Each operation is described in docs/contracts.md.
|
|
142
|
+
|
|
143
|
+
`clock` gives the time that an operation needs and the caller does not pass.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, store: Store, *, clock: Clock = utc_now) -> None:
|
|
147
|
+
self._store = store
|
|
148
|
+
self._clock = clock
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def store(self) -> Store:
|
|
152
|
+
"""The store that this memory reads and writes."""
|
|
153
|
+
return self._store
|
|
154
|
+
|
|
155
|
+
def remember(self, facts: Sequence[Fact], scope: Sequence[str], actor: str = "") -> list[str]:
|
|
156
|
+
"""Write each fact under `scope` with a derived key, and return the ids, in order.
|
|
157
|
+
|
|
158
|
+
A fact whose content equals the current revision of its key writes nothing and
|
|
159
|
+
returns the current id. The facts go through one writer, so if one fact is
|
|
160
|
+
refused, none is written.
|
|
161
|
+
"""
|
|
162
|
+
namespace = check_namespace(scope)
|
|
163
|
+
if not facts:
|
|
164
|
+
return []
|
|
165
|
+
with self._store.writer(max_rows=len(facts), interval=timedelta.max) as writer:
|
|
166
|
+
for fact in facts:
|
|
167
|
+
writer.put(
|
|
168
|
+
namespace,
|
|
169
|
+
derive_key(fact.subject, fact.content),
|
|
170
|
+
kind=fact.kind,
|
|
171
|
+
content=fact.content,
|
|
172
|
+
subject=fact.subject,
|
|
173
|
+
cues=fact.cues,
|
|
174
|
+
payload=fact.payload,
|
|
175
|
+
tags=fact.tags,
|
|
176
|
+
sources=fact.sources,
|
|
177
|
+
actor=actor,
|
|
178
|
+
observed_at=fact.observed_at,
|
|
179
|
+
valid_from=fact.valid_from,
|
|
180
|
+
valid_until=fact.valid_until,
|
|
181
|
+
forget_after=fact.forget_after,
|
|
182
|
+
forget_reason=fact.forget_reason,
|
|
183
|
+
)
|
|
184
|
+
return writer.flushed
|
|
185
|
+
|
|
186
|
+
def recall(
|
|
187
|
+
self,
|
|
188
|
+
query: str,
|
|
189
|
+
scopes: Sequence[Sequence[str]],
|
|
190
|
+
as_of: datetime | None = None,
|
|
191
|
+
budget: int = DEFAULT_BUDGET,
|
|
192
|
+
k: int = 10,
|
|
193
|
+
episodes: Episodes | None = None,
|
|
194
|
+
) -> tuple[list[Record], str]:
|
|
195
|
+
"""Return the top current records for `query` under `scopes`, and their block.
|
|
196
|
+
|
|
197
|
+
The search returns up to `k` current records of the kinds in RECALLED_KINDS. With
|
|
198
|
+
`as_of`, only the ones valid at `as_of` remain. The block holds one line for each
|
|
199
|
+
record, in order, and stops before the first line that would pass `budget`
|
|
200
|
+
characters. With `episodes`, the block then holds the episodes that fit in what is
|
|
201
|
+
left, after a line `episodes:`: those that the facts of the block cite with
|
|
202
|
+
`"cited"`, or the top `k` episodes for `query` with `"search"`. The list holds the
|
|
203
|
+
records of the block, the facts first.
|
|
204
|
+
"""
|
|
205
|
+
check_count("budget", budget)
|
|
206
|
+
if episodes not in (None, "cited", "search"):
|
|
207
|
+
raise ValueError(f"episodes must be None, 'cited', or 'search', got {episodes!r}")
|
|
208
|
+
recalled = Filter(kind=RECALLED_KINDS)
|
|
209
|
+
records = [hit.record for hit in self._store.search(query, scopes, recalled, k=k)]
|
|
210
|
+
if as_of is not None:
|
|
211
|
+
records = [record for record in records if is_valid_at(record, as_of)]
|
|
212
|
+
lines = [render_line(record) for record in records]
|
|
213
|
+
count = fit_lines(lines, budget)
|
|
214
|
+
records, lines = records[:count], lines[:count]
|
|
215
|
+
if episodes is None:
|
|
216
|
+
return records, "\n".join(lines)
|
|
217
|
+
if episodes == "cited":
|
|
218
|
+
found = self._cited(records, _scopes(scopes))
|
|
219
|
+
else:
|
|
220
|
+
kind = Filter(kind="episode")
|
|
221
|
+
found = [hit.record for hit in self._store.search(query, scopes, kind, k=k)]
|
|
222
|
+
if as_of is not None:
|
|
223
|
+
found = [episode for episode in found if is_valid_at(episode, as_of)]
|
|
224
|
+
parts: list[str] = []
|
|
225
|
+
shown: list[Record] = []
|
|
226
|
+
for episode in found:
|
|
227
|
+
more = [*parts, render_episode(episode)]
|
|
228
|
+
if len("\n".join([*lines, EPISODES_LINE, *more])) > budget:
|
|
229
|
+
break
|
|
230
|
+
parts, shown = more, [*shown, episode]
|
|
231
|
+
if not shown:
|
|
232
|
+
return records, "\n".join(lines)
|
|
233
|
+
return [*records, *shown], "\n".join([*lines, EPISODES_LINE, *parts])
|
|
234
|
+
|
|
235
|
+
def _cited(self, records: Sequence[Record], allowed: Sequence[tuple[str, ...]]) -> list[Record]:
|
|
236
|
+
"""Return the episodes that the sources of `records` name, in order, without repeats.
|
|
237
|
+
|
|
238
|
+
A source that is not a record id, is outside `allowed`, or names no episode is
|
|
239
|
+
skipped, and so is an episode past its `forget_after`. A superseded revision stays.
|
|
240
|
+
"""
|
|
241
|
+
cited: dict[str, Record | None] = {}
|
|
242
|
+
for record in records:
|
|
243
|
+
for source in record.sources:
|
|
244
|
+
if source in cited:
|
|
245
|
+
continue
|
|
246
|
+
cited[source] = None
|
|
247
|
+
try:
|
|
248
|
+
namespace, key = _slot(source)
|
|
249
|
+
except ValueError:
|
|
250
|
+
continue
|
|
251
|
+
if not any(under_prefix(namespace, scope) for scope in allowed):
|
|
252
|
+
continue
|
|
253
|
+
revision = int(source.rpartition("@")[2])
|
|
254
|
+
found = self._store.get(namespace, key, revision)
|
|
255
|
+
if (
|
|
256
|
+
found is not None
|
|
257
|
+
and found.kind == "episode"
|
|
258
|
+
and not is_forgotten(found, self._clock())
|
|
259
|
+
):
|
|
260
|
+
cited[source] = found
|
|
261
|
+
return [episode for episode in cited.values() if episode is not None]
|
|
262
|
+
|
|
263
|
+
def candidates(self, fact: Fact, scopes: Sequence[Sequence[str]], k: int = 5) -> list[Hit]:
|
|
264
|
+
"""Return up to `k` current records near the content of `fact`, closest first.
|
|
265
|
+
|
|
266
|
+
Each hit carries the cosine distance to the content. Nothing is decided or written.
|
|
267
|
+
"""
|
|
268
|
+
hits = self._store.search(fact.content, scopes, k=k)
|
|
269
|
+
return sorted(hits, key=lambda hit: hit.distance if hit.distance is not None else 0.0)
|
|
270
|
+
|
|
271
|
+
def supersede(
|
|
272
|
+
self,
|
|
273
|
+
scope: Sequence[str],
|
|
274
|
+
key: str,
|
|
275
|
+
fact: Fact,
|
|
276
|
+
valid_from: datetime | None = None,
|
|
277
|
+
actor: str = "",
|
|
278
|
+
) -> str:
|
|
279
|
+
"""Close the current revision of `key` and write `fact` as its next revision.
|
|
280
|
+
|
|
281
|
+
The new `valid_from` is `valid_from`, else that of `fact`, else the clock's time.
|
|
282
|
+
The old revision gets `valid_until` set to it and `expired_at` set to now. Raises
|
|
283
|
+
ValueError if the key has no current revision, or if the old `valid_from` is later
|
|
284
|
+
than the new one. Returns the id of the current revision after the call.
|
|
285
|
+
"""
|
|
286
|
+
if self._store.get(scope, key) is None:
|
|
287
|
+
raise ValueError(f"key {key!r} has no current revision to supersede")
|
|
288
|
+
start = next((t for t in (valid_from, fact.valid_from) if t is not None), self._clock())
|
|
289
|
+
return self._store.put(
|
|
290
|
+
scope,
|
|
291
|
+
key,
|
|
292
|
+
kind=fact.kind,
|
|
293
|
+
content=fact.content,
|
|
294
|
+
subject=fact.subject,
|
|
295
|
+
cues=fact.cues,
|
|
296
|
+
payload=fact.payload,
|
|
297
|
+
tags=fact.tags,
|
|
298
|
+
sources=fact.sources,
|
|
299
|
+
actor=actor,
|
|
300
|
+
observed_at=fact.observed_at,
|
|
301
|
+
valid_from=start,
|
|
302
|
+
valid_until=fact.valid_until,
|
|
303
|
+
forget_after=fact.forget_after,
|
|
304
|
+
forget_reason=fact.forget_reason,
|
|
305
|
+
close_previous=True,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def forget(
|
|
309
|
+
self,
|
|
310
|
+
scopes: Sequence[Sequence[str]],
|
|
311
|
+
*,
|
|
312
|
+
ids: Sequence[str] | None = None,
|
|
313
|
+
horizon: datetime | None = None,
|
|
314
|
+
) -> list[str]:
|
|
315
|
+
"""Delete every revision of the named keys, or of the keys due before `horizon`.
|
|
316
|
+
|
|
317
|
+
Pass exactly one of `ids` and `horizon`. Each id names its key. With `horizon`,
|
|
318
|
+
a key is due when its current revision has a `forget_after` before `horizon`.
|
|
319
|
+
The candidates come from `list`, up to FORGET_SCAN records under each scope, so a
|
|
320
|
+
record already past its `forget_after` is left to `Store.sweep`. Every id is
|
|
321
|
+
checked before the first delete. Raises ValueError, and deletes nothing, if an id
|
|
322
|
+
is not a valid record id or is outside `scopes`. Returns the ids of the deleted
|
|
323
|
+
revisions, sorted.
|
|
324
|
+
"""
|
|
325
|
+
if (ids is None) == (horizon is None):
|
|
326
|
+
raise ValueError("pass exactly one of ids and horizon")
|
|
327
|
+
allowed = _scopes(scopes)
|
|
328
|
+
slots: dict[tuple[tuple[str, ...], str], None] = {}
|
|
329
|
+
if ids is not None:
|
|
330
|
+
if isinstance(ids, str):
|
|
331
|
+
raise TypeError("ids must be a sequence of ids, not a string")
|
|
332
|
+
for record_id in ids:
|
|
333
|
+
namespace, key = _slot(record_id)
|
|
334
|
+
if not any(under_prefix(namespace, prefix) for prefix in allowed):
|
|
335
|
+
raise ValueError(f"id {record_id!r} is outside the allowed scopes")
|
|
336
|
+
slots[(namespace, key)] = None
|
|
337
|
+
elif horizon is not None:
|
|
338
|
+
for prefix in allowed:
|
|
339
|
+
for record in self._store.list(prefix, limit=FORGET_SCAN):
|
|
340
|
+
if record.forget_after is not None and record.forget_after < horizon:
|
|
341
|
+
slots[(record.namespace, record.key)] = None
|
|
342
|
+
deleted: list[str] = []
|
|
343
|
+
for namespace, key in slots:
|
|
344
|
+
deleted.extend(record.id for record in self._store.history(namespace, key))
|
|
345
|
+
self._store.delete(namespace, key)
|
|
346
|
+
return sorted(deleted)
|
|
347
|
+
|
|
348
|
+
def profile(
|
|
349
|
+
self, subject: str, scopes: Sequence[Sequence[str]], budget: int = DEFAULT_BUDGET
|
|
350
|
+
) -> tuple[list[Record], str]:
|
|
351
|
+
"""Return the current records of `subject` under `scopes`, and their block.
|
|
352
|
+
|
|
353
|
+
It reads only the kinds in RECALLED_KINDS, so it never returns an episode. The
|
|
354
|
+
block groups the records by kind, in the order of `Kind`, newest first in each
|
|
355
|
+
group. A line `<kind>:` starts each group. The block ends with `namespaces:` and
|
|
356
|
+
one line `- <namespace>: <count>` for each namespace under `scopes`. The count is
|
|
357
|
+
the number of current records of those kinds in that namespace, read with `list`
|
|
358
|
+
up to COUNT_LIMIT, and shows a `+` when `list` reached the limit. `list` matches
|
|
359
|
+
the namespace as a prefix, so the records of its sub-namespaces share that window,
|
|
360
|
+
and a `+` count is a lower bound. The namespace lines
|
|
361
|
+
take the budget first, and the record lines fill what is left, by whole lines.
|
|
362
|
+
The list holds the records of the block, in block order.
|
|
363
|
+
"""
|
|
364
|
+
check_count("budget", budget)
|
|
365
|
+
allowed = _scopes(scopes)
|
|
366
|
+
found: dict[str, Record] = {}
|
|
367
|
+
for prefix in allowed:
|
|
368
|
+
where = Filter(subject=subject, kind=RECALLED_KINDS)
|
|
369
|
+
for record in self._store.list(prefix, where, limit=PROFILE_LIMIT):
|
|
370
|
+
found[record.id] = record
|
|
371
|
+
body: list[tuple[str, Record | None]] = []
|
|
372
|
+
for kind in KINDS:
|
|
373
|
+
group = sorted((r for r in found.values() if r.kind == kind), key=newest_first)
|
|
374
|
+
if group:
|
|
375
|
+
body.append((f"{kind}:", None))
|
|
376
|
+
body.extend((render_line(record), record) for record in group)
|
|
377
|
+
tail = ["namespaces:", *self._counts(allowed)]
|
|
378
|
+
tail = tail[: fit_lines(tail, budget)]
|
|
379
|
+
if len(tail) < 2:
|
|
380
|
+
tail = []
|
|
381
|
+
left = budget - len("\n".join(tail)) - (1 if tail else 0)
|
|
382
|
+
kept = body[: fit_lines([line for line, _ in body], left)]
|
|
383
|
+
while kept and kept[-1][1] is None:
|
|
384
|
+
kept.pop()
|
|
385
|
+
lines = [line for line, _ in kept] + tail
|
|
386
|
+
return [record for _, record in kept if record is not None], "\n".join(lines)
|
|
387
|
+
|
|
388
|
+
def _counts(self, allowed: Sequence[tuple[str, ...]]) -> list[str]:
|
|
389
|
+
"""Return one line `- <namespace>: <count>` for each namespace under `allowed`."""
|
|
390
|
+
namespaces = sorted(
|
|
391
|
+
{ns for prefix in allowed for ns in self._store.list_namespaces(prefix)}
|
|
392
|
+
)
|
|
393
|
+
lines = []
|
|
394
|
+
for namespace in namespaces:
|
|
395
|
+
records = self._store.list(namespace, Filter(kind=RECALLED_KINDS), limit=COUNT_LIMIT)
|
|
396
|
+
count = sum(1 for record in records if record.namespace == namespace)
|
|
397
|
+
more = "+" if len(records) == COUNT_LIMIT else ""
|
|
398
|
+
lines.append(f"- {'/'.join(namespace)}: {count}{more}")
|
|
399
|
+
return lines
|
|
400
|
+
|
|
401
|
+
def capture(
|
|
402
|
+
self,
|
|
403
|
+
text: str,
|
|
404
|
+
scope: Sequence[str],
|
|
405
|
+
extractor: Extractor,
|
|
406
|
+
actor: str = "",
|
|
407
|
+
observed_at: datetime | None = None,
|
|
408
|
+
episode: str | None = None,
|
|
409
|
+
chunk_chars: int | None = None,
|
|
410
|
+
) -> list[str]:
|
|
411
|
+
"""Call `extractor` on `text` and remember the facts that it returns.
|
|
412
|
+
|
|
413
|
+
The extractor gets `text`, `observed_at`, and the records that `recall` returns for
|
|
414
|
+
`text` under `scope`. A fact with no `observed_at` gets `observed_at`. With
|
|
415
|
+
`episode`, a key, `text` is also stored as an episode under that key, written after
|
|
416
|
+
the facts, and each fact gets the id of the episode added to its `sources`. If that
|
|
417
|
+
episode is current with the same content, the extractor is not called and nothing
|
|
418
|
+
is written. With `chunk_chars` as well, `text` is cut at blank lines into chunks of
|
|
419
|
+
at most `chunk_chars` characters, and chunk n is stored as an episode under the key
|
|
420
|
+
`<episode>-<n>`, with n from 1 in four digits. The extractor still reads the whole
|
|
421
|
+
text, and each fact gets the id of every chunk added to its `sources`, in chunk
|
|
422
|
+
order. Only the chunks that changed are written, in one flush after the facts, and
|
|
423
|
+
if none changed, the extractor is not called. Raises ValueError if `episode` or a
|
|
424
|
+
chunk key is not a valid key or holds another kind, if `text` is blank, or if
|
|
425
|
+
`chunk_chars` is given without `episode` or is below 1. Returns the ids that
|
|
426
|
+
`remember` returns.
|
|
427
|
+
"""
|
|
428
|
+
if episode is None:
|
|
429
|
+
if chunk_chars is not None:
|
|
430
|
+
raise ValueError("chunk_chars needs episode")
|
|
431
|
+
return self._extract(text, scope, extractor, actor, observed_at, ())
|
|
432
|
+
check_key(episode)
|
|
433
|
+
if not text.strip():
|
|
434
|
+
raise ValueError("text must not be blank when capture stores an episode")
|
|
435
|
+
namespace = check_namespace(scope)
|
|
436
|
+
if chunk_chars is not None:
|
|
437
|
+
return self._chunks(
|
|
438
|
+
namespace, episode, text, extractor, actor, observed_at, chunk_chars
|
|
439
|
+
)
|
|
440
|
+
_, facts = self._episode(namespace, episode, text, extractor, actor, observed_at)
|
|
441
|
+
return facts
|
|
442
|
+
|
|
443
|
+
def load(
|
|
444
|
+
self,
|
|
445
|
+
document: str,
|
|
446
|
+
text: str,
|
|
447
|
+
scope: Sequence[str],
|
|
448
|
+
extractor: Extractor,
|
|
449
|
+
actor: str = "",
|
|
450
|
+
observed_at: datetime | None = None,
|
|
451
|
+
chunk_chars: int = DEFAULT_CHUNK_CHARS,
|
|
452
|
+
) -> tuple[list[str], list[str]]:
|
|
453
|
+
"""Cut the Markdown `text` into chunks, store each as an episode, and extract facts.
|
|
454
|
+
|
|
455
|
+
The chunks are those of docs/contracts.md, with bodies of at most `chunk_chars`
|
|
456
|
+
characters. Chunk n is stored as an episode under the key `<document>-<n>`, with n
|
|
457
|
+
from 1 in four digits. For each chunk, the extractor runs as in `capture`, on the
|
|
458
|
+
chunk with its heading prefix, and each fact gets the id of the episode added to
|
|
459
|
+
its `sources`. The facts of a chunk are written before its episode. A chunk whose
|
|
460
|
+
episode is current with the same content is skipped, and the extractor is not
|
|
461
|
+
called for it. Raises ValueError if `document` is not a valid key. Returns the ids
|
|
462
|
+
of the episodes, in order, and the ids of the facts that the extractor returned.
|
|
463
|
+
"""
|
|
464
|
+
check_key(document)
|
|
465
|
+
check_count("chunk_chars", chunk_chars)
|
|
466
|
+
if chunk_chars < 1:
|
|
467
|
+
raise ValueError("chunk_chars must be 1 or more")
|
|
468
|
+
namespace = check_namespace(scope)
|
|
469
|
+
episodes: list[str] = []
|
|
470
|
+
facts: list[str] = []
|
|
471
|
+
for number, chunk in enumerate(chunk_document(text, chunk_chars), start=1):
|
|
472
|
+
key = f"{document}-{number:04d}"
|
|
473
|
+
check_key(key)
|
|
474
|
+
episode, written = self._episode(namespace, key, chunk, extractor, actor, observed_at)
|
|
475
|
+
episodes.append(episode)
|
|
476
|
+
facts.extend(written)
|
|
477
|
+
return episodes, facts
|
|
478
|
+
|
|
479
|
+
def _episode(
|
|
480
|
+
self,
|
|
481
|
+
namespace: tuple[str, ...],
|
|
482
|
+
key: str,
|
|
483
|
+
text: str,
|
|
484
|
+
extractor: Extractor,
|
|
485
|
+
actor: str,
|
|
486
|
+
observed_at: datetime | None,
|
|
487
|
+
) -> tuple[str, list[str]]:
|
|
488
|
+
"""Extract the facts of `text`, then store `text` as the episode under `key`.
|
|
489
|
+
|
|
490
|
+
Each fact gets the id of the episode in its `sources`. If the episode is current
|
|
491
|
+
with the same content, nothing is called or written. Returns the id of the episode
|
|
492
|
+
and the ids of the facts.
|
|
493
|
+
"""
|
|
494
|
+
current = self._store.get(namespace, key)
|
|
495
|
+
if current is not None and current.kind != "episode":
|
|
496
|
+
raise ValueError(f"key {key!r} holds kind {current.kind!r}, not an episode")
|
|
497
|
+
if current is not None and is_duplicate(current, text, self._clock()):
|
|
498
|
+
return current.id, []
|
|
499
|
+
episode = record_id(namespace, key, next_revision(current))
|
|
500
|
+
facts = self._extract(text, namespace, extractor, actor, observed_at, (episode,))
|
|
501
|
+
written = self._store.put(
|
|
502
|
+
namespace, key, kind="episode", content=text, actor=actor, observed_at=observed_at
|
|
503
|
+
)
|
|
504
|
+
if written != episode:
|
|
505
|
+
raise RuntimeError(f"episode {key!r} was written as {written!r}, not {episode!r}")
|
|
506
|
+
return written, facts
|
|
507
|
+
|
|
508
|
+
def _chunks(
|
|
509
|
+
self,
|
|
510
|
+
namespace: tuple[str, ...],
|
|
511
|
+
episode: str,
|
|
512
|
+
text: str,
|
|
513
|
+
extractor: Extractor,
|
|
514
|
+
actor: str,
|
|
515
|
+
observed_at: datetime | None,
|
|
516
|
+
chunk_chars: int,
|
|
517
|
+
) -> list[str]:
|
|
518
|
+
"""Extract the facts of `text`, then store its changed chunks as episodes.
|
|
519
|
+
|
|
520
|
+
Each fact gets the id of every chunk in its `sources`. If every chunk is current
|
|
521
|
+
with the same content, nothing is called or written. Returns the ids of the facts.
|
|
522
|
+
"""
|
|
523
|
+
check_count("chunk_chars", chunk_chars)
|
|
524
|
+
if chunk_chars < 1:
|
|
525
|
+
raise ValueError("chunk_chars must be 1 or more")
|
|
526
|
+
now = self._clock()
|
|
527
|
+
ids: list[str] = []
|
|
528
|
+
changed: list[tuple[str, str, str]] = []
|
|
529
|
+
for number, chunk in enumerate(chunk_text(text, chunk_chars), start=1):
|
|
530
|
+
key = f"{episode}-{number:04d}"
|
|
531
|
+
check_key(key)
|
|
532
|
+
current = self._store.get(namespace, key)
|
|
533
|
+
if current is not None and current.kind != "episode":
|
|
534
|
+
raise ValueError(f"key {key!r} holds kind {current.kind!r}, not an episode")
|
|
535
|
+
if current is not None and is_duplicate(current, chunk, now):
|
|
536
|
+
ids.append(current.id)
|
|
537
|
+
continue
|
|
538
|
+
ids.append(record_id(namespace, key, next_revision(current)))
|
|
539
|
+
changed.append((key, chunk, ids[-1]))
|
|
540
|
+
if not changed:
|
|
541
|
+
return []
|
|
542
|
+
facts = self._extract(text, namespace, extractor, actor, observed_at, tuple(ids))
|
|
543
|
+
with self._store.writer(max_rows=len(changed), interval=timedelta.max) as writer:
|
|
544
|
+
for key, chunk, _ in changed:
|
|
545
|
+
writer.put(
|
|
546
|
+
namespace,
|
|
547
|
+
key,
|
|
548
|
+
kind="episode",
|
|
549
|
+
content=chunk,
|
|
550
|
+
actor=actor,
|
|
551
|
+
observed_at=observed_at,
|
|
552
|
+
)
|
|
553
|
+
expected = [chunk_id for _, _, chunk_id in changed]
|
|
554
|
+
if writer.flushed != expected:
|
|
555
|
+
raise RuntimeError(f"chunks were written as {writer.flushed!r}, not {expected!r}")
|
|
556
|
+
return facts
|
|
557
|
+
|
|
558
|
+
def _extract(
|
|
559
|
+
self,
|
|
560
|
+
text: str,
|
|
561
|
+
scope: Sequence[str],
|
|
562
|
+
extractor: Extractor,
|
|
563
|
+
actor: str,
|
|
564
|
+
observed_at: datetime | None,
|
|
565
|
+
sources: tuple[str, ...],
|
|
566
|
+
) -> list[str]:
|
|
567
|
+
"""Call `extractor` as `capture` does, add `sources` to each fact, and remember them."""
|
|
568
|
+
current, _ = self.recall(text, [scope])
|
|
569
|
+
facts = [
|
|
570
|
+
replace(
|
|
571
|
+
fact,
|
|
572
|
+
observed_at=observed_at if fact.observed_at is None else fact.observed_at,
|
|
573
|
+
sources=tuple(dict.fromkeys((*fact.sources, *sources))),
|
|
574
|
+
)
|
|
575
|
+
for fact in extractor(text, observed_at, current)
|
|
576
|
+
]
|
|
577
|
+
return self.remember(facts, scope, actor)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _scopes(scopes: Sequence[Sequence[str]]) -> list[tuple[str, ...]]:
|
|
581
|
+
"""Return each scope in `scopes` as a tuple of labels."""
|
|
582
|
+
if isinstance(scopes, str):
|
|
583
|
+
raise TypeError("scopes must be a sequence of scopes, not a string")
|
|
584
|
+
return [check_prefix(scope) for scope in scopes]
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _slot(record_id: str) -> tuple[tuple[str, ...], str]:
|
|
588
|
+
"""Return the namespace and the key that a record id names.
|
|
589
|
+
|
|
590
|
+
Raises ValueError if `record_id` is not `namespace/key@revision` with a valid
|
|
591
|
+
namespace, a valid key, and a revision of digits.
|
|
592
|
+
"""
|
|
593
|
+
path, separator, revision = record_id.rpartition("@")
|
|
594
|
+
labels = tuple(path.split("/"))
|
|
595
|
+
if not separator or len(labels) < 2 or not revision.isdigit():
|
|
596
|
+
raise ValueError(f"not a record id: {record_id!r}")
|
|
597
|
+
namespace, key = check_namespace(labels[:-1]), labels[-1]
|
|
598
|
+
check_key(key)
|
|
599
|
+
return namespace, key
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _shown_sources(sources: Sequence[str], namespace: tuple[str, ...]) -> list[str]:
|
|
603
|
+
"""Return `sources` as the block shows them, with each run of chunks shown as a range."""
|
|
604
|
+
result: list[str] = []
|
|
605
|
+
run: list[tuple[str, int, str]] = []
|
|
606
|
+
|
|
607
|
+
def close() -> None:
|
|
608
|
+
if len(run) > 1:
|
|
609
|
+
prefix, first, revision = run[0]
|
|
610
|
+
result.append(f"{prefix}-{first:04d}{RANGE_MARK}{run[-1][1]:04d}@{revision}")
|
|
611
|
+
elif run:
|
|
612
|
+
prefix, number, revision = run[0]
|
|
613
|
+
result.append(f"{prefix}-{number:04d}@{revision}")
|
|
614
|
+
run.clear()
|
|
615
|
+
|
|
616
|
+
for source in sources:
|
|
617
|
+
text = _shown_source(source, namespace)
|
|
618
|
+
found = _CHUNK_ID.match(text) if text != source else None
|
|
619
|
+
if found is None:
|
|
620
|
+
close()
|
|
621
|
+
result.append(text)
|
|
622
|
+
continue
|
|
623
|
+
prefix, number, revision = found.group(1), int(found.group(2)), found.group(3)
|
|
624
|
+
if run and (run[-1][0], run[-1][1] + 1, run[-1][2]) != (prefix, number, revision):
|
|
625
|
+
close()
|
|
626
|
+
run.append((prefix, number, revision))
|
|
627
|
+
close()
|
|
628
|
+
return result
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _shown_source(source: str, namespace: tuple[str, ...]) -> str:
|
|
632
|
+
"""Return `source` as the block shows it: `<key>@<revision>` for a record in `namespace`."""
|
|
633
|
+
prefix = "/".join(namespace) + "/"
|
|
634
|
+
rest = source.removeprefix(prefix)
|
|
635
|
+
key, separator, revision = rest.rpartition("@")
|
|
636
|
+
if rest == source or not separator or not key or "/" in key or not revision.isdigit():
|
|
637
|
+
return source
|
|
638
|
+
return rest
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _time(value: datetime | None, missing: str) -> str:
|
|
642
|
+
"""Return `value` in ISO 8601 in UTC to the second, or `missing` if it is None."""
|
|
643
|
+
if value is None:
|
|
644
|
+
return missing
|
|
645
|
+
return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|