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.
- spendsignal/__init__.py +48 -0
- spendsignal/adapters/__init__.py +20 -0
- spendsignal/adapters/actual_budget.py +179 -0
- spendsignal/adapters/extract.py +272 -0
- spendsignal/adapters/match_deterministic.py +227 -0
- spendsignal/adapters/mcp_server.py +301 -0
- spendsignal/adapters/retrieve_nn.py +131 -0
- spendsignal/aggregate.py +267 -0
- spendsignal/cli.py +275 -0
- spendsignal/models.py +253 -0
- spendsignal/py.typed +0 -0
- spendsignal/storage.py +61 -0
- spendsignal-0.3.0.dist-info/METADATA +390 -0
- spendsignal-0.3.0.dist-info/RECORD +17 -0
- spendsignal-0.3.0.dist-info/WHEEL +4 -0
- spendsignal-0.3.0.dist-info/entry_points.txt +2 -0
- spendsignal-0.3.0.dist-info/licenses/LICENSE +21 -0
spendsignal/__init__.py
ADDED
|
@@ -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
|
+
]
|