abstractnote 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- abstractnote/__init__.py +18 -0
- abstractnote/cli.py +240 -0
- abstractnote/flows/__init__.py +2 -0
- abstractnote/flows/build.py +970 -0
- abstractnote/flows/bundle.py +62 -0
- abstractnote/flows/contract.py +88 -0
- abstractnote/flows/graph.py +540 -0
- abstractnote/flows/json/note-capture.json +2280 -0
- abstractnote/flows/json/note-drain.json +2012 -0
- abstractnote/flows/json/note-mine.json +2392 -0
- abstractnote/flows/json/note-process.json +1853 -0
- abstractnote/flows/json/note-recall.json +1876 -0
- abstractnote/flows/json/note-reflect.json +2057 -0
- abstractnote/flows/prompts_fallback.py +119 -0
- abstractnote/flows/runner.py +320 -0
- abstractnote/flows/schemas_fallback.py +175 -0
- abstractnote/gateway/__init__.py +24 -0
- abstractnote/gateway/appspec.py +10 -0
- abstractnote/gateway/router.py +571 -0
- abstractnote/gateway/tools.py +74 -0
- abstractnote/ids.py +177 -0
- abstractnote/importers/__init__.py +142 -0
- abstractnote/importers/__main__.py +3 -0
- abstractnote/importers/agora.py +203 -0
- abstractnote/importers/claude_sessions.py +415 -0
- abstractnote/importers/cli.py +113 -0
- abstractnote/importers/files.py +72 -0
- abstractnote/importers/markdown.py +165 -0
- abstractnote/ingest/__init__.py +7 -0
- abstractnote/ingest/blobs.py +47 -0
- abstractnote/ingest/capture.py +265 -0
- abstractnote/ingest/extract.py +118 -0
- abstractnote/ingest/normalise.py +95 -0
- abstractnote/model.py +229 -0
- abstractnote/process/__init__.py +5 -0
- abstractnote/process/context.py +218 -0
- abstractnote/process/merge.py +60 -0
- abstractnote/process/prompts/__init__.py +39 -0
- abstractnote/process/prompts/extract.md +30 -0
- abstractnote/process/prompts/link.md +32 -0
- abstractnote/process/prompts/mine_dossier.md +13 -0
- abstractnote/process/prompts/mine_findings.md +13 -0
- abstractnote/process/prompts/mine_plan.md +13 -0
- abstractnote/process/prompts/quick_filing.md +23 -0
- abstractnote/process/prompts/reflect.md +10 -0
- abstractnote/process/prompts/synthesize.md +17 -0
- abstractnote/process/resolve.py +96 -0
- abstractnote/process/revise.py +410 -0
- abstractnote/process/schemas.py +377 -0
- abstractnote/process/vocabulary.py +68 -0
- abstractnote/recall/__init__.py +1 -0
- abstractnote/recall/changes.py +191 -0
- abstractnote/recall/expand.py +379 -0
- abstractnote/recall/insights.py +62 -0
- abstractnote/recall/reflect.py +202 -0
- abstractnote/recall/review.py +191 -0
- abstractnote/recall/search.py +205 -0
- abstractnote/recall/shapes.py +52 -0
- abstractnote/recall/then_now.py +100 -0
- abstractnote/store/__init__.py +12 -0
- abstractnote/store/archive.py +166 -0
- abstractnote/store/embeddings.py +275 -0
- abstractnote/store/kg.py +219 -0
- abstractnote/store/notebook.py +829 -0
- abstractnote/store/notes.py +1190 -0
- abstractnote/store/queue.py +159 -0
- abstractnote/store/schema.py +348 -0
- abstractnote/store/vector_index.py +88 -0
- abstractnote/tools/__init__.py +215 -0
- abstractnote/tools/capture.py +121 -0
- abstractnote/tools/context.py +19 -0
- abstractnote/tools/process.py +22 -0
- abstractnote/tools/queue.py +23 -0
- abstractnote/tools/recall.py +64 -0
- abstractnote-0.1.0.dist-info/METADATA +145 -0
- abstractnote-0.1.0.dist-info/RECORD +80 -0
- abstractnote-0.1.0.dist-info/WHEEL +5 -0
- abstractnote-0.1.0.dist-info/entry_points.txt +2 -0
- abstractnote-0.1.0.dist-info/licenses/LICENSE +21 -0
- abstractnote-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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}
|