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,377 @@
1
+ """Structured-output schemas (docs/cognition.md, "Structured output schemas").
2
+
3
+ ``SCHEMAS`` maps each name to the JSON Schema dict attached as ``resp_schema``
4
+ to the flows' ``llm_call`` nodes; the pydantic models below mirror them for
5
+ validation in Python (``note_apply_revision`` validates the Extraction and
6
+ Linking it receives). Identifiers never appear as free text in the Linking
7
+ schema: entities, existing claims and open tasks are referred to by their
8
+ INDEX in the context packet, and decisions are enumerations (ADR 0004).
9
+
10
+ The flow generator (``abstractnote.flows.build``) checks that its own copy of
11
+ these schemas has the same structure and refuses to build otherwise.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Dict, List, Literal, Optional
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
19
+
20
+ ENTITY_KINDS = ["person", "org", "project", "concept", "place", "event", "artifact", "software", "thing"]
21
+ NOTE_KINDS = ["idea", "task", "decision", "fact", "event", "question", "reflection", "reference", "log",
22
+ "conversation", "personal"]
23
+ URGENCIES = ["bulk", "normal", "high", "urgent"]
24
+ CERTAINTIES = ["stated", "probable", "possible", "reported", "disputed"]
25
+ STANCES = ["assert", "revise", "retract"]
26
+ MENTION_DECISIONS = ["reuse", "create", "merge", "unsure"]
27
+ CLAIM_RELATIONS = ["new", "restates", "refines", "supersedes", "contradicts"]
28
+ REFLECTION_KINDS = ["warning", "reminder", "connection", "contradiction", "evolution"]
29
+
30
+
31
+ def _s(description: str = "") -> Dict[str, Any]:
32
+ return {"type": "string", "description": description} if description else {"type": "string"}
33
+
34
+
35
+ def _ns(description: str = "") -> Dict[str, Any]:
36
+ out: Dict[str, Any] = {"type": ["string", "null"]}
37
+ if description:
38
+ out["description"] = description
39
+ return out
40
+
41
+
42
+ def _e(values: List[str]) -> Dict[str, Any]:
43
+ return {"type": "string", "enum": list(values)}
44
+
45
+
46
+ def _a(items: Dict[str, Any], description: str = "") -> Dict[str, Any]:
47
+ out: Dict[str, Any] = {"type": "array", "items": items}
48
+ if description:
49
+ out["description"] = description
50
+ return out
51
+
52
+
53
+ def _o(properties: Dict[str, Any]) -> Dict[str, Any]:
54
+ return {"type": "object", "properties": properties, "required": list(properties)}
55
+
56
+
57
+ QUICK_FILING: Dict[str, Any] = _o({
58
+ "title": {"type": "string", "maxLength": 80, "description": "short title, at most 80 characters"},
59
+ "kind": _e(NOTE_KINDS),
60
+ "topics": {**_a(_s(), "prefer existing topics, at most 4"), "maxItems": 4},
61
+ "urgency": _e(URGENCIES),
62
+ "language": _s("BCP-47 language tag"),
63
+ "one_line": _s("the note in one line"),
64
+ })
65
+
66
+ EXTRACTION: Dict[str, Any] = _o({
67
+ "summary": _s("1-3 sentences"),
68
+ "mentions": _a(_o({
69
+ "ref": _s("m1, m2, ..."),
70
+ "name": _s("as written in the note"),
71
+ "kind": _e(ENTITY_KINDS),
72
+ "aliases": _a(_s(), "other names as written in the note"),
73
+ "role": _s("what the note says about it, one phrase"),
74
+ })),
75
+ "claims": _a(_o({
76
+ "subject": _s("a mention ref"),
77
+ "predicate": _s("a CURIE from the vocabulary, or ex:snake_case"),
78
+ "object": _s("a mention ref, or a literal value"),
79
+ "object_is_literal": {"type": "boolean"},
80
+ "certainty": _e(CERTAINTIES),
81
+ "stance": _e(STANCES),
82
+ "evidence": _s("verbatim excerpt of the note"),
83
+ "valid_from": _ns("ISO date or null"),
84
+ "valid_until": _ns("ISO date or null"),
85
+ })),
86
+ "tasks": _a(_o({
87
+ "text": _s(),
88
+ "status": _e(["open", "done"]),
89
+ "due": _ns("ISO date or null"),
90
+ })),
91
+ "time_refs": _a(_o({"text": _s(), "iso": _ns("ISO or null")})),
92
+ "topics": _a(_s()),
93
+ })
94
+
95
+ LINKING: Dict[str, Any] = _o({
96
+ "mentions": _a(_o({
97
+ "ref": _s("the mention ref from the extraction"),
98
+ "decision": _e(MENTION_DECISIONS),
99
+ "candidate": {"type": ["integer", "null"], "description": "index in this mention's candidate list"},
100
+ "merge_candidates": _a({"type": "integer"}, "two candidate indexes that are the same entity"),
101
+ "name": _s("preferred name"),
102
+ "kind": _e(ENTITY_KINDS),
103
+ "aliases": _a(_s()),
104
+ "summary": {"type": ["string", "null"], "minLength": 1,
105
+ "description": "one sentence about the entity: required for create and unsure, and for a "
106
+ "reused or merged entity this note tells something new about; null only for a "
107
+ "reused entity the note adds nothing to"},
108
+ "reason": _s("short"),
109
+ })),
110
+ "claims": _a(_o({
111
+ "index": {"type": "integer", "description": "index of the claim in the extraction"},
112
+ "relation_to_existing": _e(CLAIM_RELATIONS),
113
+ "existing": {"type": ["integer", "null"], "description": "index of the existing claim in the packet"},
114
+ "reason": _s("short"),
115
+ })),
116
+ "tasks": _a(_o({
117
+ "index": {"type": "integer", "description": "index of the task in the extraction"},
118
+ "closes_open_task": {"type": ["integer", "null"], "description": "index of the open task it closes"},
119
+ })),
120
+ "revision_summary": _s("one sentence"),
121
+ })
122
+
123
+ ANSWER: Dict[str, Any] = _o({
124
+ "answer": _s("the answer with inline citations [nt_...]"),
125
+ "evidence": _a(_o({"note_id": _s(), "title": _s(), "occurred_at": _s(), "excerpt": _s()})),
126
+ "evolution": _o({
127
+ "earliest": _s("the earliest stance"),
128
+ "latest": _s("the latest stance"),
129
+ "turning_points": _a(_o({"note_id": _s(), "occurred_at": _s(), "change": _s()})),
130
+ }),
131
+ "open_loops": _a(_s()),
132
+ "follow_ups": _a(_s()),
133
+ })
134
+
135
+ MINING_PLAN: Dict[str, Any] = _o({
136
+ "steps": _a(_o({
137
+ "question": _s("a sub-question to search for"),
138
+ "since": _ns("ISO or null"),
139
+ "until": _ns("ISO or null"),
140
+ "entities": _a(_s(), "names to trace"),
141
+ })),
142
+ "contradictions_to_test": _a(_s()),
143
+ "rounds": {"type": "integer", "minimum": 1, "maximum": 8},
144
+ })
145
+
146
+ SLICE_FINDINGS: Dict[str, Any] = _o({
147
+ "facts": _a(_o({"text": _s(), "citations": _a(_s(), "note ids")})),
148
+ "changes": _a(_s()),
149
+ "gaps": _a(_s()),
150
+ })
151
+
152
+ DOSSIER: Dict[str, Any] = _o({
153
+ "title": _s(),
154
+ "narrative": _s("with inline citations [nt_...]"),
155
+ "timeline": _a(_o({"at": _s(), "event": _s(), "note_ids": _a(_s())})),
156
+ "evolution": _s("how the view evolved"),
157
+ "contradictions": _a(_s()),
158
+ "forgotten": _a(_s(), "items worth revisiting"),
159
+ "suggestions": _a(_s()),
160
+ "confidence": _s("a confidence statement"),
161
+ })
162
+
163
+ REFLECTIONS: Dict[str, Any] = _o({
164
+ "items": _a(_o({
165
+ "candidate": {"type": "integer", "description": "index in the candidate list"},
166
+ "kind": _e(REFLECTION_KINDS),
167
+ "title": _s(),
168
+ "body": _s(),
169
+ })),
170
+ })
171
+
172
+ SCHEMAS: Dict[str, Dict[str, Any]] = {
173
+ "QuickFiling": QUICK_FILING,
174
+ "Extraction": EXTRACTION,
175
+ "Linking": LINKING,
176
+ "Answer": ANSWER,
177
+ "MiningPlan": MINING_PLAN,
178
+ "SliceFindings": SLICE_FINDINGS,
179
+ "Dossier": DOSSIER,
180
+ "Reflections": REFLECTIONS,
181
+ }
182
+
183
+
184
+ # ----------------------------------------------------------------------------- pydantic mirrors
185
+
186
+ class _M(BaseModel):
187
+ model_config = ConfigDict(extra="ignore")
188
+
189
+
190
+ class QuickFiling(_M):
191
+ title: str
192
+ kind: Literal[tuple(NOTE_KINDS)] # type: ignore[valid-type]
193
+ topics: List[str] = Field(default_factory=list, max_length=4)
194
+ urgency: Literal[tuple(URGENCIES)] # type: ignore[valid-type]
195
+ language: str = ""
196
+ one_line: str = ""
197
+
198
+
199
+ class Mention(_M):
200
+ ref: str
201
+ name: str
202
+ kind: Literal[tuple(ENTITY_KINDS)] = "thing" # type: ignore[valid-type]
203
+ aliases: List[str] = Field(default_factory=list)
204
+ role: str = ""
205
+
206
+
207
+ class ExtractedClaim(_M):
208
+ subject: str
209
+ predicate: str
210
+ object: str
211
+ object_is_literal: bool = False
212
+ certainty: Literal[tuple(CERTAINTIES)] = "stated" # type: ignore[valid-type]
213
+ stance: Literal[tuple(STANCES)] = "assert" # type: ignore[valid-type]
214
+ evidence: str = ""
215
+ valid_from: Optional[str] = None
216
+ valid_until: Optional[str] = None
217
+
218
+
219
+ class ExtractedTask(_M):
220
+ text: str
221
+ status: Literal["open", "done"] = "open"
222
+ due: Optional[str] = None
223
+
224
+
225
+ class TimeRef(_M):
226
+ text: str
227
+ iso: Optional[str] = None
228
+
229
+
230
+ class Extraction(_M):
231
+ summary: str = ""
232
+ mentions: List[Mention] = Field(default_factory=list)
233
+ claims: List[ExtractedClaim] = Field(default_factory=list)
234
+ tasks: List[ExtractedTask] = Field(default_factory=list)
235
+ time_refs: List[TimeRef] = Field(default_factory=list)
236
+ topics: List[str] = Field(default_factory=list)
237
+
238
+
239
+ class MentionLink(_M):
240
+ """A mention decision. ``summary`` is required (non-empty) when the entity is created
241
+ (``create``, ``unsure``); for reused entities the revision builder requires it when the
242
+ note changes what is known about them."""
243
+
244
+ ref: str
245
+ decision: Literal[tuple(MENTION_DECISIONS)] # type: ignore[valid-type]
246
+ candidate: Optional[int] = None
247
+ merge_candidates: List[int] = Field(default_factory=list)
248
+ name: str = ""
249
+ kind: Optional[Literal[tuple(ENTITY_KINDS)]] = None # type: ignore[valid-type]
250
+ aliases: List[str] = Field(default_factory=list)
251
+ summary: Optional[str] = None
252
+ reason: str = ""
253
+
254
+
255
+ @model_validator(mode="after")
256
+ def _summary_for_new_entities(self) -> "MentionLink":
257
+ if self.decision in ("create", "unsure") and not (self.summary or "").strip():
258
+ raise ValueError(f"the {self.decision} decision for {self.ref} needs a one-sentence summary")
259
+ return self
260
+
261
+
262
+ class ClaimLink(_M):
263
+ index: int
264
+ relation_to_existing: Literal[tuple(CLAIM_RELATIONS)] = "new" # type: ignore[valid-type]
265
+ existing: Optional[int] = None
266
+ reason: str = ""
267
+
268
+
269
+ class TaskLink(_M):
270
+ index: int
271
+ closes_open_task: Optional[int] = None
272
+
273
+
274
+ class Linking(_M):
275
+ mentions: List[MentionLink] = Field(default_factory=list)
276
+ claims: List[ClaimLink] = Field(default_factory=list)
277
+ tasks: List[TaskLink] = Field(default_factory=list)
278
+ revision_summary: str = ""
279
+
280
+
281
+ class Evidence(_M):
282
+ note_id: str
283
+ title: str = ""
284
+ occurred_at: str = ""
285
+ excerpt: str = ""
286
+
287
+
288
+ class TurningPoint(_M):
289
+ note_id: str
290
+ occurred_at: str = ""
291
+ change: str = ""
292
+
293
+
294
+ class Evolution(_M):
295
+ earliest: str = ""
296
+ latest: str = ""
297
+ turning_points: List[TurningPoint] = Field(default_factory=list)
298
+
299
+
300
+ class Answer(_M):
301
+ answer: str
302
+ evidence: List[Evidence] = Field(default_factory=list)
303
+ evolution: Evolution = Field(default_factory=Evolution)
304
+ open_loops: List[str] = Field(default_factory=list)
305
+ follow_ups: List[str] = Field(default_factory=list)
306
+
307
+
308
+ class MiningStep(_M):
309
+ question: str
310
+ since: Optional[str] = None
311
+ until: Optional[str] = None
312
+ entities: List[str] = Field(default_factory=list)
313
+
314
+
315
+ class MiningPlan(_M):
316
+ steps: List[MiningStep] = Field(default_factory=list)
317
+ contradictions_to_test: List[str] = Field(default_factory=list)
318
+ rounds: int = Field(1, ge=1, le=8)
319
+
320
+
321
+ class Fact(_M):
322
+ text: str
323
+ citations: List[str] = Field(default_factory=list)
324
+
325
+
326
+ class SliceFindings(_M):
327
+ facts: List[Fact] = Field(default_factory=list)
328
+ changes: List[str] = Field(default_factory=list)
329
+ gaps: List[str] = Field(default_factory=list)
330
+
331
+
332
+ class TimelineEntry(_M):
333
+ at: str
334
+ event: str
335
+ note_ids: List[str] = Field(default_factory=list)
336
+
337
+
338
+ class Dossier(_M):
339
+ title: str
340
+ narrative: str
341
+ timeline: List[TimelineEntry] = Field(default_factory=list)
342
+ evolution: str = ""
343
+ contradictions: List[str] = Field(default_factory=list)
344
+ forgotten: List[str] = Field(default_factory=list)
345
+ suggestions: List[str] = Field(default_factory=list)
346
+ confidence: str = ""
347
+
348
+
349
+ class Reflection(_M):
350
+ candidate: int
351
+ kind: Literal[tuple(REFLECTION_KINDS)] # type: ignore[valid-type]
352
+ title: str
353
+ body: str
354
+
355
+
356
+ class Reflections(_M):
357
+ items: List[Reflection] = Field(default_factory=list)
358
+
359
+
360
+ MODELS = {
361
+ "QuickFiling": QuickFiling, "Extraction": Extraction, "Linking": Linking, "Answer": Answer,
362
+ "MiningPlan": MiningPlan, "SliceFindings": SliceFindings, "Dossier": Dossier, "Reflections": Reflections,
363
+ }
364
+
365
+
366
+ def validate(name: str, data: Any) -> BaseModel:
367
+ """Validate model output against a schema mirror; raises ValueError with a plain sentence."""
368
+ from pydantic import ValidationError
369
+
370
+ if not isinstance(data, dict):
371
+ raise ValueError(f"The {name} must be a JSON object.")
372
+ try:
373
+ return MODELS[name].model_validate(data)
374
+ except ValidationError as exc:
375
+ first = exc.errors()[0]
376
+ loc = ".".join(str(x) for x in first.get("loc", ())) or name
377
+ raise ValueError(f"The {name} does not match its schema ({loc}: {first.get('msg')}).") from None
@@ -0,0 +1,68 @@
1
+ """The predicate vocabulary from the AbstractSemantics registry, with one-line glosses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from functools import lru_cache
7
+ from typing import Dict, List, Optional, Tuple
8
+
9
+ EX_PREDICATE = re.compile(r"^ex:[a-z][a-z0-9_]*$")
10
+
11
+
12
+ @lru_cache(maxsize=1)
13
+ def _registry():
14
+ from abstractsemantics import load_semantics_registry
15
+
16
+ return load_semantics_registry()
17
+
18
+
19
+ def vocabulary() -> List[Dict[str, str]]:
20
+ """``[{predicate, gloss}]`` for every registry predicate (the extraction prompt's list)."""
21
+ out = []
22
+ for p in _registry().predicates:
23
+ gloss = (p.description or p.label or "").strip()
24
+ out.append({"predicate": p.id, "gloss": gloss})
25
+ return out
26
+
27
+
28
+ def predicate_words(predicate: str) -> str:
29
+ """Plain words for a predicate, for text a person reads: the registry label (``schema:worksFor``
30
+ -> "works for"), else the spelled-out local name (``ex:led_by`` -> "led by"). Never a CURIE."""
31
+ raw = str(predicate or "").strip()
32
+ for p in _registry().predicates:
33
+ if p.id == raw and (p.label or "").strip():
34
+ return p.label.strip()
35
+ local = raw.split(":", 1)[1] if ":" in raw else raw
36
+ words = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", local).replace("_", " ").strip().lower()
37
+ return " ".join(words.split()) or "related to"
38
+
39
+
40
+ def predicate_ids() -> frozenset:
41
+ return frozenset(_registry().predicate_ids())
42
+
43
+
44
+ def _snake(text: str) -> str:
45
+ """``worksFor`` / ``works for`` -> ``works_for`` (spelling only)."""
46
+ s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(text or "").strip())
47
+ s = re.sub(r"[^A-Za-z0-9]+", "_", s).strip("_").lower()
48
+ if not s or not s[0].isalpha():
49
+ s = "p_" + s
50
+ return s
51
+
52
+
53
+ def normalize_predicate(predicate: str) -> Tuple[str, Optional[str]]:
54
+ """Map an extracted predicate to a registry CURIE (exact or a registry alias); anything
55
+ else becomes ``ex:snake_case``. Returns ``(predicate, warning or None)``; CURIE case is kept."""
56
+ from abstractsemantics import normalize_kg_predicate
57
+
58
+ raw = str(predicate or "").strip()
59
+ if not raw:
60
+ return "ex:related_to", "A statement had no predicate; it was kept as ex:related_to."
61
+ canonical = normalize_kg_predicate(raw, _registry())
62
+ if canonical:
63
+ return canonical, None
64
+ if EX_PREDICATE.match(raw):
65
+ return raw, f"The predicate {raw} is outside the vocabulary."
66
+ local = raw.split(":", 1)[1] if ":" in raw else raw
67
+ mapped = "ex:" + _snake(local)
68
+ return mapped, f"The predicate {raw} is outside the vocabulary; kept as {mapped}."
@@ -0,0 +1 @@
1
+ """Recall: quick search, expansion, then-versus-now, reflect candidates, insights and the read shapes."""
@@ -0,0 +1,191 @@
1
+ """Since you last looked (``Notebook.changes``), the landing overview and the cue cards.
2
+
3
+ ``changes`` is a system-time delta: everything the notebook recorded after a
4
+ cursor. The cursor encodes the high-water ``recorded_at`` second and the items
5
+ already returned at that second, so nothing is lost or repeated across calls.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import timedelta
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ from .. import ids
14
+ from ..store.notes import clamp_limit, decode_cursor, encode_cursor
15
+ from . import shapes
16
+
17
+
18
+ def _since(since: Optional[str]) -> Tuple[str, set]:
19
+ if not since:
20
+ return "0000-01-01T00:00:00Z", set()
21
+ s = str(since).strip()
22
+ if s[:1].isdigit():
23
+ return ids.normalize_time(s, field="since"), set()
24
+ cur = decode_cursor(s)
25
+ if not cur or len(cur) != 3 or cur[0] != "chg":
26
+ raise ValueError("The cursor is not one this notebook issued.")
27
+ return str(cur[1]), set(cur[2])
28
+
29
+
30
+ def changes(nb: Any, since: Optional[str] = None, limit: int = 50, as_received: Optional[str] = None
31
+ ) -> Dict[str, Any]:
32
+ """``{cursor, notes, entities, superseded_claims, closed_loops, failed_notes, insights}``.
33
+
34
+ ``superseded_claims`` lists every claim that stopped being believed: ``kind`` ``superseded``
35
+ (a later note replaced it; ``superseded_by`` names the new claim) or ``retracted`` (withdrawn
36
+ by a note or by the person; ``superseded_by`` is null), with ``text`` in plain words.
37
+
38
+ ``since`` is a cursor from an earlier call or an ISO time. At most ``limit``
39
+ items in total are returned, earliest first; the returned cursor continues
40
+ exactly after them.
41
+
42
+ ``as_received`` keeps only what derives from the archive as it stood on that date: notes and insights
43
+ received by then, entities created and claims and tasks closed by the revisions those notes caused (or
44
+ user actions recorded by then), failed notes received by then.
45
+ """
46
+ from ..store.archive import archive_for
47
+
48
+ limit = clamp_limit(limit, 50, 500)
49
+ ts, seen = _since(since)
50
+ q = nb.store.q
51
+ arch = archive_for(nb.store, as_received)
52
+
53
+ def only(col: str, kind: str) -> Tuple[str, List[Any]]:
54
+ if arch is None:
55
+ return "", []
56
+ frag, fp = arch.notes_in(col) if kind == "note" else arch.revisions_in(col)
57
+ return f" AND {frag}", fp
58
+
59
+ f_note, p_note = only("note_id", "note")
60
+ f_qnote, p_qnote = only("q.note_id", "note")
61
+ f_created, p_created = only("created_revision_id", "revision")
62
+ f_closed, p_closed = only("c.closed_revision_id", "revision")
63
+ f_task, p_task = only("closed_revision_id", "revision")
64
+ found: List[Tuple[str, str, str, Dict[str, Any]]] = [] # (at, key, bucket, item)
65
+
66
+ def add(bucket: str, kind: str, key: str, at: Optional[str], item: Dict[str, Any]) -> None:
67
+ if not at or at < ts or (at == ts and f"{kind}:{key}" in seen):
68
+ return
69
+ found.append((at, f"{kind}:{key}", bucket, item))
70
+
71
+ for r in q("SELECT * FROM notes WHERE insight_kind IS NULL AND received_at>=? AND archived_at IS NULL" + f_note +
72
+ " ORDER BY received_at, note_id LIMIT ?", (ts, *p_note, limit + len(seen))):
73
+ add("notes", "note", r["note_id"], r["received_at"], shapes.note_ref(r))
74
+ for r in q("SELECT * FROM entities WHERE recorded_at>=?" + f_created + " ORDER BY recorded_at, entity_id LIMIT ?",
75
+ (ts, *p_created, limit + len(seen))):
76
+ add("entities", "entity", r["entity_id"], r["recorded_at"],
77
+ {"entity_id": r["entity_id"], "name": r["name"], "kind": r["kind"], "first_seen": r["first_seen"]})
78
+ for r in q("SELECT c.*, v.recorded_at AS closed_recorded_at, v.note_id AS closing_note_id FROM claims c"
79
+ " JOIN revisions v ON v.revision_id=c.closed_revision_id WHERE c.status IN ('superseded','retracted')"
80
+ " AND v.recorded_at>=?" + f_closed + " ORDER BY v.recorded_at, c.claim_id LIMIT ?",
81
+ (ts, *p_closed, limit + len(seen))):
82
+ add("superseded_claims", "claim", r["claim_id"], r["closed_recorded_at"],
83
+ {"claim_id": r["claim_id"], "kind": "superseded" if r["status"] == "superseded" else "retracted",
84
+ "text": claim_text(nb, r), "superseded_by": r["superseded_by"] if r["status"] == "superseded" else None,
85
+ "note_id": r["closing_note_id"] or r["note_id"]})
86
+ for r in q("SELECT * FROM tasks WHERE status='done' AND recorded_closed_at>=?" + f_task +
87
+ " ORDER BY recorded_closed_at, task_id LIMIT ?", (ts, *p_task, limit + len(seen))):
88
+ add("closed_loops", "task", r["task_id"], r["recorded_closed_at"],
89
+ {"task_id": r["task_id"], "text": r["text"], "closed_at": r["closed_at"]})
90
+ for r in q("SELECT q.note_id, q.last_error, q.failed_at, n.title FROM queue q JOIN notes n ON n.note_id=q.note_id"
91
+ " WHERE q.state='failed' AND q.failed_at>=?" + f_qnote + " ORDER BY q.failed_at, q.note_id LIMIT ?",
92
+ (ts, *p_qnote, limit + len(seen))):
93
+ add("failed_notes", "failed", r["note_id"], r["failed_at"],
94
+ {"note_id": r["note_id"], "title": r["title"] or "", "error": r["last_error"] or ""})
95
+ for r in q("SELECT * FROM notes WHERE insight_kind IS NOT NULL AND received_at>=? AND archived_at IS NULL" +
96
+ f_note + " ORDER BY received_at, note_id LIMIT ?", (ts, *p_note, limit + len(seen))):
97
+ add("insights", "insight", r["note_id"], r["received_at"], shapes.insight(r))
98
+
99
+ found.sort(key=lambda x: (x[0], x[1]))
100
+ taken = found[:limit]
101
+ out: Dict[str, Any] = {"notes": [], "entities": [], "superseded_claims": [], "closed_loops": [],
102
+ "failed_notes": [], "insights": []}
103
+ for _at, _key, bucket, item in taken:
104
+ out[bucket].append(item)
105
+ if taken:
106
+ high = taken[-1][0]
107
+ keys = {k for at, k, _, _ in taken if at == high}
108
+ if high == ts:
109
+ keys |= seen
110
+ else:
111
+ high, keys = ts, set(seen)
112
+ return {"cursor": encode_cursor("chg", high, sorted(keys)), **out} # pinned keys only, with or without as_received
113
+
114
+
115
+ def claim_text(nb: Any, r: Any) -> str:
116
+ """The claim in words a person reads: entity names and the predicate's plain words, never ids
117
+ ("Dana participant LUT"; an entity without a name reads "someone")."""
118
+ from ..process.vocabulary import predicate_words
119
+
120
+ subject = nb.store._name(r["subject_id"]) or "someone"
121
+ obj = (nb.store._name(r["object_id"]) or "something") if r["object_id"] else (r["object_literal"] or "something")
122
+ return f"{subject} {predicate_words(r['predicate'])} {obj}"
123
+
124
+
125
+ def overview(nb: Any, days: int = 30, *, now: Optional[str] = None) -> Dict[str, Any]:
126
+ """``{recent, entities, insights, resurfacing, activity}`` for the landing screen."""
127
+ days = max(1, min(366, int(days or 30)))
128
+ now = ids.normalize_time(now) if now else ids.utc_now()
129
+ start = (ids.parse_time(now) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
130
+ q = nb.store.q
131
+ recent = [shapes.note_ref(r) for r in q(
132
+ "SELECT * FROM notes WHERE insight_kind IS NULL AND archived_at IS NULL ORDER BY occurred_at DESC,"
133
+ " note_id DESC LIMIT 20")]
134
+ entities = [{"entity_id": r["entity_id"], "name": r["name"], "kind": r["kind"], "note_count": r["note_count"],
135
+ "last_seen": r["last_seen"]} for r in q(
136
+ "SELECT * FROM entities WHERE merged_into IS NULL AND last_seen>=? ORDER BY note_count DESC, last_seen DESC,"
137
+ " entity_id LIMIT 20", (start,))]
138
+ insights = [shapes.insight(r) for r in q(
139
+ "SELECT * FROM notes WHERE insight_kind IS NOT NULL AND acked_at IS NULL AND archived_at IS NULL"
140
+ " ORDER BY received_at DESC, note_id DESC LIMIT 10")]
141
+ from .reflect import anniversary_rows
142
+
143
+ resurfacing = [shapes.note_ref(r) for r, _rule in anniversary_rows(nb, limit=10, now=now)]
144
+ counts: Dict[str, Dict[str, int]] = {}
145
+ for r in q("SELECT substr(occurred_at,1,10) AS d, COUNT(*) AS n FROM notes WHERE occurred_at>=? AND"
146
+ " insight_kind IS NULL GROUP BY d", (start,)):
147
+ counts.setdefault(r["d"], {"notes": 0, "revisions": 0})["notes"] = r["n"]
148
+ for r in q("SELECT substr(at,1,10) AS d, COUNT(*) AS n FROM revisions WHERE at>=? AND applied_index=1"
149
+ " GROUP BY d", (start,)):
150
+ counts.setdefault(r["d"], {"notes": 0, "revisions": 0})["revisions"] = r["n"]
151
+ activity = []
152
+ day = ids.parse_time(start)
153
+ end = ids.parse_time(now)
154
+ while day.date() <= end.date():
155
+ key = day.strftime("%Y-%m-%d")
156
+ c = counts.get(key, {"notes": 0, "revisions": 0})
157
+ activity.append({"day": key, "notes": c["notes"], "revisions": c["revisions"]})
158
+ day += timedelta(days=1)
159
+ return {"recent": recent, "entities": entities, "insights": insights, "resurfacing": resurfacing,
160
+ "activity": activity}
161
+
162
+
163
+ def cues(nb: Any, limit: int = 3) -> Dict[str, Any]:
164
+ """Cue cards: unacknowledged reflect insights (not dossiers), each with the subject a reply links to.
165
+
166
+ ``{items: [{cue_id, text, kind, subject: {kind, id, title}, evidence_note_ids, entity_ids}]}``; ``cue_id`` is
167
+ the insight note id; ``evidence_note_ids`` and ``entity_ids`` are the insight's own (the notes it rests on and
168
+ the entities it is about), so a client shows why it surfaced without a second read.
169
+ """
170
+ limit = clamp_limit(limit, 3, 50)
171
+ items = []
172
+ for r in nb.store.q("SELECT * FROM notes WHERE insight_kind IS NOT NULL AND insight_kind!='dossier' AND"
173
+ " acked_at IS NULL AND archived_at IS NULL ORDER BY received_at DESC, note_id DESC LIMIT ?",
174
+ (limit,)):
175
+ ins = shapes.insight(r)
176
+ subject = {"kind": "cue", "id": r["note_id"], "title": ins["title"]}
177
+ for eid in ins["entity_ids"]:
178
+ e = nb.store.entity_row(nb.store.canonical(eid))
179
+ if e is not None:
180
+ subject = {"kind": "entity", "id": e["entity_id"], "title": e["name"]}
181
+ break
182
+ else:
183
+ for nid in ins["evidence_note_ids"]:
184
+ n = nb.store.note_row(nid)
185
+ if n is not None:
186
+ subject = {"kind": "note", "id": nid, "title": n["title"] or shapes.excerpt(n["text"], 80)}
187
+ break
188
+ items.append({"cue_id": r["note_id"], "text": ins["body"] or ins["title"], "kind": r["insight_kind"],
189
+ "subject": subject, "evidence_note_ids": ins["evidence_note_ids"],
190
+ "entity_ids": ins["entity_ids"]})
191
+ return {"items": items}