abstractnote 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.
- abstractnote/__init__.py +18 -0
- abstractnote/cli.py +240 -0
- abstractnote/flows/__init__.py +2 -0
- abstractnote/flows/build.py +970 -0
- abstractnote/flows/bundle.py +62 -0
- abstractnote/flows/contract.py +88 -0
- abstractnote/flows/graph.py +540 -0
- abstractnote/flows/json/note-capture.json +2280 -0
- abstractnote/flows/json/note-drain.json +2012 -0
- abstractnote/flows/json/note-mine.json +2392 -0
- abstractnote/flows/json/note-process.json +1853 -0
- abstractnote/flows/json/note-recall.json +1876 -0
- abstractnote/flows/json/note-reflect.json +2057 -0
- abstractnote/flows/prompts_fallback.py +119 -0
- abstractnote/flows/runner.py +320 -0
- abstractnote/flows/schemas_fallback.py +175 -0
- abstractnote/gateway/__init__.py +24 -0
- abstractnote/gateway/appspec.py +10 -0
- abstractnote/gateway/router.py +571 -0
- abstractnote/gateway/tools.py +74 -0
- abstractnote/ids.py +177 -0
- abstractnote/importers/__init__.py +142 -0
- abstractnote/importers/__main__.py +3 -0
- abstractnote/importers/agora.py +203 -0
- abstractnote/importers/claude_sessions.py +415 -0
- abstractnote/importers/cli.py +113 -0
- abstractnote/importers/files.py +72 -0
- abstractnote/importers/markdown.py +165 -0
- abstractnote/ingest/__init__.py +7 -0
- abstractnote/ingest/blobs.py +47 -0
- abstractnote/ingest/capture.py +265 -0
- abstractnote/ingest/extract.py +118 -0
- abstractnote/ingest/normalise.py +95 -0
- abstractnote/model.py +229 -0
- abstractnote/process/__init__.py +5 -0
- abstractnote/process/context.py +218 -0
- abstractnote/process/merge.py +60 -0
- abstractnote/process/prompts/__init__.py +39 -0
- abstractnote/process/prompts/extract.md +30 -0
- abstractnote/process/prompts/link.md +32 -0
- abstractnote/process/prompts/mine_dossier.md +13 -0
- abstractnote/process/prompts/mine_findings.md +13 -0
- abstractnote/process/prompts/mine_plan.md +13 -0
- abstractnote/process/prompts/quick_filing.md +23 -0
- abstractnote/process/prompts/reflect.md +10 -0
- abstractnote/process/prompts/synthesize.md +17 -0
- abstractnote/process/resolve.py +96 -0
- abstractnote/process/revise.py +410 -0
- abstractnote/process/schemas.py +377 -0
- abstractnote/process/vocabulary.py +68 -0
- abstractnote/recall/__init__.py +1 -0
- abstractnote/recall/changes.py +191 -0
- abstractnote/recall/expand.py +379 -0
- abstractnote/recall/insights.py +62 -0
- abstractnote/recall/reflect.py +202 -0
- abstractnote/recall/review.py +191 -0
- abstractnote/recall/search.py +205 -0
- abstractnote/recall/shapes.py +52 -0
- abstractnote/recall/then_now.py +100 -0
- abstractnote/store/__init__.py +12 -0
- abstractnote/store/archive.py +166 -0
- abstractnote/store/embeddings.py +275 -0
- abstractnote/store/kg.py +219 -0
- abstractnote/store/notebook.py +829 -0
- abstractnote/store/notes.py +1190 -0
- abstractnote/store/queue.py +159 -0
- abstractnote/store/schema.py +348 -0
- abstractnote/store/vector_index.py +88 -0
- abstractnote/tools/__init__.py +215 -0
- abstractnote/tools/capture.py +121 -0
- abstractnote/tools/context.py +19 -0
- abstractnote/tools/process.py +22 -0
- abstractnote/tools/queue.py +23 -0
- abstractnote/tools/recall.py +64 -0
- abstractnote-0.1.0.dist-info/METADATA +145 -0
- abstractnote-0.1.0.dist-info/RECORD +80 -0
- abstractnote-0.1.0.dist-info/WHEEL +5 -0
- abstractnote-0.1.0.dist-info/entry_points.txt +2 -0
- abstractnote-0.1.0.dist-info/licenses/LICENSE +21 -0
- abstractnote-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
"""``Notebook``: one directory, two stores, one revision log.
|
|
2
|
+
|
|
3
|
+
Layout of a notebook directory (``<plane>/abstractnote/`` inside a gateway)::
|
|
4
|
+
|
|
5
|
+
note.sqlite3 the note index (truth tables + projections)
|
|
6
|
+
kg.sqlite3 AbstractMemory SQLiteTripleStore + SQLiteJournal (claims)
|
|
7
|
+
blobs/ content-addressed attachment bytes
|
|
8
|
+
extracted/ text extracted from attachments
|
|
9
|
+
|
|
10
|
+
Revisions are the truth. :meth:`Notebook.apply_revision` is the only path that
|
|
11
|
+
changes entities, claims, relations, topics and tasks, and it is idempotent.
|
|
12
|
+
Write order: revision row (intent) -> index projection (one transaction that
|
|
13
|
+
sets ``applied_index``) -> vectors -> AbstractMemory projection -> ``applied_kg``.
|
|
14
|
+
:meth:`Notebook.recover` replays revisions whose flags are not both set.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import sqlite3
|
|
22
|
+
import threading
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence
|
|
25
|
+
|
|
26
|
+
from .. import __version__, ids
|
|
27
|
+
from ..model import Revision, RevisionOp
|
|
28
|
+
from .embeddings import (
|
|
29
|
+
HashingEmbedder,
|
|
30
|
+
TextEmbedder,
|
|
31
|
+
VectorCache,
|
|
32
|
+
embedder_for_pin,
|
|
33
|
+
embedder_model_id,
|
|
34
|
+
vector_key,
|
|
35
|
+
)
|
|
36
|
+
from .kg import ClaimStore, claim_assertion, kinds_from_index
|
|
37
|
+
from .notes import NoteStore, _j, _uj, entity_text, note_vector_text
|
|
38
|
+
|
|
39
|
+
NOTE_DB = "note.sqlite3"
|
|
40
|
+
KG_DB = "kg.sqlite3"
|
|
41
|
+
NOTE_EMBED_BUDGET = 8000 # characters of a note handed to the embedder (the stored note is never cut)
|
|
42
|
+
|
|
43
|
+
DEFAULT_SETTINGS: Dict[str, Any] = {
|
|
44
|
+
"filing_model": {"provider": None, "model": None},
|
|
45
|
+
"answering_model": {"provider": None, "model": None},
|
|
46
|
+
"reflect_schedule": {"every": "7d"},
|
|
47
|
+
# The processor automation's throughput: up to ``max_items`` notes per drain, one drain ``every``.
|
|
48
|
+
# Equal to abstractnote.flows.build DRAIN_DEFAULTS / the note-drain trigger (a test keeps them equal).
|
|
49
|
+
"processor_schedule": {"every": "2m", "max_items": 20},
|
|
50
|
+
"process_inline_default": False,
|
|
51
|
+
}
|
|
52
|
+
PROCESSOR_MAX_ITEMS = 200
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _check_processor_schedule(value: Any) -> Dict[str, Any]:
|
|
56
|
+
"""``{every: "<n>s|m|h|d", max_items: 1..200}`` or a plain sentence (ValueError)."""
|
|
57
|
+
if not isinstance(value, dict) or set(value) - {"every", "max_items"}:
|
|
58
|
+
raise ValueError("The processor schedule is {every, max_items}.")
|
|
59
|
+
every = str(value.get("every", DEFAULT_SETTINGS["processor_schedule"]["every"])).strip()
|
|
60
|
+
if not (every[:-1].isdigit() and every[-1:] in ("s", "m", "h", "d") and int(every[:-1]) > 0):
|
|
61
|
+
raise ValueError("The processor interval must be a number with s, m, h or d, for example 2m.")
|
|
62
|
+
max_items = value.get("max_items", DEFAULT_SETTINGS["processor_schedule"]["max_items"])
|
|
63
|
+
if isinstance(max_items, bool) or not isinstance(max_items, int) or not 1 <= max_items <= PROCESSOR_MAX_ITEMS:
|
|
64
|
+
raise ValueError(f"The processor takes between 1 and {PROCESSOR_MAX_ITEMS} notes at a time.")
|
|
65
|
+
return {"every": every, "max_items": max_items}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _Keys:
|
|
69
|
+
"""Vector keys for the index projection of one revision."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, cache: VectorCache, rev: Dict[str, Any]) -> None:
|
|
72
|
+
self.cache = cache
|
|
73
|
+
self.rev = rev
|
|
74
|
+
self.texts: List[str] = []
|
|
75
|
+
|
|
76
|
+
def entity(self, text: str) -> str:
|
|
77
|
+
self.texts.append(text)
|
|
78
|
+
return vector_key(text, self.cache.model_id)
|
|
79
|
+
|
|
80
|
+
def claim(self, payload: Dict[str, Any], conn: sqlite3.Connection) -> str:
|
|
81
|
+
from abstractmemory.canonical_text import canonical_text
|
|
82
|
+
|
|
83
|
+
kinds = {r[0]: r[1] for r in conn.execute(
|
|
84
|
+
"SELECT entity_id, kind FROM entities WHERE entity_id IN (?, ?)",
|
|
85
|
+
(payload["subject_id"], payload.get("object_id") or ""))}
|
|
86
|
+
text = canonical_text(claim_assertion(payload, self.rev, kinds.get(payload["subject_id"]),
|
|
87
|
+
kinds.get(payload.get("object_id") or "")))
|
|
88
|
+
self.texts.append(text)
|
|
89
|
+
return vector_key(text, self.cache.model_id)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Notebook:
|
|
93
|
+
"""A notebook directory: the note index, the AbstractMemory projection, blobs and the queue."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, root: Path, store: NoteStore, cache: VectorCache, kg: ClaimStore) -> None:
|
|
96
|
+
self.root = Path(root)
|
|
97
|
+
self.store = store
|
|
98
|
+
self.cache = cache
|
|
99
|
+
self.kg = kg
|
|
100
|
+
self._apply_lock = threading.RLock()
|
|
101
|
+
from .queue import Queue
|
|
102
|
+
|
|
103
|
+
self.queue = Queue(store)
|
|
104
|
+
from .vector_index import NoteVectorIndex
|
|
105
|
+
|
|
106
|
+
self.note_vectors = NoteVectorIndex(store)
|
|
107
|
+
# Injected processing helpers (bound by abstractnote.tools.bind or by callers)
|
|
108
|
+
self.extractors: Dict[str, Callable[..., Any]] = {}
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------ open / close
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def open(cls, root: "str | os.PathLike[str]", *, embedder: Optional[TextEmbedder] = None,
|
|
114
|
+
recover: bool = True) -> "Notebook":
|
|
115
|
+
"""Open (and create when missing) a notebook directory.
|
|
116
|
+
|
|
117
|
+
``embedder`` selects the embedding space. A new notebook without one uses
|
|
118
|
+
the deterministic :class:`HashingEmbedder`. A notebook pinned to another
|
|
119
|
+
model must be opened with that model (or rebuilt with ``reembed=True``).
|
|
120
|
+
"""
|
|
121
|
+
root = Path(root).expanduser()
|
|
122
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
(root / "blobs").mkdir(exist_ok=True)
|
|
124
|
+
(root / "extracted").mkdir(exist_ok=True)
|
|
125
|
+
store = NoteStore(str(root / NOTE_DB))
|
|
126
|
+
try:
|
|
127
|
+
cache = cls._make_cache(store, embedder)
|
|
128
|
+
kg = ClaimStore(root / KG_DB, cache)
|
|
129
|
+
except BaseException:
|
|
130
|
+
store.close()
|
|
131
|
+
raise
|
|
132
|
+
nb = cls(root, store, cache, kg)
|
|
133
|
+
if recover and nb.cache.embedder is not None:
|
|
134
|
+
nb.recover()
|
|
135
|
+
return nb
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _make_cache(store: NoteStore, embedder: Optional[TextEmbedder], *, force_new_pin: bool = False
|
|
139
|
+
) -> VectorCache:
|
|
140
|
+
pin = None if force_new_pin else store.meta_get("embedding_pin")
|
|
141
|
+
if pin:
|
|
142
|
+
if embedder is None:
|
|
143
|
+
embedder = embedder_for_pin(pin)
|
|
144
|
+
if embedder is None:
|
|
145
|
+
# Read-only for meaning: everything that needs no new vector still works.
|
|
146
|
+
cache = VectorCache(store.conn, store.lock, None, pin.get("model_id"), pin.get("dimension"))
|
|
147
|
+
cache.missing_embedder = (
|
|
148
|
+
f"This notebook's vectors were made with the embedding model {pin.get('model_id')!r}; "
|
|
149
|
+
"open it with that embedder to save notes, process them or search by meaning, or rebuild "
|
|
150
|
+
"it with reembed to change models.")
|
|
151
|
+
return cache
|
|
152
|
+
known = embedder_model_id(embedder)
|
|
153
|
+
if known and pin.get("model_id") and known != pin["model_id"] and pin["model_id"] != "unknown":
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"This notebook is pinned to the embedding model {pin['model_id']!r} but the embedder given is "
|
|
156
|
+
f"{known!r}; rebuild with reembed to change models."
|
|
157
|
+
)
|
|
158
|
+
return VectorCache(store.conn, store.lock, embedder, pin.get("model_id"), pin.get("dimension"))
|
|
159
|
+
if embedder is None:
|
|
160
|
+
embedder = HashingEmbedder()
|
|
161
|
+
model_id = embedder_model_id(embedder) or "unknown"
|
|
162
|
+
dimension = getattr(embedder, "dimension", None)
|
|
163
|
+
cache = VectorCache(store.conn, store.lock, embedder, model_id, dimension)
|
|
164
|
+
store.meta_set("embedding_pin", {"model_id": model_id, "dimension": dimension,
|
|
165
|
+
"pinned_at": ids.utc_now()})
|
|
166
|
+
return cache
|
|
167
|
+
|
|
168
|
+
def _sync_pin(self) -> None:
|
|
169
|
+
pin = self.store.meta_get("embedding_pin") or {}
|
|
170
|
+
if pin.get("dimension") is None and self.cache.dimension is not None:
|
|
171
|
+
pin["dimension"] = self.cache.dimension
|
|
172
|
+
self.store.meta_set("embedding_pin", pin)
|
|
173
|
+
|
|
174
|
+
def close(self) -> None:
|
|
175
|
+
self.kg.close()
|
|
176
|
+
self.store.close()
|
|
177
|
+
|
|
178
|
+
def __enter__(self) -> "Notebook":
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
def __exit__(self, *exc: Any) -> None:
|
|
182
|
+
self.close()
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def embedder(self) -> Optional[TextEmbedder]:
|
|
186
|
+
return self.cache.embedder
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def read_only(self) -> Optional[str]:
|
|
190
|
+
"""Why this notebook cannot embed (opened without the embedder it is pinned to), or None."""
|
|
191
|
+
return self.cache.missing_embedder if self.cache.embedder is None else None
|
|
192
|
+
|
|
193
|
+
def require_embedder(self) -> None:
|
|
194
|
+
"""Raise the plain sentence when an operation would need new vectors and there is no embedder."""
|
|
195
|
+
if self.cache.embedder is None:
|
|
196
|
+
raise ValueError(self.cache.missing_embedder)
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------ revisions
|
|
199
|
+
|
|
200
|
+
def apply_revision(self, revision: "Revision | Dict[str, Any]", *, _fail_after_index: bool = False
|
|
201
|
+
) -> Dict[str, Any]:
|
|
202
|
+
"""Apply a revision to both stores. Re-applying the same ``revision_id`` is a no-op
|
|
203
|
+
that returns the stored result. Raises on invalid ops (nothing is applied)."""
|
|
204
|
+
rev = self._rev_dict(revision)
|
|
205
|
+
self.require_embedder()
|
|
206
|
+
with self._apply_lock:
|
|
207
|
+
existing = self.store.revision_row(rev["revision_id"])
|
|
208
|
+
if existing is not None:
|
|
209
|
+
if existing["abandoned"]:
|
|
210
|
+
raise ValueError(f"The revision {rev['revision_id']} failed before and was set aside.")
|
|
211
|
+
if existing["applied_index"] and existing["applied_kg"]:
|
|
212
|
+
return self._result(existing, already_applied=True)
|
|
213
|
+
return self._finish(self.store.revision_dict(existing), _fail_after_index=_fail_after_index)
|
|
214
|
+
self.store.insert_revision(rev)
|
|
215
|
+
return self._finish(rev, _fail_after_index=_fail_after_index)
|
|
216
|
+
|
|
217
|
+
def _rev_dict(self, revision: "Revision | Dict[str, Any]") -> Dict[str, Any]:
|
|
218
|
+
if isinstance(revision, Revision):
|
|
219
|
+
data = revision.model_dump()
|
|
220
|
+
else:
|
|
221
|
+
data = dict(revision)
|
|
222
|
+
data.setdefault("recorded_at", None)
|
|
223
|
+
data["recorded_at"] = data["recorded_at"] or ids.utc_now()
|
|
224
|
+
data["at"] = ids.normalize_time(data["at"], field="revision time")
|
|
225
|
+
ops = []
|
|
226
|
+
for op in data.get("ops") or []:
|
|
227
|
+
o = op.model_dump() if isinstance(op, RevisionOp) else RevisionOp(**op).model_dump()
|
|
228
|
+
ops.append(o)
|
|
229
|
+
data["ops"] = ops
|
|
230
|
+
if not data.get("revision_id"):
|
|
231
|
+
raise ValueError("A revision needs a revision_id.")
|
|
232
|
+
return data
|
|
233
|
+
|
|
234
|
+
def _finish(self, rev: Dict[str, Any], *, _fail_after_index: bool = False) -> Dict[str, Any]:
|
|
235
|
+
row = self.store.revision_row(rev["revision_id"])
|
|
236
|
+
stats: Dict[str, Any] = {}
|
|
237
|
+
if not row["applied_index"]:
|
|
238
|
+
keys = _Keys(self.cache, rev)
|
|
239
|
+
try:
|
|
240
|
+
stats = self.store.project(rev, keys=keys)
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
self.store.abandon_revision(rev["revision_id"], f"{type(exc).__name__}: {exc}")
|
|
243
|
+
raise
|
|
244
|
+
self.cache.ensure(keys.texts)
|
|
245
|
+
self._sync_pin()
|
|
246
|
+
result = dict(_uj(row["result_json"], {}) or {})
|
|
247
|
+
result.update({k: v for k, v in stats.items() if k not in ("touched_entities", "touched_claims")})
|
|
248
|
+
with self.store.lock:
|
|
249
|
+
self.store.conn.execute("UPDATE revisions SET result_json=? WHERE revision_id=?",
|
|
250
|
+
(_j(result), rev["revision_id"]))
|
|
251
|
+
if _fail_after_index:
|
|
252
|
+
raise RuntimeError("Simulated stop between the index and the AbstractMemory commits.")
|
|
253
|
+
row = self.store.revision_row(rev["revision_id"])
|
|
254
|
+
if not row["applied_kg"]:
|
|
255
|
+
with self.store.lock:
|
|
256
|
+
kinds = kinds_from_index(self.store.conn, rev)
|
|
257
|
+
self.kg.project(rev, kinds)
|
|
258
|
+
self.store.set_applied_kg(rev["revision_id"])
|
|
259
|
+
return self._result(self.store.revision_row(rev["revision_id"]))
|
|
260
|
+
|
|
261
|
+
def _result(self, row: sqlite3.Row, *, already_applied: bool = False) -> Dict[str, Any]:
|
|
262
|
+
result = dict(_uj(row["result_json"], {}) or {})
|
|
263
|
+
out = {
|
|
264
|
+
"revision_id": row["revision_id"], "summary": row["summary"],
|
|
265
|
+
"entities_created": result.get("entities_created", []),
|
|
266
|
+
"entities_merged": result.get("entities_merged", []),
|
|
267
|
+
"claims_added": result.get("claims_added", []),
|
|
268
|
+
"claims_reinforced": result.get("claims_reinforced", []),
|
|
269
|
+
"claims_superseded": result.get("claims_superseded", []),
|
|
270
|
+
"claims_retracted": result.get("claims_retracted", []),
|
|
271
|
+
"tasks_opened": result.get("tasks_opened", []),
|
|
272
|
+
"tasks_closed": result.get("tasks_closed", []),
|
|
273
|
+
"warnings": list(_uj(row["warnings_json"], []) or []),
|
|
274
|
+
}
|
|
275
|
+
if already_applied:
|
|
276
|
+
out["already_applied"] = True
|
|
277
|
+
return out
|
|
278
|
+
|
|
279
|
+
def recover(self) -> List[str]:
|
|
280
|
+
"""Finish every revision whose projections are not both applied. Returns their ids."""
|
|
281
|
+
done = []
|
|
282
|
+
with self._apply_lock:
|
|
283
|
+
for row in self.store.pending_revisions():
|
|
284
|
+
self._finish(self.store.revision_dict(row))
|
|
285
|
+
done.append(row["revision_id"])
|
|
286
|
+
return done
|
|
287
|
+
|
|
288
|
+
def revision(self, revision_id: str) -> Optional[Dict[str, Any]]:
|
|
289
|
+
row = self.store.revision_row(revision_id)
|
|
290
|
+
return self.store.revision_dict(row) if row else None
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------ user actions (M5)
|
|
293
|
+
|
|
294
|
+
def _user_revision(self, action: str, target: str, ops: List[Dict[str, Any]], account: Optional[str],
|
|
295
|
+
summary: str, *, client: str = "web", occurred_at: Optional[str] = None) -> Dict[str, Any]:
|
|
296
|
+
at = ids.normalize_time(occurred_at) if occurred_at else ids.utc_now()
|
|
297
|
+
rev = {
|
|
298
|
+
"revision_id": ids.revision_id_for_action(action, target, at), "note_id": None, "action": action,
|
|
299
|
+
"target": target, "at": at, "ops": ops, "summary": summary,
|
|
300
|
+
"source": {"client": client, "account": account},
|
|
301
|
+
}
|
|
302
|
+
return self.apply_revision(rev)
|
|
303
|
+
|
|
304
|
+
def close_task(self, task_id: str, account: Optional[str], note: Optional[str] = None, *,
|
|
305
|
+
client: str = "web", occurred_at: Optional[str] = None) -> Dict[str, Any]:
|
|
306
|
+
row = self.store.q1("SELECT * FROM tasks WHERE task_id=?", (task_id,))
|
|
307
|
+
if row is None:
|
|
308
|
+
raise ValueError("That task is not in this notebook.")
|
|
309
|
+
if row["status"] != "open":
|
|
310
|
+
raise ValueError("That task is already closed.")
|
|
311
|
+
summary = f"Closed the task “{row['text']}”." + (f" {note}" if note else "")
|
|
312
|
+
return self._user_revision("task.close", task_id, [{"op": "task.close", "payload": {
|
|
313
|
+
"task_id": task_id, "text": row["text"], "by_note_id": None, "note": note}}], account, summary,
|
|
314
|
+
client=client, occurred_at=occurred_at)
|
|
315
|
+
|
|
316
|
+
def retract_claim(self, claim_id: str, account: Optional[str], reason: str, *, client: str = "web",
|
|
317
|
+
occurred_at: Optional[str] = None) -> Dict[str, Any]:
|
|
318
|
+
row = self.store.claim_row(claim_id)
|
|
319
|
+
if row is None:
|
|
320
|
+
raise ValueError("That statement is not in this notebook.")
|
|
321
|
+
if row["status"] == "retracted":
|
|
322
|
+
raise ValueError("That statement was already retracted.")
|
|
323
|
+
if not str(reason or "").strip():
|
|
324
|
+
raise ValueError("Say why the statement is wrong.")
|
|
325
|
+
# The person says it was never true: the window closes where it opens (an empty window),
|
|
326
|
+
# never at the moment they said so, which can be before the claim's start.
|
|
327
|
+
never = row["valid_from"] or row["observed_at"]
|
|
328
|
+
return self._user_revision("claim.retract", claim_id, [{"op": "claim.retract", "payload": {
|
|
329
|
+
"claim_id": claim_id, "reason": reason, "valid_until": never}}], account,
|
|
330
|
+
f"Retracted a statement: {reason}", client=client, occurred_at=occurred_at)
|
|
331
|
+
|
|
332
|
+
def unmerge_entity(self, entity_id: str, from_id: str, account: Optional[str], reason: str, *,
|
|
333
|
+
client: str = "web", occurred_at: Optional[str] = None) -> Dict[str, Any]:
|
|
334
|
+
row = self.store.entity_row(from_id)
|
|
335
|
+
if row is None or self.store.entity_row(entity_id) is None:
|
|
336
|
+
raise ValueError("That entity is not in this notebook.")
|
|
337
|
+
if row["merged_into"] != entity_id:
|
|
338
|
+
raise ValueError("Those two entities are not merged.")
|
|
339
|
+
return self._user_revision("entity.unmerge", f"{entity_id}|{from_id}", [{"op": "entity.unmerge", "payload": {
|
|
340
|
+
"entity_id": entity_id, "from_id": from_id, "reason": reason or "undone by the user"}}], account,
|
|
341
|
+
f"Separated {row['name']} again.", client=client, occurred_at=occurred_at)
|
|
342
|
+
|
|
343
|
+
# ------------------------------------------------------------------ capture and filing (0002)
|
|
344
|
+
|
|
345
|
+
def capture(self, text: str = "", **kwargs: Any) -> Any:
|
|
346
|
+
"""Store a note now; see :func:`abstractnote.ingest.capture.capture`. Returns a ``CaptureResult``."""
|
|
347
|
+
from ..ingest.capture import capture
|
|
348
|
+
|
|
349
|
+
kwargs.setdefault("vision", self.extractors.get("vision"))
|
|
350
|
+
kwargs.setdefault("stt", self.extractors.get("stt"))
|
|
351
|
+
return capture(self, text, **kwargs)
|
|
352
|
+
|
|
353
|
+
def file_note(self, note_id: str, **kwargs: Any) -> Dict[str, Any]:
|
|
354
|
+
from ..ingest.capture import file_note
|
|
355
|
+
|
|
356
|
+
return file_note(self, note_id, **kwargs)
|
|
357
|
+
|
|
358
|
+
def note(self, note_id: str) -> Optional[Dict[str, Any]]:
|
|
359
|
+
"""A note in the pinned shape of ``GET /note/notes/{id}``: ``{note_id, title, text, kind, occurred_at,
|
|
360
|
+
received_at, status, topics, summary, source, location, attachments, revision, entities, claims, revisits,
|
|
361
|
+
insight}``.
|
|
362
|
+
|
|
363
|
+
``revision`` is the note's applied revision (or null); ``revisits`` lists the notes linked to it by
|
|
364
|
+
``ex:revisits`` in either direction as ``NoteRef``; ``insight`` is ``{kind, evidence_note_ids, entity_ids,
|
|
365
|
+
acked_at}`` for an insight note (``kind`` is its ``insight_kind``) and null for every other note.
|
|
366
|
+
"""
|
|
367
|
+
from ..recall import shapes
|
|
368
|
+
|
|
369
|
+
row = self.store.note_row(note_id)
|
|
370
|
+
if row is None:
|
|
371
|
+
return None
|
|
372
|
+
atts = self.store.attachments_of(note_id)
|
|
373
|
+
for a in atts:
|
|
374
|
+
path = self.root / "extracted" / f"{a['sha256']}.txt"
|
|
375
|
+
a["extracted_text"] = path.read_text(encoding="utf-8") if a["extracted_chars"] and path.exists() else None
|
|
376
|
+
rev = self.store.q1("SELECT * FROM revisions WHERE note_id=? AND applied_index=1 ORDER BY seq DESC LIMIT 1",
|
|
377
|
+
(note_id,))
|
|
378
|
+
revisits = self.store.q(
|
|
379
|
+
"SELECT n.* FROM note_links l JOIN notes n ON n.note_id=l.dst_note_id WHERE l.src_note_id=?"
|
|
380
|
+
" AND l.predicate='ex:revisits' UNION SELECT n.* FROM note_links l JOIN notes n"
|
|
381
|
+
" ON n.note_id=l.src_note_id WHERE l.dst_note_id=? AND l.predicate='ex:revisits'"
|
|
382
|
+
" ORDER BY occurred_at, note_id", (note_id, note_id))
|
|
383
|
+
return {
|
|
384
|
+
"note_id": row["note_id"], "title": row["title"], "text": row["text"], "kind": row["kind"],
|
|
385
|
+
"occurred_at": row["occurred_at"], "received_at": row["received_at"], "status": row["status"],
|
|
386
|
+
"topics": self.store.note_topics(note_id), "summary": row["summary"] or row["one_line"],
|
|
387
|
+
"source": _uj(row["source_json"], {}), "location": _uj(row["location_json"]),
|
|
388
|
+
"attachments": atts,
|
|
389
|
+
"revision": self.store.revision_dict(rev) if rev else None,
|
|
390
|
+
"entities": [b for b in (self.store.entity_brief(r[0]) for r in self.store.q(
|
|
391
|
+
"SELECT entity_id FROM entity_notes WHERE note_id=? ORDER BY entity_id", (note_id,))) if b],
|
|
392
|
+
"claims": [self.store.claim_dict(r) for r in self.store.q(
|
|
393
|
+
"SELECT * FROM claims WHERE note_id=? ORDER BY seq", (note_id,))],
|
|
394
|
+
"revisits": [shapes.note_ref(r) for r in revisits],
|
|
395
|
+
"insight": shapes.insight_facts(row),
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
def note_links(self, note_id: str) -> Dict[str, Any]:
|
|
399
|
+
"""Every link of a note (revisits both ways, same bytes, references) with how it matched."""
|
|
400
|
+
out = {"outgoing": [], "incoming": []}
|
|
401
|
+
for r in self.store.q("SELECT * FROM note_links WHERE src_note_id=? ORDER BY created_at", (note_id,)):
|
|
402
|
+
out["outgoing"].append({"note_id": r["dst_note_id"], "predicate": r["predicate"],
|
|
403
|
+
"matched_by": r["matched_by"], "score": r["score"],
|
|
404
|
+
"detail": _uj(r["detail_json"])})
|
|
405
|
+
for r in self.store.q("SELECT * FROM note_links WHERE dst_note_id=? ORDER BY created_at", (note_id,)):
|
|
406
|
+
out["incoming"].append({"note_id": r["src_note_id"], "predicate": r["predicate"],
|
|
407
|
+
"matched_by": r["matched_by"], "score": r["score"],
|
|
408
|
+
"detail": _uj(r["detail_json"])})
|
|
409
|
+
return out
|
|
410
|
+
|
|
411
|
+
def attachment_bytes(self, note_id: str, attachment_id: str) -> Dict[str, Any]:
|
|
412
|
+
"""``{data, mime, filename, size}`` of one attachment of a note."""
|
|
413
|
+
row = self.store.q1("SELECT * FROM attachments WHERE note_id=? AND attachment_id=?", (note_id, attachment_id))
|
|
414
|
+
if row is None:
|
|
415
|
+
raise ValueError("That attachment is not part of this note.")
|
|
416
|
+
from ..ingest.blobs import BlobStore
|
|
417
|
+
|
|
418
|
+
data = BlobStore(self.root / "blobs").read(row["sha256"])
|
|
419
|
+
return {"data": data, "mime": row["mime"], "filename": row["filename"], "size": row["size"]}
|
|
420
|
+
|
|
421
|
+
def archive_note(self, note_id: str) -> Dict[str, Any]:
|
|
422
|
+
"""Archive a note (never deleted); it leaves lists and search but keeps its links."""
|
|
423
|
+
if self.store.note_row(note_id) is None:
|
|
424
|
+
raise ValueError("That note is not in this notebook.")
|
|
425
|
+
now = ids.utc_now()
|
|
426
|
+
with self.store.tx() as c:
|
|
427
|
+
c.execute("UPDATE notes SET archived_at=COALESCE(archived_at, ?), status='archived' WHERE note_id=?",
|
|
428
|
+
(now, note_id))
|
|
429
|
+
return {"note_id": note_id, "status": "archived", "archived_at": self.store.note_row(note_id)["archived_at"]}
|
|
430
|
+
|
|
431
|
+
def ack_insight(self, note_id: str) -> Dict[str, Any]:
|
|
432
|
+
row = self.store.note_row(note_id)
|
|
433
|
+
if row is None or not row["insight_kind"]:
|
|
434
|
+
raise ValueError("That insight is not in this notebook.")
|
|
435
|
+
now = ids.utc_now()
|
|
436
|
+
with self.store.tx() as c:
|
|
437
|
+
c.execute("UPDATE notes SET acked_at=COALESCE(acked_at, ?) WHERE note_id=?", (now, note_id))
|
|
438
|
+
return {"note_id": note_id, "acked_at": self.store.note_row(note_id)["acked_at"]}
|
|
439
|
+
|
|
440
|
+
# ------------------------------------------------------------------ reads (store-level)
|
|
441
|
+
|
|
442
|
+
def beliefs_at(self, at: str, *, entity_id: Optional[str] = None, subject_id: Optional[str] = None
|
|
443
|
+
) -> List[Dict[str, Any]]:
|
|
444
|
+
return self.store.beliefs_at(at, entity_id=entity_id, subject_id=subject_id)
|
|
445
|
+
|
|
446
|
+
def graph(self, *, focus: Optional[Sequence[str]] = None, at: Optional[str] = None, depth: int = 1,
|
|
447
|
+
since: Optional[str] = None, until: Optional[str] = None, kinds: Optional[Sequence[str]] = None,
|
|
448
|
+
min_count: int = 1, limit: int = 200, as_of: Optional[str] = None,
|
|
449
|
+
as_received: Optional[str] = None) -> Dict[str, Any]:
|
|
450
|
+
"""Nodes and edges as of ``at``; ``as_received`` keeps what the archive held on that date
|
|
451
|
+
(docs/data-model.md "Time")."""
|
|
452
|
+
return self.store.graph(focus=focus, at=at or as_of, depth=depth, since=since, until=until, kinds=kinds,
|
|
453
|
+
min_count=min_count, limit=limit, as_received=as_received)
|
|
454
|
+
|
|
455
|
+
def day(self, day: str, tz: Optional[str] = None, as_received: Optional[str] = None) -> Dict[str, Any]:
|
|
456
|
+
return self.store.day(day, tz, as_received)
|
|
457
|
+
|
|
458
|
+
def timeline(self, since: Optional[str] = None, until: Optional[str] = None, granularity: str = "day"
|
|
459
|
+
) -> Dict[str, Any]:
|
|
460
|
+
return self.store.timeline(since=since, until=until, granularity=granularity)
|
|
461
|
+
|
|
462
|
+
def notes(self, **kwargs: Any) -> Dict[str, Any]:
|
|
463
|
+
return self.store.list_notes(**kwargs)
|
|
464
|
+
|
|
465
|
+
def entities(self, **kwargs: Any) -> Dict[str, Any]:
|
|
466
|
+
return self.store.list_entities(**kwargs)
|
|
467
|
+
|
|
468
|
+
def revisions(self, **kwargs: Any) -> Dict[str, Any]:
|
|
469
|
+
return self.store.revisions_feed(**kwargs)
|
|
470
|
+
|
|
471
|
+
def topics(self) -> Dict[str, Any]:
|
|
472
|
+
return {"items": self.store.topics()}
|
|
473
|
+
|
|
474
|
+
def tasks(self, status: Optional[str] = "open", entity: Optional[str] = None, limit: int = 100
|
|
475
|
+
) -> Dict[str, Any]:
|
|
476
|
+
"""``{items: [{task_id, note_id, text, status, due, closed_at}]}`` (``status=None`` for all)."""
|
|
477
|
+
keys = ("task_id", "note_id", "text", "status", "due", "closed_at")
|
|
478
|
+
return {"items": [{k: t[k] for k in keys} for t in self.store.tasks(status=status, entity=entity,
|
|
479
|
+
limit=limit)]}
|
|
480
|
+
|
|
481
|
+
def insights(self, kind: Optional[str] = None, acked: Optional[bool] = None, limit: int = 50
|
|
482
|
+
) -> Dict[str, Any]:
|
|
483
|
+
"""``{items: [Insight]}``, newest first."""
|
|
484
|
+
from ..recall import shapes
|
|
485
|
+
from .notes import clamp_limit
|
|
486
|
+
|
|
487
|
+
sql = "SELECT * FROM notes WHERE insight_kind IS NOT NULL AND archived_at IS NULL"
|
|
488
|
+
params: List[Any] = []
|
|
489
|
+
if kind:
|
|
490
|
+
sql += " AND insight_kind=?"
|
|
491
|
+
params.append(kind)
|
|
492
|
+
if acked is True:
|
|
493
|
+
sql += " AND acked_at IS NOT NULL"
|
|
494
|
+
elif acked is False:
|
|
495
|
+
sql += " AND acked_at IS NULL"
|
|
496
|
+
sql += " ORDER BY received_at DESC, note_id DESC LIMIT ?"
|
|
497
|
+
params.append(clamp_limit(limit))
|
|
498
|
+
return {"items": [shapes.insight(r) for r in self.store.q(sql, params)]}
|
|
499
|
+
|
|
500
|
+
def changes(self, since: Optional[str] = None, limit: int = 50, as_received: Optional[str] = None
|
|
501
|
+
) -> Dict[str, Any]:
|
|
502
|
+
"""Since you last looked; see :func:`abstractnote.recall.changes.changes`."""
|
|
503
|
+
from ..recall.changes import changes
|
|
504
|
+
|
|
505
|
+
return changes(self, since, limit, as_received)
|
|
506
|
+
|
|
507
|
+
def overview(self, days: int = 30, *, now: Optional[str] = None) -> Dict[str, Any]:
|
|
508
|
+
from ..recall.changes import overview
|
|
509
|
+
|
|
510
|
+
return overview(self, days, now=now)
|
|
511
|
+
|
|
512
|
+
def cues(self, limit: int = 3) -> Dict[str, Any]:
|
|
513
|
+
from ..recall.changes import cues
|
|
514
|
+
|
|
515
|
+
return cues(self, limit)
|
|
516
|
+
|
|
517
|
+
def review(self, limit: int = 50) -> Dict[str, Any]:
|
|
518
|
+
"""Proposed matches and disagreements for a person (``GET /note/review``); see :mod:`abstractnote.recall.review`."""
|
|
519
|
+
from ..recall.review import review
|
|
520
|
+
|
|
521
|
+
return review(self, limit)
|
|
522
|
+
|
|
523
|
+
def review_item(self, review_id: str) -> Optional[Dict[str, Any]]:
|
|
524
|
+
"""One item of :meth:`review` by id (None when it is not waiting for review)."""
|
|
525
|
+
from ..recall.review import _public, review_item
|
|
526
|
+
|
|
527
|
+
item = review_item(self, review_id)
|
|
528
|
+
return _public(item) if item else None
|
|
529
|
+
|
|
530
|
+
def confirm_match(self, review_id: str, account: Optional[str], *, client: str = "web",
|
|
531
|
+
occurred_at: Optional[str] = None) -> Dict[str, Any]:
|
|
532
|
+
"""Confirm a proposed match: a user-action revision ``match.confirm`` (see :mod:`abstractnote.recall.review`)."""
|
|
533
|
+
from ..recall.review import confirm
|
|
534
|
+
|
|
535
|
+
return confirm(self, review_id, account, client=client, occurred_at=occurred_at)
|
|
536
|
+
|
|
537
|
+
# ------------------------------------------------------------------ recall (0006)
|
|
538
|
+
|
|
539
|
+
def search(self, query: str = "", **kwargs: Any) -> Dict[str, Any]:
|
|
540
|
+
"""Quick recall: full-text plus vector over notes, entities and claims, with facets."""
|
|
541
|
+
from ..recall.search import search
|
|
542
|
+
|
|
543
|
+
return search(self, query, **kwargs)
|
|
544
|
+
|
|
545
|
+
def entity(self, entity_id: str, at: Optional[str] = None, as_received: Optional[str] = None) -> Dict[str, Any]:
|
|
546
|
+
"""The entity card as of ``at`` (merged ids resolve to the surviving entity); ``as_received`` builds it from
|
|
547
|
+
what the archive held on that date."""
|
|
548
|
+
from ..recall.expand import entity_card
|
|
549
|
+
|
|
550
|
+
return entity_card(self, entity_id, at, as_received)
|
|
551
|
+
|
|
552
|
+
def entity_record(self, entity_id: str) -> Dict[str, Any]:
|
|
553
|
+
"""The entry's own record, not followed to the surviving entity of a merge (``GET /note/entities/{id}?
|
|
554
|
+
as_merged=true``): ``{entity_id, kind, name, aliases, summary, first_seen, last_seen, note_count, merged_into,
|
|
555
|
+
merged_at, merge, notes}``. ``merge`` is the revision that joined it into ``merged_into`` (``{revision_id, at,
|
|
556
|
+
recorded_at, reason, summary, note_id}``) or null when it is not merged; ``notes`` are its own notes."""
|
|
557
|
+
from ..recall import shapes
|
|
558
|
+
|
|
559
|
+
brief = self.store.entity_brief(entity_id)
|
|
560
|
+
if brief is None:
|
|
561
|
+
raise ValueError("That entity is not in this notebook.")
|
|
562
|
+
row = self.store.entity_row(entity_id)
|
|
563
|
+
merge = None
|
|
564
|
+
if row["merged_into"]:
|
|
565
|
+
m = self.store.q1("SELECT m.*, r.summary, r.note_id, r.recorded_at FROM merges m JOIN revisions r ON"
|
|
566
|
+
" r.revision_id=m.revision_id WHERE m.from_id=? AND m.into_id=? AND m.undone_at IS NULL"
|
|
567
|
+
" ORDER BY m.at DESC LIMIT 1", (entity_id, row["merged_into"]))
|
|
568
|
+
if m is not None:
|
|
569
|
+
merge = {"revision_id": m["revision_id"], "at": m["at"], "recorded_at": m["recorded_at"],
|
|
570
|
+
"reason": m["reason"], "summary": m["summary"], "note_id": m["note_id"]}
|
|
571
|
+
notes = self.store.q("SELECT n.* FROM entity_notes en JOIN notes n ON n.note_id=en.note_id WHERE"
|
|
572
|
+
" en.entity_id=? AND n.archived_at IS NULL ORDER BY n.occurred_at DESC LIMIT 10",
|
|
573
|
+
(entity_id,))
|
|
574
|
+
return {**brief, "merged_at": row["merged_at"], "merge": merge, "notes": [shapes.note_ref(r) for r in notes]}
|
|
575
|
+
|
|
576
|
+
def entity_timeline(self, entity_id: str, since: Optional[str] = None, until: Optional[str] = None
|
|
577
|
+
) -> Dict[str, Any]:
|
|
578
|
+
from ..recall.expand import entity_timeline
|
|
579
|
+
|
|
580
|
+
return entity_timeline(self, entity_id, since, until)
|
|
581
|
+
|
|
582
|
+
def then_now(self, entity_id: str) -> Dict[str, Any]:
|
|
583
|
+
from ..recall.then_now import then_now
|
|
584
|
+
|
|
585
|
+
return then_now(self, entity_id)
|
|
586
|
+
|
|
587
|
+
def relation(self, a: str, b: str, since: Optional[str] = None, until: Optional[str] = None) -> Dict[str, Any]:
|
|
588
|
+
"""The relationship between two entities through time (M6)."""
|
|
589
|
+
from ..recall.expand import relation
|
|
590
|
+
|
|
591
|
+
return relation(self, a, b, since, until)
|
|
592
|
+
|
|
593
|
+
def claim(self, claim_id: str) -> Dict[str, Any]:
|
|
594
|
+
"""Why do I believe this: the evidence chain of a claim."""
|
|
595
|
+
from ..recall.expand import claim_chain
|
|
596
|
+
|
|
597
|
+
return claim_chain(self, claim_id)
|
|
598
|
+
|
|
599
|
+
def expand(self, note_ids: Optional[Sequence[str]] = None, entity_ids: Optional[Sequence[str]] = None,
|
|
600
|
+
as_of: Optional[str] = None) -> Dict[str, Any]:
|
|
601
|
+
from ..recall.expand import expand
|
|
602
|
+
|
|
603
|
+
return expand(self, note_ids or [], entity_ids or [], as_of)
|
|
604
|
+
|
|
605
|
+
def write_insight(self, kind: str, title: str, body: str, evidence_note_ids: Optional[Sequence[str]] = None,
|
|
606
|
+
entity_ids: Optional[Sequence[str]] = None, run_id: Optional[str] = None) -> Dict[str, Any]:
|
|
607
|
+
from ..recall.insights import write_insight
|
|
608
|
+
|
|
609
|
+
return write_insight(self, kind, title, body, evidence_note_ids, entity_ids, run_id)
|
|
610
|
+
|
|
611
|
+
def open_loops(self, limit: int = 20, since: Optional[str] = None, *, now: Optional[str] = None) -> Dict[str, Any]:
|
|
612
|
+
from ..recall.reflect import open_loops
|
|
613
|
+
|
|
614
|
+
return open_loops(self, limit, since, now=now)
|
|
615
|
+
|
|
616
|
+
def contradictions(self, limit: int = 20, since: Optional[str] = None) -> Dict[str, Any]:
|
|
617
|
+
from ..recall.reflect import contradictions
|
|
618
|
+
|
|
619
|
+
return contradictions(self, limit, since)
|
|
620
|
+
|
|
621
|
+
def resurface(self, limit: int = 20, since: Optional[str] = None, *, now: Optional[str] = None) -> Dict[str, Any]:
|
|
622
|
+
from ..recall.reflect import resurface
|
|
623
|
+
|
|
624
|
+
return resurface(self, limit, since, now=now)
|
|
625
|
+
|
|
626
|
+
def anniversaries(self, limit: int = 20, since: Optional[str] = None, *, now: Optional[str] = None
|
|
627
|
+
) -> Dict[str, Any]:
|
|
628
|
+
from ..recall.reflect import anniversaries
|
|
629
|
+
|
|
630
|
+
return anniversaries(self, limit, since, now=now)
|
|
631
|
+
|
|
632
|
+
# ------------------------------------------------------------------ settings and status
|
|
633
|
+
|
|
634
|
+
def settings(self) -> Dict[str, Any]:
|
|
635
|
+
stored = self.store.meta_get("settings", {}) or {}
|
|
636
|
+
out = {k: (dict(v) if isinstance(v, dict) else v) for k, v in DEFAULT_SETTINGS.items()}
|
|
637
|
+
for k, v in stored.items():
|
|
638
|
+
if k in out:
|
|
639
|
+
out[k] = v
|
|
640
|
+
return out
|
|
641
|
+
|
|
642
|
+
def update_settings(self, changes: Dict[str, Any]) -> Dict[str, Any]:
|
|
643
|
+
unknown = sorted(set(changes) - set(DEFAULT_SETTINGS))
|
|
644
|
+
if unknown:
|
|
645
|
+
raise ValueError(f"Unknown setting: {', '.join(unknown)}.")
|
|
646
|
+
if "processor_schedule" in changes:
|
|
647
|
+
changes = {**changes, "processor_schedule": _check_processor_schedule(changes["processor_schedule"])}
|
|
648
|
+
current = self.settings()
|
|
649
|
+
current.update(changes)
|
|
650
|
+
self.store.meta_set("settings", current)
|
|
651
|
+
return current
|
|
652
|
+
|
|
653
|
+
def processor_throughput(self) -> Dict[str, Any]:
|
|
654
|
+
"""``{max_items, interval}`` of the processor: what its automation was created with (recorded by
|
|
655
|
+
``POST /note/processor/ensure`` under ``processor_schedule_applied``) while it exists, else the stored
|
|
656
|
+
``processor_schedule`` setting it will be created with."""
|
|
657
|
+
applied = self.store.meta_get("processor_schedule_applied") if self.store.meta_get(
|
|
658
|
+
"processor_automation_id") else None
|
|
659
|
+
sched = applied if isinstance(applied, dict) else self.settings()["processor_schedule"]
|
|
660
|
+
return {"max_items": int(sched["max_items"]), "interval": str(sched["every"])}
|
|
661
|
+
|
|
662
|
+
def meta(self, key: str, default: Any = None) -> Any:
|
|
663
|
+
return self.store.meta_get(key, default)
|
|
664
|
+
|
|
665
|
+
def set_meta(self, key: str, value: Any) -> None:
|
|
666
|
+
if key in ("schema_version", "embedding_pin", "settings"):
|
|
667
|
+
raise ValueError(f"The {key} entry is managed by the notebook.")
|
|
668
|
+
self.store.meta_set(key, value)
|
|
669
|
+
|
|
670
|
+
def status(self) -> Dict[str, Any]:
|
|
671
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
672
|
+
|
|
673
|
+
try:
|
|
674
|
+
am_version: Optional[str] = version("abstractmemory")
|
|
675
|
+
except PackageNotFoundError: # pragma: no cover
|
|
676
|
+
am_version = None
|
|
677
|
+
pending = len(self.store.pending_revisions())
|
|
678
|
+
return {
|
|
679
|
+
"notebook": str(self.root),
|
|
680
|
+
"counts": self.store.counts(),
|
|
681
|
+
"queue": self.queue.stats(),
|
|
682
|
+
"embedding_pin": self.store.meta_get("embedding_pin"),
|
|
683
|
+
"kg_embedding_pin": self.kg.embedding_pin(),
|
|
684
|
+
"kg_assertions": self.kg.count(),
|
|
685
|
+
"pending_revisions": pending,
|
|
686
|
+
"read_only": self.read_only,
|
|
687
|
+
"settings": self.settings(),
|
|
688
|
+
"automations": {"processor_id": self.store.meta_get("processor_automation_id"),
|
|
689
|
+
"reflect_id": self.store.meta_get("reflect_automation_id")},
|
|
690
|
+
"versions": {"abstractnote": __version__, "schema": int(self.store.meta_get("schema_version", 0)),
|
|
691
|
+
"abstractmemory": am_version},
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
# ------------------------------------------------------------------ rebuild
|
|
695
|
+
|
|
696
|
+
def rebuild(self, *, reembed: bool = False, embedder: Optional[TextEmbedder] = None) -> Dict[str, Any]:
|
|
697
|
+
"""Replay notes and revisions into fresh index projections and a fresh triple store.
|
|
698
|
+
|
|
699
|
+
Without ``reembed`` stored vectors are reused (no embedder calls for known
|
|
700
|
+
texts). With ``reembed`` every vector is recomputed with ``embedder`` (or the
|
|
701
|
+
current one) and the notebook is pinned to that model.
|
|
702
|
+
"""
|
|
703
|
+
with self._apply_lock:
|
|
704
|
+
before = self.store.counts()
|
|
705
|
+
new_embedder = embedder or self.cache.embedder
|
|
706
|
+
if new_embedder is None:
|
|
707
|
+
raise ValueError(self.cache.missing_embedder)
|
|
708
|
+
self.kg.close()
|
|
709
|
+
with self.store.lock:
|
|
710
|
+
self.store.conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
711
|
+
self.store.close()
|
|
712
|
+
note_path, kg_path = self.root / NOTE_DB, self.root / KG_DB
|
|
713
|
+
old_note = self.root / (NOTE_DB + ".pre-rebuild")
|
|
714
|
+
old_kg = self.root / (KG_DB + ".pre-rebuild")
|
|
715
|
+
for p in (old_note, old_kg):
|
|
716
|
+
_remove_db(p)
|
|
717
|
+
os.replace(note_path, old_note)
|
|
718
|
+
_remove_db_sidecars(note_path)
|
|
719
|
+
if kg_path.exists():
|
|
720
|
+
os.replace(kg_path, old_kg)
|
|
721
|
+
_remove_db_sidecars(kg_path)
|
|
722
|
+
try:
|
|
723
|
+
store = NoteStore(str(note_path))
|
|
724
|
+
self._copy_truth(store, old_note, reembed=reembed)
|
|
725
|
+
cache = self._make_cache(store, new_embedder, force_new_pin=reembed)
|
|
726
|
+
self.store, self.cache = store, cache
|
|
727
|
+
self.queue.store = store
|
|
728
|
+
self.note_vectors.store = store
|
|
729
|
+
self.note_vectors.reset()
|
|
730
|
+
self._reindex_notes(reembed=reembed)
|
|
731
|
+
self.kg = ClaimStore(kg_path, cache)
|
|
732
|
+
replayed = 0
|
|
733
|
+
for row in self.store.q("SELECT * FROM revisions WHERE abandoned=0 ORDER BY seq"):
|
|
734
|
+
self._finish(self.store.revision_dict(row))
|
|
735
|
+
replayed += 1
|
|
736
|
+
self._sync_pin()
|
|
737
|
+
except BaseException:
|
|
738
|
+
try:
|
|
739
|
+
self.kg.close()
|
|
740
|
+
except Exception:
|
|
741
|
+
pass
|
|
742
|
+
try:
|
|
743
|
+
self.store.close()
|
|
744
|
+
except Exception:
|
|
745
|
+
pass
|
|
746
|
+
_remove_db(note_path)
|
|
747
|
+
_remove_db(kg_path)
|
|
748
|
+
os.replace(old_note, note_path)
|
|
749
|
+
if old_kg.exists():
|
|
750
|
+
os.replace(old_kg, kg_path)
|
|
751
|
+
self.store = NoteStore(str(note_path))
|
|
752
|
+
self.cache = self._make_cache(self.store, new_embedder if not reembed else None)
|
|
753
|
+
self.queue.store = self.store
|
|
754
|
+
self.note_vectors.store = self.store
|
|
755
|
+
self.note_vectors.reset()
|
|
756
|
+
self.kg = ClaimStore(kg_path, self.cache)
|
|
757
|
+
raise
|
|
758
|
+
_remove_db(old_note)
|
|
759
|
+
_remove_db(old_kg)
|
|
760
|
+
return {"replayed_revisions": replayed, "before": before, "after": self.store.counts(),
|
|
761
|
+
"reembedded": reembed, "embedding_pin": self.store.meta_get("embedding_pin")}
|
|
762
|
+
|
|
763
|
+
def _copy_truth(self, store: NoteStore, old_path: Path, *, reembed: bool) -> None:
|
|
764
|
+
with store.lock:
|
|
765
|
+
c = store.conn
|
|
766
|
+
c.execute("ATTACH DATABASE ? AS old", (str(old_path),))
|
|
767
|
+
try:
|
|
768
|
+
c.execute("BEGIN IMMEDIATE")
|
|
769
|
+
c.execute("DELETE FROM meta")
|
|
770
|
+
c.execute("INSERT INTO meta SELECT * FROM old.meta" + (" WHERE key!='embedding_pin'" if reembed else ""))
|
|
771
|
+
tables = ["notes", "attachments", "note_links", "queue", "context_packets", "revisions"]
|
|
772
|
+
if not reembed:
|
|
773
|
+
tables.append("vectors")
|
|
774
|
+
for table in tables:
|
|
775
|
+
new_cols = [r[1] for r in c.execute(f"PRAGMA main.table_info({table})")]
|
|
776
|
+
old_cols = {r[1] for r in c.execute(f"PRAGMA old.table_info({table})")}
|
|
777
|
+
collist = ",".join(col for col in new_cols if col in old_cols)
|
|
778
|
+
c.execute(f"INSERT INTO main.{table}({collist}) SELECT {collist} FROM old.{table}")
|
|
779
|
+
c.execute("UPDATE revisions SET applied_index=0, applied_kg=0, result_json=NULL WHERE abandoned=0")
|
|
780
|
+
c.execute("UPDATE notes SET summary=NULL")
|
|
781
|
+
c.execute("COMMIT")
|
|
782
|
+
except BaseException:
|
|
783
|
+
c.execute("ROLLBACK")
|
|
784
|
+
raise
|
|
785
|
+
finally:
|
|
786
|
+
c.execute("DETACH DATABASE old")
|
|
787
|
+
|
|
788
|
+
def _reindex_notes(self, *, reembed: bool = False) -> None:
|
|
789
|
+
"""Note FTS rows, filing topics and (when missing or re-embedding) note vectors."""
|
|
790
|
+
rows = self.store.q("SELECT n.note_id, n.title, n.text, n.filing_topics_json, n.occurred_at, n.vector_key,"
|
|
791
|
+
" v.vector_key AS have FROM notes n LEFT JOIN vectors v ON v.vector_key=n.vector_key"
|
|
792
|
+
" ORDER BY n.seq")
|
|
793
|
+
texts = []
|
|
794
|
+
with self.store.tx() as c:
|
|
795
|
+
for r in rows:
|
|
796
|
+
extracted = self.extracted_texts(r["note_id"])
|
|
797
|
+
self.store.refresh_note_fts(c, r["note_id"], extracted)
|
|
798
|
+
for topic in _uj(r["filing_topics_json"], []) or []:
|
|
799
|
+
self.store.assign_topic(c, r["note_id"], topic, by="filing", revision_id=None, at=r["occurred_at"])
|
|
800
|
+
if reembed or (r["vector_key"] and not r["have"]):
|
|
801
|
+
text = note_vector_text(r["title"], r["text"], extracted, NOTE_EMBED_BUDGET)
|
|
802
|
+
if text.strip():
|
|
803
|
+
texts.append((r["note_id"], text))
|
|
804
|
+
if texts:
|
|
805
|
+
keys = self.cache.ensure([t for _, t in texts])
|
|
806
|
+
with self.store.tx() as c:
|
|
807
|
+
c.executemany("UPDATE notes SET vector_key=? WHERE note_id=?",
|
|
808
|
+
[(k, nid) for (nid, _), k in zip(texts, keys)])
|
|
809
|
+
|
|
810
|
+
def extracted_texts(self, note_id: str) -> List[str]:
|
|
811
|
+
out = []
|
|
812
|
+
for a in self.store.attachments_of(note_id):
|
|
813
|
+
path = self.root / "extracted" / f"{a['sha256']}.txt"
|
|
814
|
+
if a["extracted_chars"] and path.exists():
|
|
815
|
+
out.append(path.read_text(encoding="utf-8"))
|
|
816
|
+
return out
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _remove_db_sidecars(path: Path) -> None:
|
|
820
|
+
for suffix in ("-wal", "-shm"):
|
|
821
|
+
p = Path(str(path) + suffix)
|
|
822
|
+
if p.exists():
|
|
823
|
+
p.unlink()
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _remove_db(path: Path) -> None:
|
|
827
|
+
if path.exists():
|
|
828
|
+
path.unlink()
|
|
829
|
+
_remove_db_sidecars(path)
|