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,95 @@
|
|
|
1
|
+
"""Text normalisation and sectioning.
|
|
2
|
+
|
|
3
|
+
``text_norm`` is the hashing and full-text form of a note: Unicode NFC,
|
|
4
|
+
whitespace collapsed, lower-cased. ``sections`` splits a long text for
|
|
5
|
+
processing by Markdown headings, then paragraphs, packing pieces up to a
|
|
6
|
+
character budget; every character of the input is kept (no truncation).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
from typing import Dict, List
|
|
14
|
+
|
|
15
|
+
DEFAULT_SECTION_CHARS = 12000
|
|
16
|
+
|
|
17
|
+
_HEADING = re.compile(r"^#{1,6}\s") # Markdown heading line (document structure, not meaning)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def text_norm(text: str) -> str:
|
|
21
|
+
return " ".join(unicodedata.normalize("NFC", str(text or "")).split()).lower()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalize_topic(topic: str) -> str:
|
|
25
|
+
"""Topic spelling: no leading ``#``, words joined with ``-``, lower case."""
|
|
26
|
+
t = str(topic or "").strip().lstrip("#").strip()
|
|
27
|
+
return "-".join(t.lower().split())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _blocks(text: str) -> List[str]:
|
|
31
|
+
"""Heading-led blocks, each split further into paragraphs (blank-line separated)."""
|
|
32
|
+
lines = text.splitlines(keepends=True)
|
|
33
|
+
blocks: List[List[str]] = [[]]
|
|
34
|
+
for line in lines:
|
|
35
|
+
if _HEADING.match(line) and blocks[-1]:
|
|
36
|
+
blocks.append([])
|
|
37
|
+
blocks[-1].append(line)
|
|
38
|
+
out: List[str] = []
|
|
39
|
+
for b in blocks:
|
|
40
|
+
chunk = "".join(b)
|
|
41
|
+
if not chunk:
|
|
42
|
+
continue
|
|
43
|
+
paras = re.split(r"(\n[ \t]*\n)", chunk)
|
|
44
|
+
# keep separators attached to the paragraph before them so nothing is lost
|
|
45
|
+
cur = ""
|
|
46
|
+
for piece in paras:
|
|
47
|
+
cur += piece
|
|
48
|
+
if re.fullmatch(r"\n[ \t]*\n", piece):
|
|
49
|
+
out.append(cur)
|
|
50
|
+
cur = ""
|
|
51
|
+
if cur:
|
|
52
|
+
out.append(cur)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _split_hard(piece: str, budget: int) -> List[str]:
|
|
57
|
+
"""Split an over-budget paragraph at line ends, then sentence ends, then the budget itself."""
|
|
58
|
+
out: List[str] = []
|
|
59
|
+
rest = piece
|
|
60
|
+
while len(rest) > budget:
|
|
61
|
+
window = rest[:budget]
|
|
62
|
+
cut = max(window.rfind("\n"), window.rfind(". "), window.rfind("? "), window.rfind("! "))
|
|
63
|
+
cut = cut + 1 if cut > budget // 4 else budget
|
|
64
|
+
out.append(rest[:cut])
|
|
65
|
+
rest = rest[cut:]
|
|
66
|
+
if rest:
|
|
67
|
+
out.append(rest)
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def sections(text: str, budget: int = DEFAULT_SECTION_CHARS) -> List[Dict[str, object]]:
|
|
72
|
+
"""``[{index, start, end, chars, text}]`` covering ``text`` exactly once, each <= budget chars."""
|
|
73
|
+
text = str(text or "")
|
|
74
|
+
budget = max(200, int(budget))
|
|
75
|
+
if len(text) <= budget:
|
|
76
|
+
return [{"index": 0, "start": 0, "end": len(text), "chars": len(text), "text": text}] if text else []
|
|
77
|
+
pieces: List[str] = []
|
|
78
|
+
for block in _blocks(text):
|
|
79
|
+
pieces.extend(_split_hard(block, budget) if len(block) > budget else [block])
|
|
80
|
+
packed: List[str] = []
|
|
81
|
+
cur = ""
|
|
82
|
+
for p in pieces:
|
|
83
|
+
if cur and len(cur) + len(p) > budget:
|
|
84
|
+
packed.append(cur)
|
|
85
|
+
cur = ""
|
|
86
|
+
cur += p
|
|
87
|
+
if cur:
|
|
88
|
+
packed.append(cur)
|
|
89
|
+
out = []
|
|
90
|
+
pos = 0
|
|
91
|
+
for i, s in enumerate(packed):
|
|
92
|
+
out.append({"index": i, "start": pos, "end": pos + len(s), "chars": len(s), "text": s})
|
|
93
|
+
pos += len(s)
|
|
94
|
+
assert pos == len(text), "sectioning must cover the text exactly"
|
|
95
|
+
return out
|
abstractnote/model.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""The records AbstractNote keeps (docs/data-model.md), as pydantic v2 models.
|
|
2
|
+
|
|
3
|
+
These types are the read and write shapes of the library. The SQLite schema
|
|
4
|
+
in ``abstractnote.store.schema`` stores them; ``abstractnote.store.kg`` projects
|
|
5
|
+
claims, names, aliases and merges into AbstractMemory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
NoteKind = Literal[
|
|
15
|
+
"idea", "task", "decision", "fact", "event", "question", "reflection", "reference", "log",
|
|
16
|
+
"conversation", "personal", "synthesis", "insight",
|
|
17
|
+
]
|
|
18
|
+
NOTE_KINDS: tuple = NoteKind.__args__ # type: ignore[attr-defined]
|
|
19
|
+
FILING_KINDS: tuple = tuple(k for k in NOTE_KINDS if k not in ("synthesis", "insight"))
|
|
20
|
+
|
|
21
|
+
NoteStatus = Literal["received", "queued", "processing", "processed", "failed", "duplicate", "archived"]
|
|
22
|
+
Client = Literal["web", "watch", "iphone", "cli", "agent", "importer"]
|
|
23
|
+
CLIENTS: tuple = Client.__args__ # type: ignore[attr-defined]
|
|
24
|
+
EntityKind = Literal["person", "org", "project", "concept", "place", "event", "artifact", "software", "thing"]
|
|
25
|
+
ENTITY_KINDS: tuple = EntityKind.__args__ # type: ignore[attr-defined]
|
|
26
|
+
Certainty = Literal["stated", "probable", "possible", "reported", "disputed"]
|
|
27
|
+
CERTAINTIES: tuple = Certainty.__args__ # type: ignore[attr-defined]
|
|
28
|
+
Stance = Literal["assert", "revise", "retract"]
|
|
29
|
+
ClaimStatus = Literal["active", "superseded", "retracted"]
|
|
30
|
+
TaskStatus = Literal["open", "done", "dropped"]
|
|
31
|
+
InsightKind = Literal["warning", "reminder", "connection", "contradiction", "evolution", "dossier", "cue"]
|
|
32
|
+
INSIGHT_KINDS: tuple = InsightKind.__args__ # type: ignore[attr-defined]
|
|
33
|
+
ExtractedBy = str # plain | markdown | pdf | vision:<model> | stt:<model> | media-metadata | none
|
|
34
|
+
|
|
35
|
+
CERTAINTY_CONFIDENCE: Dict[str, float] = {
|
|
36
|
+
"stated": 0.9, "probable": 0.7, "possible": 0.5, "reported": 0.6, "disputed": 0.3,
|
|
37
|
+
}
|
|
38
|
+
URGENCY_PRIORITY: Dict[str, int] = {"bulk": 0, "normal": 1, "high": 2, "urgent": 3}
|
|
39
|
+
|
|
40
|
+
OP_NAMES = (
|
|
41
|
+
"entity.create", "entity.alias", "entity.rename", "entity.summary", "entity.merge", "entity.unmerge",
|
|
42
|
+
"entity.mention", "claim.add", "claim.supersede", "claim.retract", "claim.reinforce", "topic.assign",
|
|
43
|
+
"task.open", "task.close", "task.drop", "note.summary",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _Model(BaseModel):
|
|
48
|
+
model_config = ConfigDict(extra="forbid")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Location(_Model):
|
|
52
|
+
lat: float
|
|
53
|
+
lon: float
|
|
54
|
+
accuracy_m: Optional[float] = None
|
|
55
|
+
label: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Provenance(_Model):
|
|
59
|
+
"""The ``source`` of a note or a user action."""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(extra="allow")
|
|
62
|
+
client: Client
|
|
63
|
+
importer: Optional[str] = None
|
|
64
|
+
origin: Dict[str, Any] = Field(default_factory=dict)
|
|
65
|
+
author: Optional[str] = None
|
|
66
|
+
account: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Attachment(_Model):
|
|
70
|
+
attachment_id: str
|
|
71
|
+
note_id: str
|
|
72
|
+
sha256: str
|
|
73
|
+
size: int
|
|
74
|
+
mime: str
|
|
75
|
+
filename: Optional[str] = None
|
|
76
|
+
artifact_ref: Optional[Dict[str, Any]] = None
|
|
77
|
+
blob_path: str
|
|
78
|
+
extracted_text: Optional[str] = None
|
|
79
|
+
extracted_by: ExtractedBy = "none"
|
|
80
|
+
extracted_chars: int = 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Note(_Model):
|
|
84
|
+
note_id: str
|
|
85
|
+
kind: Optional[NoteKind] = None
|
|
86
|
+
title: Optional[str] = None
|
|
87
|
+
text: str = ""
|
|
88
|
+
text_norm: str = ""
|
|
89
|
+
summary: Optional[str] = None
|
|
90
|
+
occurred_at: str
|
|
91
|
+
received_at: str
|
|
92
|
+
processed_at: Optional[str] = None
|
|
93
|
+
source: Provenance
|
|
94
|
+
location: Optional[Location] = None
|
|
95
|
+
language: Optional[str] = None
|
|
96
|
+
topics: List[str] = Field(default_factory=list)
|
|
97
|
+
priority: int = 1
|
|
98
|
+
status: NoteStatus = "received"
|
|
99
|
+
duplicate_of: Optional[str] = None
|
|
100
|
+
session_id: Optional[str] = None
|
|
101
|
+
run_id: Optional[str] = None
|
|
102
|
+
archived_at: Optional[str] = None
|
|
103
|
+
attachments: List[Attachment] = Field(default_factory=list)
|
|
104
|
+
# Insight notes (kind insight|synthesis)
|
|
105
|
+
insight_kind: Optional[InsightKind] = None
|
|
106
|
+
evidence_note_ids: List[str] = Field(default_factory=list)
|
|
107
|
+
entity_ids: List[str] = Field(default_factory=list)
|
|
108
|
+
acked_at: Optional[str] = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Entity(_Model):
|
|
112
|
+
entity_id: str
|
|
113
|
+
kind: EntityKind
|
|
114
|
+
name: str
|
|
115
|
+
aliases: List[str] = Field(default_factory=list)
|
|
116
|
+
summary: Optional[str] = None
|
|
117
|
+
first_seen: Optional[str] = None
|
|
118
|
+
last_seen: Optional[str] = None
|
|
119
|
+
note_count: int = 0
|
|
120
|
+
merged_into: Optional[str] = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Claim(_Model):
|
|
124
|
+
claim_id: str
|
|
125
|
+
subject_id: str
|
|
126
|
+
predicate: str
|
|
127
|
+
object_id: Optional[str] = None
|
|
128
|
+
object_literal: Optional[str] = None
|
|
129
|
+
certainty: Certainty = "stated"
|
|
130
|
+
stance: Stance = "assert"
|
|
131
|
+
observed_at: str
|
|
132
|
+
valid_from: Optional[str] = None
|
|
133
|
+
valid_until: Optional[str] = None
|
|
134
|
+
status: ClaimStatus = "active"
|
|
135
|
+
superseded_by: Optional[str] = None
|
|
136
|
+
evidence: Optional[str] = None
|
|
137
|
+
note_id: Optional[str] = None
|
|
138
|
+
revision_id: Optional[str] = None
|
|
139
|
+
assertion_id: Optional[str] = None
|
|
140
|
+
disputed_with: List[str] = Field(default_factory=list)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Relation(_Model):
|
|
144
|
+
subject_id: str
|
|
145
|
+
object_id: str
|
|
146
|
+
predicate: str
|
|
147
|
+
first_seen: str
|
|
148
|
+
last_seen: str
|
|
149
|
+
count: int
|
|
150
|
+
active_claim_ids: List[str] = Field(default_factory=list)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class RevisionOp(_Model):
|
|
154
|
+
op: Literal[OP_NAMES] # type: ignore[valid-type]
|
|
155
|
+
payload: Dict[str, Any] = Field(default_factory=dict)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class Revision(_Model):
|
|
159
|
+
revision_id: str
|
|
160
|
+
note_id: Optional[str] = None
|
|
161
|
+
attempt: Optional[int] = None
|
|
162
|
+
action: Optional[str] = None
|
|
163
|
+
at: str
|
|
164
|
+
recorded_at: Optional[str] = None
|
|
165
|
+
run_id: Optional[str] = None
|
|
166
|
+
ops: List[RevisionOp] = Field(default_factory=list)
|
|
167
|
+
summary: str = ""
|
|
168
|
+
source: Optional[Dict[str, Any]] = None
|
|
169
|
+
warnings: List[str] = Field(default_factory=list)
|
|
170
|
+
applied_index: bool = False
|
|
171
|
+
applied_kg: bool = False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class QueueItem(_Model):
|
|
175
|
+
note_id: str
|
|
176
|
+
priority: int
|
|
177
|
+
enqueued_at: str
|
|
178
|
+
occurred_at: str
|
|
179
|
+
size: int = 0
|
|
180
|
+
state: Literal["queued", "leased", "done", "failed"] = "queued"
|
|
181
|
+
leased_by: Optional[str] = None
|
|
182
|
+
lease_until: Optional[str] = None
|
|
183
|
+
attempts: int = 0
|
|
184
|
+
failures: int = 0
|
|
185
|
+
next_attempt_at: Optional[str] = None
|
|
186
|
+
last_error: Optional[str] = None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Task(_Model):
|
|
190
|
+
task_id: str
|
|
191
|
+
note_id: Optional[str] = None
|
|
192
|
+
text: str
|
|
193
|
+
status: TaskStatus = "open"
|
|
194
|
+
due: Optional[str] = None
|
|
195
|
+
opened_at: Optional[str] = None
|
|
196
|
+
closed_at: Optional[str] = None
|
|
197
|
+
closed_by_note_id: Optional[str] = None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class Topic(_Model):
|
|
201
|
+
topic: str
|
|
202
|
+
note_count: int = 0
|
|
203
|
+
first_seen: Optional[str] = None
|
|
204
|
+
last_seen: Optional[str] = None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Insight(_Model):
|
|
208
|
+
note_id: str
|
|
209
|
+
insight_kind: InsightKind
|
|
210
|
+
title: str
|
|
211
|
+
body: str
|
|
212
|
+
evidence_note_ids: List[str] = Field(default_factory=list)
|
|
213
|
+
entity_ids: List[str] = Field(default_factory=list)
|
|
214
|
+
occurred_at: str
|
|
215
|
+
acked_at: Optional[str] = None
|
|
216
|
+
run_id: Optional[str] = None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class CaptureResult(_Model):
|
|
220
|
+
note_id: str
|
|
221
|
+
status: Literal["new", "duplicate"]
|
|
222
|
+
duplicate_of: Optional[str] = None
|
|
223
|
+
first_saved_at: Optional[str] = None
|
|
224
|
+
received_at: str
|
|
225
|
+
revisits: List[Dict[str, Any]] = Field(default_factory=list)
|
|
226
|
+
similar: List[Dict[str, Any]] = Field(default_factory=list)
|
|
227
|
+
topics: List[str] = Field(default_factory=list)
|
|
228
|
+
attachments: List[Dict[str, Any]] = Field(default_factory=list)
|
|
229
|
+
warnings: List[str] = Field(default_factory=list)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""``note_context``: what the notebook already knows that is relevant to one note.
|
|
2
|
+
|
|
3
|
+
The packet lists candidate entities (exact name or alias found in the note's
|
|
4
|
+
words, then vector neighbours of the note), their active claims and the open
|
|
5
|
+
tasks, each with an ``index``. The Linking step refers to existing claims and
|
|
6
|
+
open tasks only by those indexes; the packet is stored so that
|
|
7
|
+
``note_apply_revision`` can check every reference by set membership.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
from .. import ids
|
|
17
|
+
from ..ingest.normalise import DEFAULT_SECTION_CHARS, sections
|
|
18
|
+
from ..recall.shapes import excerpt
|
|
19
|
+
from ..store.embeddings import top_k
|
|
20
|
+
from .vocabulary import vocabulary
|
|
21
|
+
|
|
22
|
+
MAX_CANDIDATE_ENTITIES = 15
|
|
23
|
+
MAX_CLAIMS_PER_ENTITY = 8
|
|
24
|
+
MAX_OPEN_TASKS = 20
|
|
25
|
+
SIMILAR_NOTES = 5
|
|
26
|
+
ENTITY_VECTOR_MIN = 0.3
|
|
27
|
+
|
|
28
|
+
_WORD = re.compile(r"\w+", re.UNICODE)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def words(text: str) -> List[str]:
|
|
32
|
+
return _WORD.findall(ids.fold(text))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ----------------------------------------------------------------------------- packet storage
|
|
36
|
+
|
|
37
|
+
def load_packet(nb: Any, note_id: str) -> Optional[Dict[str, Any]]:
|
|
38
|
+
row = nb.store.q1("SELECT packet_json FROM context_packets WHERE note_id=?", (note_id,))
|
|
39
|
+
return json.loads(row[0]) if row else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def save_packet(nb: Any, note_id: str, packet: Dict[str, Any]) -> None:
|
|
43
|
+
with nb.store.lock:
|
|
44
|
+
nb.store.conn.execute("INSERT OR REPLACE INTO context_packets(note_id, created_at, packet_json) VALUES (?,?,?)",
|
|
45
|
+
(note_id, ids.utc_now(), json.dumps(packet, sort_keys=True)))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def empty_packet() -> Dict[str, Any]:
|
|
49
|
+
return {"candidate_entities": [], "existing_claims": [], "open_tasks": [], "mentions": {}}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ----------------------------------------------------------------------------- views
|
|
53
|
+
|
|
54
|
+
def claim_view(nb: Any, row: Any, index: int) -> Dict[str, Any]:
|
|
55
|
+
subject = nb.store._name(row["subject_id"]) or row["subject_id"]
|
|
56
|
+
obj = (nb.store._name(row["object_id"]) or row["object_id"]) if row["object_id"] else row["object_literal"]
|
|
57
|
+
return {"index": index, "claim_id": row["claim_id"], "subject": subject, "predicate": row["predicate"],
|
|
58
|
+
"object": obj, "object_is_literal": row["object_id"] is None, "observed_at": row["observed_at"],
|
|
59
|
+
"certainty": row["certainty"], "evidence": row["evidence"]}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def active_claim_rows(nb: Any, entity_id: str, limit: int = MAX_CLAIMS_PER_ENTITY) -> List[Any]:
|
|
63
|
+
members = nb.store.merged_members(nb.store.canonical(entity_id))
|
|
64
|
+
q = ",".join("?" for _ in members)
|
|
65
|
+
return nb.store.q(
|
|
66
|
+
f"SELECT * FROM claims WHERE status='active' AND valid_until IS NULL AND (subject_id IN ({q}) OR object_id IN"
|
|
67
|
+
f" ({q})) ORDER BY observed_at DESC, claim_id LIMIT ?", [*members, *members, int(limit)])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def entity_view(nb: Any, entity_id: str) -> Dict[str, Any]:
|
|
71
|
+
e = nb.store.entity_row(entity_id)
|
|
72
|
+
return {"entity_id": e["entity_id"], "name": e["name"], "kind": e["kind"], "aliases": nb.store.aliases(entity_id),
|
|
73
|
+
"summary": e["summary"], "last_seen": e["last_seen"]}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def attach_claims(nb: Any, packet: Dict[str, Any], entity_id: str, views: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
77
|
+
"""Active claims of an entity as indexed views, appending new ones to the packet."""
|
|
78
|
+
out = []
|
|
79
|
+
for row in active_claim_rows(nb, entity_id):
|
|
80
|
+
cid = row["claim_id"]
|
|
81
|
+
if cid in packet["existing_claims"]:
|
|
82
|
+
idx = packet["existing_claims"].index(cid)
|
|
83
|
+
else:
|
|
84
|
+
idx = len(packet["existing_claims"])
|
|
85
|
+
packet["existing_claims"].append(cid)
|
|
86
|
+
views.append(claim_view(nb, row, idx))
|
|
87
|
+
out.append(claim_view(nb, row, idx))
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ----------------------------------------------------------------------------- name index
|
|
92
|
+
|
|
93
|
+
def name_index(nb: Any) -> Dict[str, List[str]]:
|
|
94
|
+
"""Case-folded entity names and aliases -> canonical entity ids (exact matching only)."""
|
|
95
|
+
out: Dict[str, List[str]] = {}
|
|
96
|
+
for r in nb.store.q("SELECT entity_id, name FROM entities"):
|
|
97
|
+
out.setdefault(ids.fold(r[1]), []).append(r[0])
|
|
98
|
+
for r in nb.store.q("SELECT entity_id, alias_fold FROM entity_aliases"):
|
|
99
|
+
out.setdefault(r[1], []).append(r[0])
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def entities_named_in(nb: Any, text: str) -> List[str]:
|
|
104
|
+
"""Entities whose exact name or alias (case-folded, as whole words) occurs in the text."""
|
|
105
|
+
token_set = set(words(text))
|
|
106
|
+
if not token_set:
|
|
107
|
+
return []
|
|
108
|
+
folded = " " + " ".join(words(text)) + " "
|
|
109
|
+
found: List[str] = []
|
|
110
|
+
for name, eids in name_index(nb).items():
|
|
111
|
+
parts = words(name)
|
|
112
|
+
if not parts or any(p not in token_set for p in parts):
|
|
113
|
+
continue
|
|
114
|
+
if len(parts) > 1 and (" " + " ".join(parts) + " ") not in folded:
|
|
115
|
+
continue
|
|
116
|
+
for eid in eids:
|
|
117
|
+
c = nb.store.canonical(eid)
|
|
118
|
+
if c not in found:
|
|
119
|
+
found.append(c)
|
|
120
|
+
return found
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ----------------------------------------------------------------------------- note_context
|
|
124
|
+
|
|
125
|
+
def note_context(nb: Any, note_id: str, max_chars: Optional[int] = None) -> Dict[str, Any]:
|
|
126
|
+
row = nb.store.note_row(note_id)
|
|
127
|
+
if row is None:
|
|
128
|
+
raise ValueError("That note is not in this notebook.")
|
|
129
|
+
budget = int(max_chars) if max_chars else DEFAULT_SECTION_CHARS
|
|
130
|
+
atts = nb.store.attachments_of(note_id)
|
|
131
|
+
secs: List[Dict[str, Any]] = []
|
|
132
|
+
for s in sections(row["text"] or "", budget):
|
|
133
|
+
secs.append({"index": len(secs), "source": "note", "chars": s["chars"], "text": s["text"]})
|
|
134
|
+
full_text = [row["title"] or "", row["text"] or ""]
|
|
135
|
+
for a in atts:
|
|
136
|
+
path = nb.root / "extracted" / f"{a['sha256']}.txt"
|
|
137
|
+
if not (a["extracted_chars"] and path.exists()):
|
|
138
|
+
continue
|
|
139
|
+
body = path.read_text(encoding="utf-8")
|
|
140
|
+
full_text.append(body)
|
|
141
|
+
for s in sections(body, budget):
|
|
142
|
+
secs.append({"index": len(secs), "source": f"attachment:{a['filename'] or a['attachment_id']}",
|
|
143
|
+
"chars": s["chars"], "text": s["text"]})
|
|
144
|
+
if not secs and row["title"]:
|
|
145
|
+
secs.append({"index": 0, "source": "title", "chars": len(row["title"]), "text": row["title"]})
|
|
146
|
+
|
|
147
|
+
source = json.loads(row["source_json"] or "{}")
|
|
148
|
+
about = json.loads(row["about_json"]) if row["about_json"] else None
|
|
149
|
+
note = {
|
|
150
|
+
"note_id": note_id, "title": row["title"], "kind": row["kind"], "occurred_at": row["occurred_at"],
|
|
151
|
+
"language": row["language"], "topics": nb.store.note_topics(note_id),
|
|
152
|
+
"source": {k: source[k] for k in ("client", "importer", "author") if source.get(k)},
|
|
153
|
+
"location": (json.loads(row["location_json"]).get("label") if row["location_json"] else None),
|
|
154
|
+
"attachments": [{"attachment_id": a["attachment_id"], "filename": a["filename"], "mime": a["mime"],
|
|
155
|
+
"extracted_by": a["extracted_by"]} for a in atts],
|
|
156
|
+
"chars": sum(s["chars"] for s in secs), "about": about,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
vector = nb.cache.get(row["vector_key"]) if row["vector_key"] else None
|
|
160
|
+
similar: List[Dict[str, Any]] = []
|
|
161
|
+
if vector is not None:
|
|
162
|
+
for nid, score in nb.note_vectors.nearest(vector, SIMILAR_NOTES * 3, 0.2, exclude=[note_id],
|
|
163
|
+
notes_only=True):
|
|
164
|
+
n = nb.store.note_row(nid)
|
|
165
|
+
if n["archived_at"] or len(similar) >= SIMILAR_NOTES:
|
|
166
|
+
continue
|
|
167
|
+
similar.append({"note_id": nid, "title": n["title"], "occurred_at": n["occurred_at"],
|
|
168
|
+
"summary": excerpt(n["summary"] or n["one_line"] or n["text"], 200),
|
|
169
|
+
"score": round(score, 4)})
|
|
170
|
+
|
|
171
|
+
packet = empty_packet()
|
|
172
|
+
ordered: List[tuple] = []
|
|
173
|
+
if about and about["kind"] == "entity" and nb.store.entity_row(about["id"]) is not None:
|
|
174
|
+
ordered.append((nb.store.canonical(about["id"]), "about"))
|
|
175
|
+
for eid in entities_named_in(nb, "\n".join(full_text)):
|
|
176
|
+
ordered.append((eid, "name_in_text"))
|
|
177
|
+
if vector is not None:
|
|
178
|
+
rows = nb.store.q("SELECT e.entity_id, v.vector FROM entities e JOIN vectors v ON v.vector_key=e.vector_key"
|
|
179
|
+
" WHERE e.merged_into IS NULL")
|
|
180
|
+
for eid, _score in top_k(vector, [(r[0], r[1]) for r in rows], MAX_CANDIDATE_ENTITIES, ENTITY_VECTOR_MIN):
|
|
181
|
+
ordered.append((eid, "vector"))
|
|
182
|
+
candidates: List[Dict[str, Any]] = []
|
|
183
|
+
claim_views: List[Dict[str, Any]] = []
|
|
184
|
+
for eid, how in ordered:
|
|
185
|
+
if eid in packet["candidate_entities"] or len(candidates) >= MAX_CANDIDATE_ENTITIES:
|
|
186
|
+
continue
|
|
187
|
+
packet["candidate_entities"].append(eid)
|
|
188
|
+
view = entity_view(nb, eid)
|
|
189
|
+
view.update({"index": len(candidates), "matched_by": how,
|
|
190
|
+
"active_claims": attach_claims(nb, packet, eid, claim_views)})
|
|
191
|
+
candidates.append(view)
|
|
192
|
+
|
|
193
|
+
task_rows: List[Any] = []
|
|
194
|
+
if about and about["kind"] == "task":
|
|
195
|
+
t = nb.store.q1("SELECT * FROM tasks WHERE task_id=? AND status='open'", (about["id"],))
|
|
196
|
+
if t is not None:
|
|
197
|
+
task_rows.append(t)
|
|
198
|
+
if packet["candidate_entities"]:
|
|
199
|
+
members: List[str] = []
|
|
200
|
+
for eid in packet["candidate_entities"]:
|
|
201
|
+
members.extend(nb.store.merged_members(eid))
|
|
202
|
+
q = ",".join("?" for _ in members)
|
|
203
|
+
task_rows.extend(nb.store.q(
|
|
204
|
+
f"SELECT * FROM tasks WHERE status='open' AND note_id IN (SELECT note_id FROM entity_notes WHERE entity_id"
|
|
205
|
+
f" IN ({q})) ORDER BY opened_at DESC LIMIT ?", [*members, MAX_OPEN_TASKS]))
|
|
206
|
+
task_rows.extend(nb.store.q("SELECT * FROM tasks WHERE status='open' ORDER BY opened_at DESC LIMIT ?",
|
|
207
|
+
(MAX_OPEN_TASKS,)))
|
|
208
|
+
open_tasks: List[Dict[str, Any]] = []
|
|
209
|
+
for t in task_rows:
|
|
210
|
+
if t["task_id"] in packet["open_tasks"] or len(open_tasks) >= MAX_OPEN_TASKS:
|
|
211
|
+
continue
|
|
212
|
+
packet["open_tasks"].append(t["task_id"])
|
|
213
|
+
open_tasks.append({"index": len(open_tasks), "task_id": t["task_id"], "text": t["text"],
|
|
214
|
+
"opened_at": t["opened_at"], "due": t["due"], "note_id": t["note_id"]})
|
|
215
|
+
save_packet(nb, note_id, packet)
|
|
216
|
+
return {"note": note, "sections": secs, "similar_notes": similar, "candidate_entities": candidates,
|
|
217
|
+
"existing_claims": claim_views, "open_tasks": open_tasks, "topics": nb.store.topic_names(),
|
|
218
|
+
"vocabulary": vocabulary()}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Merge partial Extractions of a multi-section note before linking.
|
|
2
|
+
|
|
3
|
+
Mentions are unioned by exact case-folded name and kind (refs renumbered m1,
|
|
4
|
+
m2, ...; aliases unioned); claims, tasks and time references are concatenated
|
|
5
|
+
with their refs remapped; topics are unioned; summaries are joined. This is
|
|
6
|
+
the same rule as the flow's merge step.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, List, Sequence, Tuple
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def merge_extractions(parts: Sequence[Dict[str, Any]]) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
|
|
15
|
+
"""Returns ``(merged extraction, mention list for note_resolve_candidates)``."""
|
|
16
|
+
parts = [p for p in (parts or []) if isinstance(p, dict)]
|
|
17
|
+
if not parts:
|
|
18
|
+
raise ValueError("There is no extraction to merge.")
|
|
19
|
+
if len(parts) == 1:
|
|
20
|
+
merged = dict(parts[0])
|
|
21
|
+
else:
|
|
22
|
+
mentions: List[Dict[str, Any]] = []
|
|
23
|
+
refmap: Dict[Tuple[int, Any], str] = {}
|
|
24
|
+
by_key: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
|
25
|
+
claims: List[Dict[str, Any]] = []
|
|
26
|
+
tasks: List[Dict[str, Any]] = []
|
|
27
|
+
time_refs: List[Dict[str, Any]] = []
|
|
28
|
+
topics: List[str] = []
|
|
29
|
+
summaries: List[str] = []
|
|
30
|
+
for i, part in enumerate(parts):
|
|
31
|
+
for m in part.get("mentions") or []:
|
|
32
|
+
key = (str(m.get("name") or "").casefold(), str(m.get("kind") or ""))
|
|
33
|
+
if key in by_key:
|
|
34
|
+
kept = by_key[key]
|
|
35
|
+
for a in m.get("aliases") or []:
|
|
36
|
+
if a not in kept["aliases"]:
|
|
37
|
+
kept["aliases"].append(a)
|
|
38
|
+
else:
|
|
39
|
+
kept = dict(m, ref="m" + str(len(mentions) + 1), aliases=list(m.get("aliases") or []))
|
|
40
|
+
by_key[key] = kept
|
|
41
|
+
mentions.append(kept)
|
|
42
|
+
refmap[(i, m.get("ref"))] = kept["ref"]
|
|
43
|
+
for c in part.get("claims") or []:
|
|
44
|
+
c2 = dict(c)
|
|
45
|
+
c2["subject"] = refmap.get((i, c.get("subject")), c.get("subject"))
|
|
46
|
+
if not c.get("object_is_literal"):
|
|
47
|
+
c2["object"] = refmap.get((i, c.get("object")), c.get("object"))
|
|
48
|
+
claims.append(c2)
|
|
49
|
+
tasks.extend(part.get("tasks") or [])
|
|
50
|
+
time_refs.extend(part.get("time_refs") or [])
|
|
51
|
+
for t in part.get("topics") or []:
|
|
52
|
+
if t not in topics:
|
|
53
|
+
topics.append(t)
|
|
54
|
+
if part.get("summary"):
|
|
55
|
+
summaries.append(str(part["summary"]))
|
|
56
|
+
merged = {"summary": " ".join(summaries), "mentions": mentions, "claims": claims, "tasks": tasks,
|
|
57
|
+
"time_refs": time_refs, "topics": topics}
|
|
58
|
+
names = [{"ref": m.get("ref"), "name": m.get("name"), "kind": m.get("kind"), "aliases": m.get("aliases") or []}
|
|
59
|
+
for m in merged.get("mentions") or []]
|
|
60
|
+
return merged, names
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Prompt templates (Markdown with ``{{var}}`` placeholders) and their tiny renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict
|
|
9
|
+
|
|
10
|
+
_DIR = Path(__file__).resolve().parent
|
|
11
|
+
_VAR = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}")
|
|
12
|
+
|
|
13
|
+
NAMES = ("quick_filing", "extract", "link", "synthesize", "mine_plan", "mine_findings", "mine_dossier", "reflect")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load(name: str) -> str:
|
|
17
|
+
if name not in NAMES:
|
|
18
|
+
raise ValueError(f"There is no prompt named {name!r}.")
|
|
19
|
+
return (_DIR / f"{name}.md").read_text(encoding="utf-8")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def variables(template: str) -> set:
|
|
23
|
+
return set(_VAR.findall(template))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _text(value: Any) -> str:
|
|
27
|
+
if value is None:
|
|
28
|
+
return ""
|
|
29
|
+
if isinstance(value, str):
|
|
30
|
+
return value
|
|
31
|
+
return json.dumps(value, ensure_ascii=False, indent=1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def render(template: str, values: Dict[str, Any]) -> str:
|
|
35
|
+
"""Replace ``{{var}}`` with the value (JSON for non-strings); a missing variable is an error."""
|
|
36
|
+
missing = variables(template) - set(values)
|
|
37
|
+
if missing:
|
|
38
|
+
raise ValueError(f"The prompt needs values for: {', '.join(sorted(missing))}.")
|
|
39
|
+
return _VAR.sub(lambda m: _text(values[m.group(1)]), template)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
You read one section of a person's note and write down what it says, so it can be linked to what they already know.
|
|
2
|
+
|
|
3
|
+
The note: {{note}}
|
|
4
|
+
|
|
5
|
+
Section to read:
|
|
6
|
+
{{section}}
|
|
7
|
+
|
|
8
|
+
What the notebook already knows (for orientation only; do not copy it into the output):
|
|
9
|
+
- similar notes: {{similar_notes}}
|
|
10
|
+
- entities that may be mentioned: {{candidate_entities}}
|
|
11
|
+
- open tasks: {{open_tasks}}
|
|
12
|
+
- existing topics: {{topics}}
|
|
13
|
+
|
|
14
|
+
Predicate vocabulary (use one of these CURIEs when it fits; otherwise write `ex:snake_case`):
|
|
15
|
+
{{vocabulary}}
|
|
16
|
+
|
|
17
|
+
Return:
|
|
18
|
+
- `summary`: one to three sentences about this section;
|
|
19
|
+
- `mentions`: every person, organisation, project, concept, place, event, artifact (document, dataset, repository), piece of software or other thing the section talks about, each with a `ref` (m1, m2, ...), the `name` exactly as written, its `kind`, other names used for it in the note (`aliases`), and its `role` in one phrase;
|
|
20
|
+
- `claims`: one per statement, with `subject` a mention ref, a `predicate`, and an `object` that is a mention ref, or a literal value with `object_is_literal: true`; `certainty` (stated, probable, possible, reported, disputed), `stance` (assert; revise when the note corrects earlier knowledge; retract when it says something is no longer true), `evidence` (a verbatim excerpt), and `valid_from` / `valid_until` only when the note gives or implies those dates from its own date;
|
|
21
|
+
Quantities, amounts, dates, deadlines and decisions are facts about their subject: write them as literal-object claims with an explicit `ex:` predicate rather than forcing a vocabulary predicate that does not mean the same thing. For example, "We capped the LUT budget at 25k; Dana needs the Pareto by 24 October" gives `{"subject": "m1", "predicate": "ex:budget_cap", "object": "25k", "object_is_literal": true}`, `{"subject": "m1", "predicate": "ex:deadline", "object": "2026-10-24", "object_is_literal": true}` and `{"subject": "m1", "predicate": "ex:decided", "object": "cap the budget at 25k", "object_is_literal": true}` (with m1 = LUT), not `schema:nextItem` or `schema:knowsAbout`;
|
|
22
|
+
- `tasks`: things to do (`open`) and things reported done (`done`), with a due date when one is given;
|
|
23
|
+
- `time_refs`: explicit time references ("last Tuesday", "in 2024") with their ISO date when the note's own date resolves them;
|
|
24
|
+
- `topics`: the topics the section belongs to, preferring existing ones.
|
|
25
|
+
|
|
26
|
+
Rules:
|
|
27
|
+
- Use only the note and the provided context; never invent entities or dates.
|
|
28
|
+
- Keep names as written by the author; put variants in aliases.
|
|
29
|
+
- Quote evidence verbatim; one claim per statement; no inferred hierarchy.
|
|
30
|
+
- Output JSON only, matching the schema.
|