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,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
|
+
]
|