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.
Files changed (80) hide show
  1. abstractnote/__init__.py +18 -0
  2. abstractnote/cli.py +240 -0
  3. abstractnote/flows/__init__.py +2 -0
  4. abstractnote/flows/build.py +970 -0
  5. abstractnote/flows/bundle.py +62 -0
  6. abstractnote/flows/contract.py +88 -0
  7. abstractnote/flows/graph.py +540 -0
  8. abstractnote/flows/json/note-capture.json +2280 -0
  9. abstractnote/flows/json/note-drain.json +2012 -0
  10. abstractnote/flows/json/note-mine.json +2392 -0
  11. abstractnote/flows/json/note-process.json +1853 -0
  12. abstractnote/flows/json/note-recall.json +1876 -0
  13. abstractnote/flows/json/note-reflect.json +2057 -0
  14. abstractnote/flows/prompts_fallback.py +119 -0
  15. abstractnote/flows/runner.py +320 -0
  16. abstractnote/flows/schemas_fallback.py +175 -0
  17. abstractnote/gateway/__init__.py +24 -0
  18. abstractnote/gateway/appspec.py +10 -0
  19. abstractnote/gateway/router.py +571 -0
  20. abstractnote/gateway/tools.py +74 -0
  21. abstractnote/ids.py +177 -0
  22. abstractnote/importers/__init__.py +142 -0
  23. abstractnote/importers/__main__.py +3 -0
  24. abstractnote/importers/agora.py +203 -0
  25. abstractnote/importers/claude_sessions.py +415 -0
  26. abstractnote/importers/cli.py +113 -0
  27. abstractnote/importers/files.py +72 -0
  28. abstractnote/importers/markdown.py +165 -0
  29. abstractnote/ingest/__init__.py +7 -0
  30. abstractnote/ingest/blobs.py +47 -0
  31. abstractnote/ingest/capture.py +265 -0
  32. abstractnote/ingest/extract.py +118 -0
  33. abstractnote/ingest/normalise.py +95 -0
  34. abstractnote/model.py +229 -0
  35. abstractnote/process/__init__.py +5 -0
  36. abstractnote/process/context.py +218 -0
  37. abstractnote/process/merge.py +60 -0
  38. abstractnote/process/prompts/__init__.py +39 -0
  39. abstractnote/process/prompts/extract.md +30 -0
  40. abstractnote/process/prompts/link.md +32 -0
  41. abstractnote/process/prompts/mine_dossier.md +13 -0
  42. abstractnote/process/prompts/mine_findings.md +13 -0
  43. abstractnote/process/prompts/mine_plan.md +13 -0
  44. abstractnote/process/prompts/quick_filing.md +23 -0
  45. abstractnote/process/prompts/reflect.md +10 -0
  46. abstractnote/process/prompts/synthesize.md +17 -0
  47. abstractnote/process/resolve.py +96 -0
  48. abstractnote/process/revise.py +410 -0
  49. abstractnote/process/schemas.py +377 -0
  50. abstractnote/process/vocabulary.py +68 -0
  51. abstractnote/recall/__init__.py +1 -0
  52. abstractnote/recall/changes.py +191 -0
  53. abstractnote/recall/expand.py +379 -0
  54. abstractnote/recall/insights.py +62 -0
  55. abstractnote/recall/reflect.py +202 -0
  56. abstractnote/recall/review.py +191 -0
  57. abstractnote/recall/search.py +205 -0
  58. abstractnote/recall/shapes.py +52 -0
  59. abstractnote/recall/then_now.py +100 -0
  60. abstractnote/store/__init__.py +12 -0
  61. abstractnote/store/archive.py +166 -0
  62. abstractnote/store/embeddings.py +275 -0
  63. abstractnote/store/kg.py +219 -0
  64. abstractnote/store/notebook.py +829 -0
  65. abstractnote/store/notes.py +1190 -0
  66. abstractnote/store/queue.py +159 -0
  67. abstractnote/store/schema.py +348 -0
  68. abstractnote/store/vector_index.py +88 -0
  69. abstractnote/tools/__init__.py +215 -0
  70. abstractnote/tools/capture.py +121 -0
  71. abstractnote/tools/context.py +19 -0
  72. abstractnote/tools/process.py +22 -0
  73. abstractnote/tools/queue.py +23 -0
  74. abstractnote/tools/recall.py +64 -0
  75. abstractnote-0.1.0.dist-info/METADATA +145 -0
  76. abstractnote-0.1.0.dist-info/RECORD +80 -0
  77. abstractnote-0.1.0.dist-info/WHEEL +5 -0
  78. abstractnote-0.1.0.dist-info/entry_points.txt +2 -0
  79. abstractnote-0.1.0.dist-info/licenses/LICENSE +21 -0
  80. 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))