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,379 @@
1
+ """Graph expansion for standard recall: entity cards as of a moment, claim and relation
2
+ timelines, neighbours, then-versus-now pairs, and the evidence chain of a claim."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Dict, Iterable, List, Optional, Sequence
7
+
8
+ from .. import ids
9
+ from ..store.notes import _uj, clamp_limit
10
+ from . import shapes
11
+ from .then_now import relation_then_now, then_now
12
+
13
+ MAX_EXPAND_ENTITIES = 10
14
+ MAX_RELATION_PAIRS = 10
15
+ CARD_NOTES = 10
16
+
17
+
18
+ def _members(nb: Any, entity_id: str) -> List[str]:
19
+ return nb.store.merged_members(nb.store.canonical(entity_id))
20
+
21
+
22
+ def _require_entity(nb: Any, entity_id: str) -> Any:
23
+ row = nb.store.entity_row(entity_id)
24
+ if row is None:
25
+ raise ValueError("That entity is not in this notebook.")
26
+ return row
27
+
28
+
29
+ def entity_card(nb: Any, entity_id: str, at: Optional[str] = None, as_received: Optional[str] = None
30
+ ) -> Dict[str, Any]:
31
+ """The entity as known at ``at`` (default now): name, aliases, summary and its history,
32
+ beliefs valid at ``at``, relations, notes, open tasks and a monthly activity sparkline.
33
+
34
+ ``as_received`` builds the card from what the archive held on that date only (notes received by then and
35
+ the revisions they caused, :mod:`abstractnote.store.archive`); it composes with ``at``."""
36
+ from ..store.archive import archive_for
37
+
38
+ _require_entity(nb, entity_id)
39
+ at_n = ids.optional_time(at, field="at")
40
+ s = nb.store
41
+ arch = archive_for(s, as_received)
42
+ if arch is not None:
43
+ return _archive_card(nb, arch, entity_id, at_n)
44
+ eid = s.canonical_at(entity_id, at_n)
45
+ row = s.entity_row(eid)
46
+ if at_n and (row["first_seen"] or row["created_at"]) > at_n:
47
+ raise ValueError("That entity was not known yet at that time.")
48
+ members = s.merged_members(eid) if at_n is None else [
49
+ m for m in s.merged_members(s.canonical(eid)) if s.canonical_at(m, at_n) == eid]
50
+ if eid not in members:
51
+ members.insert(0, eid)
52
+ ref = at_n or "9999-12-31T23:59:59Z"
53
+ q = ",".join("?" for _ in members)
54
+ name_row = s.q1("SELECT name FROM entity_names WHERE entity_id=? AND at<=? ORDER BY at DESC, revision_id DESC LIMIT 1",
55
+ (eid, ref))
56
+ name = name_row[0] if (name_row and at_n) else row["name"]
57
+ aliases: List[str] = []
58
+ for m in members:
59
+ for a in s.aliases(m, at_n):
60
+ if ids.fold(a) != ids.fold(name) and ids.fold(a) not in {ids.fold(x) for x in aliases}:
61
+ aliases.append(a)
62
+ if m != eid:
63
+ mrow = s.entity_row(m)
64
+ if ids.fold(mrow["name"]) != ids.fold(name) and ids.fold(mrow["name"]) not in {ids.fold(x) for x in aliases}:
65
+ aliases.append(mrow["name"])
66
+ history = [{"at": r["at"], "summary": r["summary"], "note_id": r["note_id"], "revision_id": r["revision_id"]}
67
+ for r in s.q(f"SELECT * FROM entity_summaries WHERE entity_id IN ({q}) AND at<=? ORDER BY at, revision_id",
68
+ [*members, ref])]
69
+ summary = history[-1]["summary"] if history else (row["summary"] if at_n is None else None)
70
+ notes = s.q(f"SELECT DISTINCT n.* FROM entity_notes en JOIN notes n ON n.note_id=en.note_id WHERE en.entity_id IN"
71
+ f" ({q}) AND en.at<=? AND n.archived_at IS NULL ORDER BY n.occurred_at DESC LIMIT ?",
72
+ [*members, ref, CARD_NOTES])
73
+ stats = s.q1(f"SELECT MIN(at), MAX(at), COUNT(DISTINCT note_id) FROM entity_notes WHERE entity_id IN ({q}) AND at<=?",
74
+ [*members, ref])
75
+ tasks = s.q(f"SELECT * FROM tasks WHERE note_id IN (SELECT note_id FROM entity_notes WHERE entity_id IN ({q}))"
76
+ f" AND opened_at<=? AND (closed_at IS NULL OR closed_at>?) ORDER BY opened_at", [*members, ref, ref])
77
+ months: Dict[str, int] = {}
78
+ for r in s.q(f"SELECT substr(at,1,7), COUNT(DISTINCT note_id) FROM entity_notes WHERE entity_id IN ({q}) AND at<=?"
79
+ f" GROUP BY substr(at,1,7) ORDER BY 1", [*members, ref]):
80
+ months[r[0]] = r[1]
81
+ return {
82
+ "entity_id": eid, "resolved_from": entity_id if entity_id != eid else None, "as_of": at_n,
83
+ "kind": row["kind"], "name": name, "aliases": aliases, "summary": summary, "summary_history": history,
84
+ "first_seen": stats[0] or row["first_seen"], "last_seen": stats[1] or row["last_seen"],
85
+ "note_count": int(stats[2] or 0), "merged_from": [m for m in members if m != eid],
86
+ "merged_into": row["merged_into"] if at_n is None else None,
87
+ "beliefs": [c for c in s.beliefs_at(at_n or ids.utc_now())
88
+ if c["subject_id"] in members or c["object_id"] in members],
89
+ "relations": relations_of(nb, eid, at_n),
90
+ "notes": [shapes.note_ref(r) for r in notes],
91
+ "open_tasks": [shapes.task(r) | {"status": "open", "closed_at": None} for r in tasks],
92
+ "sparkline": [{"month": k, "notes": v} for k, v in months.items()],
93
+ }
94
+
95
+
96
+ def _archive_card(nb: Any, arch: Any, entity_id: str, at_n: Optional[str]) -> Dict[str, Any]:
97
+ """:func:`entity_card` from what the archive held at ``arch.at`` (same keys, plus ``as_received``)."""
98
+ s = nb.store
99
+ eid = arch.canonical(entity_id, at_n)
100
+ row = s.entity_row(eid)
101
+ members = arch.members(eid, at_n)
102
+ held = [m for m in members if arch.holds_entity(m)]
103
+ if not held:
104
+ raise ValueError("That entity was not in your notebook yet on that date.")
105
+ ref = at_n or "9999-12-31T23:59:59Z"
106
+ q = ",".join("?" for _ in members)
107
+ in_rev, p_rev = arch.revisions_in("revision_id")
108
+ in_note, p_note = arch.notes_in("en.note_id")
109
+ stats = s.q1(f"SELECT MIN(en.at), MAX(en.at), COUNT(DISTINCT en.note_id) FROM entity_notes en WHERE en.entity_id"
110
+ f" IN ({q}) AND en.at<=? AND {in_note}", [*members, ref, *p_note])
111
+ created = s.q1(f"SELECT MIN(created_at) FROM entities WHERE entity_id IN ({q}) AND"
112
+ f" {arch.revisions_in('created_revision_id')[0]}", [*members, arch.at])[0]
113
+ first = min([x for x in (stats[0], created) if x], default=None)
114
+ if at_n and (first is None or first > at_n):
115
+ raise ValueError("That entity was not known yet at that time.")
116
+ name = arch.name(eid, at_n) or row["name"]
117
+ aliases: List[str] = []
118
+ for m in members:
119
+ for (a,) in s.q(f"SELECT alias FROM entity_aliases WHERE entity_id=? AND added_at<=? AND {in_rev}"
120
+ " ORDER BY added_at, alias", [m, ref, *p_rev]):
121
+ if ids.fold(a) != ids.fold(name) and ids.fold(a) not in {ids.fold(x) for x in aliases}:
122
+ aliases.append(a)
123
+ if m != eid:
124
+ mname = arch.name(m, at_n) or s.entity_row(m)["name"]
125
+ if ids.fold(mname) != ids.fold(name) and ids.fold(mname) not in {ids.fold(x) for x in aliases}:
126
+ aliases.append(mname)
127
+ history = [{"at": r["at"], "summary": r["summary"], "note_id": r["note_id"], "revision_id": r["revision_id"]}
128
+ for r in s.q(f"SELECT * FROM entity_summaries WHERE entity_id IN ({q}) AND at<=? AND {in_rev}"
129
+ " ORDER BY at, revision_id", [*members, ref, *p_rev])]
130
+ notes = s.q(f"SELECT DISTINCT n.* FROM entity_notes en JOIN notes n ON n.note_id=en.note_id WHERE en.entity_id IN"
131
+ f" ({q}) AND en.at<=? AND n.archived_at IS NULL AND {in_note} ORDER BY n.occurred_at DESC LIMIT ?",
132
+ [*members, ref, *p_note, CARD_NOTES])
133
+ opened, p_open = arch.revisions_in("t.opened_revision_id")
134
+ tasks = []
135
+ for r in s.q(f"SELECT t.* FROM tasks t WHERE t.note_id IN (SELECT en.note_id FROM entity_notes en WHERE"
136
+ f" en.entity_id IN ({q})) AND t.opened_at<=? AND {opened} ORDER BY t.opened_at",
137
+ [*members, ref, *p_open]):
138
+ closed = r["closed_at"] is not None and arch.has(r["closed_revision_id"]) and r["closed_at"] <= ref
139
+ if not closed:
140
+ tasks.append(shapes.task(r) | {"status": "open", "closed_at": None})
141
+ months: Dict[str, int] = {}
142
+ for r in s.q(f"SELECT substr(en.at,1,7), COUNT(DISTINCT en.note_id) FROM entity_notes en WHERE en.entity_id IN"
143
+ f" ({q}) AND en.at<=? AND {in_note} GROUP BY substr(en.at,1,7) ORDER BY 1", [*members, ref, *p_note]):
144
+ months[r[0]] = r[1]
145
+ return {
146
+ "entity_id": eid, "resolved_from": entity_id if entity_id != eid else None, "as_of": at_n,
147
+ "as_received": arch.at, "kind": row["kind"], "name": name, "aliases": aliases,
148
+ "summary": history[-1]["summary"] if history else None, "summary_history": history,
149
+ "first_seen": first, "last_seen": stats[1] or first, "note_count": int(stats[2] or 0),
150
+ "merged_from": [m for m in members if m != eid], "merged_into": None,
151
+ "beliefs": [c for c in s.beliefs_at(at_n or ids.utc_now(), as_received=arch.at)
152
+ if c["subject_id"] in members or c["object_id"] in members],
153
+ "relations": relations_of(nb, eid, at_n, as_received=arch.at),
154
+ "notes": [shapes.note_ref(r) for r in notes],
155
+ "open_tasks": tasks,
156
+ "sparkline": [{"month": k, "notes": v} for k, v in months.items()],
157
+ }
158
+
159
+
160
+ def relations_of(nb: Any, entity_id: str, at: Optional[str] = None, *, as_received: Optional[str] = None
161
+ ) -> List[Dict[str, Any]]:
162
+ graph = nb.store.graph(focus=[entity_id], at=at, depth=1, limit=2000, as_received=as_received)
163
+ if as_received:
164
+ from ..store.archive import Archive
165
+
166
+ eid = Archive(nb.store, as_received).canonical(entity_id, at)
167
+ else:
168
+ eid = nb.store.canonical_at(entity_id, at)
169
+ names = {n["id"]: n["name"] for n in graph["nodes"]}
170
+ out = []
171
+ for e in graph["edges"]:
172
+ if eid not in (e["source"], e["target"]):
173
+ continue
174
+ other = e["target"] if e["source"] == eid else e["source"]
175
+ out.append({"other_id": other, "other_name": names.get(other), "predicate": e["predicate"],
176
+ "direction": "out" if e["source"] == eid else "in", "first_seen": e["first_seen"],
177
+ "last_seen": e["last_seen"], "count": e["count"], "active": e["active"]})
178
+ out.sort(key=lambda r: (-r["count"], r["other_id"], r["predicate"]))
179
+ return out
180
+
181
+
182
+ def _claim_events(nb: Any, rows: Iterable[Any]) -> List[Dict[str, Any]]:
183
+ out = []
184
+ for r in rows:
185
+ d = nb.store.claim_dict(r)
186
+ closed = None
187
+ if r["closed_revision_id"]:
188
+ rv = nb.store.revision_row(r["closed_revision_id"])
189
+ closed = {"revision_id": r["closed_revision_id"], "at": rv["at"] if rv else None,
190
+ "note_id": rv["note_id"] if rv else None, "reason": r["closed_reason"],
191
+ "kind": "retract" if r["status"] == "retracted" else "supersede"}
192
+ d["closed"] = closed
193
+ d["reinforcements"] = [{"note_id": x["note_id"], "at": x["at"]} for x in nb.store.q(
194
+ "SELECT * FROM claim_reinforcements WHERE claim_id=? ORDER BY at", (r["claim_id"],))]
195
+ out.append(d)
196
+ return out
197
+
198
+
199
+ def claims_timeline(nb: Any, entity_id: str, since: Optional[str] = None, until: Optional[str] = None
200
+ ) -> List[Dict[str, Any]]:
201
+ """Claims about an entity (as subject or object) in history order, with their supersessions."""
202
+ _require_entity(nb, entity_id)
203
+ members = _members(nb, entity_id)
204
+ q = ",".join("?" for _ in members)
205
+ sql = f"SELECT * FROM claims WHERE (subject_id IN ({q}) OR object_id IN ({q}))"
206
+ params: List[Any] = [*members, *members]
207
+ if since:
208
+ sql += " AND observed_at>=?"
209
+ params.append(ids.normalize_time(since, field="since"))
210
+ if until:
211
+ sql += " AND observed_at<=?"
212
+ params.append(ids.normalize_time(until, field="until"))
213
+ return _claim_events(nb, nb.store.q(sql + " ORDER BY observed_at, seq", params))
214
+
215
+
216
+ def entity_timeline(nb: Any, entity_id: str, since: Optional[str] = None, until: Optional[str] = None
217
+ ) -> Dict[str, Any]:
218
+ """Claims and notes about the entity in history order (``GET /note/entities/{id}/timeline``)."""
219
+ _require_entity(nb, entity_id)
220
+ eid = nb.store.canonical(entity_id)
221
+ members = _members(nb, entity_id)
222
+ q = ",".join("?" for _ in members)
223
+ since_n = ids.optional_time(since, field="since")
224
+ until_n = ids.optional_time(until, field="until")
225
+ items: List[Dict[str, Any]] = []
226
+ for c in claims_timeline(nb, eid, since_n, until_n):
227
+ items.append({"type": "claim", "at": c["observed_at"], "claim": c})
228
+ if c["closed"] and c["closed"]["at"]:
229
+ items.append({"type": c["closed"]["kind"], "at": c["closed"]["at"], "claim_id": c["claim_id"],
230
+ "note_id": c["closed"]["note_id"], "reason": c["closed"]["reason"],
231
+ "superseded_by": c["superseded_by"]})
232
+ sql = (f"SELECT DISTINCT n.* FROM entity_notes en JOIN notes n ON n.note_id=en.note_id WHERE en.entity_id IN ({q})"
233
+ " AND n.archived_at IS NULL")
234
+ params: List[Any] = list(members)
235
+ if since_n:
236
+ sql += " AND n.occurred_at>=?"
237
+ params.append(since_n)
238
+ if until_n:
239
+ sql += " AND n.occurred_at<=?"
240
+ params.append(until_n)
241
+ for r in nb.store.q(sql, params):
242
+ items.append({"type": "note", "at": r["occurred_at"], "note": shapes.note_ref(r)})
243
+ for r in nb.store.q(f"SELECT * FROM merges WHERE from_id IN ({q}) OR into_id IN ({q})", [*members, *members]):
244
+ items.append({"type": "merge", "at": r["at"], "from_id": r["from_id"], "into_id": r["into_id"],
245
+ "reason": r["reason"], "undone_at": r["undone_at"]})
246
+ order = {"note": 0, "claim": 1, "supersede": 2, "retract": 2, "merge": 3}
247
+ items = [i for i in items if (not since_n or i["at"] >= since_n) and (not until_n or i["at"] <= until_n)]
248
+ items.sort(key=lambda i: (i["at"], order.get(i["type"], 9)))
249
+ return {"entity_id": eid, "since": since_n, "until": until_n, "items": items}
250
+
251
+
252
+ def relation(nb: Any, a: str, b: str, since: Optional[str] = None, until: Optional[str] = None) -> Dict[str, Any]:
253
+ """The relationship between two entities through time: claims between them in history order,
254
+ supersessions, and a then-versus-now of the link (``GET /note/relations/{a}/{b}``)."""
255
+ _require_entity(nb, a)
256
+ _require_entity(nb, b)
257
+ ma, mb = _members(nb, a), _members(nb, b)
258
+ qa, qb = ",".join("?" for _ in ma), ",".join("?" for _ in mb)
259
+ sql = (f"SELECT * FROM claims WHERE ((subject_id IN ({qa}) AND object_id IN ({qb})) OR"
260
+ f" (subject_id IN ({qb}) AND object_id IN ({qa})))")
261
+ params: List[Any] = [*ma, *mb, *mb, *ma]
262
+ since_n = ids.optional_time(since, field="since")
263
+ until_n = ids.optional_time(until, field="until")
264
+ if since_n:
265
+ sql += " AND observed_at>=?"
266
+ params.append(since_n)
267
+ if until_n:
268
+ sql += " AND observed_at<=?"
269
+ params.append(until_n)
270
+ claims = _claim_events(nb, nb.store.q(sql + " ORDER BY observed_at, seq", params))
271
+ supersessions = [{"claim_id": c["claim_id"], "superseded_by": c["superseded_by"], **(c["closed"] or {})}
272
+ for c in claims if c["closed"]]
273
+ return {"a": nb.store.entity_brief(nb.store.canonical(a)), "b": nb.store.entity_brief(nb.store.canonical(b)),
274
+ "since": since_n, "until": until_n, "claims": claims, "supersessions": supersessions,
275
+ "then_now": relation_then_now(claims)}
276
+
277
+
278
+ def neighbours(nb: Any, entity_ids: Sequence[str], at: Optional[str] = None, limit: int = 20) -> List[Dict[str, Any]]:
279
+ seen = {nb.store.canonical_at(e, at) for e in entity_ids}
280
+ out: Dict[str, Dict[str, Any]] = {}
281
+ for e in seen:
282
+ for r in relations_of(nb, e, at):
283
+ if r["other_id"] in seen:
284
+ continue
285
+ n = out.setdefault(r["other_id"], {"entity_id": r["other_id"], "name": r["other_name"], "via": [],
286
+ "count": 0})
287
+ n["via"].append({"entity_id": e, "predicate": r["predicate"], "direction": r["direction"]})
288
+ n["count"] += r["count"]
289
+ ranked = sorted(out.values(), key=lambda n: (-n["count"], n["entity_id"]))[: clamp_limit(limit, 20, 200)]
290
+ for n in ranked:
291
+ row = nb.store.entity_row(n["entity_id"])
292
+ n["kind"] = row["kind"] if row else None
293
+ return ranked
294
+
295
+
296
+ def expand(nb: Any, note_ids: Any = None, entity_ids: Any = None, as_of: Optional[str] = None) -> Dict[str, Any]:
297
+ """``note_expand``: cards, claim timelines, relation timelines, neighbours and then-versus-now
298
+ for the given entities plus the entities the given notes mention."""
299
+ as_of_n = ids.optional_time(as_of, field="as_of")
300
+ wanted: List[str] = []
301
+ for e in list(entity_ids or []):
302
+ if nb.store.entity_row(str(e)) is not None:
303
+ c = nb.store.canonical_at(str(e), as_of_n)
304
+ if c not in wanted:
305
+ wanted.append(c)
306
+ for nid in list(note_ids or []):
307
+ for r in nb.store.q("SELECT entity_id FROM entity_notes WHERE note_id=? ORDER BY entity_id", (str(nid),)):
308
+ c = nb.store.canonical_at(r[0], as_of_n)
309
+ if c not in wanted:
310
+ wanted.append(c)
311
+ wanted = wanted[:MAX_EXPAND_ENTITIES]
312
+ cards, timelines, thens = [], [], []
313
+ for e in wanted:
314
+ try:
315
+ cards.append(entity_card(nb, e, as_of_n))
316
+ except ValueError:
317
+ continue # not known yet at as_of
318
+ tl = claims_timeline(nb, e, until=as_of_n)
319
+ timelines.append({"entity_id": e, "claims": tl})
320
+ thens.append(then_now(nb, e))
321
+ pairs = []
322
+ known = {c["entity_id"] for c in cards}
323
+ for i, a in enumerate(sorted(known)):
324
+ for b in sorted(known)[i + 1:]:
325
+ if nb.store.q1("SELECT 1 FROM relations WHERE (subject_id=? AND object_id=?) OR (subject_id=? AND"
326
+ " object_id=?)", (a, b, b, a)):
327
+ pairs.append((a, b))
328
+ rel = [relation(nb, a, b, until=as_of_n) for a, b in pairs[:MAX_RELATION_PAIRS]]
329
+ return {"as_of": as_of_n, "entities": cards, "claims_timeline": timelines, "relation_timelines": rel,
330
+ "neighbours": neighbours(nb, list(known), as_of_n), "then_now": thens}
331
+
332
+
333
+ def claim_chain(nb: Any, claim_id: str) -> Dict[str, Any]:
334
+ """Why do I believe this: the claim, what it superseded or was superseded by, the notes and
335
+ attachment excerpts behind it (``GET /note/claims/{id}``)."""
336
+ row = nb.store.claim_row(claim_id)
337
+ if row is None:
338
+ raise ValueError("That statement is not in this notebook.")
339
+ [claim] = _claim_events(nb, [row])
340
+ supersedes = _claim_events(nb, nb.store.q("SELECT * FROM claims WHERE superseded_by=? ORDER BY observed_at",
341
+ (claim_id,)))
342
+ superseded_by = None
343
+ if row["superseded_by"]:
344
+ r2 = nb.store.claim_row(row["superseded_by"])
345
+ superseded_by = _claim_events(nb, [r2])[0] if r2 else None
346
+ disputed = [nb.store.claim_dict(x) for x in (nb.store.claim_row(c) for c in _uj(row["disputed_with_json"], []))
347
+ if x is not None]
348
+ note_ids = [row["note_id"]] if row["note_id"] else []
349
+ note_ids += [r["note_id"] for r in nb.store.q("SELECT note_id FROM claim_reinforcements WHERE claim_id=? AND"
350
+ " note_id IS NOT NULL ORDER BY at", (claim_id,))]
351
+ notes = []
352
+ for nid in dict.fromkeys(note_ids):
353
+ n = nb.store.note_row(nid)
354
+ if n is None:
355
+ continue
356
+ ref = shapes.note_ref(n)
357
+ ref["attachments"] = []
358
+ for a in nb.store.attachments_of(nid):
359
+ path = nb.root / "extracted" / f"{a['sha256']}.txt"
360
+ text = path.read_text(encoding="utf-8") if a["extracted_chars"] and path.exists() else ""
361
+ ref["attachments"].append({"attachment_id": a["attachment_id"], "filename": a["filename"],
362
+ "mime": a["mime"], "extracted_by": a["extracted_by"],
363
+ "excerpt": _around(text, row["evidence"])})
364
+ notes.append(ref)
365
+ rev = nb.store.revision_row(row["revision_id"])
366
+ return {"claim": claim, "supersedes": supersedes, "superseded_by": superseded_by, "disputed_with": disputed,
367
+ "notes": notes, "revision": {k: v for k, v in nb.store.revision_dict(rev).items() if k != "ops"}
368
+ if rev else None}
369
+
370
+
371
+ def _around(text: str, evidence: Optional[str], width: int = 400) -> str:
372
+ """An excerpt of attachment text around the evidence quote when it occurs verbatim, else its start."""
373
+ if not text:
374
+ return ""
375
+ pos = text.find(evidence) if evidence else -1
376
+ if pos < 0:
377
+ return shapes.excerpt(text, width)
378
+ start = max(0, pos - width // 2)
379
+ return ("…" if start else "") + text[start: start + width] + ("…" if start + width < len(text) else "")
@@ -0,0 +1,62 @@
1
+ """``note_insight_write``: system-authored insight and synthesis notes with provenance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Dict, List, Optional, Sequence
7
+
8
+ from .. import ids
9
+ from ..ingest.normalise import text_norm
10
+ from ..model import INSIGHT_KINDS
11
+ from ..store.notes import _j, note_vector_text
12
+
13
+
14
+ def write_insight(nb: Any, kind: str, title: str, body: str, evidence_note_ids: Optional[Sequence[str]] = None,
15
+ entity_ids: Optional[Sequence[str]] = None, run_id: Optional[str] = None,
16
+ author: str = "abstractnote") -> Dict[str, Any]:
17
+ """Store an insight (``dossier`` -> a ``synthesis`` note). Unknown evidence notes or entities are
18
+ left out and named in ``warnings``. Writing the same insight twice returns the same note."""
19
+ nb.require_embedder()
20
+ if kind not in INSIGHT_KINDS:
21
+ raise ValueError(f"The insight kind must be one of {', '.join(INSIGHT_KINDS)}.")
22
+ title = str(title or "").strip()
23
+ body = str(body or "").strip()
24
+ if not title and not body:
25
+ raise ValueError("An insight needs a title or a body.")
26
+ warnings: List[str] = []
27
+ evidence: List[str] = []
28
+ for nid in evidence_note_ids or []:
29
+ nid = str(nid)
30
+ if nb.store.note_row(nid) is None:
31
+ warnings.append(f"The evidence note {nid} is not in this notebook; it was left out.")
32
+ elif nid not in evidence:
33
+ evidence.append(nid)
34
+ ents: List[str] = []
35
+ for eid in entity_ids or []:
36
+ eid = str(eid)
37
+ if nb.store.entity_row(eid) is None:
38
+ warnings.append(f"The entity {eid} is not in this notebook; it was left out.")
39
+ else:
40
+ c = nb.store.canonical(eid)
41
+ if c not in ents:
42
+ ents.append(c)
43
+ note_id = ids.note_id_for_insight(kind, title, body, evidence)
44
+ if nb.store.note_row(note_id) is not None:
45
+ return {"note_id": note_id, "status": "duplicate", "warnings": warnings}
46
+ now = ids.utc_now()
47
+ vtext = note_vector_text(title, body, [], 8000)
48
+ [vkey] = nb.cache.ensure([vtext]) if vtext.strip() else [None]
49
+ with nb.store.tx() as c:
50
+ nb.store.insert_note(c, {
51
+ "note_id": note_id, "kind": "synthesis" if kind == "dossier" else "insight", "title": title or None,
52
+ "text": body, "text_norm": text_norm(body), "text_hash": None, "occurred_at": now, "received_at": now,
53
+ "processed_at": now, "source_json": _j({"client": "agent", "author": author}), "client": "agent",
54
+ "priority": 1, "status": "processed", "run_id": run_id, "size": len(body), "vector_key": vkey,
55
+ "insight_kind": kind, "evidence_note_ids_json": json.dumps(evidence), "entity_ids_json": json.dumps(ents),
56
+ })
57
+ for nid in evidence:
58
+ c.execute("INSERT OR IGNORE INTO note_links(src_note_id, dst_note_id, predicate, score, matched_by,"
59
+ " detail_json, created_at) VALUES (?,?,'cito:citesAsEvidence',1.0,'insight',NULL,?)",
60
+ (note_id, nid, now))
61
+ nb.store.refresh_note_fts(c, note_id, [])
62
+ return {"note_id": note_id, "status": "new", "warnings": warnings}
@@ -0,0 +1,202 @@
1
+ """Deterministic reflect candidates (docs/cognition.md, "note-reflect").
2
+
3
+ Each function returns ``{"items": [...]}``; every item names the ``rule`` that
4
+ produced it and carries ``note_ids`` and ``entity_ids`` so the reflect flow can
5
+ write an insight with provenance. Thresholds are explicit constants; the model
6
+ decides what deserves attention.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import timedelta
12
+ from typing import Any, Dict, List, Optional, Tuple
13
+
14
+ from .. import ids
15
+ from ..store.notes import _uj, clamp_limit
16
+ from . import shapes
17
+
18
+ OPEN_TASK_AGE_DAYS = 14
19
+ QUESTION_AGE_DAYS = 14
20
+ TASK_MENTIONS_MIN = 2 # an open task whose note was revisited this many times
21
+ SILENT_DAYS = 60 # an entity or topic with no note for this long has gone quiet
22
+ RECENT_DAYS = 7 # "recent" notes for resurfacing
23
+
24
+
25
+ def _now(now: Optional[str]) -> str:
26
+ return ids.normalize_time(now) if now else ids.utc_now()
27
+
28
+
29
+ def _minus(ts: str, days: int) -> str:
30
+ return (ids.parse_time(ts) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
31
+
32
+
33
+ def _days(a: str, b: str) -> int:
34
+ return int((ids.parse_time(b) - ids.parse_time(a)).total_seconds() // 86400)
35
+
36
+
37
+ def _since(since: Optional[str]) -> Optional[str]:
38
+ return ids.optional_time(since, field="since")
39
+
40
+
41
+ def _entities_of_note(nb: Any, note_id: Optional[str]) -> List[str]:
42
+ if not note_id:
43
+ return []
44
+ return sorted({nb.store.canonical(r[0]) for r in nb.store.q("SELECT entity_id FROM entity_notes WHERE note_id=?",
45
+ (note_id,))})
46
+
47
+
48
+ def open_loops(nb: Any, limit: int = 20, since: Optional[str] = None, *, now: Optional[str] = None
49
+ ) -> Dict[str, Any]:
50
+ limit = clamp_limit(limit, 20, 200)
51
+ now = _now(now)
52
+ since_n = _since(since)
53
+ items: List[Dict[str, Any]] = []
54
+ q = nb.store.q
55
+ sql = "SELECT * FROM tasks WHERE status='open' AND opened_at<=?"
56
+ params: List[Any] = [_minus(now, OPEN_TASK_AGE_DAYS)]
57
+ if since_n:
58
+ sql += " AND opened_at>=?"
59
+ params.append(since_n)
60
+ for r in q(sql + " ORDER BY opened_at, task_id", params):
61
+ mentions = 0
62
+ if r["note_id"]:
63
+ mentions = q("SELECT COUNT(*) FROM note_links WHERE dst_note_id=? AND predicate='ex:revisits'",
64
+ (r["note_id"],))[0][0]
65
+ rule = "task_mentioned_repeatedly" if mentions >= TASK_MENTIONS_MIN else f"open_task_older_than_{OPEN_TASK_AGE_DAYS}_days"
66
+ items.append({"rule": rule, "kind": "task", "task_id": r["task_id"], "text": r["text"],
67
+ "opened_at": r["opened_at"], "age_days": _days(r["opened_at"], now), "mentions": mentions + 1,
68
+ "note_ids": [r["note_id"]] if r["note_id"] else [], "entity_ids": _entities_of_note(nb, r["note_id"])})
69
+ sql = ("SELECT * FROM notes n WHERE n.kind='question' AND n.archived_at IS NULL AND n.occurred_at<=? AND NOT"
70
+ " EXISTS (SELECT 1 FROM note_links l WHERE l.dst_note_id=n.note_id AND l.predicate IN"
71
+ " ('ex:revisits','dcterms:references'))")
72
+ params = [_minus(now, QUESTION_AGE_DAYS)]
73
+ if since_n:
74
+ sql += " AND n.occurred_at>=?"
75
+ params.append(since_n)
76
+ for r in q(sql + " ORDER BY n.occurred_at, n.note_id", params):
77
+ items.append({"rule": "question_without_followup", "kind": "question", "text": r["title"] or
78
+ shapes.excerpt(r["text"], 160), "opened_at": r["occurred_at"],
79
+ "age_days": _days(r["occurred_at"], now), "note_ids": [r["note_id"]],
80
+ "entity_ids": _entities_of_note(nb, r["note_id"])})
81
+ items.sort(key=lambda it: (0 if it["rule"] == "task_mentioned_repeatedly" else 1, it["opened_at"]))
82
+ return {"items": items[:limit]}
83
+
84
+
85
+ def contradictions(nb: Any, limit: int = 20, since: Optional[str] = None) -> Dict[str, Any]:
86
+ limit = clamp_limit(limit, 20, 200)
87
+ since_n = _since(since)
88
+ q = nb.store.q
89
+ items: List[Dict[str, Any]] = []
90
+ seen = set()
91
+ sql = "SELECT * FROM claims WHERE status='active' AND disputed_with_json!='[]'"
92
+ params: List[Any] = []
93
+ if since_n:
94
+ sql += " AND observed_at>=?"
95
+ params.append(since_n)
96
+ for r in q(sql + " ORDER BY observed_at, claim_id", params):
97
+ for other in _uj(r["disputed_with_json"], []) or []:
98
+ pair = tuple(sorted((r["claim_id"], other)))
99
+ if pair in seen:
100
+ continue
101
+ o = nb.store.claim_row(other)
102
+ if o is None or o["status"] != "active":
103
+ continue
104
+ seen.add(pair)
105
+ claims = [nb.store.claim_dict(x) for x in (r, o)]
106
+ items.append({"rule": "disputed_pair", "claims": claims,
107
+ "text": " / ".join(_claim_line(c) for c in claims),
108
+ "note_ids": sorted({c["note_id"] for c in claims if c["note_id"]}),
109
+ "entity_ids": sorted({nb.store.canonical(c["subject_id"]) for c in claims})})
110
+ groups: Dict[Tuple[str, str], List[Any]] = {}
111
+ for r in q("SELECT * FROM claims WHERE status='active' AND valid_until IS NULL ORDER BY observed_at, claim_id"):
112
+ groups.setdefault((nb.store.canonical(r["subject_id"]), r["predicate"]), []).append(r)
113
+ for (subject, predicate), rows in sorted(groups.items()):
114
+ objects = {(nb.store.canonical(x["object_id"]) if x["object_id"] else None, x["object_literal"]) for x in rows}
115
+ if len(objects) < 2:
116
+ continue
117
+ if since_n and max(x["observed_at"] for x in rows) < since_n:
118
+ continue
119
+ claims = [nb.store.claim_dict(x) for x in rows]
120
+ items.append({"rule": "several_active_objects", "subject_id": subject, "predicate": predicate,
121
+ "claims": claims, "text": " / ".join(_claim_line(c) for c in claims),
122
+ "note_ids": sorted({c["note_id"] for c in claims if c["note_id"]}), "entity_ids": [subject]})
123
+ return {"items": items[:limit]}
124
+
125
+
126
+ def _claim_line(c: Dict[str, Any]) -> str:
127
+ obj = c.get("object_name") or c.get("object_literal") or c.get("object_id")
128
+ return f"{c.get('subject_name') or c['subject_id']} {c['predicate']} {obj} ({c['observed_at'][:10]})"
129
+
130
+
131
+ def resurface(nb: Any, limit: int = 20, since: Optional[str] = None, *, now: Optional[str] = None
132
+ ) -> Dict[str, Any]:
133
+ limit = clamp_limit(limit, 20, 200)
134
+ now = _now(now)
135
+ recent_from = _since(since) or _minus(now, RECENT_DAYS)
136
+ quiet_before = _minus(now, SILENT_DAYS)
137
+ q = nb.store.q
138
+ recent_entities = {nb.store.canonical(r[0]) for r in q("SELECT DISTINCT entity_id FROM entity_notes WHERE at>=?",
139
+ (recent_from,))}
140
+ items: List[Dict[str, Any]] = []
141
+ if recent_entities:
142
+ marks = ",".join("?" for _ in recent_entities)
143
+ rel = q(f"SELECT subject_id, object_id FROM relations WHERE subject_id IN ({marks}) OR object_id IN ({marks})",
144
+ [*recent_entities, *recent_entities])
145
+ neighbours: Dict[str, set] = {}
146
+ for r in rel:
147
+ for a, b in ((r[0], r[1]), (r[1], r[0])):
148
+ if a in recent_entities and b not in recent_entities:
149
+ neighbours.setdefault(b, set()).add(a)
150
+ for eid, via in sorted(neighbours.items()):
151
+ e = nb.store.entity_row(eid)
152
+ if e is None or e["merged_into"] or not e["last_seen"] or e["last_seen"] >= quiet_before:
153
+ continue
154
+ last_notes = [r[0] for r in q("SELECT note_id FROM entity_notes WHERE entity_id=? ORDER BY at DESC LIMIT 3",
155
+ (eid,))]
156
+ items.append({"rule": "silent_but_related", "kind": "entity", "entity_id": eid, "text": e["name"],
157
+ "last_seen": e["last_seen"], "silent_days": _days(e["last_seen"], now),
158
+ "related_to": sorted(via), "note_ids": last_notes, "entity_ids": [eid, *sorted(via)]})
159
+ for r in q("SELECT DISTINCT t.topic FROM note_topics t JOIN notes n ON n.note_id=t.note_id WHERE n.occurred_at>=?",
160
+ (recent_from,)):
161
+ prev = q("SELECT MAX(n.occurred_at), GROUP_CONCAT(n.note_id) FROM note_topics t JOIN notes n ON"
162
+ " n.note_id=t.note_id WHERE t.topic=? AND n.occurred_at<?", (r[0], recent_from))[0]
163
+ if prev[0] and prev[0] < _minus(recent_from, SILENT_DAYS):
164
+ items.append({"rule": "topic_back_after_silence", "kind": "topic", "topic": r[0], "text": r[0],
165
+ "last_seen": prev[0], "silent_days": _days(prev[0], recent_from),
166
+ "note_ids": (prev[1] or "").split(",")[-3:], "entity_ids": []})
167
+ items.sort(key=lambda it: -it["silent_days"])
168
+ return {"items": items[:limit]}
169
+
170
+
171
+ def anniversary_rows(nb: Any, limit: int = 20, since: Optional[str] = None, *, now: Optional[str] = None
172
+ ) -> List[Tuple[Any, str]]:
173
+ """Notes from today's month and day in earlier years, then today's day of month in the past 12 months."""
174
+ now = _now(now)
175
+ since_n = _since(since)
176
+ md, d, ym = now[5:10], now[8:10], now[:7]
177
+ out: List[Tuple[Any, str]] = []
178
+ base = "SELECT * FROM notes WHERE insight_kind IS NULL AND archived_at IS NULL"
179
+ params: List[Any] = []
180
+ if since_n:
181
+ base += " AND occurred_at>=?"
182
+ params.append(since_n)
183
+ for r in nb.store.q(base + " AND substr(occurred_at,6,5)=? AND substr(occurred_at,1,4)<? ORDER BY occurred_at DESC",
184
+ [*params, md, now[:4]]):
185
+ out.append((r, "same_day_previous_year"))
186
+ year_ago = _minus(now, 366)
187
+ for r in nb.store.q(base + " AND substr(occurred_at,9,2)=? AND substr(occurred_at,1,7)<? AND occurred_at>=?"
188
+ " AND substr(occurred_at,6,5)!=? ORDER BY occurred_at DESC", [*params, d, ym, year_ago, md]):
189
+ out.append((r, "same_day_previous_month"))
190
+ return out[: clamp_limit(limit, 20, 200)]
191
+
192
+
193
+ def anniversaries(nb: Any, limit: int = 20, since: Optional[str] = None, *, now: Optional[str] = None
194
+ ) -> Dict[str, Any]:
195
+ now_n = _now(now)
196
+ items = []
197
+ for r, rule in anniversary_rows(nb, limit, since, now=now_n):
198
+ ref = shapes.note_ref(r)
199
+ items.append({"rule": rule, "kind": "note", **ref, "text": ref["title"] or ref["excerpt"],
200
+ "years_ago": int(now_n[:4]) - int(r["occurred_at"][:4]),
201
+ "note_ids": [r["note_id"]], "entity_ids": _entities_of_note(nb, r["note_id"])})
202
+ return {"items": items}