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,1190 @@
|
|
|
1
|
+
"""The note index: ``NoteStore`` over ``note.sqlite3``.
|
|
2
|
+
|
|
3
|
+
The store owns the SQLite connection and two kinds of code:
|
|
4
|
+
|
|
5
|
+
- writes of the truth tables (notes, attachments, links, queue rows, revision
|
|
6
|
+
rows, vectors, context packets), used by capture, filing and the queue;
|
|
7
|
+
- the **index projection** of a revision (:meth:`NoteStore.project`), the only
|
|
8
|
+
code that changes entities, aliases, claims, relations, topics and tasks. It
|
|
9
|
+
runs in one transaction that also sets the revision's ``applied_index`` flag.
|
|
10
|
+
|
|
11
|
+
Reads (entity cards, beliefs, graph, day, timeline, feeds) live here too so
|
|
12
|
+
that every surface (tools, read API, CLI) shares one implementation of
|
|
13
|
+
``as_of``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
import sqlite3
|
|
21
|
+
import threading
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from datetime import datetime, timedelta, timezone
|
|
24
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple
|
|
25
|
+
|
|
26
|
+
from .. import ids
|
|
27
|
+
from .schema import connect, ensure_schema
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _j(value: Any) -> str:
|
|
31
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _uj(raw: Optional[str], default: Any = None) -> Any:
|
|
35
|
+
if raw is None or raw == "":
|
|
36
|
+
return default
|
|
37
|
+
try:
|
|
38
|
+
return json.loads(raw)
|
|
39
|
+
except (TypeError, ValueError):
|
|
40
|
+
return default
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def encode_cursor(*parts: Any) -> str:
|
|
44
|
+
return base64.urlsafe_b64encode(_j(list(parts)).encode("utf-8")).decode("ascii").rstrip("=")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def decode_cursor(cursor: Optional[str]) -> Optional[List[Any]]:
|
|
48
|
+
if not cursor:
|
|
49
|
+
return None
|
|
50
|
+
try:
|
|
51
|
+
pad = "=" * (-len(cursor) % 4)
|
|
52
|
+
value = json.loads(base64.urlsafe_b64decode(cursor + pad).decode("utf-8"))
|
|
53
|
+
except Exception:
|
|
54
|
+
raise ValueError("The cursor is not one this notebook issued.") from None
|
|
55
|
+
if not isinstance(value, list):
|
|
56
|
+
raise ValueError("The cursor is not one this notebook issued.")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def clamp_limit(limit: Any, default: int = 50, maximum: int = 500) -> int:
|
|
61
|
+
try:
|
|
62
|
+
n = int(limit) if limit is not None else default
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
raise ValueError("The limit must be a whole number.") from None
|
|
65
|
+
return max(1, min(maximum, n))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def entity_text(name: str, aliases: Sequence[str], summary: Optional[str]) -> str:
|
|
69
|
+
"""The embedded text of an entity: name, aliases and summary."""
|
|
70
|
+
parts = [name]
|
|
71
|
+
if aliases:
|
|
72
|
+
parts.append("also known as " + ", ".join(aliases))
|
|
73
|
+
if summary:
|
|
74
|
+
parts.append(summary)
|
|
75
|
+
return "\n".join(parts)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def note_vector_text(title: Optional[str], text: str, attachment_texts: Sequence[str], budget: int) -> str:
|
|
79
|
+
"""The embedded text of a note: title, body and attachment text, bounded to
|
|
80
|
+
``budget`` characters for the embedder call only (full text stays stored and
|
|
81
|
+
searchable through FTS)."""
|
|
82
|
+
parts = [p for p in [title or "", text or "", *attachment_texts] if p]
|
|
83
|
+
joined = "\n".join(parts)
|
|
84
|
+
return joined[:budget] # embedder input bound; the stored note is never cut
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class NoteStore:
|
|
88
|
+
"""SQLite note index. Thread-safe through one RLock around the connection."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, path: str) -> None:
|
|
91
|
+
self.path = str(path)
|
|
92
|
+
self.lock = threading.RLock()
|
|
93
|
+
self.conn = connect(self.path)
|
|
94
|
+
with self.lock:
|
|
95
|
+
ensure_schema(self.conn)
|
|
96
|
+
|
|
97
|
+
def close(self) -> None:
|
|
98
|
+
with self.lock:
|
|
99
|
+
try:
|
|
100
|
+
self.conn.close()
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------ plumbing
|
|
105
|
+
|
|
106
|
+
@contextmanager
|
|
107
|
+
def tx(self) -> Iterator[sqlite3.Connection]:
|
|
108
|
+
"""One write transaction (BEGIN IMMEDIATE ... COMMIT; ROLLBACK on error)."""
|
|
109
|
+
with self.lock:
|
|
110
|
+
self.conn.execute("BEGIN IMMEDIATE")
|
|
111
|
+
try:
|
|
112
|
+
yield self.conn
|
|
113
|
+
except BaseException:
|
|
114
|
+
self.conn.execute("ROLLBACK")
|
|
115
|
+
raise
|
|
116
|
+
self.conn.execute("COMMIT")
|
|
117
|
+
|
|
118
|
+
def q(self, sql: str, params: Sequence[Any] = ()) -> List[sqlite3.Row]:
|
|
119
|
+
with self.lock:
|
|
120
|
+
return self.conn.execute(sql, params).fetchall()
|
|
121
|
+
|
|
122
|
+
def q1(self, sql: str, params: Sequence[Any] = ()) -> Optional[sqlite3.Row]:
|
|
123
|
+
with self.lock:
|
|
124
|
+
return self.conn.execute(sql, params).fetchone()
|
|
125
|
+
|
|
126
|
+
def meta_get(self, key: str, default: Any = None) -> Any:
|
|
127
|
+
row = self.q1("SELECT value FROM meta WHERE key=?", (key,))
|
|
128
|
+
return _uj(row[0], default) if row else default
|
|
129
|
+
|
|
130
|
+
def meta_set(self, key: str, value: Any, conn: Optional[sqlite3.Connection] = None) -> None:
|
|
131
|
+
c = conn or self.conn
|
|
132
|
+
with self.lock:
|
|
133
|
+
c.execute("INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)", (key, _j(value)))
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------ notes
|
|
136
|
+
|
|
137
|
+
def note_exists(self, note_id: str) -> bool:
|
|
138
|
+
return self.q1("SELECT 1 FROM notes WHERE note_id=?", (note_id,)) is not None
|
|
139
|
+
|
|
140
|
+
def note_row(self, note_id: str) -> Optional[sqlite3.Row]:
|
|
141
|
+
return self.q1("SELECT * FROM notes WHERE note_id=?", (note_id,))
|
|
142
|
+
|
|
143
|
+
def note_topics(self, note_id: str) -> List[str]:
|
|
144
|
+
return [r[0] for r in self.q("SELECT topic FROM note_topics WHERE note_id=? ORDER BY topic", (note_id,))]
|
|
145
|
+
|
|
146
|
+
def attachments_of(self, note_id: str) -> List[Dict[str, Any]]:
|
|
147
|
+
rows = self.q("SELECT * FROM attachments WHERE note_id=? ORDER BY position, attachment_id", (note_id,))
|
|
148
|
+
return [self._attachment_dict(r) for r in rows]
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _attachment_dict(r: sqlite3.Row) -> Dict[str, Any]:
|
|
152
|
+
return {
|
|
153
|
+
"attachment_id": r["attachment_id"], "note_id": r["note_id"], "sha256": r["sha256"],
|
|
154
|
+
"size": r["size"], "mime": r["mime"], "filename": r["filename"],
|
|
155
|
+
"artifact_ref": _uj(r["artifact_ref_json"]), "blob_path": r["blob_path"],
|
|
156
|
+
"extracted_by": r["extracted_by"], "extracted_chars": r["extracted_chars"],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
def note_dict(self, r: sqlite3.Row, *, full: bool = True) -> Dict[str, Any]:
|
|
160
|
+
out: Dict[str, Any] = {
|
|
161
|
+
"note_id": r["note_id"], "kind": r["kind"], "title": r["title"],
|
|
162
|
+
"summary": r["summary"] or r["one_line"], "occurred_at": r["occurred_at"],
|
|
163
|
+
"received_at": r["received_at"], "processed_at": r["processed_at"],
|
|
164
|
+
"source": _uj(r["source_json"], {}), "location": _uj(r["location_json"]),
|
|
165
|
+
"language": r["language"], "topics": self.note_topics(r["note_id"]),
|
|
166
|
+
"priority": r["priority"], "status": r["status"], "duplicate_of": r["duplicate_of"],
|
|
167
|
+
"session_id": r["session_id"], "run_id": r["run_id"], "archived_at": r["archived_at"],
|
|
168
|
+
}
|
|
169
|
+
if r["insight_kind"]:
|
|
170
|
+
out.update({
|
|
171
|
+
"insight_kind": r["insight_kind"], "evidence_note_ids": _uj(r["evidence_note_ids_json"], []),
|
|
172
|
+
"entity_ids": _uj(r["entity_ids_json"], []), "acked_at": r["acked_at"],
|
|
173
|
+
})
|
|
174
|
+
if full:
|
|
175
|
+
out["text"] = r["text"]
|
|
176
|
+
else:
|
|
177
|
+
text = r["text"] or ""
|
|
178
|
+
out["excerpt"] = text[:280] + ("…" if len(text) > 280 else "") # excerpt for lists; full text via get
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
def insert_note(self, conn: sqlite3.Connection, row: Dict[str, Any]) -> None:
|
|
182
|
+
cols = ",".join(row.keys())
|
|
183
|
+
marks = ",".join("?" for _ in row)
|
|
184
|
+
seq = conn.execute("SELECT COALESCE(MAX(seq), 0) + 1 FROM notes").fetchone()[0]
|
|
185
|
+
conn.execute(f"INSERT INTO notes({cols}, seq) VALUES ({marks}, ?)", [*row.values(), seq])
|
|
186
|
+
|
|
187
|
+
def refresh_note_fts(self, conn: sqlite3.Connection, note_id: str, extracted: Optional[Sequence[str]] = None) -> None:
|
|
188
|
+
r = conn.execute("SELECT note_id, title, text, summary, one_line FROM notes WHERE note_id=?", (note_id,)).fetchone()
|
|
189
|
+
if r is None:
|
|
190
|
+
return
|
|
191
|
+
if extracted is None:
|
|
192
|
+
prev = conn.execute("SELECT attachments FROM notes_fts WHERE note_id=?", (note_id,)).fetchone()
|
|
193
|
+
att = prev[0] if prev else ""
|
|
194
|
+
else:
|
|
195
|
+
att = "\n".join(extracted)
|
|
196
|
+
conn.execute("DELETE FROM notes_fts WHERE note_id=?", (note_id,))
|
|
197
|
+
conn.execute(
|
|
198
|
+
"INSERT INTO notes_fts(note_id, title, body, summary, attachments) VALUES (?,?,?,?,?)",
|
|
199
|
+
(note_id, r["title"] or "", r["text"] or "", r["summary"] or r["one_line"] or "", att or ""),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------ revisions (truth rows)
|
|
203
|
+
|
|
204
|
+
def revision_row(self, revision_id: str) -> Optional[sqlite3.Row]:
|
|
205
|
+
return self.q1("SELECT * FROM revisions WHERE revision_id=?", (revision_id,))
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def revision_dict(r: sqlite3.Row) -> Dict[str, Any]:
|
|
209
|
+
return {
|
|
210
|
+
"revision_id": r["revision_id"], "note_id": r["note_id"], "attempt": r["attempt"],
|
|
211
|
+
"action": r["action"], "target": r["target"], "at": r["at"], "recorded_at": r["recorded_at"],
|
|
212
|
+
"run_id": r["run_id"], "ops": _uj(r["ops_json"], []), "summary": r["summary"],
|
|
213
|
+
"source": _uj(r["source_json"]), "warnings": _uj(r["warnings_json"], []),
|
|
214
|
+
"applied_index": bool(r["applied_index"]), "applied_kg": bool(r["applied_kg"]),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
def insert_revision(self, rev: Dict[str, Any]) -> bool:
|
|
218
|
+
"""Write the revision row (the intent). Returns False when the id exists (no-op)."""
|
|
219
|
+
with self.lock:
|
|
220
|
+
cur = self.conn.execute(
|
|
221
|
+
"INSERT OR IGNORE INTO revisions(revision_id, note_id, attempt, action, target, at, recorded_at,"
|
|
222
|
+
" run_id, ops_json, summary, source_json, warnings_json, result_json, applied_index, applied_kg)"
|
|
223
|
+
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,0,0)",
|
|
224
|
+
(rev["revision_id"], rev.get("note_id"), rev.get("attempt"), rev.get("action"), rev.get("target"),
|
|
225
|
+
rev["at"], rev["recorded_at"], rev.get("run_id"), _j(rev.get("ops") or []), rev.get("summary") or "",
|
|
226
|
+
_j(rev.get("source")) if rev.get("source") is not None else None, _j(rev.get("warnings") or []),
|
|
227
|
+
_j(rev.get("result")) if rev.get("result") is not None else None),
|
|
228
|
+
)
|
|
229
|
+
return cur.rowcount > 0
|
|
230
|
+
|
|
231
|
+
def pending_revisions(self) -> List[sqlite3.Row]:
|
|
232
|
+
return self.q("SELECT * FROM revisions WHERE abandoned=0 AND (applied_index=0 OR applied_kg=0) ORDER BY seq")
|
|
233
|
+
|
|
234
|
+
def abandon_revision(self, revision_id: str, error: str) -> None:
|
|
235
|
+
with self.lock:
|
|
236
|
+
self.conn.execute("UPDATE revisions SET abandoned=1, error=? WHERE revision_id=? AND applied_index=0",
|
|
237
|
+
(error, revision_id))
|
|
238
|
+
|
|
239
|
+
def set_applied_kg(self, revision_id: str) -> None:
|
|
240
|
+
with self.lock:
|
|
241
|
+
self.conn.execute("UPDATE revisions SET applied_kg=1 WHERE revision_id=?", (revision_id,))
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------------------ entities helpers
|
|
244
|
+
|
|
245
|
+
def entity_row(self, entity_id: str) -> Optional[sqlite3.Row]:
|
|
246
|
+
return self.q1("SELECT * FROM entities WHERE entity_id=?", (entity_id,))
|
|
247
|
+
|
|
248
|
+
def entity_ids_taken(self) -> "_TakenIds":
|
|
249
|
+
return _TakenIds(self)
|
|
250
|
+
|
|
251
|
+
def canonical(self, entity_id: str, conn: Optional[sqlite3.Connection] = None) -> str:
|
|
252
|
+
"""Follow ``merged_into`` to the surviving entity."""
|
|
253
|
+
c = conn or self.conn
|
|
254
|
+
seen = set()
|
|
255
|
+
cur = entity_id
|
|
256
|
+
with self.lock:
|
|
257
|
+
while cur not in seen:
|
|
258
|
+
seen.add(cur)
|
|
259
|
+
r = c.execute("SELECT merged_into FROM entities WHERE entity_id=?", (cur,)).fetchone()
|
|
260
|
+
if r is None or not r[0]:
|
|
261
|
+
return cur
|
|
262
|
+
cur = r[0]
|
|
263
|
+
return cur
|
|
264
|
+
|
|
265
|
+
def canonical_at(self, entity_id: str, at: Optional[str]) -> str:
|
|
266
|
+
"""The surviving entity as of history time ``at`` (merges done by then and not undone by then)."""
|
|
267
|
+
if at is None:
|
|
268
|
+
return self.canonical(entity_id)
|
|
269
|
+
seen = set()
|
|
270
|
+
cur = entity_id
|
|
271
|
+
while cur not in seen:
|
|
272
|
+
seen.add(cur)
|
|
273
|
+
r = self.q1(
|
|
274
|
+
"SELECT into_id FROM merges WHERE from_id=? AND at<=? AND (undone_at IS NULL OR undone_at>?)"
|
|
275
|
+
" ORDER BY at DESC LIMIT 1", (cur, at, at))
|
|
276
|
+
if r is None:
|
|
277
|
+
return cur
|
|
278
|
+
cur = r[0]
|
|
279
|
+
return cur
|
|
280
|
+
|
|
281
|
+
def merged_members(self, entity_id: str, conn: Optional[sqlite3.Connection] = None) -> List[str]:
|
|
282
|
+
"""The entity and every entity currently merged into it (transitively)."""
|
|
283
|
+
c = conn or self.conn
|
|
284
|
+
out = [entity_id]
|
|
285
|
+
frontier = [entity_id]
|
|
286
|
+
with self.lock:
|
|
287
|
+
while frontier:
|
|
288
|
+
nxt = []
|
|
289
|
+
for eid in frontier:
|
|
290
|
+
for r in c.execute("SELECT entity_id FROM entities WHERE merged_into=?", (eid,)):
|
|
291
|
+
if r[0] not in out:
|
|
292
|
+
out.append(r[0])
|
|
293
|
+
nxt.append(r[0])
|
|
294
|
+
frontier = nxt
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
def aliases(self, entity_id: str, at: Optional[str] = None) -> List[str]:
|
|
298
|
+
if at is None:
|
|
299
|
+
rows = self.q("SELECT alias FROM entity_aliases WHERE entity_id=? ORDER BY added_at, alias", (entity_id,))
|
|
300
|
+
else:
|
|
301
|
+
rows = self.q("SELECT alias FROM entity_aliases WHERE entity_id=? AND added_at<=? ORDER BY added_at, alias",
|
|
302
|
+
(entity_id, at))
|
|
303
|
+
return [r[0] for r in rows]
|
|
304
|
+
|
|
305
|
+
def entity_brief(self, entity_id: str) -> Optional[Dict[str, Any]]:
|
|
306
|
+
r = self.entity_row(entity_id)
|
|
307
|
+
if r is None:
|
|
308
|
+
return None
|
|
309
|
+
return {
|
|
310
|
+
"entity_id": r["entity_id"], "kind": r["kind"], "name": r["name"],
|
|
311
|
+
"aliases": self.aliases(r["entity_id"]), "summary": r["summary"], "first_seen": r["first_seen"],
|
|
312
|
+
"last_seen": r["last_seen"], "note_count": r["note_count"], "merged_into": r["merged_into"],
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
# ------------------------------------------------------------------ claims helpers
|
|
316
|
+
|
|
317
|
+
def claim_row(self, claim_id: str) -> Optional[sqlite3.Row]:
|
|
318
|
+
return self.q1("SELECT * FROM claims WHERE claim_id=?", (claim_id,))
|
|
319
|
+
|
|
320
|
+
def claim_dict(self, r: sqlite3.Row, *, names: bool = True) -> Dict[str, Any]:
|
|
321
|
+
out = {
|
|
322
|
+
"claim_id": r["claim_id"], "subject_id": r["subject_id"], "predicate": r["predicate"],
|
|
323
|
+
"object_id": r["object_id"], "object_literal": r["object_literal"], "certainty": r["certainty"],
|
|
324
|
+
"stance": r["stance"], "observed_at": r["observed_at"], "valid_from": r["valid_from"],
|
|
325
|
+
"valid_until": r["valid_until"], "status": r["status"], "superseded_by": r["superseded_by"],
|
|
326
|
+
"evidence": r["evidence"], "note_id": r["note_id"], "revision_id": r["revision_id"],
|
|
327
|
+
"assertion_id": r["assertion_id"], "disputed_with": _uj(r["disputed_with_json"], []),
|
|
328
|
+
"reinforced_count": r["reinforced_count"],
|
|
329
|
+
}
|
|
330
|
+
if names:
|
|
331
|
+
out["subject_name"] = self._name(r["subject_id"])
|
|
332
|
+
if r["object_id"]:
|
|
333
|
+
out["object_name"] = self._name(r["object_id"])
|
|
334
|
+
return out
|
|
335
|
+
|
|
336
|
+
def _name(self, entity_id: Optional[str]) -> Optional[str]:
|
|
337
|
+
if not entity_id:
|
|
338
|
+
return None
|
|
339
|
+
r = self.q1("SELECT name FROM entities WHERE entity_id=?", (entity_id,))
|
|
340
|
+
return r[0] if r else None
|
|
341
|
+
|
|
342
|
+
@staticmethod
|
|
343
|
+
def claim_text(subject_name: str, predicate: str, object_text: str, evidence: Optional[str]) -> str:
|
|
344
|
+
return f"{subject_name} {predicate} {object_text}" + (f"\n{evidence}" if evidence else "")
|
|
345
|
+
|
|
346
|
+
# ------------------------------------------------------------------ projection
|
|
347
|
+
|
|
348
|
+
def project(self, rev: Dict[str, Any], *, keys: Any) -> Dict[str, Any]:
|
|
349
|
+
"""Apply one revision's ops to the index in one transaction and set ``applied_index``.
|
|
350
|
+
|
|
351
|
+
``keys.entity(text)`` and ``keys.claim(payload, conn)`` name the vectors of
|
|
352
|
+
changed entities and new claims (the caller fills them after commit).
|
|
353
|
+
Returns the projection stats. Re-projection is a no-op once the flag is set.
|
|
354
|
+
"""
|
|
355
|
+
stats: Dict[str, Any] = {
|
|
356
|
+
"entities_created": [], "entities_merged": [], "claims_added": [], "claims_reinforced": [],
|
|
357
|
+
"claims_superseded": [], "claims_retracted": [], "tasks_opened": [], "tasks_closed": [],
|
|
358
|
+
"touched_entities": [], "touched_claims": [],
|
|
359
|
+
}
|
|
360
|
+
with self.tx() as c:
|
|
361
|
+
row = c.execute("SELECT applied_index FROM revisions WHERE revision_id=?", (rev["revision_id"],)).fetchone()
|
|
362
|
+
if row is None:
|
|
363
|
+
raise RuntimeError(f"Revision {rev['revision_id']} has no row; write the intent first.")
|
|
364
|
+
if row[0]:
|
|
365
|
+
return stats
|
|
366
|
+
ctx = _OpContext(self, c, rev, stats, keys)
|
|
367
|
+
for op in rev.get("ops") or []:
|
|
368
|
+
ctx.apply(op["op"], dict(op.get("payload") or {}))
|
|
369
|
+
ctx.finish()
|
|
370
|
+
c.execute("UPDATE revisions SET applied_index=1 WHERE revision_id=?", (rev["revision_id"],))
|
|
371
|
+
return stats
|
|
372
|
+
|
|
373
|
+
def refresh_relation(self, c: sqlite3.Connection, subject_id: str, object_id: str, predicate: str) -> None:
|
|
374
|
+
"""Recompute one relation row (canonical endpoints) from its claims."""
|
|
375
|
+
s_members = self.merged_members(subject_id, c)
|
|
376
|
+
o_members = self.merged_members(object_id, c)
|
|
377
|
+
qs = ",".join("?" for _ in s_members)
|
|
378
|
+
qo = ",".join("?" for _ in o_members)
|
|
379
|
+
rows = c.execute(
|
|
380
|
+
f"SELECT claim_id, observed_at, status, valid_until FROM claims WHERE subject_id IN ({qs})"
|
|
381
|
+
f" AND object_id IN ({qo}) AND predicate=?", [*s_members, *o_members, predicate]).fetchall()
|
|
382
|
+
c.execute("DELETE FROM relations WHERE subject_id=? AND object_id=? AND predicate=?",
|
|
383
|
+
(subject_id, object_id, predicate))
|
|
384
|
+
if not rows:
|
|
385
|
+
return
|
|
386
|
+
ids_ = [r["claim_id"] for r in rows]
|
|
387
|
+
reinf = c.execute(
|
|
388
|
+
f"SELECT COUNT(*), MAX(at) FROM claim_reinforcements WHERE claim_id IN ({','.join('?' for _ in ids_)})",
|
|
389
|
+
ids_).fetchone()
|
|
390
|
+
first = min(r["observed_at"] for r in rows)
|
|
391
|
+
last = max([r["observed_at"] for r in rows] + ([reinf[1]] if reinf[1] else []))
|
|
392
|
+
active = sorted(r["claim_id"] for r in rows if r["status"] == "active" and r["valid_until"] is None)
|
|
393
|
+
c.execute(
|
|
394
|
+
"INSERT INTO relations(subject_id, object_id, predicate, first_seen, last_seen, count, active_claim_ids_json)"
|
|
395
|
+
" VALUES (?,?,?,?,?,?,?)",
|
|
396
|
+
(subject_id, object_id, predicate, first, last, len(rows) + int(reinf[0] or 0), _j(active)))
|
|
397
|
+
|
|
398
|
+
def refresh_relations_for(self, c: sqlite3.Connection, entity_ids: Iterable[str]) -> None:
|
|
399
|
+
"""Recompute every relation touching the given entities (after merges/unmerges)."""
|
|
400
|
+
members: set = set()
|
|
401
|
+
for eid in entity_ids:
|
|
402
|
+
members.update(self.merged_members(self.canonical(eid, c), c))
|
|
403
|
+
members.add(eid)
|
|
404
|
+
if not members:
|
|
405
|
+
return
|
|
406
|
+
q = ",".join("?" for _ in members)
|
|
407
|
+
triples = c.execute(
|
|
408
|
+
f"SELECT DISTINCT subject_id, object_id, predicate FROM claims WHERE object_id IS NOT NULL AND"
|
|
409
|
+
f" (subject_id IN ({q}) OR object_id IN ({q}))", [*members, *members]).fetchall()
|
|
410
|
+
c.execute(f"DELETE FROM relations WHERE subject_id IN ({q}) OR object_id IN ({q})", [*members, *members])
|
|
411
|
+
seen = set()
|
|
412
|
+
for t in triples:
|
|
413
|
+
key = (self.canonical(t[0], c), self.canonical(t[1], c), t[2])
|
|
414
|
+
if key in seen:
|
|
415
|
+
continue
|
|
416
|
+
seen.add(key)
|
|
417
|
+
self.refresh_relation(c, *key)
|
|
418
|
+
|
|
419
|
+
def refresh_entity_stats(self, c: sqlite3.Connection, entity_id: str) -> None:
|
|
420
|
+
members = self.merged_members(entity_id, c)
|
|
421
|
+
q = ",".join("?" for _ in members)
|
|
422
|
+
r = c.execute(f"SELECT MIN(at), MAX(at), COUNT(DISTINCT note_id) FROM entity_notes WHERE entity_id IN ({q})",
|
|
423
|
+
members).fetchone()
|
|
424
|
+
created = c.execute("SELECT created_at FROM entities WHERE entity_id=?", (entity_id,)).fetchone()
|
|
425
|
+
first = min([x for x in (r[0], created[0] if created else None) if x], default=None)
|
|
426
|
+
c.execute("UPDATE entities SET first_seen=?, last_seen=?, note_count=? WHERE entity_id=?",
|
|
427
|
+
(first, r[1] or first, int(r[2] or 0), entity_id))
|
|
428
|
+
|
|
429
|
+
def refresh_entity_fts(self, c: sqlite3.Connection, entity_id: str) -> None:
|
|
430
|
+
r = c.execute("SELECT name, summary FROM entities WHERE entity_id=?", (entity_id,)).fetchone()
|
|
431
|
+
if r is None:
|
|
432
|
+
return
|
|
433
|
+
aliases = [a[0] for a in c.execute("SELECT alias FROM entity_aliases WHERE entity_id=? ORDER BY alias",
|
|
434
|
+
(entity_id,))]
|
|
435
|
+
c.execute("DELETE FROM entities_fts WHERE entity_id=?", (entity_id,))
|
|
436
|
+
c.execute("INSERT INTO entities_fts(entity_id, name, aliases, summary) VALUES (?,?,?,?)",
|
|
437
|
+
(entity_id, r["name"], " ".join(aliases), r["summary"] or ""))
|
|
438
|
+
|
|
439
|
+
def refresh_topic(self, c: sqlite3.Connection, topic: str) -> None:
|
|
440
|
+
r = c.execute(
|
|
441
|
+
"SELECT MIN(n.occurred_at), MAX(n.occurred_at), COUNT(*) FROM note_topics t JOIN notes n"
|
|
442
|
+
" ON n.note_id=t.note_id WHERE t.topic=?", (topic,)).fetchone()
|
|
443
|
+
if not r[2]:
|
|
444
|
+
c.execute("DELETE FROM topics WHERE topic=?", (topic,))
|
|
445
|
+
return
|
|
446
|
+
c.execute("INSERT OR REPLACE INTO topics(topic, first_seen, last_seen, note_count) VALUES (?,?,?,?)",
|
|
447
|
+
(topic, r[0], r[1], r[2]))
|
|
448
|
+
|
|
449
|
+
def assign_topic(self, c: sqlite3.Connection, note_id: str, topic: str, *, by: str,
|
|
450
|
+
revision_id: Optional[str], at: str) -> bool:
|
|
451
|
+
cur = c.execute("INSERT OR IGNORE INTO note_topics(note_id, topic, assigned_by, revision_id, at)"
|
|
452
|
+
" VALUES (?,?,?,?,?)", (note_id, topic, by, revision_id, at))
|
|
453
|
+
self.refresh_topic(c, topic)
|
|
454
|
+
return cur.rowcount > 0
|
|
455
|
+
|
|
456
|
+
# ------------------------------------------------------------------ reads: beliefs and graph
|
|
457
|
+
|
|
458
|
+
def beliefs_at(self, at: str, *, subject_id: Optional[str] = None, entity_id: Optional[str] = None,
|
|
459
|
+
as_received: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
460
|
+
"""Claims valid at ``at``: ``valid_from <= at`` and (``valid_until`` empty or ``> at``); with
|
|
461
|
+
``as_received``, from what the archive held on that date (see :mod:`abstractnote.store.archive`)."""
|
|
462
|
+
from .archive import archive_for
|
|
463
|
+
|
|
464
|
+
at = ids.normalize_time(at, field="at")
|
|
465
|
+
arch = archive_for(self, as_received)
|
|
466
|
+
sql = "SELECT * FROM claims WHERE (valid_from IS NULL OR valid_from<=?)"
|
|
467
|
+
params: List[Any] = [at]
|
|
468
|
+
if arch is None:
|
|
469
|
+
sql += " AND (valid_until IS NULL OR valid_until>?)"
|
|
470
|
+
params.append(at)
|
|
471
|
+
else:
|
|
472
|
+
frag, fp = arch.revisions_in("revision_id")
|
|
473
|
+
sql += f" AND {frag}"
|
|
474
|
+
params += fp
|
|
475
|
+
|
|
476
|
+
def members_of(eid: str) -> List[str]:
|
|
477
|
+
return arch.members(arch.canonical(eid)) if arch else self.merged_members(self.canonical(eid))
|
|
478
|
+
|
|
479
|
+
if subject_id:
|
|
480
|
+
members = members_of(subject_id)
|
|
481
|
+
sql += f" AND subject_id IN ({','.join('?' for _ in members)})"
|
|
482
|
+
params += members
|
|
483
|
+
if entity_id:
|
|
484
|
+
members = members_of(entity_id)
|
|
485
|
+
q = ",".join("?" for _ in members)
|
|
486
|
+
sql += f" AND (subject_id IN ({q}) OR object_id IN ({q}))"
|
|
487
|
+
params += members + members
|
|
488
|
+
sql += " ORDER BY observed_at, claim_id"
|
|
489
|
+
rows = self.q(sql, params)
|
|
490
|
+
if arch is None:
|
|
491
|
+
return [self.claim_dict(r) for r in rows]
|
|
492
|
+
out = []
|
|
493
|
+
for r in rows:
|
|
494
|
+
d = arch.claim_dict(r)
|
|
495
|
+
if d["valid_until"] is None or d["valid_until"] > at:
|
|
496
|
+
out.append(d)
|
|
497
|
+
return out
|
|
498
|
+
|
|
499
|
+
def graph(self, *, focus: Optional[Sequence[str]] = None, at: Optional[str] = None, depth: int = 1,
|
|
500
|
+
since: Optional[str] = None, until: Optional[str] = None, kinds: Optional[Sequence[str]] = None,
|
|
501
|
+
min_count: int = 1, limit: int = 200, as_received: Optional[str] = None) -> Dict[str, Any]:
|
|
502
|
+
"""Nodes and edges as of ``at`` (None = now). An entity exists at T when it was
|
|
503
|
+
first seen at or before T; an edge exists when one of its claims was observed by T.
|
|
504
|
+
``as_received`` restricts both to what the archive held on that date (notes received by then and what
|
|
505
|
+
they caused; :mod:`abstractnote.store.archive`); it composes with ``at``."""
|
|
506
|
+
from .archive import archive_for
|
|
507
|
+
|
|
508
|
+
arch = archive_for(self, as_received)
|
|
509
|
+
at_n = ids.optional_time(at, field="at")
|
|
510
|
+
canon = (lambda e: arch.canonical(e, at_n)) if arch else (lambda e: self.canonical_at(e, at_n))
|
|
511
|
+
since_n = ids.optional_time(since, field="since")
|
|
512
|
+
until_n = ids.optional_time(until, field="until")
|
|
513
|
+
limit = clamp_limit(limit, 200, 2000)
|
|
514
|
+
depth = max(1, min(3, int(depth or 1)))
|
|
515
|
+
bound = at_n
|
|
516
|
+
if until_n and (bound is None or until_n < bound):
|
|
517
|
+
bound = until_n
|
|
518
|
+
sql = "SELECT * FROM claims WHERE object_id IS NOT NULL"
|
|
519
|
+
params: List[Any] = []
|
|
520
|
+
if bound:
|
|
521
|
+
sql += " AND observed_at<=?"
|
|
522
|
+
params.append(bound)
|
|
523
|
+
if since_n:
|
|
524
|
+
sql += " AND observed_at>=?"
|
|
525
|
+
params.append(since_n)
|
|
526
|
+
if arch:
|
|
527
|
+
frag, fp = arch.revisions_in("revision_id")
|
|
528
|
+
sql += f" AND {frag}"
|
|
529
|
+
params += fp
|
|
530
|
+
claims = self.q(sql, params)
|
|
531
|
+
edges: Dict[Tuple[str, str, str], Dict[str, Any]] = {}
|
|
532
|
+
for r in claims:
|
|
533
|
+
s = canon(r["subject_id"])
|
|
534
|
+
o = canon(r["object_id"])
|
|
535
|
+
if s == o:
|
|
536
|
+
continue # both ends are one entity by then (a confirmed close match): no edge to draw
|
|
537
|
+
key = (s, o, r["predicate"])
|
|
538
|
+
e = edges.setdefault(key, {"source": s, "target": o, "predicate": r["predicate"], "first_seen": None,
|
|
539
|
+
"last_seen": None, "count": 0, "active": False, "claim_ids": []})
|
|
540
|
+
e["count"] += 1
|
|
541
|
+
e["claim_ids"].append(r["claim_id"])
|
|
542
|
+
e["first_seen"] = min(filter(None, [e["first_seen"], r["observed_at"]]))
|
|
543
|
+
e["last_seen"] = max(filter(None, [e["last_seen"], r["observed_at"]]))
|
|
544
|
+
ref = at_n or "9999-12-31T23:59:59Z"
|
|
545
|
+
st = arch.claim_state(r) if arch else r
|
|
546
|
+
vf, vu = r["valid_from"], st["valid_until"]
|
|
547
|
+
if (vf is None or vf <= ref) and (vu is None or vu > ref) and (at_n is not None or st["status"] == "active"):
|
|
548
|
+
e["active"] = True
|
|
549
|
+
edge_list = [e for e in edges.values() if e["count"] >= max(1, int(min_count or 1))]
|
|
550
|
+
|
|
551
|
+
nodes: Dict[str, Any]
|
|
552
|
+
if arch:
|
|
553
|
+
nodes = self._archive_nodes(arch, at_n)
|
|
554
|
+
else:
|
|
555
|
+
node_sql = "SELECT * FROM entities WHERE 1=1"
|
|
556
|
+
nparams: List[Any] = []
|
|
557
|
+
if at_n:
|
|
558
|
+
node_sql += " AND COALESCE(first_seen, created_at)<=? AND (merged_into IS NULL OR merged_at>?)"
|
|
559
|
+
nparams += [at_n, at_n]
|
|
560
|
+
else:
|
|
561
|
+
node_sql += " AND merged_into IS NULL"
|
|
562
|
+
nodes = {r["entity_id"]: r for r in self.q(node_sql, nparams)}
|
|
563
|
+
if kinds:
|
|
564
|
+
nodes = {k: v for k, v in nodes.items() if v["kind"] in set(kinds)}
|
|
565
|
+
edge_list = [e for e in edge_list if e["source"] in nodes and e["target"] in nodes]
|
|
566
|
+
|
|
567
|
+
if focus:
|
|
568
|
+
keep = {canon(f) for f in focus}
|
|
569
|
+
frontier = set(keep)
|
|
570
|
+
for _ in range(depth):
|
|
571
|
+
nxt = set()
|
|
572
|
+
for e in edge_list:
|
|
573
|
+
if e["source"] in frontier and e["target"] not in keep:
|
|
574
|
+
nxt.add(e["target"])
|
|
575
|
+
if e["target"] in frontier and e["source"] not in keep:
|
|
576
|
+
nxt.add(e["source"])
|
|
577
|
+
keep |= nxt
|
|
578
|
+
frontier = nxt
|
|
579
|
+
nodes = {k: v for k, v in nodes.items() if k in keep}
|
|
580
|
+
edge_list = [e for e in edge_list if e["source"] in nodes and e["target"] in nodes]
|
|
581
|
+
elif since_n or until_n:
|
|
582
|
+
touched = {e["source"] for e in edge_list} | {e["target"] for e in edge_list}
|
|
583
|
+
nodes = {k: v for k, v in nodes.items() if k in touched or (
|
|
584
|
+
(not since_n or (v["last_seen"] or "") >= since_n) and (not until_n or (v["first_seen"] or "") <= until_n))}
|
|
585
|
+
|
|
586
|
+
ranked = sorted(nodes.values(), key=lambda r: (-(r["note_count"] or 0), r["entity_id"]))[:limit]
|
|
587
|
+
keep_ids = {r["entity_id"] for r in ranked}
|
|
588
|
+
edge_list = [e for e in edge_list if e["source"] in keep_ids and e["target"] in keep_ids]
|
|
589
|
+
out_nodes = []
|
|
590
|
+
for r in ranked:
|
|
591
|
+
summary = r["summary"]
|
|
592
|
+
if at_n and not arch:
|
|
593
|
+
srow = self.q1("SELECT summary FROM entity_summaries WHERE entity_id=? AND at<=? ORDER BY at DESC,"
|
|
594
|
+
" revision_id DESC LIMIT 1", (r["entity_id"], at_n))
|
|
595
|
+
summary = srow[0] if srow else None
|
|
596
|
+
out_nodes.append({"id": r["entity_id"], "kind": r["kind"], "name": r["name"],
|
|
597
|
+
"first_seen": r["first_seen"], "last_seen": r["last_seen"],
|
|
598
|
+
"note_count": r["note_count"], "summary": summary})
|
|
599
|
+
for e in edge_list:
|
|
600
|
+
e.pop("claim_ids", None)
|
|
601
|
+
edge_list.sort(key=lambda e: (e["source"], e["target"], e["predicate"]))
|
|
602
|
+
out: Dict[str, Any] = {"as_of": at_n or ids.utc_now(), "nodes": out_nodes, "edges": edge_list}
|
|
603
|
+
if arch:
|
|
604
|
+
out["as_received"] = arch.at
|
|
605
|
+
return out
|
|
606
|
+
|
|
607
|
+
def _archive_nodes(self, arch: Any, at_n: Optional[str]) -> Dict[str, Dict[str, Any]]:
|
|
608
|
+
"""Graph nodes from what the archive held (as of ``at_n``): stats over the archive's notes of each surviving
|
|
609
|
+
entity and the entries merged into it, its name and summary as the archive's revisions left them."""
|
|
610
|
+
nodes: Dict[str, Dict[str, Any]] = {}
|
|
611
|
+
for eid, st in arch.entity_stats().items():
|
|
612
|
+
c = arch.canonical(eid, at_n)
|
|
613
|
+
agg = nodes.setdefault(c, {"first_seen": st["first_seen"], "last_seen": st["last_seen"], "notes": set()})
|
|
614
|
+
agg["first_seen"] = min(agg["first_seen"], st["first_seen"])
|
|
615
|
+
agg["last_seen"] = max(agg["last_seen"], st["last_seen"])
|
|
616
|
+
agg["notes"] |= st["notes"]
|
|
617
|
+
out: Dict[str, Dict[str, Any]] = {}
|
|
618
|
+
for c, agg in nodes.items():
|
|
619
|
+
row = self.entity_row(c)
|
|
620
|
+
if row is None or (at_n and agg["first_seen"] > at_n):
|
|
621
|
+
continue
|
|
622
|
+
out[c] = {"entity_id": c, "kind": row["kind"], "name": arch.name(c, at_n) or row["name"],
|
|
623
|
+
"first_seen": agg["first_seen"], "last_seen": agg["last_seen"], "note_count": len(agg["notes"]),
|
|
624
|
+
"summary": arch.summary(c, at_n)}
|
|
625
|
+
return out
|
|
626
|
+
|
|
627
|
+
# ------------------------------------------------------------------ reads: lists
|
|
628
|
+
|
|
629
|
+
def list_notes(self, *, q: Optional[str] = None, since: Optional[str] = None, until: Optional[str] = None,
|
|
630
|
+
kind: Optional[str] = None, topic: Optional[str] = None, entity: Optional[str] = None,
|
|
631
|
+
status: Optional[str] = None, cursor: Optional[str] = None, limit: int = 50,
|
|
632
|
+
include_archived: bool = False) -> Dict[str, Any]:
|
|
633
|
+
limit = clamp_limit(limit)
|
|
634
|
+
sql = "SELECT n.* FROM notes n WHERE 1=1"
|
|
635
|
+
params: List[Any] = []
|
|
636
|
+
if not include_archived and status != "archived":
|
|
637
|
+
sql += " AND n.archived_at IS NULL"
|
|
638
|
+
if since:
|
|
639
|
+
sql += " AND n.occurred_at>=?"
|
|
640
|
+
params.append(ids.normalize_time(since, field="since"))
|
|
641
|
+
if until:
|
|
642
|
+
sql += " AND n.occurred_at<=?"
|
|
643
|
+
params.append(ids.normalize_time(until, field="until"))
|
|
644
|
+
if kind:
|
|
645
|
+
sql += " AND n.kind=?"
|
|
646
|
+
params.append(kind)
|
|
647
|
+
if status:
|
|
648
|
+
sql += " AND n.status=?" if status != "archived" else " AND n.archived_at IS NOT NULL"
|
|
649
|
+
if status != "archived":
|
|
650
|
+
params.append(status)
|
|
651
|
+
if topic:
|
|
652
|
+
sql += " AND n.note_id IN (SELECT note_id FROM note_topics WHERE topic=?)"
|
|
653
|
+
params.append(topic)
|
|
654
|
+
if entity:
|
|
655
|
+
members = self.merged_members(self.canonical(entity))
|
|
656
|
+
sql += f" AND n.note_id IN (SELECT note_id FROM entity_notes WHERE entity_id IN ({','.join('?' for _ in members)}))"
|
|
657
|
+
params += members
|
|
658
|
+
if q:
|
|
659
|
+
terms = fts_terms(q)
|
|
660
|
+
if terms:
|
|
661
|
+
sql += " AND n.note_id IN (SELECT note_id FROM notes_fts WHERE notes_fts MATCH ?)"
|
|
662
|
+
params.append(terms)
|
|
663
|
+
cur = decode_cursor(cursor)
|
|
664
|
+
if cur:
|
|
665
|
+
sql += " AND (n.occurred_at<? OR (n.occurred_at=? AND n.note_id<?))"
|
|
666
|
+
params += [cur[0], cur[0], cur[1]]
|
|
667
|
+
sql += " ORDER BY n.occurred_at DESC, n.note_id DESC LIMIT ?"
|
|
668
|
+
params.append(limit + 1)
|
|
669
|
+
rows = self.q(sql, params)
|
|
670
|
+
items = [self.note_dict(r, full=False) for r in rows[:limit]]
|
|
671
|
+
nxt = encode_cursor(rows[limit - 1]["occurred_at"], rows[limit - 1]["note_id"]) if len(rows) > limit else None
|
|
672
|
+
return {"items": items, "next_cursor": nxt}
|
|
673
|
+
|
|
674
|
+
def list_entities(self, *, q: Optional[str] = None, kind: Optional[str] = None, sort: str = "recent",
|
|
675
|
+
cursor: Optional[str] = None, limit: int = 50, include_merged: bool = False) -> Dict[str, Any]:
|
|
676
|
+
limit = clamp_limit(limit)
|
|
677
|
+
sort = sort or "recent"
|
|
678
|
+
if sort not in ("recent", "frequent", "name"):
|
|
679
|
+
raise ValueError("The sort must be recent, frequent or name.")
|
|
680
|
+
sql = "SELECT e.* FROM entities e WHERE 1=1"
|
|
681
|
+
params: List[Any] = []
|
|
682
|
+
if not include_merged:
|
|
683
|
+
sql += " AND e.merged_into IS NULL"
|
|
684
|
+
if kind:
|
|
685
|
+
sql += " AND e.kind=?"
|
|
686
|
+
params.append(kind)
|
|
687
|
+
if q:
|
|
688
|
+
terms = fts_terms(q)
|
|
689
|
+
if terms:
|
|
690
|
+
sql += " AND e.entity_id IN (SELECT entity_id FROM entities_fts WHERE entities_fts MATCH ?)"
|
|
691
|
+
params.append(terms)
|
|
692
|
+
key_col = {"recent": "COALESCE(e.last_seen, '')", "frequent": "printf('%012d', e.note_count)",
|
|
693
|
+
"name": "lower(e.name)"}[sort]
|
|
694
|
+
direction = "ASC" if sort == "name" else "DESC"
|
|
695
|
+
cur = decode_cursor(cursor)
|
|
696
|
+
if cur:
|
|
697
|
+
op = ">" if direction == "ASC" else "<"
|
|
698
|
+
sql += f" AND ({key_col}{op}? OR ({key_col}=? AND e.entity_id{op}?))"
|
|
699
|
+
params += [cur[0], cur[0], cur[1]]
|
|
700
|
+
sql = sql.replace("SELECT e.*", f"SELECT e.*, {key_col} AS sort_key")
|
|
701
|
+
sql += f" ORDER BY sort_key {direction}, e.entity_id {direction} LIMIT ?"
|
|
702
|
+
params.append(limit + 1)
|
|
703
|
+
rows = self.q(sql, params)
|
|
704
|
+
items = [self.entity_brief(r["entity_id"]) for r in rows[:limit]]
|
|
705
|
+
nxt = encode_cursor(rows[limit - 1]["sort_key"], rows[limit - 1]["entity_id"]) if len(rows) > limit else None
|
|
706
|
+
return {"items": items, "next_cursor": nxt}
|
|
707
|
+
|
|
708
|
+
def revisions_feed(self, *, since: Optional[str] = None, until: Optional[str] = None,
|
|
709
|
+
entity: Optional[str] = None, cursor: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
|
|
710
|
+
limit = clamp_limit(limit)
|
|
711
|
+
sql = "SELECT * FROM revisions WHERE applied_index=1"
|
|
712
|
+
params: List[Any] = []
|
|
713
|
+
if since:
|
|
714
|
+
sql += " AND at>=?"
|
|
715
|
+
params.append(ids.normalize_time(since, field="since"))
|
|
716
|
+
if until:
|
|
717
|
+
sql += " AND at<=?"
|
|
718
|
+
params.append(ids.normalize_time(until, field="until"))
|
|
719
|
+
if entity:
|
|
720
|
+
members = self.merged_members(self.canonical(entity))
|
|
721
|
+
likes = " OR ".join("ops_json LIKE ?" for _ in members)
|
|
722
|
+
sql += f" AND ({likes})"
|
|
723
|
+
params += [f'%"{m}"%' for m in members] # exact id string inside the stored ops JSON
|
|
724
|
+
cur = decode_cursor(cursor)
|
|
725
|
+
if cur:
|
|
726
|
+
sql += " AND seq<?"
|
|
727
|
+
params.append(int(cur[0]))
|
|
728
|
+
sql += " ORDER BY seq DESC LIMIT ?"
|
|
729
|
+
params.append(limit + 1)
|
|
730
|
+
rows = self.q(sql, params)
|
|
731
|
+
items = [self.revision_dict(r) for r in rows[:limit]]
|
|
732
|
+
nxt = encode_cursor(rows[limit - 1]["seq"]) if len(rows) > limit else None
|
|
733
|
+
return {"items": items, "next_cursor": nxt}
|
|
734
|
+
|
|
735
|
+
def topics(self) -> List[Dict[str, Any]]:
|
|
736
|
+
return [dict(r) for r in self.q("SELECT topic, first_seen, last_seen, note_count FROM topics"
|
|
737
|
+
" ORDER BY note_count DESC, topic")]
|
|
738
|
+
|
|
739
|
+
def topic_names(self) -> List[str]:
|
|
740
|
+
return [r[0] for r in self.q("SELECT topic FROM topics ORDER BY note_count DESC, topic")]
|
|
741
|
+
|
|
742
|
+
def tasks(self, *, status: Optional[str] = "open", entity: Optional[str] = None, limit: int = 100
|
|
743
|
+
) -> List[Dict[str, Any]]:
|
|
744
|
+
limit = clamp_limit(limit, 100, 1000)
|
|
745
|
+
sql = "SELECT * FROM tasks WHERE 1=1"
|
|
746
|
+
params: List[Any] = []
|
|
747
|
+
if status:
|
|
748
|
+
sql += " AND status=?"
|
|
749
|
+
params.append(status)
|
|
750
|
+
if entity:
|
|
751
|
+
members = self.merged_members(self.canonical(entity))
|
|
752
|
+
sql += (" AND note_id IN (SELECT note_id FROM entity_notes WHERE entity_id IN"
|
|
753
|
+
f" ({','.join('?' for _ in members)}))")
|
|
754
|
+
params += members
|
|
755
|
+
sql += " ORDER BY opened_at, task_id LIMIT ?"
|
|
756
|
+
params.append(limit)
|
|
757
|
+
return [self.task_dict(r) for r in self.q(sql, params)]
|
|
758
|
+
|
|
759
|
+
@staticmethod
|
|
760
|
+
def task_dict(r: sqlite3.Row) -> Dict[str, Any]:
|
|
761
|
+
return {"task_id": r["task_id"], "note_id": r["note_id"], "text": r["text"], "status": r["status"],
|
|
762
|
+
"due": r["due"], "opened_at": r["opened_at"], "closed_at": r["closed_at"],
|
|
763
|
+
"closed_by_note_id": r["closed_by_note_id"]}
|
|
764
|
+
|
|
765
|
+
def insights(self, *, kind: Optional[str] = None, acked: Optional[bool] = None, limit: int = 50
|
|
766
|
+
) -> List[Dict[str, Any]]:
|
|
767
|
+
limit = clamp_limit(limit)
|
|
768
|
+
sql = "SELECT * FROM notes WHERE insight_kind IS NOT NULL AND archived_at IS NULL"
|
|
769
|
+
params: List[Any] = []
|
|
770
|
+
if kind:
|
|
771
|
+
sql += " AND insight_kind=?"
|
|
772
|
+
params.append(kind)
|
|
773
|
+
if acked is True:
|
|
774
|
+
sql += " AND acked_at IS NOT NULL"
|
|
775
|
+
elif acked is False:
|
|
776
|
+
sql += " AND acked_at IS NULL"
|
|
777
|
+
sql += " ORDER BY received_at DESC, note_id DESC LIMIT ?"
|
|
778
|
+
params.append(limit)
|
|
779
|
+
return [self.note_dict(r) for r in self.q(sql, params)]
|
|
780
|
+
|
|
781
|
+
def day(self, day: str, tz: Optional[str] = None, as_received: Optional[str] = None) -> Dict[str, Any]:
|
|
782
|
+
"""Notes, revisions and entities touched on one calendar day in ``tz`` (default UTC); with ``as_received``,
|
|
783
|
+
only what the archive held on that date (notes received by then and the revisions they caused)."""
|
|
784
|
+
from .archive import archive_for
|
|
785
|
+
|
|
786
|
+
arch = archive_for(self, as_received)
|
|
787
|
+
try:
|
|
788
|
+
d = datetime.strptime(day, "%Y-%m-%d")
|
|
789
|
+
except ValueError:
|
|
790
|
+
raise ValueError("The day must be written YYYY-MM-DD.") from None
|
|
791
|
+
zone = timezone.utc
|
|
792
|
+
if tz:
|
|
793
|
+
try:
|
|
794
|
+
from zoneinfo import ZoneInfo
|
|
795
|
+
|
|
796
|
+
zone = ZoneInfo(tz)
|
|
797
|
+
except Exception:
|
|
798
|
+
raise ValueError(f"The time zone {tz!r} is not known.") from None
|
|
799
|
+
start = d.replace(tzinfo=zone).astimezone(timezone.utc)
|
|
800
|
+
end = (d + timedelta(days=1)).replace(tzinfo=zone).astimezone(timezone.utc)
|
|
801
|
+
s, e = start.strftime("%Y-%m-%dT%H:%M:%SZ"), end.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
802
|
+
|
|
803
|
+
def only(col: str, kind: str) -> Tuple[str, List[Any]]:
|
|
804
|
+
if arch is None:
|
|
805
|
+
return "", []
|
|
806
|
+
frag, fp = arch.notes_in(col) if kind == "note" else arch.revisions_in(col)
|
|
807
|
+
return f" AND {frag}", fp
|
|
808
|
+
|
|
809
|
+
f_note, p_note = only("note_id", "note")
|
|
810
|
+
f_rev, p_rev = only("revision_id", "revision")
|
|
811
|
+
f_created, p_created = only("created_revision_id", "revision")
|
|
812
|
+
f_closed, p_closed = only("closed_revision_id", "revision")
|
|
813
|
+
notes = [self.note_dict(r, full=False) for r in self.q(
|
|
814
|
+
"SELECT * FROM notes WHERE occurred_at>=? AND occurred_at<? AND archived_at IS NULL" + f_note +
|
|
815
|
+
" ORDER BY occurred_at, note_id", (s, e, *p_note))]
|
|
816
|
+
revs = [self.revision_dict(r) for r in self.q(
|
|
817
|
+
"SELECT * FROM revisions WHERE applied_index=1 AND at>=? AND at<?" + f_rev + " ORDER BY at, seq",
|
|
818
|
+
(s, e, *p_rev))]
|
|
819
|
+
touched = [self.entity_brief(r[0]) for r in self.q(
|
|
820
|
+
"SELECT DISTINCT entity_id FROM entity_notes WHERE at>=? AND at<?" + f_note + " ORDER BY entity_id",
|
|
821
|
+
(s, e, *p_note))]
|
|
822
|
+
created = [r[0] for r in self.q("SELECT entity_id FROM entities WHERE created_at>=? AND created_at<?" +
|
|
823
|
+
f_created, (s, e, *p_created))]
|
|
824
|
+
superseded = [self.claim_dict(r) for r in self.q(
|
|
825
|
+
"SELECT * FROM claims WHERE status='superseded' AND valid_until>=? AND valid_until<?" + f_closed,
|
|
826
|
+
(s, e, *p_closed))]
|
|
827
|
+
for r in revs:
|
|
828
|
+
r.pop("ops", None)
|
|
829
|
+
out = {"day": day, "tz": tz or "UTC", "start": s, "end": e, "notes": notes, "revisions": revs,
|
|
830
|
+
"entities_touched": [t for t in touched if t], "entities_created": created,
|
|
831
|
+
"claims_superseded": superseded}
|
|
832
|
+
if arch:
|
|
833
|
+
out["as_received"] = arch.at
|
|
834
|
+
return out
|
|
835
|
+
|
|
836
|
+
def timeline(self, *, since: Optional[str] = None, until: Optional[str] = None,
|
|
837
|
+
granularity: str = "day") -> Dict[str, Any]:
|
|
838
|
+
"""Activity per bucket (history time): notes, revisions, new entities, superseded claims."""
|
|
839
|
+
width = {"day": 10, "month": 7, "week": None}
|
|
840
|
+
if granularity not in width:
|
|
841
|
+
raise ValueError("The granularity must be day, week or month.")
|
|
842
|
+
since_n = ids.optional_time(since, field="since")
|
|
843
|
+
until_n = ids.optional_time(until, field="until")
|
|
844
|
+
|
|
845
|
+
def bucket(ts: Optional[str]) -> Optional[str]:
|
|
846
|
+
if not ts:
|
|
847
|
+
return None
|
|
848
|
+
if granularity == "week":
|
|
849
|
+
dt = ids.parse_time(ts)
|
|
850
|
+
monday = dt - timedelta(days=dt.weekday())
|
|
851
|
+
return monday.strftime("%Y-%m-%d")
|
|
852
|
+
return ts[: width[granularity]]
|
|
853
|
+
|
|
854
|
+
def rng(col: str, table: str, extra: str = "") -> List[str]:
|
|
855
|
+
sql = f"SELECT {col} FROM {table} WHERE {col} IS NOT NULL {extra}"
|
|
856
|
+
params: List[Any] = []
|
|
857
|
+
if since_n:
|
|
858
|
+
sql += f" AND {col}>=?"
|
|
859
|
+
params.append(since_n)
|
|
860
|
+
if until_n:
|
|
861
|
+
sql += f" AND {col}<=?"
|
|
862
|
+
params.append(until_n)
|
|
863
|
+
return [r[0] for r in self.q(sql, params)]
|
|
864
|
+
|
|
865
|
+
buckets: Dict[str, Dict[str, int]] = {}
|
|
866
|
+
for name, values in (
|
|
867
|
+
("notes", rng("occurred_at", "notes", "AND archived_at IS NULL")),
|
|
868
|
+
("revisions", rng("at", "revisions", "AND applied_index=1")),
|
|
869
|
+
("entities_created", rng("created_at", "entities")),
|
|
870
|
+
("claims_superseded", rng("valid_until", "claims", "AND status='superseded'")),
|
|
871
|
+
):
|
|
872
|
+
for v in values:
|
|
873
|
+
b = bucket(v)
|
|
874
|
+
if b is None:
|
|
875
|
+
continue
|
|
876
|
+
buckets.setdefault(b, {"notes": 0, "revisions": 0, "entities_created": 0, "claims_superseded": 0})
|
|
877
|
+
buckets[b][name] += 1
|
|
878
|
+
return {"granularity": granularity, "since": since_n, "until": until_n,
|
|
879
|
+
"buckets": [{"bucket": k, **v} for k, v in sorted(buckets.items())]}
|
|
880
|
+
|
|
881
|
+
def counts(self) -> Dict[str, int]:
|
|
882
|
+
def n(sql: str) -> int:
|
|
883
|
+
return int(self.q1(sql)[0])
|
|
884
|
+
|
|
885
|
+
return {
|
|
886
|
+
"notes": n("SELECT COUNT(*) FROM notes WHERE insight_kind IS NULL"),
|
|
887
|
+
"insights": n("SELECT COUNT(*) FROM notes WHERE insight_kind IS NOT NULL"),
|
|
888
|
+
"attachments": n("SELECT COUNT(*) FROM attachments"),
|
|
889
|
+
"entities": n("SELECT COUNT(*) FROM entities WHERE merged_into IS NULL"),
|
|
890
|
+
"entities_merged": n("SELECT COUNT(*) FROM entities WHERE merged_into IS NOT NULL"),
|
|
891
|
+
"claims": n("SELECT COUNT(*) FROM claims"),
|
|
892
|
+
"claims_active": n("SELECT COUNT(*) FROM claims WHERE status='active'"),
|
|
893
|
+
"relations": n("SELECT COUNT(*) FROM relations"),
|
|
894
|
+
"revisions": n("SELECT COUNT(*) FROM revisions WHERE applied_index=1"),
|
|
895
|
+
"topics": n("SELECT COUNT(*) FROM topics"),
|
|
896
|
+
"tasks_open": n("SELECT COUNT(*) FROM tasks WHERE status='open'"),
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
class _TakenIds:
|
|
901
|
+
"""Container view of used entity ids (``in`` support for ``ids.mint_entity_id``)."""
|
|
902
|
+
|
|
903
|
+
def __init__(self, store: NoteStore, extra: Optional[set] = None) -> None:
|
|
904
|
+
self._store = store
|
|
905
|
+
self.extra = set(extra or ())
|
|
906
|
+
|
|
907
|
+
def __contains__(self, entity_id: object) -> bool:
|
|
908
|
+
return entity_id in self.extra or self._store.entity_row(str(entity_id)) is not None
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def fts_terms(text: str) -> str:
|
|
912
|
+
"""FTS5 MATCH string: every word of the query as a quoted term, OR-joined (BM25 ranks)."""
|
|
913
|
+
words = [w for w in "".join(ch if ch.isalnum() else " " for ch in str(text or "")).split() if w]
|
|
914
|
+
return " OR ".join(f'"{w}"' for w in words)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
class _OpContext:
|
|
918
|
+
"""Applies ops inside the projection transaction."""
|
|
919
|
+
|
|
920
|
+
def __init__(self, store: NoteStore, c: sqlite3.Connection, rev: Dict[str, Any], stats: Dict[str, Any],
|
|
921
|
+
keys: Any) -> None:
|
|
922
|
+
self.s = store
|
|
923
|
+
self.c = c
|
|
924
|
+
self.rev = rev
|
|
925
|
+
self.stats = stats
|
|
926
|
+
self.at = rev["at"]
|
|
927
|
+
self.recorded_at = rev["recorded_at"]
|
|
928
|
+
self.rid = rev["revision_id"]
|
|
929
|
+
self.note_id = rev.get("note_id")
|
|
930
|
+
self.keys = keys
|
|
931
|
+
self.dirty_entities: set = set()
|
|
932
|
+
self.dirty_relations: set = set()
|
|
933
|
+
self.dirty_relation_entities: set = set()
|
|
934
|
+
|
|
935
|
+
def apply(self, op: str, p: Dict[str, Any]) -> None:
|
|
936
|
+
fn = getattr(self, "op_" + op.replace(".", "_"), None)
|
|
937
|
+
if fn is None:
|
|
938
|
+
raise ValueError(f"The revision op {op!r} is not known.")
|
|
939
|
+
fn(p)
|
|
940
|
+
|
|
941
|
+
# --- entities
|
|
942
|
+
def op_entity_create(self, p: Dict[str, Any]) -> None:
|
|
943
|
+
eid = p["entity_id"]
|
|
944
|
+
if self.c.execute("SELECT 1 FROM entities WHERE entity_id=?", (eid,)).fetchone():
|
|
945
|
+
return
|
|
946
|
+
self.c.execute(
|
|
947
|
+
"INSERT INTO entities(entity_id, kind, name, summary, created_at, created_revision_id, recorded_at,"
|
|
948
|
+
" first_seen, last_seen, note_count) VALUES (?,?,?,?,?,?,?,?,?,0)",
|
|
949
|
+
(eid, p["kind"], p["name"], p.get("summary"), self.at, self.rid, self.recorded_at, self.at, self.at))
|
|
950
|
+
self.c.execute("INSERT OR IGNORE INTO entity_names(entity_id, name, at, revision_id) VALUES (?,?,?,?)",
|
|
951
|
+
(eid, p["name"], self.at, self.rid))
|
|
952
|
+
for alias in p.get("aliases") or []:
|
|
953
|
+
self._alias(eid, alias)
|
|
954
|
+
if p.get("summary"):
|
|
955
|
+
self.c.execute("INSERT OR IGNORE INTO entity_summaries(entity_id, revision_id, at, note_id, summary,"
|
|
956
|
+
" previous) VALUES (?,?,?,?,?,NULL)", (eid, self.rid, self.at, self.note_id, p["summary"]))
|
|
957
|
+
self.stats["entities_created"].append(eid)
|
|
958
|
+
self.dirty_entities.add(eid)
|
|
959
|
+
|
|
960
|
+
def _alias(self, eid: str, alias: str) -> None:
|
|
961
|
+
alias = str(alias or "").strip()
|
|
962
|
+
if not alias:
|
|
963
|
+
return
|
|
964
|
+
f = ids.fold(alias)
|
|
965
|
+
name = self.c.execute("SELECT name FROM entities WHERE entity_id=?", (eid,)).fetchone()
|
|
966
|
+
if name is not None and ids.fold(name[0]) == f:
|
|
967
|
+
return
|
|
968
|
+
self.c.execute("INSERT OR IGNORE INTO entity_aliases(entity_id, alias, alias_fold, added_at, revision_id)"
|
|
969
|
+
" VALUES (?,?,?,?,?)", (eid, alias, f, self.at, self.rid))
|
|
970
|
+
self.dirty_entities.add(eid)
|
|
971
|
+
|
|
972
|
+
def op_entity_alias(self, p: Dict[str, Any]) -> None:
|
|
973
|
+
self._require_entity(p["entity_id"])
|
|
974
|
+
self._alias(p["entity_id"], p["alias"])
|
|
975
|
+
|
|
976
|
+
def op_entity_rename(self, p: Dict[str, Any]) -> None:
|
|
977
|
+
eid = p["entity_id"]
|
|
978
|
+
row = self._require_entity(eid)
|
|
979
|
+
previous = row["name"]
|
|
980
|
+
if previous == p["name"]:
|
|
981
|
+
return
|
|
982
|
+
self.c.execute("UPDATE entities SET name=? WHERE entity_id=?", (p["name"], eid))
|
|
983
|
+
self.c.execute("INSERT OR IGNORE INTO entity_names(entity_id, name, at, revision_id) VALUES (?,?,?,?)",
|
|
984
|
+
(eid, p["name"], self.at, self.rid))
|
|
985
|
+
self.c.execute("DELETE FROM entity_aliases WHERE entity_id=? AND alias_fold=?", (eid, ids.fold(p["name"])))
|
|
986
|
+
self._alias(eid, previous)
|
|
987
|
+
self.dirty_entities.add(eid)
|
|
988
|
+
|
|
989
|
+
def op_entity_summary(self, p: Dict[str, Any]) -> None:
|
|
990
|
+
eid = p["entity_id"]
|
|
991
|
+
row = self._require_entity(eid)
|
|
992
|
+
self.c.execute("INSERT OR IGNORE INTO entity_summaries(entity_id, revision_id, at, note_id, summary, previous)"
|
|
993
|
+
" VALUES (?,?,?,?,?,?)", (eid, self.rid, self.at, self.note_id, p["summary"], row["summary"]))
|
|
994
|
+
latest = self.c.execute("SELECT summary FROM entity_summaries WHERE entity_id=? ORDER BY at DESC,"
|
|
995
|
+
" rowid DESC LIMIT 1", (eid,)).fetchone()
|
|
996
|
+
self.c.execute("UPDATE entities SET summary=? WHERE entity_id=?", (latest[0] if latest else p["summary"], eid))
|
|
997
|
+
self.dirty_entities.add(eid)
|
|
998
|
+
|
|
999
|
+
def op_entity_merge(self, p: Dict[str, Any]) -> None:
|
|
1000
|
+
from_id, into_id = p["from_id"], p["into_id"]
|
|
1001
|
+
self._require_entity(from_id)
|
|
1002
|
+
self._require_entity(into_id)
|
|
1003
|
+
if from_id == into_id or self.s.canonical(into_id, self.c) == from_id:
|
|
1004
|
+
raise ValueError(f"Merging {from_id} into {into_id} would make a cycle.")
|
|
1005
|
+
self.c.execute("UPDATE entities SET merged_into=?, merged_at=? WHERE entity_id=?", (into_id, self.at, from_id))
|
|
1006
|
+
self.c.execute("INSERT OR IGNORE INTO merges(from_id, into_id, at, revision_id, reason, assertion_id)"
|
|
1007
|
+
" VALUES (?,?,?,?,?,?)", (from_id, into_id, self.at, self.rid, p.get("reason"),
|
|
1008
|
+
ids.kg_assertion_id(from_id, "schema:sameAs", into_id)))
|
|
1009
|
+
self.stats["entities_merged"].append({"from_id": from_id, "into_id": into_id})
|
|
1010
|
+
self.dirty_relation_entities.update({from_id, into_id})
|
|
1011
|
+
self.dirty_entities.update({from_id, into_id})
|
|
1012
|
+
|
|
1013
|
+
def op_entity_unmerge(self, p: Dict[str, Any]) -> None:
|
|
1014
|
+
into_id, from_id = p["entity_id"], p["from_id"]
|
|
1015
|
+
row = self._require_entity(from_id)
|
|
1016
|
+
if row["merged_into"] != into_id:
|
|
1017
|
+
raise ValueError(f"{from_id} is not merged into {into_id}.")
|
|
1018
|
+
self.c.execute("UPDATE entities SET merged_into=NULL, merged_at=NULL WHERE entity_id=?", (from_id,))
|
|
1019
|
+
self.c.execute("UPDATE merges SET undone_at=?, undone_revision_id=? WHERE from_id=? AND into_id=? AND"
|
|
1020
|
+
" undone_at IS NULL", (self.at, self.rid, from_id, into_id))
|
|
1021
|
+
self.dirty_relation_entities.update({from_id, into_id})
|
|
1022
|
+
self.dirty_entities.update({from_id, into_id})
|
|
1023
|
+
|
|
1024
|
+
def op_entity_mention(self, p: Dict[str, Any]) -> None:
|
|
1025
|
+
eid = p["entity_id"]
|
|
1026
|
+
self._require_entity(eid)
|
|
1027
|
+
note_id = p.get("note_id") or self.note_id
|
|
1028
|
+
at = self.at
|
|
1029
|
+
if note_id:
|
|
1030
|
+
nrow = self.c.execute("SELECT occurred_at FROM notes WHERE note_id=?", (note_id,)).fetchone()
|
|
1031
|
+
if nrow:
|
|
1032
|
+
at = nrow[0]
|
|
1033
|
+
self.c.execute("INSERT OR IGNORE INTO entity_notes(entity_id, note_id, at, revision_id) VALUES (?,?,?,?)",
|
|
1034
|
+
(eid, note_id, at, self.rid))
|
|
1035
|
+
self.dirty_entities.add(eid)
|
|
1036
|
+
|
|
1037
|
+
def _require_entity(self, eid: str) -> sqlite3.Row:
|
|
1038
|
+
row = self.c.execute("SELECT * FROM entities WHERE entity_id=?", (eid,)).fetchone()
|
|
1039
|
+
if row is None:
|
|
1040
|
+
raise ValueError(f"The entity {eid} does not exist in this notebook.")
|
|
1041
|
+
return row
|
|
1042
|
+
|
|
1043
|
+
# --- claims
|
|
1044
|
+
def op_claim_add(self, p: Dict[str, Any]) -> None:
|
|
1045
|
+
cid = p["claim_id"]
|
|
1046
|
+
if self.c.execute("SELECT 1 FROM claims WHERE claim_id=?", (cid,)).fetchone():
|
|
1047
|
+
return
|
|
1048
|
+
self._require_entity(p["subject_id"])
|
|
1049
|
+
if p.get("object_id"):
|
|
1050
|
+
self._require_entity(p["object_id"])
|
|
1051
|
+
subject_name = self.c.execute("SELECT name FROM entities WHERE entity_id=?", (p["subject_id"],)).fetchone()[0]
|
|
1052
|
+
disputed = [p["disputes"]] if p.get("disputes") else []
|
|
1053
|
+
seq = self.c.execute("SELECT COALESCE(MAX(seq), 0) + 1 FROM claims").fetchone()[0]
|
|
1054
|
+
self.c.execute(
|
|
1055
|
+
"INSERT INTO claims(claim_id, subject_id, predicate, object_id, object_literal, certainty, stance,"
|
|
1056
|
+
" observed_at, valid_from, valid_until, status, evidence, note_id, revision_id, recorded_at, assertion_id,"
|
|
1057
|
+
" vector_key, disputed_with_json, seq) VALUES (?,?,?,?,?,?,?,?,?,?,'active',?,?,?,?,?,?,?,?)",
|
|
1058
|
+
(cid, p["subject_id"], p["predicate"], p.get("object_id"), p.get("object_literal"), p["certainty"],
|
|
1059
|
+
p.get("stance") or "assert", p["observed_at"], p.get("valid_from"), p.get("valid_until"),
|
|
1060
|
+
p.get("evidence"), p.get("note_id") or self.note_id, self.rid, self.recorded_at, cid,
|
|
1061
|
+
self.keys.claim(p, self.c), _j(disputed), seq))
|
|
1062
|
+
if p.get("disputes"):
|
|
1063
|
+
other = self.c.execute("SELECT disputed_with_json FROM claims WHERE claim_id=?", (p["disputes"],)).fetchone()
|
|
1064
|
+
if other is not None:
|
|
1065
|
+
lst = _uj(other[0], [])
|
|
1066
|
+
if cid not in lst:
|
|
1067
|
+
lst.append(cid)
|
|
1068
|
+
self.c.execute("UPDATE claims SET disputed_with_json=? WHERE claim_id=?", (_j(lst), p["disputes"]))
|
|
1069
|
+
obj_text = p.get("object_literal") or ""
|
|
1070
|
+
if p.get("object_id"):
|
|
1071
|
+
obj_text = self.c.execute("SELECT name FROM entities WHERE entity_id=?", (p["object_id"],)).fetchone()[0]
|
|
1072
|
+
self.c.execute("INSERT INTO claims_fts(claim_id, text) VALUES (?,?)",
|
|
1073
|
+
(cid, NoteStore.claim_text(subject_name, p["predicate"], obj_text, p.get("evidence"))))
|
|
1074
|
+
self.stats["claims_added"].append(cid)
|
|
1075
|
+
self.stats["touched_claims"].append(cid)
|
|
1076
|
+
self.dirty_entities.add(p["subject_id"])
|
|
1077
|
+
if p.get("object_id"):
|
|
1078
|
+
self.dirty_entities.add(p["object_id"])
|
|
1079
|
+
self.dirty_relations.add((p["subject_id"], p["object_id"], p["predicate"]))
|
|
1080
|
+
|
|
1081
|
+
def _claim(self, cid: str) -> sqlite3.Row:
|
|
1082
|
+
row = self.c.execute("SELECT * FROM claims WHERE claim_id=?", (cid,)).fetchone()
|
|
1083
|
+
if row is None:
|
|
1084
|
+
raise ValueError(f"The claim {cid} does not exist in this notebook.")
|
|
1085
|
+
return row
|
|
1086
|
+
|
|
1087
|
+
def _dirty_claim_relation(self, row: sqlite3.Row) -> None:
|
|
1088
|
+
self.dirty_entities.add(row["subject_id"]) # its history changed (then-versus-now cache key)
|
|
1089
|
+
if row["object_id"]:
|
|
1090
|
+
self.dirty_entities.add(row["object_id"])
|
|
1091
|
+
self.dirty_relations.add((row["subject_id"], row["object_id"], row["predicate"]))
|
|
1092
|
+
|
|
1093
|
+
def op_claim_supersede(self, p: Dict[str, Any]) -> None:
|
|
1094
|
+
row = self._claim(p["claim_id"])
|
|
1095
|
+
cur = self.c.execute(
|
|
1096
|
+
"UPDATE claims SET status='superseded', superseded_by=?, valid_until=COALESCE(valid_until, ?),"
|
|
1097
|
+
" closed_reason=?, closed_revision_id=? WHERE claim_id=? AND status='active'",
|
|
1098
|
+
(p.get("by_claim_id"), p["valid_until"], p.get("reason"), self.rid, p["claim_id"]))
|
|
1099
|
+
if cur.rowcount:
|
|
1100
|
+
self.stats["claims_superseded"].append(p["claim_id"])
|
|
1101
|
+
self._dirty_claim_relation(row)
|
|
1102
|
+
|
|
1103
|
+
def op_claim_retract(self, p: Dict[str, Any]) -> None:
|
|
1104
|
+
row = self._claim(p["claim_id"])
|
|
1105
|
+
cur = self.c.execute(
|
|
1106
|
+
"UPDATE claims SET status='retracted', valid_until=COALESCE(valid_until, ?), closed_reason=?,"
|
|
1107
|
+
" closed_revision_id=? WHERE claim_id=? AND status!='retracted'",
|
|
1108
|
+
(p.get("valid_until") or self.at, p.get("reason"), self.rid, p["claim_id"]))
|
|
1109
|
+
if cur.rowcount:
|
|
1110
|
+
self.stats["claims_retracted"].append(p["claim_id"])
|
|
1111
|
+
self._dirty_claim_relation(row)
|
|
1112
|
+
|
|
1113
|
+
def op_claim_reinforce(self, p: Dict[str, Any]) -> None:
|
|
1114
|
+
row = self._claim(p["claim_id"])
|
|
1115
|
+
note_id = p.get("note_id") or self.note_id
|
|
1116
|
+
at = self.at
|
|
1117
|
+
cur = self.c.execute("INSERT OR IGNORE INTO claim_reinforcements(claim_id, note_id, revision_id, at)"
|
|
1118
|
+
" VALUES (?,?,?,?)", (p["claim_id"], note_id, self.rid, at))
|
|
1119
|
+
if cur.rowcount:
|
|
1120
|
+
self.c.execute("UPDATE claims SET reinforced_count=reinforced_count+1 WHERE claim_id=?", (p["claim_id"],))
|
|
1121
|
+
self.stats["claims_reinforced"].append(p["claim_id"])
|
|
1122
|
+
self._dirty_claim_relation(row)
|
|
1123
|
+
|
|
1124
|
+
# --- topics, tasks, notes
|
|
1125
|
+
def op_topic_assign(self, p: Dict[str, Any]) -> None:
|
|
1126
|
+
note_id = p.get("note_id") or self.note_id
|
|
1127
|
+
self.s.assign_topic(self.c, note_id, p["topic"], by="revision", revision_id=self.rid, at=self.at)
|
|
1128
|
+
|
|
1129
|
+
def op_task_open(self, p: Dict[str, Any]) -> None:
|
|
1130
|
+
cur = self.c.execute(
|
|
1131
|
+
"INSERT OR IGNORE INTO tasks(task_id, note_id, text, status, due, opened_at, opened_revision_id)"
|
|
1132
|
+
" VALUES (?,?,?,'open',?,?,?)",
|
|
1133
|
+
(p["task_id"], p.get("note_id") or self.note_id, p["text"], p.get("due"), self.at, self.rid))
|
|
1134
|
+
if cur.rowcount:
|
|
1135
|
+
self.stats["tasks_opened"].append(p["task_id"])
|
|
1136
|
+
|
|
1137
|
+
def _close_task(self, p: Dict[str, Any], status: str) -> None:
|
|
1138
|
+
row = self.c.execute("SELECT status FROM tasks WHERE task_id=?", (p["task_id"],)).fetchone()
|
|
1139
|
+
if row is None:
|
|
1140
|
+
raise ValueError(f"The task {p['task_id']} does not exist in this notebook.")
|
|
1141
|
+
cur = self.c.execute(
|
|
1142
|
+
"UPDATE tasks SET status=?, closed_at=?, closed_by_note_id=?, closed_revision_id=?, recorded_closed_at=?"
|
|
1143
|
+
" WHERE task_id=? AND status='open'",
|
|
1144
|
+
(status, self.at, p.get("by_note_id"), self.rid, self.recorded_at, p["task_id"]))
|
|
1145
|
+
if cur.rowcount and status == "done":
|
|
1146
|
+
self.stats["tasks_closed"].append(p["task_id"])
|
|
1147
|
+
|
|
1148
|
+
def op_task_close(self, p: Dict[str, Any]) -> None:
|
|
1149
|
+
self._close_task(p, "done")
|
|
1150
|
+
|
|
1151
|
+
def op_task_drop(self, p: Dict[str, Any]) -> None:
|
|
1152
|
+
self._close_task(p, "dropped")
|
|
1153
|
+
|
|
1154
|
+
def op_note_summary(self, p: Dict[str, Any]) -> None:
|
|
1155
|
+
note_id = p.get("note_id") or self.note_id
|
|
1156
|
+
sets, params = ["summary=?"], [p.get("summary")]
|
|
1157
|
+
if p.get("kind"):
|
|
1158
|
+
sets.append("kind=?")
|
|
1159
|
+
params.append(p["kind"])
|
|
1160
|
+
if p.get("language"):
|
|
1161
|
+
sets.append("language=?")
|
|
1162
|
+
params.append(p["language"])
|
|
1163
|
+
self.c.execute(f"UPDATE notes SET {', '.join(sets)} WHERE note_id=?", [*params, note_id])
|
|
1164
|
+
self.s.refresh_note_fts(self.c, note_id)
|
|
1165
|
+
|
|
1166
|
+
# --- end of revision
|
|
1167
|
+
def finish(self) -> None:
|
|
1168
|
+
if self.dirty_relation_entities:
|
|
1169
|
+
self.s.refresh_relations_for(self.c, self.dirty_relation_entities)
|
|
1170
|
+
for s, o, p in sorted(self.dirty_relations):
|
|
1171
|
+
self.s.refresh_relation(self.c, self.s.canonical(s, self.c), self.s.canonical(o, self.c), p)
|
|
1172
|
+
stat_targets = set()
|
|
1173
|
+
for eid in self.dirty_entities:
|
|
1174
|
+
stat_targets.add(eid)
|
|
1175
|
+
stat_targets.add(self.s.canonical(eid, self.c))
|
|
1176
|
+
for eid in sorted(stat_targets):
|
|
1177
|
+
self.s.refresh_entity_stats(self.c, eid)
|
|
1178
|
+
row = self.c.execute("SELECT name, summary FROM entities WHERE entity_id=?", (eid,)).fetchone()
|
|
1179
|
+
aliases = [a[0] for a in self.c.execute(
|
|
1180
|
+
"SELECT alias FROM entity_aliases WHERE entity_id=? ORDER BY added_at, alias", (eid,))]
|
|
1181
|
+
key = self.keys.entity(entity_text(row["name"], aliases, row["summary"]))
|
|
1182
|
+
self.c.execute("UPDATE entities SET vector_key=?, revision_count=revision_count+1 WHERE entity_id=?",
|
|
1183
|
+
(key, eid))
|
|
1184
|
+
self.s.refresh_entity_fts(self.c, eid)
|
|
1185
|
+
self.stats["touched_entities"] = sorted(stat_targets)
|
|
1186
|
+
if self.note_id and not self.rev.get("action"):
|
|
1187
|
+
self.c.execute("UPDATE notes SET status=CASE WHEN status='archived' THEN status ELSE 'processed' END,"
|
|
1188
|
+
" processed_at=? WHERE note_id=?", (self.recorded_at, self.note_id))
|
|
1189
|
+
self.c.execute("UPDATE queue SET state='done', done_at=?, leased_by=NULL, lease_until=NULL,"
|
|
1190
|
+
" last_error=NULL WHERE note_id=?", (self.recorded_at, self.note_id))
|