spendsignal 0.3.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.
@@ -0,0 +1,48 @@
1
+ """SpendSignal - log line-item purchases with your own retrospective outcomes.
2
+
3
+ A small local-first library. Retrieval over your own past, not prediction.
4
+ See https://github.com/YemaneSG/SpendSignal for the full description.
5
+ """
6
+
7
+ from spendsignal.aggregate import summarize
8
+ from spendsignal.models import (
9
+ Abstention,
10
+ AxisDistribution,
11
+ BoolFeedbackValue,
12
+ CategoricalFeedbackValue,
13
+ Contradiction,
14
+ Coverage,
15
+ EvidenceSummary,
16
+ ExternalRef,
17
+ FeedbackEvent,
18
+ FeedbackValue,
19
+ Likert5FeedbackValue,
20
+ LineItem,
21
+ PurchaseEvent,
22
+ Query,
23
+ ReflectionExposure,
24
+ TargetRef,
25
+ )
26
+
27
+ __version__ = "0.3.0"
28
+
29
+ __all__ = [
30
+ "Abstention",
31
+ "AxisDistribution",
32
+ "BoolFeedbackValue",
33
+ "CategoricalFeedbackValue",
34
+ "Contradiction",
35
+ "Coverage",
36
+ "EvidenceSummary",
37
+ "ExternalRef",
38
+ "FeedbackEvent",
39
+ "FeedbackValue",
40
+ "Likert5FeedbackValue",
41
+ "LineItem",
42
+ "PurchaseEvent",
43
+ "Query",
44
+ "ReflectionExposure",
45
+ "TargetRef",
46
+ "__version__",
47
+ "summarize",
48
+ ]
@@ -0,0 +1,20 @@
1
+ """Adapters that feed the SpendSignal evidence engine.
2
+
3
+ See `ADR-0005` for the architecture rules. Each adapter lives in its own
4
+ module, defines its own local pydantic types, and must not modify the
5
+ four core event schemas or the `summarize` API.
6
+
7
+ Available adapters:
8
+
9
+ - `match_deterministic` - deterministic receipt-to-transaction matcher.
10
+ No runtime deps beyond stdlib. [match extra]
11
+ - `retrieve_nn` - nearest-neighbor retrieval over comparison_keys.
12
+ Calls `spendsignal.aggregate.summarize` for each matched subject.
13
+ No runtime deps beyond stdlib. [retrieval extra]
14
+ - `extract` - receipt text → PurchaseEvent. Mock + TextParser (stdlib).
15
+ Invoice2DataProvider optional under [extract] extra.
16
+ - `mcp_server` - MCP server exposing summarize, retrieve, match as tools.
17
+ [mcp] extra. Run via `spendsignal serve`.
18
+ - `actual_budget` - pulls BankTransaction records from Actual Budget.
19
+ MockActualProvider (no creds). ActualBudgetProvider under [actual] extra.
20
+ """
@@ -0,0 +1,179 @@
1
+ """Actual Budget adapter.
2
+
3
+ Pulls transactions from a running Actual Budget instance and converts them
4
+ to SpendSignal `BankTransaction` records for use with the matching adapter.
5
+
6
+ Two providers:
7
+
8
+ - `MockActualProvider` - returns the fixture transactions. No credentials,
9
+ no Actual Budget instance required. Default for CI and demos.
10
+ - `ActualBudgetProvider` - wraps `actualpy` to pull from a real Actual Budget
11
+ server. Install with `pip install spendsignal[actual]`.
12
+
13
+ Per ADR-0005 / D-007 this adapter does not modify core schemas or summarize.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from datetime import UTC
19
+ from typing import Protocol, runtime_checkable
20
+
21
+ from spendsignal.adapters.match_deterministic import BankTransaction
22
+
23
+
24
+ @runtime_checkable
25
+ class ActualProvider(Protocol):
26
+ """Protocol for Actual Budget data sources."""
27
+
28
+ def get_transactions(
29
+ self,
30
+ account_id: str | None = None,
31
+ since_date: str | None = None,
32
+ ) -> list[BankTransaction]:
33
+ """Return bank transactions as SpendSignal BankTransaction records."""
34
+ ...
35
+
36
+
37
+ class MockActualProvider:
38
+ """Returns the four committed fixture transactions. No credentials needed."""
39
+
40
+ def get_transactions(
41
+ self,
42
+ account_id: str | None = None,
43
+ since_date: str | None = None,
44
+ ) -> list[BankTransaction]:
45
+ from datetime import datetime
46
+
47
+ del account_id, since_date # mock ignores filters
48
+ return [
49
+ BankTransaction(
50
+ source_ref="actual-mock-txn-2026-01-16-costco",
51
+ occurred_at=datetime(2026, 1, 16, tzinfo=UTC),
52
+ amount_minor_units=9966,
53
+ currency="USD",
54
+ merchant_hint="COSTCO WHOLESALE #123",
55
+ ),
56
+ BankTransaction(
57
+ source_ref="actual-mock-txn-2026-06-15-costco",
58
+ occurred_at=datetime(2026, 6, 15, tzinfo=UTC),
59
+ amount_minor_units=9405,
60
+ currency="USD",
61
+ merchant_hint="COSTCO WHSE",
62
+ ),
63
+ BankTransaction(
64
+ source_ref="actual-mock-txn-2026-08-15-costco",
65
+ occurred_at=datetime(2026, 8, 15, tzinfo=UTC),
66
+ amount_minor_units=4999,
67
+ currency="USD",
68
+ merchant_hint="COSTCO",
69
+ ),
70
+ BankTransaction(
71
+ source_ref="actual-mock-txn-2026-03-02-shell",
72
+ occurred_at=datetime(2026, 3, 2, tzinfo=UTC),
73
+ amount_minor_units=4523,
74
+ currency="USD",
75
+ merchant_hint="SHELL OIL #12345",
76
+ ),
77
+ ]
78
+
79
+
80
+ class ActualBudgetProvider:
81
+ """Wraps `actualpy` to pull transactions from a live Actual Budget server.
82
+
83
+ Install with: pip install spendsignal[actual]
84
+
85
+ Usage:
86
+ provider = ActualBudgetProvider(
87
+ server_url="http://localhost:5006",
88
+ password="your-password",
89
+ budget_id="your-budget-id",
90
+ )
91
+ txns = provider.get_transactions()
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ server_url: str,
97
+ password: str,
98
+ budget_id: str,
99
+ encryption_password: str | None = None,
100
+ ) -> None:
101
+ try:
102
+ import actualpy # type: ignore[import-not-found]
103
+ except ImportError as exc:
104
+ raise ImportError(
105
+ "actualpy is not installed. Install it with: pip install spendsignal[actual]"
106
+ ) from exc
107
+ self._actual_cls = actualpy.Actual
108
+ self._server_url = server_url
109
+ self._password = password
110
+ self._budget_id = budget_id
111
+ self._encryption_password = encryption_password
112
+
113
+ def get_transactions(
114
+ self,
115
+ account_id: str | None = None,
116
+ since_date: str | None = None,
117
+ ) -> list[BankTransaction]:
118
+ from datetime import datetime
119
+
120
+ with self._actual_cls(
121
+ base_url=self._server_url,
122
+ password=self._password,
123
+ file=self._budget_id,
124
+ encryption_password=self._encryption_password,
125
+ ) as actual:
126
+ actual.download_budget()
127
+ raw_txns = actual.session.query(actual.get_transactions()).all()
128
+
129
+ result: list[BankTransaction] = []
130
+ for t in raw_txns:
131
+ if account_id and str(getattr(t, "account_id", "")) != account_id:
132
+ continue
133
+ date_val = getattr(t, "date", None)
134
+ if since_date and date_val and str(date_val) < since_date:
135
+ continue
136
+ amount = getattr(t, "amount", 0) or 0
137
+ if isinstance(date_val, str):
138
+ occurred_at = datetime.fromisoformat(date_val).replace(tzinfo=UTC)
139
+ elif date_val is not None:
140
+ occurred_at = datetime(date_val.year, date_val.month, date_val.day, tzinfo=UTC)
141
+ else:
142
+ occurred_at = datetime.now(UTC)
143
+ payee = str(
144
+ getattr(t, "payee_name", None) or getattr(t, "imported_payee", None) or "unknown"
145
+ )
146
+ result.append(
147
+ BankTransaction(
148
+ source_ref=str(getattr(t, "id", f"actual-{len(result)}")),
149
+ occurred_at=occurred_at,
150
+ # Actual stores amounts in milliunits (1/1000 of currency unit).
151
+ # Convert to minor units (cents/pence): divide by 10.
152
+ amount_minor_units=abs(int(amount)) // 10,
153
+ currency="USD",
154
+ merchant_hint=payee[:512],
155
+ )
156
+ )
157
+ return result
158
+
159
+
160
+ def load_actual_transactions(
161
+ provider: ActualProvider | None = None,
162
+ account_id: str | None = None,
163
+ since_date: str | None = None,
164
+ ) -> list[BankTransaction]:
165
+ """Pull transactions from Actual Budget (or the mock by default).
166
+
167
+ Returns SpendSignal BankTransaction records ready for the matching adapter.
168
+ """
169
+ if provider is None:
170
+ provider = MockActualProvider()
171
+ return provider.get_transactions(account_id=account_id, since_date=since_date)
172
+
173
+
174
+ __all__ = [
175
+ "ActualBudgetProvider",
176
+ "ActualProvider",
177
+ "MockActualProvider",
178
+ "load_actual_transactions",
179
+ ]
@@ -0,0 +1,272 @@
1
+ """Receipt extraction adapter.
2
+
3
+ Converts raw receipt text (pre-extracted from an image or PDF by the caller)
4
+ into a SpendSignal `PurchaseEvent` via a swappable `ExtractionProvider`.
5
+
6
+ The adapter ships two providers:
7
+
8
+ - `MockExtractionProvider` - deterministic. Returns a pre-built PurchaseEvent
9
+ from template data. No deps, no network, CI-safe by default.
10
+ - `Invoice2DataProvider` - wraps `invoice2data` using template YAML files.
11
+ Install with `pip install spendsignal[extract]`.
12
+
13
+ Design (ADR-0005 / D-007): adapter-local types only. Does not modify core
14
+ schemas or the `summarize` API.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path
22
+ from typing import Protocol, runtime_checkable
23
+
24
+ from spendsignal.models import ExternalRef, LineItem, PurchaseEvent
25
+
26
+ # --- Adapter-local types -----------------------------------------------------
27
+
28
+
29
+ @runtime_checkable
30
+ class ExtractionProvider(Protocol):
31
+ """Protocol for receipt text → PurchaseEvent converters."""
32
+
33
+ def extract(self, text: str, source_ref: str) -> PurchaseEvent:
34
+ """Convert raw receipt text to a PurchaseEvent.
35
+
36
+ `source_ref` is an opaque caller-supplied ID for the external_ref.
37
+ """
38
+ ...
39
+
40
+
41
+ # --- Mock provider -----------------------------------------------------------
42
+
43
+
44
+ class MockExtractionProvider:
45
+ """Deterministic mock for use in tests and CI.
46
+
47
+ Returns a realistic PurchaseEvent without parsing anything. The returned
48
+ event contains the Costco basket from the committed fixtures so tests have
49
+ a stable reference to assert against.
50
+ """
51
+
52
+ def extract(self, text: str, source_ref: str) -> PurchaseEvent:
53
+ del text # mock ignores content; output is deterministic
54
+ return PurchaseEvent(
55
+ event_id=f"mock-{source_ref}",
56
+ occurred_at=datetime(2026, 1, 15, tzinfo=UTC),
57
+ external_ref=ExternalRef(source="mock-extractor", id=source_ref),
58
+ total_minor_units=9966,
59
+ currency="USD",
60
+ merchant_hint="COSTCO",
61
+ line_items=[
62
+ LineItem(
63
+ line_id="pp",
64
+ name="Protein powder",
65
+ amount_minor_units=4999,
66
+ comparison_key="protein-powder",
67
+ ),
68
+ LineItem(
69
+ line_id="vt",
70
+ name="Vitamins",
71
+ amount_minor_units=2850,
72
+ comparison_key="vitamins",
73
+ ),
74
+ LineItem(
75
+ line_id="ck",
76
+ name="Chicken",
77
+ amount_minor_units=2117,
78
+ comparison_key="chicken",
79
+ ),
80
+ ],
81
+ )
82
+
83
+
84
+ # --- Regex text parser (stdlib, no extra deps) --------------------------------
85
+
86
+
87
+ _LINE_PATTERN = re.compile(
88
+ r"(?P<name>[A-Za-z][A-Za-z0-9 &/,'-]{1,80}?)\s+"
89
+ r"\$?(?P<amount>\d{1,6}(?:\.\d{2})?)\s*$",
90
+ re.MULTILINE,
91
+ )
92
+ _TOTAL_PATTERN = re.compile(
93
+ r"(?:total|amount due|balance due|subtotal)[^\d]*\$?(?P<amount>\d{1,6}(?:\.\d{2})?)",
94
+ re.IGNORECASE,
95
+ )
96
+ _DATE_PATTERNS = [
97
+ re.compile(r"(\d{1,2})[/\-](\d{1,2})[/\-](\d{2,4})"),
98
+ re.compile(r"(\d{4})[/\-](\d{1,2})[/\-](\d{1,2})"),
99
+ ]
100
+
101
+
102
+ def _parse_amount(raw: str) -> int:
103
+ """Convert a decimal dollar string to integer minor units."""
104
+ return round(float(raw.replace(",", "")) * 100)
105
+
106
+
107
+ def _parse_date(text: str) -> datetime:
108
+ """Best-effort date extraction from receipt text. Falls back to now."""
109
+ for pat in _DATE_PATTERNS:
110
+ m = pat.search(text)
111
+ if m:
112
+ groups = m.groups()
113
+ try:
114
+ if len(groups[0]) == 4: # YYYY-MM-DD
115
+ return datetime(int(groups[0]), int(groups[1]), int(groups[2]), tzinfo=UTC)
116
+ y = int(groups[2])
117
+ if y < 100:
118
+ y += 2000
119
+ return datetime(y, int(groups[0]), int(groups[1]), tzinfo=UTC)
120
+ except ValueError:
121
+ continue
122
+ return datetime.now(UTC)
123
+
124
+
125
+ def _slug(name: str) -> str:
126
+ """Lowercase hyphenated slug from a display name."""
127
+ return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:64]
128
+
129
+
130
+ class TextParserProvider:
131
+ """Stdlib-only regex parser for plain-text receipt content.
132
+
133
+ Best-effort. Works well on machine-printed receipts with consistent
134
+ formatting (one item per line: name + price). Misses hand-written or
135
+ highly irregular receipts gracefully (produces fewer line items or
136
+ abstains on total).
137
+ """
138
+
139
+ def extract(self, text: str, source_ref: str) -> PurchaseEvent:
140
+ occurred_at = _parse_date(text)
141
+
142
+ lines_raw = _LINE_PATTERN.findall(text)
143
+ line_items = []
144
+ for i, (name, amount_str) in enumerate(lines_raw):
145
+ name = name.strip()
146
+ if not name or len(name) < 2:
147
+ continue
148
+ line_items.append(
149
+ LineItem(
150
+ line_id=f"l{i}",
151
+ name=name,
152
+ amount_minor_units=_parse_amount(amount_str),
153
+ comparison_key=_slug(name),
154
+ )
155
+ )
156
+
157
+ total_match = _TOTAL_PATTERN.search(text)
158
+ total_minor = (
159
+ _parse_amount(total_match.group("amount"))
160
+ if total_match
161
+ else sum(li.amount_minor_units for li in line_items)
162
+ )
163
+
164
+ first_line = text.strip().splitlines()[0] if text.strip() else "unknown"
165
+ merchant = first_line[:64].strip()
166
+
167
+ return PurchaseEvent(
168
+ event_id=f"text-parsed-{source_ref}",
169
+ occurred_at=occurred_at,
170
+ external_ref=ExternalRef(source="text-parser", id=source_ref),
171
+ total_minor_units=total_minor or 0,
172
+ currency="USD",
173
+ merchant_hint=merchant or "unknown",
174
+ line_items=line_items or None,
175
+ )
176
+
177
+
178
+ # --- Invoice2Data provider (optional dep) ------------------------------------
179
+
180
+
181
+ class Invoice2DataProvider:
182
+ """Wraps `invoice2data` for template-based PDF/text extraction.
183
+
184
+ Install with: `pip install spendsignal[extract]`.
185
+
186
+ Templates are YAML files that define patterns for known merchants. See
187
+ the invoice2data documentation for the template format. A templates_dir
188
+ of None uses invoice2data's bundled templates.
189
+ """
190
+
191
+ def __init__(self, templates_dir: str | Path | None = None) -> None:
192
+ try:
193
+ from invoice2data import extract_data # type: ignore[import-not-found]
194
+ from invoice2data.extract.loader import read_templates # type: ignore[import-not-found]
195
+ except ImportError as exc:
196
+ raise ImportError(
197
+ "invoice2data is not installed. Install it with: pip install spendsignal[extract]"
198
+ ) from exc
199
+ self._extract_data = extract_data
200
+ self._templates = read_templates(str(templates_dir)) if templates_dir else read_templates()
201
+
202
+ def extract(self, text: str, source_ref: str) -> PurchaseEvent:
203
+ import tempfile
204
+
205
+ with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as tmp:
206
+ tmp.write(text)
207
+ tmp_path = tmp.name
208
+
209
+ result = self._extract_data(tmp_path, templates=self._templates)
210
+ Path(tmp_path).unlink(missing_ok=True)
211
+
212
+ if not result:
213
+ return TextParserProvider().extract(text, source_ref)
214
+
215
+ occurred_at = datetime.now(UTC)
216
+ if "date" in result and isinstance(result["date"], datetime):
217
+ occurred_at = (
218
+ result["date"].replace(tzinfo=UTC)
219
+ if result["date"].tzinfo is None
220
+ else result["date"]
221
+ )
222
+
223
+ items = result.get("lines", []) or []
224
+ line_items = [
225
+ LineItem(
226
+ line_id=f"l{i}",
227
+ name=str(item.get("description", f"item-{i}")),
228
+ amount_minor_units=round(float(item.get("price", 0)) * 100),
229
+ comparison_key=_slug(str(item.get("description", f"item-{i}"))),
230
+ )
231
+ for i, item in enumerate(items)
232
+ ]
233
+
234
+ total_raw = result.get("amount", result.get("total", 0)) or 0
235
+ return PurchaseEvent(
236
+ event_id=f"invoice2data-{source_ref}",
237
+ occurred_at=occurred_at,
238
+ external_ref=ExternalRef(source="invoice2data", id=source_ref),
239
+ total_minor_units=round(float(total_raw) * 100),
240
+ currency=str(result.get("currency", "USD")).upper(),
241
+ merchant_hint=str(result.get("issuer", "unknown"))[:512],
242
+ line_items=line_items or None,
243
+ )
244
+
245
+
246
+ # --- Main entry point --------------------------------------------------------
247
+
248
+
249
+ def extract(
250
+ text: str,
251
+ source_ref: str,
252
+ provider: ExtractionProvider | None = None,
253
+ ) -> PurchaseEvent:
254
+ """Extract a PurchaseEvent from receipt text using the given provider.
255
+
256
+ Defaults to `MockExtractionProvider` so CI never needs real credentials.
257
+ Pass `TextParserProvider()` for stdlib-only best-effort parsing.
258
+ Pass `Invoice2DataProvider()` for template-based extraction (requires
259
+ `pip install spendsignal[extract]`).
260
+ """
261
+ if provider is None:
262
+ provider = MockExtractionProvider()
263
+ return provider.extract(text, source_ref)
264
+
265
+
266
+ __all__ = [
267
+ "ExtractionProvider",
268
+ "Invoice2DataProvider",
269
+ "MockExtractionProvider",
270
+ "TextParserProvider",
271
+ "extract",
272
+ ]