wealthbraid 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 (64) hide show
  1. wealthbraid/__init__.py +7 -0
  2. wealthbraid/__main__.py +8 -0
  3. wealthbraid/book/__init__.py +9 -0
  4. wealthbraid/book/book.py +397 -0
  5. wealthbraid/book/config.py +333 -0
  6. wealthbraid/book/schema.py +295 -0
  7. wealthbraid/book/state.py +753 -0
  8. wealthbraid/book/verify.py +181 -0
  9. wealthbraid/cli/__init__.py +1 -0
  10. wealthbraid/cli/commands_analysis.py +309 -0
  11. wealthbraid/cli/commands_book.py +236 -0
  12. wealthbraid/cli/commands_ledger.py +407 -0
  13. wealthbraid/cli/commands_ops.py +219 -0
  14. wealthbraid/cli/commands_statements.py +279 -0
  15. wealthbraid/cli/common.py +390 -0
  16. wealthbraid/cli/main.py +99 -0
  17. wealthbraid/engine/__init__.py +39 -0
  18. wealthbraid/engine/account.py +150 -0
  19. wealthbraid/engine/balancing.py +158 -0
  20. wealthbraid/engine/errors.py +53 -0
  21. wealthbraid/engine/inventory.py +151 -0
  22. wealthbraid/engine/ledger.py +198 -0
  23. wealthbraid/engine/money.py +218 -0
  24. wealthbraid/engine/prices.py +230 -0
  25. wealthbraid/engine/transaction.py +107 -0
  26. wealthbraid/errors.py +65 -0
  27. wealthbraid/services/__init__.py +6 -0
  28. wealthbraid/services/categorize.py +241 -0
  29. wealthbraid/services/explain.py +225 -0
  30. wealthbraid/services/importing.py +285 -0
  31. wealthbraid/services/reconcile.py +215 -0
  32. wealthbraid/services/reports.py +301 -0
  33. wealthbraid/services/review.py +78 -0
  34. wealthbraid/services/scenarios.py +283 -0
  35. wealthbraid/store/__init__.py +8 -0
  36. wealthbraid/store/records.py +243 -0
  37. wealthbraid/store/store.py +487 -0
  38. wealthbraid/utils/__init__.py +7 -0
  39. wealthbraid/utils/log.py +70 -0
  40. wealthbraid/version.py +11 -0
  41. wealthbraid/web/__init__.py +1 -0
  42. wealthbraid/web/app.py +418 -0
  43. wealthbraid/web/static/VENDOR.json +13 -0
  44. wealthbraid/web/static/app.css +609 -0
  45. wealthbraid/web/static/htmx.min.js +1 -0
  46. wealthbraid/web/templates/_macros.html +26 -0
  47. wealthbraid/web/templates/_operation_card.html +64 -0
  48. wealthbraid/web/templates/base.html +35 -0
  49. wealthbraid/web/templates/entries.html +37 -0
  50. wealthbraid/web/templates/error.html +7 -0
  51. wealthbraid/web/templates/lines.html +36 -0
  52. wealthbraid/web/templates/operation.html +13 -0
  53. wealthbraid/web/templates/operations.html +36 -0
  54. wealthbraid/web/templates/overview.html +53 -0
  55. wealthbraid/web/templates/reconciliations.html +30 -0
  56. wealthbraid/web/templates/reports.html +71 -0
  57. wealthbraid/web/templates/review.html +25 -0
  58. wealthbraid/web/templates/trace.html +53 -0
  59. wealthbraid/web/templates/verify.html +21 -0
  60. wealthbraid-0.1.0.dist-info/METADATA +133 -0
  61. wealthbraid-0.1.0.dist-info/RECORD +64 -0
  62. wealthbraid-0.1.0.dist-info/WHEEL +4 -0
  63. wealthbraid-0.1.0.dist-info/entry_points.txt +2 -0
  64. wealthbraid-0.1.0.dist-info/licenses/LICENSE +26 -0
@@ -0,0 +1,7 @@
1
+ """wealthbraid: local-first, AI-native personal wealth management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from wealthbraid.version import __version__
6
+
7
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """Main entry point for the wealthbraid package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ if __name__ == "__main__":
6
+ from wealthbraid.utils.log import setup_logger
7
+
8
+ setup_logger(print_version=True)
@@ -0,0 +1,9 @@
1
+ """Books: settings, the projection of records into state, and the operation workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from wealthbraid.book.book import Book
6
+ from wealthbraid.book.config import BookConfig, find_book, init_book, load_config
7
+ from wealthbraid.book.state import BookState, Issue, OperationState
8
+
9
+ __all__ = ["Book", "BookConfig", "BookState", "Issue", "OperationState", "find_book", "init_book", "load_config"]
@@ -0,0 +1,397 @@
1
+ """The :class:`Book` facade: the only way records enter a book.
2
+
3
+ Every mutation is an *operation*. An operation records who proposed it, the
4
+ inputs and evidence it used, a reasoning summary, a confidence, and the exact
5
+ records it would add. A proposal is validated against the current book before it
6
+ is stored, so an agent learns immediately whether its changes could apply.
7
+
8
+ An operation's changes are written only after a decision:
9
+
10
+ * a human approves or rejects it (``decide``), or
11
+ * policy auto-approves it, which is allowed only when every change is
12
+ non-sensitive (evidence, notes) and the book allows it.
13
+
14
+ Approval re-validates against the book as it is *now*; if the book moved on and
15
+ the changes no longer apply, nothing is written. Agents can never approve.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import datetime as dt
21
+ import mimetypes
22
+ import re
23
+ from collections.abc import Callable, Mapping, Sequence
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from pydantic import ValidationError as PydanticValidationError
28
+
29
+ from wealthbraid.book.config import BookConfig, find_book, load_config
30
+ from wealthbraid.book.schema import (
31
+ ChangeData,
32
+ DecisionData,
33
+ EvidenceData,
34
+ OperationData,
35
+ dump_data,
36
+ format_validation_error,
37
+ parse_data,
38
+ )
39
+ from wealthbraid.book.state import (
40
+ HUMAN_PREFIX,
41
+ NON_SENSITIVE_KINDS,
42
+ POLICY_ACTOR,
43
+ BookState,
44
+ OperationState,
45
+ has_refs,
46
+ resolve_refs,
47
+ )
48
+ from wealthbraid.errors import ConflictError, IntegrityError, NotFoundError, PolicyError, ValidationError
49
+ from wealthbraid.store.records import Record, RecordKind
50
+ from wealthbraid.store.store import PendingAppend, RecordStore, format_timestamp, utc_now
51
+
52
+ _ACTOR_RE = re.compile(r"\A(human|agent):[A-Za-z0-9._-]+\Z")
53
+ _DRY_RUN_APPROVER = "human:dry-run"
54
+
55
+
56
+ def check_actor(actor: str) -> str:
57
+ """Validate a caller-supplied actor string.
58
+
59
+ Args:
60
+ actor: The actor, ``human:<name>`` or ``agent:<name>``.
61
+
62
+ Returns:
63
+ The actor unchanged.
64
+
65
+ Raises:
66
+ PolicyError: If the actor is malformed or claims a reserved ``system:`` identity.
67
+
68
+ """
69
+ if not _ACTOR_RE.match(actor):
70
+ raise PolicyError(f"invalid actor {actor!r}; use human:<name> or agent:<name>")
71
+ return actor
72
+
73
+
74
+ class Book:
75
+ """A wealthbraid book on disk."""
76
+
77
+ def __init__(self, root: Path, *, clock: Callable[[], dt.datetime] = utc_now) -> None:
78
+ """Open a book directory.
79
+
80
+ Args:
81
+ root: The book directory (must contain ``wealthbraid.toml``).
82
+ clock: Source of write timestamps; injectable for deterministic tests.
83
+
84
+ """
85
+ self.root = Path(root)
86
+ self.config: BookConfig = load_config(self.root)
87
+ self.store = RecordStore(self.root, clock=clock)
88
+ self._clock = clock
89
+
90
+ @classmethod
91
+ def discover(cls, explicit: Path | None = None, **kwargs: Any) -> Book:
92
+ """Open the book found by :func:`~wealthbraid.book.config.find_book`.
93
+
94
+ Args:
95
+ explicit: An explicit book path, if given.
96
+ **kwargs: Passed to :class:`Book`.
97
+
98
+ Returns:
99
+ The opened book.
100
+
101
+ """
102
+ return cls(find_book(explicit=explicit), **kwargs)
103
+
104
+ # -- reading ------------------------------------------------------------
105
+
106
+ def state(self, *, at: str | None = None) -> BookState:
107
+ """Project the book's records into state.
108
+
109
+ Args:
110
+ at: Stop after this record id, reproducing the book as it was then.
111
+
112
+ Returns:
113
+ The :class:`BookState`.
114
+
115
+ Raises:
116
+ NotFoundError: If ``at`` is not a record in the book.
117
+
118
+ """
119
+ state = BookState()
120
+ for record in self.store.iter_records():
121
+ state.apply(record)
122
+ if record.id == at:
123
+ return state
124
+ if at is not None:
125
+ raise NotFoundError(f"record not found: {at}")
126
+ return state
127
+
128
+ # -- operations ---------------------------------------------------------
129
+
130
+ def propose( # noqa: PLR0913 - keyword-only; mirrors the operation record's fields
131
+ self,
132
+ *,
133
+ actor: str,
134
+ tool: str,
135
+ summary: str,
136
+ changes: Sequence[Mapping[str, Any]],
137
+ reasoning: str,
138
+ confidence: float,
139
+ evidence: Sequence[str] = (),
140
+ inputs: Mapping[str, Any] | None = None,
141
+ approve: bool = False,
142
+ note: str | None = None,
143
+ ) -> OperationState:
144
+ """Record an operation, applying it at once when allowed.
145
+
146
+ Args:
147
+ actor: The proposer (``human:<name>`` or ``agent:<name>``).
148
+ tool: The operation name, e.g. ``"categorize"``.
149
+ summary: A one-line description of the proposal.
150
+ changes: Proposed records as ``{"kind", "data", "rationale"?}`` mappings.
151
+ reasoning: Why these changes are proposed.
152
+ confidence: The proposer's confidence in ``[0, 1]``.
153
+ evidence: Evidence record ids supporting the proposal.
154
+ inputs: The parameters and settings the operation used.
155
+ approve: Approve immediately; only a human proposer may do this.
156
+ note: The approval note, when ``approve`` is set.
157
+
158
+ Returns:
159
+ The stored operation with its resulting status.
160
+
161
+ Raises:
162
+ PolicyError: If a non-human asks to approve.
163
+ ValidationError: If the proposal is malformed or its changes cannot apply.
164
+
165
+ """
166
+ check_actor(actor)
167
+ if approve and not actor.startswith(HUMAN_PREFIX):
168
+ raise PolicyError(f"{actor} cannot approve operations; only a human can")
169
+ operation = self._build_operation(
170
+ tool=tool,
171
+ summary=summary,
172
+ changes=changes,
173
+ reasoning=reasoning,
174
+ confidence=confidence,
175
+ evidence=evidence,
176
+ inputs=inputs,
177
+ )
178
+ with self.store.lock():
179
+ state = self._writable_state()
180
+ operation_data = dump_data(operation.model_copy(update={"base": state.head.id if state.head else None}))
181
+ sensitive = any(RecordKind(change.kind) not in NON_SENSITIVE_KINDS for change in operation.changes)
182
+ if approve:
183
+ decider = actor
184
+ elif self.config.auto_apply and not sensitive:
185
+ decider = POLICY_ACTOR
186
+ else:
187
+ decider = None
188
+ timestamp = format_timestamp(self._clock())
189
+
190
+ staged = self.store.begin(recorded_at=timestamp)
191
+ op_record = staged.add(RecordKind.OPERATION, operation_data, actor=actor)
192
+ self._stage_decision(
193
+ staged,
194
+ op_record=op_record,
195
+ operation=operation,
196
+ actor=decider or _DRY_RUN_APPROVER,
197
+ verdict="approve",
198
+ note=note,
199
+ )
200
+ self._check(state, staged.records, "proposal cannot be applied")
201
+
202
+ if decider is None:
203
+ final = self.store.begin(recorded_at=timestamp)
204
+ final.add(RecordKind.OPERATION, operation_data, actor=actor)
205
+ final.commit()
206
+ else:
207
+ staged.commit()
208
+ return self._operation(op_record.id)
209
+
210
+ def decide(self, operation_id: str, *, actor: str, verdict: str, note: str | None = None) -> OperationState:
211
+ """Approve or reject a pending operation.
212
+
213
+ Args:
214
+ operation_id: The operation id.
215
+ actor: The deciding human.
216
+ verdict: ``"approve"`` or ``"reject"``.
217
+ note: An optional note explaining the decision.
218
+
219
+ Returns:
220
+ The operation with its new status.
221
+
222
+ Raises:
223
+ PolicyError: If the actor is not a human.
224
+ NotFoundError: If the operation does not exist.
225
+ ConflictError: If the operation was already decided.
226
+ ValidationError: If approved changes no longer apply to the book.
227
+
228
+ """
229
+ check_actor(actor)
230
+ if not actor.startswith(HUMAN_PREFIX):
231
+ raise PolicyError(f"{actor} cannot decide operations; only a human can")
232
+ if verdict not in ("approve", "reject"):
233
+ raise ValidationError(f"verdict must be 'approve' or 'reject', not {verdict!r}")
234
+ with self.store.lock():
235
+ state = self._writable_state()
236
+ operation = state.operations.get(operation_id)
237
+ if operation is None:
238
+ raise NotFoundError(f"operation not found: {operation_id}")
239
+ if operation.status != "pending":
240
+ raise ConflictError(f"operation {operation_id} is already {operation.status}")
241
+ staged = self.store.begin()
242
+ self._stage_decision(
243
+ staged, op_record=operation.record, operation=operation.data, actor=actor, verdict=verdict, note=note
244
+ )
245
+ self._check(state, staged.records, "cannot approve: the changes no longer apply")
246
+ staged.commit()
247
+ return self._operation(operation_id)
248
+
249
+ def add_evidence(
250
+ self,
251
+ content: bytes,
252
+ *,
253
+ filename: str,
254
+ actor: str,
255
+ source: str | None = None,
256
+ description: str | None = None,
257
+ ) -> tuple[str, OperationState | None]:
258
+ """Store a source document and record it as evidence.
259
+
260
+ Adding identical bytes again returns the existing evidence record.
261
+
262
+ Args:
263
+ content: The document bytes.
264
+ filename: The original file name.
265
+ actor: Who is adding the document.
266
+ source: Where it came from, e.g. a bank name.
267
+ description: A free-text description.
268
+
269
+ Returns:
270
+ The evidence record id and the operation that created it (``None`` if it already existed).
271
+
272
+ Raises:
273
+ ConflictError: If the book requires approval even for evidence.
274
+
275
+ """
276
+ check_actor(actor)
277
+ digest = self.store.put_evidence(content)
278
+ existing = self.state().evidence_by_sha.get(digest)
279
+ if existing is not None:
280
+ return existing, None
281
+ data = EvidenceData(
282
+ sha256=digest,
283
+ filename=Path(filename).name,
284
+ media_type=mimetypes.guess_type(filename)[0] or "application/octet-stream",
285
+ size=len(content),
286
+ source=source,
287
+ description=description,
288
+ )
289
+ operation = self.propose(
290
+ actor=actor,
291
+ tool="evidence.add",
292
+ summary=f"Add evidence {data.filename}",
293
+ changes=[{"kind": "evidence", "data": dump_data(data)}],
294
+ reasoning="Store the source document so later records can cite it.",
295
+ confidence=1.0,
296
+ inputs={"filename": data.filename},
297
+ )
298
+ if operation.status != "applied":
299
+ raise ConflictError(
300
+ f"evidence operation {operation.id} awaits approval; approve it before citing the evidence"
301
+ )
302
+ return operation.results[0], operation
303
+
304
+ # -- helpers ------------------------------------------------------------
305
+
306
+ def _writable_state(self) -> BookState:
307
+ state = self.state()
308
+ if state.integrity_issues:
309
+ first = state.integrity_issues[0]
310
+ raise IntegrityError(
311
+ f"refusing to write: {len(state.integrity_issues)} record(s) fail integrity checks "
312
+ f"(first: {first.record}: {first.message}); run `wealthbraid verify`"
313
+ )
314
+ return state
315
+
316
+ def _operation(self, operation_id: str) -> OperationState:
317
+ return self.state().operations[operation_id]
318
+
319
+ @staticmethod
320
+ def _build_operation( # noqa: PLR0913
321
+ *,
322
+ tool: str,
323
+ summary: str,
324
+ changes: Sequence[Mapping[str, Any]],
325
+ reasoning: str,
326
+ confidence: float,
327
+ evidence: Sequence[str],
328
+ inputs: Mapping[str, Any] | None,
329
+ ) -> OperationData:
330
+ normalised = []
331
+ for index, raw in enumerate(changes):
332
+ change = parse_data_change(raw, index)
333
+ if not has_refs(change.data):
334
+ change = change.model_copy(update={"data": dump_data(parse_data(RecordKind(change.kind), change.data))})
335
+ normalised.append(change)
336
+ try:
337
+ return OperationData(
338
+ tool=tool,
339
+ summary=summary,
340
+ inputs=dict(inputs or {}),
341
+ evidence=list(evidence),
342
+ reasoning=reasoning,
343
+ confidence=confidence,
344
+ changes=normalised,
345
+ )
346
+ except PydanticValidationError as exc:
347
+ raise ValidationError(f"invalid operation: {format_validation_error(exc)}") from exc
348
+
349
+ @staticmethod
350
+ def _stage_decision( # noqa: PLR0913
351
+ staged: PendingAppend,
352
+ *,
353
+ op_record: Record,
354
+ operation: OperationData,
355
+ actor: str,
356
+ verdict: str,
357
+ note: str | None,
358
+ ) -> None:
359
+ decision = DecisionData(operation=op_record.id, verdict=verdict, note=note) # type: ignore[arg-type]
360
+ staged.add(RecordKind.DECISION, dump_data(decision), actor=actor)
361
+ if verdict != "approve":
362
+ return
363
+ produced: list[str] = []
364
+ for change in operation.changes:
365
+ data = resolve_refs(change.data, produced)
366
+ record = staged.add(RecordKind(change.kind), data, actor=op_record.actor, operation=op_record.id)
367
+ produced.append(record.id)
368
+ staged.add(RecordKind.APPLIED, {"operation": op_record.id, "results": produced}, actor=actor)
369
+
370
+ @staticmethod
371
+ def _check(state: BookState, records: Sequence[Record], message: str) -> None:
372
+ problems = []
373
+ for record in records:
374
+ problems.extend(state.apply(record))
375
+ if problems:
376
+ details = "; ".join(issue.message for issue in problems)
377
+ raise ValidationError(f"{message}: {details}")
378
+
379
+
380
+ def parse_data_change(raw: Mapping[str, Any], index: int) -> ChangeData:
381
+ """Parse one proposed change.
382
+
383
+ Args:
384
+ raw: The change mapping.
385
+ index: Its position, for error messages.
386
+
387
+ Returns:
388
+ The parsed :class:`ChangeData`.
389
+
390
+ Raises:
391
+ ValidationError: If the change is malformed.
392
+
393
+ """
394
+ try:
395
+ return ChangeData.model_validate(raw)
396
+ except PydanticValidationError as exc:
397
+ raise ValidationError(f"change {index}: {format_validation_error(exc)}") from exc