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,191 @@
1
+ """Proposed matches and disagreements for a person to look at (``GET /note/review``), and the confirm action.
2
+
3
+ Every item comes from stored records, never from reading text (ADR 0004):
4
+
5
+ - ``close_match`` (rule ``unsure_match``): the processor answered ``unsure`` for a mention, so it created a new
6
+ entry and recorded an active ``skos:closeMatch`` claim from it to the candidate (``process/revise.py``). Shown
7
+ while the claim is active and the two are still separate entries.
8
+ - ``merge`` (rule ``merged_by_model``): an ``entity.merge`` the processor made on its own, still in force and not
9
+ confirmed by you. Merges you confirm here are never listed.
10
+ - ``contradiction`` (rule ``disputed_pair``): two active claims that a note marked as disagreeing
11
+ (``claim.add`` with ``disputes``, the model's ``contradicts`` decision). Both are kept until one is withdrawn.
12
+
13
+ Actions: ``confirm`` (``POST /note/review/{id}/confirm``) records a user-action revision with action
14
+ ``match.confirm``: for a close match it merges the new entry into the candidate and supersedes the close-match
15
+ claim; for a merge it records that you keep it (no op changes the graph). The other actions are the existing
16
+ endpoints: ``unmerge`` a merge, ``keep_apart`` (retract the close-match claim) and ``retract`` one side of a
17
+ disagreement.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ from .. import ids
25
+ from ..store.notes import _uj, clamp_limit
26
+
27
+ CLOSE_MATCH = "skos:closeMatch" # the predicate process/revise.py records for an unsure match
28
+ CONFIRM_ACTION = "match.confirm"
29
+ SIDE_NOTES = 3
30
+
31
+
32
+ def review_id(kind: str, key: str) -> str:
33
+ return "rq_" + ids.digest("review", kind, key)[:26]
34
+
35
+
36
+ def _entity_side(nb: Any, eid: str) -> Dict[str, Any]:
37
+ s = nb.store
38
+ row = s.entity_row(eid)
39
+ members = s.merged_members(eid)
40
+ q = ",".join("?" for _ in members)
41
+ notes = [r[0] for r in s.q(f"SELECT en.note_id FROM entity_notes en JOIN notes n ON n.note_id=en.note_id WHERE"
42
+ f" en.entity_id IN ({q}) AND n.archived_at IS NULL GROUP BY en.note_id"
43
+ f" ORDER BY MAX(en.at) DESC LIMIT ?", [*members, SIDE_NOTES])]
44
+ return {"type": "entity", "id": eid, "name": row["name"] if row else eid, "kind": row["kind"] if row else None,
45
+ "note_ids": notes}
46
+
47
+
48
+ def _claim_side(nb: Any, row: Any) -> Dict[str, Any]:
49
+ from .changes import claim_text
50
+
51
+ return {"type": "claim", "id": row["claim_id"], "name": claim_text(nb, row), "kind": None,
52
+ "note_ids": [row["note_id"]] if row["note_id"] else []}
53
+
54
+
55
+ def _evidence(first: Optional[str], sides: List[Dict[str, Any]]) -> List[str]:
56
+ out: List[str] = []
57
+ for nid in [first, *[n for side in sides for n in side["note_ids"]]]:
58
+ if nid and nid not in out:
59
+ out.append(nid)
60
+ return out
61
+
62
+
63
+ def _act(action: str, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
64
+ return {"action": action, "method": "POST", "path": path, "body": body}
65
+
66
+
67
+ def _confirmed(nb: Any) -> set:
68
+ return {r[0] for r in nb.store.q("SELECT target FROM revisions WHERE action=? AND applied_index=1",
69
+ (CONFIRM_ACTION,))}
70
+
71
+
72
+ def _close_matches(nb: Any) -> List[Dict[str, Any]]:
73
+ s = nb.store
74
+ out = []
75
+ for r in s.q("SELECT * FROM claims WHERE predicate=? AND status='active' AND object_id IS NOT NULL"
76
+ " ORDER BY recorded_at DESC, claim_id", (CLOSE_MATCH,)):
77
+ a, b = s.canonical(r["subject_id"]), s.canonical(r["object_id"])
78
+ if a == b:
79
+ continue # joined since: nothing left to decide
80
+ rid = review_id("close_match", r["claim_id"])
81
+ sides = [_entity_side(nb, a), _entity_side(nb, b)]
82
+ out.append({
83
+ "review_id": rid, "kind": "close_match", "why": "unsure_match", "reason": None, "at": r["recorded_at"],
84
+ "sides": sides, "evidence_note_ids": _evidence(r["note_id"], sides),
85
+ "actions": [_act("confirm", f"/note/review/{rid}/confirm", {}),
86
+ _act("keep_apart", f"/note/claims/{r['claim_id']}/retract", {"reason": None})],
87
+ "_target": {"claim_id": r["claim_id"], "from_id": a, "into_id": b},
88
+ })
89
+ return out
90
+
91
+
92
+ def _merges(nb: Any, confirmed: set) -> List[Dict[str, Any]]:
93
+ s = nb.store
94
+ out = []
95
+ for m in s.q("SELECT m.*, r.recorded_at, r.note_id AS cause_note_id, r.action FROM merges m JOIN revisions r ON"
96
+ " r.revision_id=m.revision_id WHERE m.undone_at IS NULL AND r.applied_index=1"
97
+ " ORDER BY r.recorded_at DESC, m.from_id"):
98
+ if m["action"] == CONFIRM_ACTION:
99
+ continue # a merge you made here needs no review
100
+ row = s.entity_row(m["from_id"])
101
+ if row is None or row["merged_into"] != m["into_id"]:
102
+ continue # merged elsewhere since
103
+ rid = review_id("merge", f"{m['from_id']}|{m['into_id']}|{m['revision_id']}")
104
+ if rid in confirmed:
105
+ continue
106
+ sides = [_entity_side(nb, m["from_id"]), _entity_side(nb, m["into_id"])]
107
+ out.append({
108
+ "review_id": rid, "kind": "merge", "why": "merged_by_model", "reason": m["reason"] or None,
109
+ "at": m["recorded_at"], "sides": sides, "evidence_note_ids": _evidence(m["cause_note_id"], sides),
110
+ "actions": [_act("confirm", f"/note/review/{rid}/confirm", {}),
111
+ _act("unmerge", f"/note/entities/{m['into_id']}/unmerge",
112
+ {"from_id": m["from_id"], "reason": ""})],
113
+ "_target": {"from_id": m["from_id"], "into_id": m["into_id"], "revision_id": m["revision_id"]},
114
+ })
115
+ return out
116
+
117
+
118
+ def _contradictions(nb: Any) -> List[Dict[str, Any]]:
119
+ s = nb.store
120
+ out = []
121
+ seen = set()
122
+ for r in s.q("SELECT * FROM claims WHERE status='active' AND disputed_with_json!='[]'"
123
+ " ORDER BY recorded_at DESC, claim_id"):
124
+ for other in _uj(r["disputed_with_json"], []) or []:
125
+ pair = tuple(sorted((r["claim_id"], other)))
126
+ if pair in seen:
127
+ continue
128
+ o = s.claim_row(other)
129
+ if o is None or o["status"] != "active":
130
+ continue
131
+ seen.add(pair)
132
+ first, second = sorted((r, o), key=lambda x: (x["observed_at"], x["claim_id"]))
133
+ rid = review_id("contradiction", "|".join(pair))
134
+ sides = [_claim_side(nb, first), _claim_side(nb, second)]
135
+ out.append({
136
+ "review_id": rid, "kind": "contradiction", "why": "disputed_pair", "reason": None,
137
+ "at": max(r["recorded_at"], o["recorded_at"]), "sides": sides,
138
+ "evidence_note_ids": _evidence(second["note_id"], sides),
139
+ "actions": [_act("retract", f"/note/claims/{side['id']}/retract", {"reason": None}) for side in sides],
140
+ "_target": {"claim_ids": list(pair)},
141
+ })
142
+ return out
143
+
144
+
145
+ def _all(nb: Any) -> List[Dict[str, Any]]:
146
+ items = _close_matches(nb) + _merges(nb, _confirmed(nb)) + _contradictions(nb)
147
+ items.sort(key=lambda it: (it["at"] or "", it["review_id"]), reverse=True)
148
+ return items
149
+
150
+
151
+ def _public(item: Dict[str, Any]) -> Dict[str, Any]:
152
+ return {k: v for k, v in item.items() if not k.startswith("_")}
153
+
154
+
155
+ def review(nb: Any, limit: int = 50) -> Dict[str, Any]:
156
+ """``{items: [ReviewItem], next_cursor: null}``, newest first (docs/api.md, ``GET /note/review``)."""
157
+ return {"items": [_public(it) for it in _all(nb)[: clamp_limit(limit, 50, 500)]], "next_cursor": None}
158
+
159
+
160
+ def review_item(nb: Any, rid: str) -> Optional[Dict[str, Any]]:
161
+ for it in _all(nb):
162
+ if it["review_id"] == rid:
163
+ return it
164
+ return None
165
+
166
+
167
+ def confirm(nb: Any, rid: str, account: Optional[str], *, client: str = "web",
168
+ occurred_at: Optional[str] = None) -> Dict[str, Any]:
169
+ """Confirm a proposed match: a user-action revision ``match.confirm`` targeting the review id."""
170
+ item = review_item(nb, rid)
171
+ if item is None:
172
+ raise ValueError("That match is not waiting for review.")
173
+ s = nb.store
174
+ at = ids.normalize_time(occurred_at) if occurred_at else ids.utc_now()
175
+ target = item["_target"]
176
+ names = [side["name"] for side in item["sides"]]
177
+ if item["kind"] == "close_match":
178
+ ops = [{"op": "entity.merge", "payload": {"from_id": target["from_id"], "into_id": target["into_id"],
179
+ "reason": "You confirmed they are the same."}},
180
+ {"op": "claim.supersede", "payload": {"claim_id": target["claim_id"], "by_claim_id": None,
181
+ "reason": "confirmed as the same", "valid_until": at}}]
182
+ summary = f"Joined {names[0]} into {names[1]}: you confirmed they are the same."
183
+ elif item["kind"] == "merge":
184
+ if s.entity_row(target["from_id"])["merged_into"] != target["into_id"]:
185
+ raise ValueError("That match is not waiting for review.")
186
+ ops = []
187
+ summary = f"You confirmed that {names[0]} and {names[1]} are the same."
188
+ else:
189
+ raise ValueError("A disagreement is settled by retracting one of its two statements.")
190
+ out = nb._user_revision(CONFIRM_ACTION, rid, ops, account, summary, client=client, occurred_at=at)
191
+ return {**out, "review_id": rid}
@@ -0,0 +1,205 @@
1
+ """Quick recall (``note_search``): full-text (FTS5 BM25) plus vector cosine, fused by reciprocal rank.
2
+
3
+ Runs over notes, entities and claims with filters (since/until on history
4
+ time, kinds, entities, topics) and ``as_of`` (only what existed by then), and
5
+ returns facets by kind, topic, entity and month. No model is called; a query
6
+ vector is one embedder call (none with the hashing embedder's local vectors).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, List, Optional, Sequence
12
+
13
+ from .. import ids
14
+ from ..store.embeddings import top_k, vector_key
15
+ from ..store.notes import clamp_limit, fts_terms
16
+ from . import shapes
17
+
18
+ RRF_K = 60
19
+ POOL = 200
20
+ NOTE_VECTOR_MIN = 0.2
21
+ ENTITY_LIMIT = 10
22
+ CLAIM_LIMIT = 10
23
+
24
+
25
+ def _rrf(*ranked: Sequence[str]) -> Dict[str, float]:
26
+ scores: Dict[str, float] = {}
27
+ for lst in ranked:
28
+ for rank, key in enumerate(lst):
29
+ scores[key] = scores.get(key, 0.0) + 1.0 / (RRF_K + rank + 1)
30
+ return scores
31
+
32
+
33
+ def _list(value: Any) -> List[str]:
34
+ if value in (None, "", []):
35
+ return []
36
+ if isinstance(value, str):
37
+ return [v.strip() for v in value.split(",") if v.strip()]
38
+ return [str(v) for v in value if str(v).strip()]
39
+
40
+
41
+ def search(nb: Any, query: str = "", *, since: Optional[str] = None, until: Optional[str] = None,
42
+ kinds: Any = None, entities: Any = None, topics: Any = None, limit: int = 20,
43
+ as_of: Optional[str] = None) -> Dict[str, Any]:
44
+ limit = clamp_limit(limit, 20, 200)
45
+ query = str(query or "").strip()
46
+ since_n = ids.optional_time(since, field="since")
47
+ until_n = ids.optional_time(until, field="until")
48
+ as_of_n = ids.optional_time(as_of, field="as_of")
49
+ kinds_l, topics_l = _list(kinds), [t.lstrip("#").lower() for t in _list(topics)]
50
+ entity_l = _list(entities)
51
+ store = nb.store
52
+
53
+ # ---------------------------------------------------------------- note filter (SQL)
54
+ where = ["n.archived_at IS NULL"]
55
+ params: List[Any] = []
56
+ if since_n:
57
+ where.append("n.occurred_at>=?")
58
+ params.append(since_n)
59
+ bound = min(x for x in (until_n, as_of_n) if x) if (until_n or as_of_n) else None
60
+ if bound:
61
+ where.append("n.occurred_at<=?")
62
+ params.append(bound)
63
+ if kinds_l:
64
+ where.append(f"n.kind IN ({','.join('?' for _ in kinds_l)})")
65
+ params += kinds_l
66
+ if topics_l:
67
+ where.append(f"n.note_id IN (SELECT note_id FROM note_topics WHERE topic IN ({','.join('?' for _ in topics_l)}))")
68
+ params += topics_l
69
+ if entity_l:
70
+ members: List[str] = []
71
+ for e in entity_l:
72
+ members += store.merged_members(store.canonical(e))
73
+ where.append(f"n.note_id IN (SELECT note_id FROM entity_notes WHERE entity_id IN ({','.join('?' for _ in members)}))")
74
+ params += members
75
+ cond = " AND ".join(where)
76
+
77
+ terms = fts_terms(query)
78
+ fts_notes: List[str] = []
79
+ snippets: Dict[str, str] = {}
80
+ if terms:
81
+ for r in store.q(
82
+ f"SELECT f.note_id, snippet(notes_fts, 2, '', '', '…', 24) AS snip FROM notes_fts f JOIN notes n"
83
+ f" ON n.note_id=f.note_id WHERE notes_fts MATCH ? AND {cond} ORDER BY bm25(notes_fts) LIMIT ?",
84
+ [terms, *params, POOL]):
85
+ fts_notes.append(r[0])
86
+ snippets[r[0]] = r[1]
87
+ warnings: List[str] = []
88
+ qvec = None
89
+ if query:
90
+ if nb.cache.embedder is None and nb.cache.get(vector_key(query, nb.cache.model_id)) is None:
91
+ warnings.append(nb.cache.missing_embedder + " These results match the words only.")
92
+ else:
93
+ qvec = nb.cache.embed_query(query)
94
+ vec_notes: List[str] = []
95
+ vec_score: Dict[str, float] = {}
96
+ if qvec is not None:
97
+ filtered = len(params) > 0
98
+ allowed = {r[0] for r in store.q(f"SELECT n.note_id FROM notes n WHERE {cond}", params)} if filtered else None
99
+ archived = set() if filtered else {r[0] for r in store.q("SELECT note_id FROM notes WHERE archived_at IS NOT NULL")}
100
+ for nid, s in nb.note_vectors.nearest(qvec, POOL, NOTE_VECTOR_MIN, allowed=allowed, exclude=archived):
101
+ vec_notes.append(nid)
102
+ vec_score[nid] = s
103
+ if query:
104
+ fused = _rrf(fts_notes, vec_notes)
105
+ order = sorted(fused, key=lambda k: (-fused[k], k))
106
+ else:
107
+ order = [r[0] for r in store.q(f"SELECT n.note_id FROM notes n WHERE {cond} ORDER BY n.occurred_at DESC LIMIT ?",
108
+ [*params, POOL])]
109
+ fused = {k: 0.0 for k in order}
110
+ fts_set, vec_set = set(fts_notes), set(vec_notes)
111
+ note_items = []
112
+ facet_kind: Dict[str, int] = {}
113
+ facet_topic: Dict[str, int] = {}
114
+ facet_entity: Dict[str, int] = {}
115
+ facet_month: Dict[str, int] = {}
116
+ for i, nid in enumerate(order):
117
+ row = store.note_row(nid)
118
+ if row is None:
119
+ continue
120
+ k = row["kind"] or "unfiled"
121
+ facet_kind[k] = facet_kind.get(k, 0) + 1
122
+ facet_month[row["occurred_at"][:7]] = facet_month.get(row["occurred_at"][:7], 0) + 1
123
+ note_topics = store.note_topics(nid)
124
+ for t in note_topics:
125
+ facet_topic[t] = facet_topic.get(t, 0) + 1
126
+ ents = sorted({store.canonical(r[0]) for r in store.q("SELECT entity_id FROM entity_notes WHERE note_id=?",
127
+ (nid,))})
128
+ for e in ents:
129
+ facet_entity[e] = facet_entity.get(e, 0) + 1
130
+ if len(note_items) < limit:
131
+ item = shapes.note_ref(row)
132
+ if snippets.get(nid):
133
+ item["excerpt"] = " ".join(snippets[nid].split())
134
+ item.update({"score": round(fused[nid], 6), "matched_by": [m for m, s in (("fts", fts_set), ("vector", vec_set))
135
+ if nid in s],
136
+ "topics": note_topics, "entity_ids": ents, "insight_kind": row["insight_kind"]})
137
+ note_items.append(item)
138
+
139
+ # ---------------------------------------------------------------- entities
140
+ ent_items: List[Dict[str, Any]] = []
141
+ if query:
142
+ e_cond = "e.merged_into IS NULL"
143
+ e_params: List[Any] = []
144
+ if as_of_n:
145
+ e_cond += " AND COALESCE(e.first_seen, e.created_at)<=?"
146
+ e_params.append(as_of_n)
147
+ fts_e = [r[0] for r in store.q(
148
+ f"SELECT f.entity_id FROM entities_fts f JOIN entities e ON e.entity_id=f.entity_id WHERE entities_fts MATCH ?"
149
+ f" AND {e_cond} ORDER BY bm25(entities_fts) LIMIT ?", [terms, *e_params, POOL])] if terms else []
150
+ vec_e: List[str] = []
151
+ if qvec is not None:
152
+ rows = store.q(f"SELECT e.entity_id, v.vector FROM entities e JOIN vectors v ON v.vector_key=e.vector_key"
153
+ f" WHERE {e_cond}", e_params)
154
+ vec_e = [e for e, _ in top_k(qvec, [(r[0], r[1]) for r in rows], POOL, NOTE_VECTOR_MIN)]
155
+ fe = _rrf(fts_e, vec_e)
156
+ for eid in sorted(fe, key=lambda k: (-fe[k], k))[:ENTITY_LIMIT]:
157
+ b = store.entity_brief(eid)
158
+ if b:
159
+ b.update({"score": round(fe[eid], 6),
160
+ "matched_by": [m for m, s in (("fts", set(fts_e)), ("vector", set(vec_e))) if eid in s]})
161
+ ent_items.append(b)
162
+
163
+ # ---------------------------------------------------------------- claims
164
+ claim_items: List[Dict[str, Any]] = []
165
+ if query:
166
+ c_cond = "1=1"
167
+ c_params: List[Any] = []
168
+ if as_of_n:
169
+ c_cond += " AND c.observed_at<=?"
170
+ c_params.append(as_of_n)
171
+ if since_n:
172
+ c_cond += " AND c.observed_at>=?"
173
+ c_params.append(since_n)
174
+ if until_n:
175
+ c_cond += " AND c.observed_at<=?"
176
+ c_params.append(until_n)
177
+ fts_c = [r[0] for r in store.q(
178
+ f"SELECT f.claim_id FROM claims_fts f JOIN claims c ON c.claim_id=f.claim_id WHERE claims_fts MATCH ?"
179
+ f" AND {c_cond} ORDER BY bm25(claims_fts) LIMIT ?", [terms, *c_params, POOL])] if terms else []
180
+ vec_c: List[str] = []
181
+ if qvec is not None:
182
+ rows = store.q(f"SELECT c.claim_id, v.vector FROM claims c JOIN vectors v ON v.vector_key=c.vector_key"
183
+ f" WHERE {c_cond}", c_params)
184
+ vec_c = [c for c, _ in top_k(qvec, [(r[0], r[1]) for r in rows], POOL, NOTE_VECTOR_MIN)]
185
+ fc = _rrf(fts_c, vec_c)
186
+ for cid in sorted(fc, key=lambda k: (-fc[k], k))[:CLAIM_LIMIT]:
187
+ row = store.claim_row(cid)
188
+ if row is not None:
189
+ d = store.claim_dict(row)
190
+ if as_of_n:
191
+ vf, vu = d["valid_from"], d["valid_until"]
192
+ d["believed_at_as_of"] = (vf is None or vf <= as_of_n) and (vu is None or vu > as_of_n)
193
+ d["score"] = round(fc[cid], 6)
194
+ claim_items.append(d)
195
+
196
+ names = {e: store._name(e) for e in facet_entity}
197
+ facets = {
198
+ "kinds": [{"kind": k, "count": v} for k, v in sorted(facet_kind.items(), key=lambda x: (-x[1], x[0]))],
199
+ "topics": [{"topic": k, "count": v} for k, v in sorted(facet_topic.items(), key=lambda x: (-x[1], x[0]))],
200
+ "entities": [{"entity_id": k, "name": names[k], "count": v}
201
+ for k, v in sorted(facet_entity.items(), key=lambda x: (-x[1], x[0]))[:20]],
202
+ "months": [{"month": k, "count": v} for k, v in sorted(facet_month.items())],
203
+ }
204
+ return {"query": query, "as_of": as_of_n, "notes": note_items, "entities": ent_items, "claims": claim_items,
205
+ "facets": facets, "considered": len(order), "warnings": warnings}
@@ -0,0 +1,52 @@
1
+ """The pinned response shapes of docs/api.md ("Pinned response shapes"); clients decode them strictly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ from typing import Any, Dict, Optional
8
+
9
+ EXCERPT_CHARS = 280
10
+
11
+
12
+ def excerpt(text: Optional[str], limit: int = EXCERPT_CHARS) -> str:
13
+ """A list excerpt of a note body (the full text is always available from the note itself)."""
14
+ t = " ".join(str(text or "").split())
15
+ return t if len(t) <= limit else t[: limit - 1].rstrip() + "…"
16
+
17
+
18
+ def note_ref(r: sqlite3.Row) -> Dict[str, Any]:
19
+ """``NoteRef``: ``{note_id, title, kind, occurred_at, excerpt}``."""
20
+ return {"note_id": r["note_id"], "title": r["title"] or "", "kind": r["kind"], "occurred_at": r["occurred_at"],
21
+ "excerpt": excerpt(r["summary"] or r["one_line"] or r["text"])}
22
+
23
+
24
+ def insight(r: sqlite3.Row) -> Dict[str, Any]:
25
+ """``Insight``: ``{note_id, title, body, insight_kind, occurred_at, acked_at, evidence_note_ids, entity_ids}``."""
26
+ return {"note_id": r["note_id"], "title": r["title"] or "", "body": r["text"] or "",
27
+ "insight_kind": r["insight_kind"], "occurred_at": r["occurred_at"], "acked_at": r["acked_at"],
28
+ "evidence_note_ids": _list(r["evidence_note_ids_json"]), "entity_ids": _list(r["entity_ids_json"])}
29
+
30
+
31
+ def insight_facts(r: sqlite3.Row) -> Optional[Dict[str, Any]]:
32
+ """The ``insight`` field of ``GET /note/notes/{id}``: ``{kind, evidence_note_ids, entity_ids, acked_at}`` for an
33
+ insight note, ``None`` for any other note."""
34
+ if not r["insight_kind"]:
35
+ return None
36
+ return {"kind": r["insight_kind"], "evidence_note_ids": _list(r["evidence_note_ids_json"]),
37
+ "entity_ids": _list(r["entity_ids_json"]), "acked_at": r["acked_at"]}
38
+
39
+
40
+ def task(r: sqlite3.Row) -> Dict[str, Any]:
41
+ return {"task_id": r["task_id"], "note_id": r["note_id"], "text": r["text"], "status": r["status"],
42
+ "due": r["due"], "closed_at": r["closed_at"]}
43
+
44
+
45
+ def _list(raw: Optional[str]) -> list:
46
+ if not raw:
47
+ return []
48
+ try:
49
+ v = json.loads(raw)
50
+ except ValueError:
51
+ return []
52
+ return v if isinstance(v, list) else []
@@ -0,0 +1,100 @@
1
+ """Then versus now: the earliest and latest understanding of an entity (or a relationship) and the
2
+ turning points between them, each with the note that caused it. Deterministic; cached per entity
3
+ revision count (``then_now_cache``)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from ..store.notes import _uj
11
+
12
+
13
+ def _claim_line(c: Dict[str, Any]) -> str:
14
+ obj = c.get("object_name") or c.get("object_literal") or c.get("object_id")
15
+ return f"{c.get('subject_name') or c['subject_id']} {c['predicate']} {obj}"
16
+
17
+
18
+ def then_now(nb: Any, entity_id: str) -> Dict[str, Any]:
19
+ s = nb.store
20
+ if s.entity_row(entity_id) is None:
21
+ raise ValueError("That entity is not in this notebook.")
22
+ eid = s.canonical(entity_id)
23
+ members = s.merged_members(eid)
24
+ q = ",".join("?" for _ in members)
25
+ count = int(s.q1(f"SELECT COALESCE(SUM(revision_count), 0) FROM entities WHERE entity_id IN ({q})", members)[0])
26
+ key = "entity:" + eid
27
+ cached = s.q1("SELECT revision_count, payload_json FROM then_now_cache WHERE key=?", (key,))
28
+ if cached is not None and int(cached[0]) == count:
29
+ return json.loads(cached[1])
30
+
31
+ summaries = s.q(f"SELECT * FROM entity_summaries WHERE entity_id IN ({q}) ORDER BY at, revision_id", members)
32
+ earliest_claims = s.q(f"SELECT * FROM claims WHERE subject_id IN ({q}) OR object_id IN ({q})"
33
+ f" ORDER BY observed_at, seq LIMIT 5", [*members, *members])
34
+ current = [c for c in s.beliefs_at(_now(), entity_id=eid)]
35
+
36
+ def summary_point(r: Any) -> Dict[str, Any]:
37
+ return {"at": r["at"], "summary": r["summary"], "note_id": r["note_id"], "revision_id": r["revision_id"]}
38
+
39
+ turning: List[Dict[str, Any]] = []
40
+ for r in summaries[1:]:
41
+ turning.append({"at": r["at"], "kind": "summary", "text": r["summary"], "note_id": r["note_id"],
42
+ "revision_id": r["revision_id"]})
43
+ for r in s.q(f"SELECT c.*, v.at AS closed_at, v.note_id AS closing_note FROM claims c JOIN revisions v ON"
44
+ f" v.revision_id=c.closed_revision_id WHERE (c.subject_id IN ({q}) OR c.object_id IN ({q}))",
45
+ [*members, *members]):
46
+ d = s.claim_dict(r)
47
+ turning.append({"at": r["closed_at"], "kind": "retract" if r["status"] == "retracted" else "supersede",
48
+ "text": _claim_line(d) + (f" (replaced: {r['closed_reason']})" if r["closed_reason"] else ""),
49
+ "claim_id": r["claim_id"], "superseded_by": r["superseded_by"],
50
+ "note_id": r["closing_note"], "revision_id": r["closed_revision_id"]})
51
+ for r in s.q(f"SELECT * FROM claims WHERE (subject_id IN ({q}) OR object_id IN ({q})) AND disputed_with_json!='[]'"
52
+ f" AND certainty='disputed'", [*members, *members]):
53
+ turning.append({"at": r["observed_at"], "kind": "dispute", "text": _claim_line(s.claim_dict(r)),
54
+ "claim_id": r["claim_id"], "note_id": r["note_id"], "revision_id": r["revision_id"]})
55
+ for r in s.q(f"SELECT * FROM merges WHERE from_id IN ({q}) OR into_id IN ({q})", [*members, *members]):
56
+ turning.append({"at": r["at"], "kind": "merge", "text": f"{r['from_id']} is {r['into_id']}",
57
+ "note_id": (s.revision_row(r["revision_id"]) or {"note_id": None})["note_id"],
58
+ "revision_id": r["revision_id"]})
59
+ turning.sort(key=lambda t: (t["at"] or "", t["kind"]))
60
+ payload = {
61
+ "entity_id": eid,
62
+ "earliest": summary_point(summaries[0]) if summaries else None,
63
+ "latest": summary_point(summaries[-1]) if summaries else None,
64
+ "earliest_claims": [s.claim_dict(r) for r in earliest_claims],
65
+ "current_beliefs": current,
66
+ "turning_points": turning,
67
+ "revision_count": count,
68
+ }
69
+ with s.lock:
70
+ s.conn.execute("INSERT OR REPLACE INTO then_now_cache(key, revision_count, payload_json) VALUES (?,?,?)",
71
+ (key, count, json.dumps(payload, sort_keys=True)))
72
+ return payload
73
+
74
+
75
+ def _now() -> str:
76
+ from .. import ids
77
+
78
+ return ids.utc_now()
79
+
80
+
81
+ def relation_then_now(claims: List[Dict[str, Any]]) -> Dict[str, Any]:
82
+ """Then versus now of a link, from its claims in history order (as built by ``expand.relation``)."""
83
+ if not claims:
84
+ return {"earliest": [], "latest": [], "turning_points": []}
85
+ first_at = claims[0]["observed_at"]
86
+ earliest = [c for c in claims if c["observed_at"] == first_at]
87
+ latest = [c for c in claims if c["status"] == "active" and c["valid_until"] is None]
88
+ turning = [{"at": c["closed"]["at"], "kind": c["closed"]["kind"], "claim_id": c["claim_id"],
89
+ "superseded_by": c["superseded_by"], "note_id": c["closed"]["note_id"],
90
+ "reason": c["closed"]["reason"]} for c in claims if c.get("closed")]
91
+ turning += [{"at": c["observed_at"], "kind": "dispute", "claim_id": c["claim_id"], "note_id": c["note_id"],
92
+ "disputed_with": c["disputed_with"]} for c in claims if c["certainty"] == "disputed"]
93
+ turning.sort(key=lambda t: (t["at"] or "", t["kind"]))
94
+ return {"earliest": [_brief(c) for c in earliest], "latest": [_brief(c) for c in latest],
95
+ "turning_points": turning}
96
+
97
+
98
+ def _brief(c: Dict[str, Any]) -> Dict[str, Any]:
99
+ return {"claim_id": c["claim_id"], "text": _claim_line(c), "observed_at": c["observed_at"],
100
+ "note_id": c["note_id"], "certainty": c["certainty"]}
@@ -0,0 +1,12 @@
1
+ """Storage: the note index, the AbstractMemory projection, embeddings, the queue and ``Notebook``."""
2
+
3
+ from .embeddings import HashingEmbedder, TextEmbedder, VectorCache, gateway_embedder, openai_compat_embedder
4
+ from .kg import ClaimStore
5
+ from .notebook import Notebook
6
+ from .notes import NoteStore
7
+ from .queue import Queue
8
+
9
+ __all__ = [
10
+ "ClaimStore", "HashingEmbedder", "NoteStore", "Notebook", "Queue", "TextEmbedder", "VectorCache",
11
+ "gateway_embedder", "openai_compat_embedder",
12
+ ]