Metadata-Version: 2.5
Name: spendsignal
Version: 0.4.0
Summary: A small local-first library for logging line-item purchases with your own retrospective outcomes, and retrieving that history when you're about to buy something similar.
Project-URL: Homepage, https://github.com/YemaneSG/SpendSignal
Project-URL: Issues, https://github.com/YemaneSG/SpendSignal/issues
Project-URL: Source, https://github.com/YemaneSG/SpendSignal
Project-URL: Changelog, https://github.com/YemaneSG/SpendSignal/blob/main/CHANGELOG.md
Author: Yemane
License-Expression: MIT
License-File: LICENSE
Keywords: evidence,local-first,personal-finance,personal-informatics,purchase-history,receipts
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: pydantic<3,>=2.5
Provides-Extra: actual
Requires-Dist: actualpy>=0.20; extra == 'actual'
Provides-Extra: all
Requires-Dist: actualpy>=0.20; extra == 'all'
Requires-Dist: invoice2data>=1.0; extra == 'all'
Requires-Dist: mcp>=1.0; extra == 'all'
Provides-Extra: extract
Requires-Dist: invoice2data>=1.0; extra == 'extract'
Provides-Extra: match
Provides-Extra: mcp
Requires-Dist: mcp>=1.0; extra == 'mcp'
Provides-Extra: retrieval
Description-Content-Type: text/markdown

# SpendSignal

A small local-first library for logging line-item purchases with your own retrospective outcomes, and retrieving that history when you're about to buy something similar.

`v0.3.0` · [PyPI](https://pypi.org/project/spendsignal/) · [Releases](https://github.com/YemaneSG/SpendSignal/releases) · MIT

Runs on synthetic data. No cloud, no accounts, no API keys.

Non-technical reader? Start with [docs/what-is-spendsignal.md](docs/what-is-spendsignal.md) - a plain-English walkthrough with a concrete example and the research ideas behind the design.

---

## Try it in 5 minutes

Requires Python 3.12+.

```bash
pip install spendsignal

git clone git@github.com:YemaneSG/SpendSignal.git
cd SpendSignal

# Given my three past protein-powder purchases, what does my current view say?
spendsignal summarize \
  --purchases fixtures/purchases.jsonl \
  --exposures fixtures/exposures.jsonl \
  --feedback fixtures/feedback.jsonl \
  --subject protein-powder \
  --as-of 2026-12-01T00:00:00+00:00
```

The package is on PyPI — `pip install spendsignal` works without cloning. Clone only if you want to run the committed fixtures or browse the source. Optional extras: `spendsignal[mcp]` for the MCP server, `spendsignal[actual]` for Actual Budget, `spendsignal[all]` for everything.

Real output:

```json
{
  "schema_version": "1",
  "generated_at": "2026-12-01T00:00:00Z",
  "query": {"subject": "protein-powder", "as_of": "2026-12-01T00:00:00Z"},
  "coverage": {
    "comparable_purchase_count": 3,
    "exposure_count": 3,
    "feedback_count": 6
  },
  "distributions": [
    {"axis": "worth_it", "count": 3, "distribution": {"5": 2, "4": 1}},
    {"axis": "would_buy_again", "count": 3, "distribution": {"true": 3}}
  ],
  "contradictions": [],
  "revisions": 0,
  "contributing_event_ids": [
    "fb-p1-again", "fb-p1-worth",
    "fb-p2-again", "fb-p2-worth",
    "fb-p3-again", "fb-p3-worth"
  ],
  "abstention": null
}
```

Three past purchases, six feedback events, no contradictions, no revisions. Every event ID supporting the summary is enumerated. Nothing invented.

---

## What SpendSignal does

- Logs line items with typed retrospective labels (`would_buy_again`, `worth_it`, `still_using`, `returned`, `regretted`, `planned`, ...).
- Runs locally on synthetic data. No cloud, no accounts, no API keys required for the demo.
- Deterministic first, LLM second. Financial truth lives in typed data structures. LLMs interpret.
- Interoperates with mature tools (Actual Budget for the ledger, existing OCR libraries for image ingest, MCP for assistant surfaces) instead of replacing them.

## What SpendSignal is not

- Not a budgeting app. No envelopes, no forecasts, no net worth.
- Not an accounting engine. Use Actual, hledger, or beancount.
- Not an AI financial advisor. Nothing here recommends stocks or predicts markets.
- Not a receipt-photo app. Ingest lives at the edges. This library is what happens after the receipt is text.

---

## The loop

```
bank transaction
  → matched receipt
  → individual line items
  → your retrospective labels  (would_buy_again, worth_it, still_using,
                                returned, regretted, planned, ...)
  → a queryable log of what actually happened
  → retrieval when you're about to buy something similar
  → new outcome feedback
```

Retrieval over your own past, not prediction. The tool does not decide whether you should buy something. You do.

---

## Point-in-time is real

The same query at an earlier `--as-of` returns the state as of that date. In the fixture, the wireless-headphones feedback was initially positive in July and revised to negative in October via supersession. Ask "as of August" and you get the pre-revision state.

```bash
# Latest view - both labels revised, revisions=2
spendsignal summarize \
  --purchases fixtures/purchases.jsonl \
  --exposures fixtures/exposures.jsonl \
  --feedback fixtures/feedback.jsonl \
  --subject wireless-headphones \
  --as-of 2026-12-01T00:00:00+00:00
# → distributions: worth_it {1: 1}, would_buy_again {false: 1}, revisions: 2

# Earlier view - original opinion, revisions=0
spendsignal summarize \
  --purchases fixtures/purchases.jsonl \
  --exposures fixtures/exposures.jsonl \
  --feedback fixtures/feedback.jsonl \
  --subject wireless-headphones \
  --as-of 2026-08-01T00:00:00+00:00
# → distributions: worth_it {4: 1}, would_buy_again {true: 1}, revisions: 0
```

History is append-only. Old opinions are preserved next to new ones. The past does not change.

---

## Explicit abstention when evidence is insufficient

SpendSignal refuses to invent a score when it has none. Ask about a subject with a comparable purchase but no feedback and you get an explicit refusal with a reason and a still-populated coverage count.

```bash
spendsignal summarize \
  --purchases fixtures/purchases.jsonl \
  --exposures fixtures/exposures.jsonl \
  --feedback fixtures/feedback.jsonl \
  --subject chicken \
  --as-of 2026-12-01T00:00:00+00:00
```

```json
{
  "coverage": {"comparable_purchase_count": 1, "exposure_count": 1, "feedback_count": 0},
  "distributions": [],
  "revisions": 0,
  "contributing_event_ids": [],
  "abstention": {
    "reason": "no_feedback",
    "detail": "Comparable purchases exist but no feedback has been recorded on them."
  }
}
```

Three abstention reasons are possible - `no_comparable_purchases`, `no_feedback`, `no_feedback_within_scope` - each with a human-readable `detail`.

---

## Adapters

Two adapters ship in Phase 2, building on the same evidence contracts. Both stdlib-only — no extra dependencies.

### `spendsignal extract` — receipt text → PurchaseEvent

Convert raw receipt text into a `PurchaseEvent`. Ships with a deterministic mock (default) and a stdlib regex `TextParserProvider`. `Invoice2DataProvider` available under `pip install spendsignal[extract]`.

```python
from spendsignal.adapters.extract import TextParserProvider, extract

ev = extract("COSTCO\n01/15/2026\nProtein powder  $49.99\nTotal  $49.99", "receipt-001")
# PurchaseEvent with line_items and comparison_keys

ev = extract("receipt text", "r1")  # MockExtractionProvider by default — CI-safe
```

---

### `spendsignal retrieve` — "have I bought anything like this?"

Fuzzy-matches a free-text query against all known `comparison_key`s in your purchase history, then returns each matched subject's `EvidenceSummary`.

```bash
spendsignal retrieve \
  --purchases fixtures/purchases.jsonl \
  --exposures fixtures/exposures.jsonl \
  --feedback fixtures/feedback.jsonl \
  --query "protein shake" \
  --as-of 2026-12-01T00:00:00+00:00 \
  --top-n 3
```

Returns a JSON array. Each element has `comparison_key`, `similarity`, and a full `EvidenceSummary`. Abstentions pass through - if a match has no feedback yet, the result still surfaces it with `abstention.reason = "no_feedback"` rather than silently dropping it.

Hyphens and spaces normalised: `"protein shake"` matches `"protein-powder"` near 1.0.

### `spendsignal match` — pair bank transactions with receipts

Deterministic amount + date + fuzzy merchant scoring. Amount and date are hard gates; merchant is a soft ranking signal. Greedy highest-confidence assignment. One-to-one.

```bash
spendsignal match \
  --transactions fixtures/transactions.jsonl \
  --purchases fixtures/purchases.jsonl
```

```
txn-2026-08-15-costco  <->  costco-3  confidence=1.0    (exact amount, date, merchant)
txn-2026-06-15-costco  <->  costco-2  confidence=0.9118 (exact amount, date, fuzzy merchant)
txn-2026-01-15-costco  <->  costco-1  confidence=0.7667 (exact amount, 1-day gap, fuzzy merchant)
```

The SHELL transaction has no matching receipt and stays unmatched.

### `spendsignal serve` — MCP server

Exposes `summarize_subject`, `retrieve_similar`, and `match_receipts` as MCP tools. Compatible with Claude Desktop, Continue, Cursor, and any MCP-capable LLM host.

```bash
pip install spendsignal[mcp]

spendsignal serve \
  --purchases fixtures/purchases.jsonl \
  --exposures fixtures/exposures.jsonl \
  --feedback  fixtures/feedback.jsonl
```

Add to your Claude Desktop `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "spendsignal": {
      "command": "spendsignal",
      "args": [
        "serve",
        "--purchases", "/path/to/purchases.jsonl",
        "--exposures", "/path/to/exposures.jsonl",
        "--feedback",  "/path/to/feedback.jsonl"
      ]
    }
  }
}
```

Then ask Claude: *"What does my purchase history say about protein powder?"*

### Actual Budget integration

Pull bank transactions directly from a running Actual Budget instance for use with the matching adapter.

```python
from spendsignal.adapters.actual_budget import ActualBudgetProvider, load_actual_transactions

# Mock — no credentials needed (default for demos and CI)
txns = load_actual_transactions()

# Real Actual Budget (requires pip install spendsignal[actual])
provider = ActualBudgetProvider(
    server_url="http://localhost:5006",
    password="your-password",
    budget_id="your-budget-id",
)
txns = load_actual_transactions(provider=provider)
```

### Library use

```python
from spendsignal.adapters.extract import TextParserProvider, extract
from spendsignal.adapters.retrieve_nn import retrieve
from spendsignal.adapters.match_deterministic import BankTransaction, Receipt, match
from spendsignal.adapters.actual_budget import load_actual_transactions
```

See [`src/spendsignal/adapters/`](src/spendsignal/adapters/) and [`fixtures/README.md`](fixtures/README.md) for usage examples.

---

## Library use

`spendsignal.summarize` is a pure function that takes iterables of validated pydantic models and returns an `EvidenceSummary`.

```python
from datetime import UTC, datetime

from spendsignal import summarize
from spendsignal.storage import load_exposures, load_feedback, load_purchases

summary = summarize(
    purchases=load_purchases("fixtures/purchases.jsonl"),
    exposures=load_exposures("fixtures/exposures.jsonl"),
    feedback=load_feedback("fixtures/feedback.jsonl"),
    subject="protein-powder",
    as_of=datetime(2026, 12, 1, tzinfo=UTC),
)
print(summary.model_dump_json(indent=2))
```

Same inputs always produce byte-identical output. `generated_at` defaults to `as_of` for reproducibility - pass `now=` when a real wall clock matters.

---

## Contracts

Four JSON schemas under [`schemas/`](schemas/) define the cross-language wire format.

| Schema | What it carries |
|---|---|
| [`purchase-event`](schemas/purchase-event.schema.json) | Source-neutral purchase record with optional line items and caller-supplied comparison keys. |
| [`reflection-exposure`](schemas/reflection-exposure.schema.json) | Records that the user was asked for feedback about a purchase or line item. Selection reason and policy version enable bias detection. |
| [`feedback-event`](schemas/feedback-event.schema.json) | One axis of retrospective feedback per event. Append-only; revisions use `supersedes_event_id`. Discriminated `value`: `bool` / `likert_5` / `categorical`. |
| [`evidence-summary`](schemas/evidence-summary.schema.json) | Deterministic output shape. Coverage, per-axis distributions, contradictions, revisions, contributing event IDs, explicit abstention. |

JSON Schema Draft 2020-12. Python consumers use the pydantic mirrors in [`src/spendsignal/models.py`](src/spendsignal/models.py). Other languages read the schemas directly.

---

## Development

```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -e .
pip install ruff pytest hypothesis mypy   # dev tools

pytest -q            # 113 tests, under 1 second
ruff check .
ruff format --check .
mypy
```

Regenerate the fixtures:

```bash
python scripts/generate_fixtures.py
```

---

## Status

Phase 2 and Phase 3 complete. Five adapters shipped: `match_deterministic`, `retrieve_nn`, `extract`, `mcp_server`, `actual_budget`. See [`ROADMAP.md`](ROADMAP.md) for what's next.

Design decisions are recorded as ADRs in [`docs/decisions/`](docs/decisions/). The load-bearing one is [`ADR-0002`](docs/decisions/ADR-0002-v0.1-module-boundary.md) - the v0.1 module boundary.

---

## License

MIT. See [LICENSE](LICENSE) and [ADR-0004](docs/decisions/ADR-0004-license.md).

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md).

## Security

See [SECURITY.md](SECURITY.md).
