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,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,5 @@
1
+ """Processing: the pure functions behind note_context, note_resolve_candidates and note_apply_revision.
2
+
3
+ The model calls themselves are flow nodes (abstractnote.flows); this package
4
+ provides their schemas, prompts and the deterministic steps around them.
5
+ """
@@ -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.