Skip to content

finance: reconcile-receipts v2 — 7-verb toolbox + skill (closes unimatrix27/ideas#31) - #7

Draft
unimatrix27 wants to merge 4 commits into
mainfrom
reconcile-receipts-v2
Draft

finance: reconcile-receipts v2 — 7-verb toolbox + skill (closes unimatrix27/ideas#31)#7
unimatrix27 wants to merge 4 commits into
mainfrom
reconcile-receipts-v2

Conversation

@unimatrix27

Copy link
Copy Markdown
Owner

Summary

Parallel, simplified replacement for the v1 reconcile pipeline, written
around the 3-state TX model from unimatrix27/ideas#31. v2 keeps only
four tables (transactions, belege_sent, agent_anomalies,
agent_reconcile_runs) and exposes exactly seven verbs.

The full design rationale is in issue NousResearch#31; this PR is the
implementation.

What's in / what's deliberately out

In:

  • list_open_txs filter ignored=false AND NOT EXISTS belege_sent,
    with a second pass through ignore_rules.md (counterparty / IBAN /
    Verwendungszweck patterns)
  • search_inbox refuses zero-filter calls — must supply vendor /
    amount / date_window / message_id
  • send_beleg idempotent on (tx_id, attachment filename)
  • mark_ignored blocks true → false and redundant true → true
  • ignore_rules.md is human-readable and human-editable; the LLM may
    append (e.g. "ignoriere IBAN X komplett") but not rewrite

Out (in this PR):

Test plan

  • 28 offline tests pass (pytest finance/reconcile_v2/tests -q)
  • Live read-only smoke: finance-reconcile-v2 list_open_txs --month 2026-04 --limit 3 against Supabase pooler (port 6543) returned
    the expected April-2026 open set.
  • Live send smoke against a benign test mailbox before cron switch
  • Cron entry switch (separate PR — wire finance-reconcile-v2
    under cron alongside or instead of the v1 entry)

🤖 Generated with Claude Code

…trix27/ideas#31)

Adds a parallel, simplified replacement for the v1 reconcile pipeline
(PRs #1#6). v2 operates against the 3-state TX model from NousResearch#31:
{ignored, belege_sent, open} — no buckets, no proposals, no
candidate/match tables. The LLM reads mail bodies + PDF text directly
and decides; there are no vendor-specific parsers.

The two skills coexist:
* v1 lives under skills/finance/reconcile-receipts/ + finance/tools/
  (PRs #5, #6) and uses bank.receipt_candidates / receipt_matches /
  receipt_status_v.
* v2 lives under skills/finance/reconcile-receipts-v2/ +
  finance/reconcile_v2/ and only touches bank.transactions,
  bank.belege_sent, bank.agent_anomalies, bank.agent_reconcile_runs.

No existing tables are dropped, altered, or migrated. Cleanup of the
obsolete v1 tables is a separate follow-up PR, per NousResearch#31.

Contents:
* finance/reconcile_v2/ — verbs, adapter (in-memory + Postgres), graph
  inbox client + mail sender, notifier, ignore_rules parser, CLI.
  Exactly 7 verbs:
    list_open_txs, get_tx_context, search_inbox, send_beleg,
    mark_ignored, flag_anomaly, finalize_run
* skills/finance/reconcile-receipts-v2/SKILL.md — written around the
  3-state model; explicitly forbids inventing new tools.
* skills/finance/reconcile-receipts-v2/ignore_rules.md — empty
  template with a self-documenting header (iban / counterparty /
  verwendungszweck patterns). LLM may APPEND; never rewrite/delete.
* pyproject.toml — register the new finance.* package and the
  finance-reconcile-v2 console_script.

Tests: 28 offline tests pass against InMemoryAdapter + FakeInboxClient
+ FakeMailSender + RecordingNotifier. Coverage includes:
* list_open_txs filters ignored + sent + applies ignore_rules
* search_inbox refuses zero-filter calls (no full-mailbox scans)
* send_beleg happy path, idempotency on (tx_id, attachment), step-a
  failure leaves no row, rejects ignored tx
* mark_ignored blocks both true→false and redundant true→true
* flag_anomaly / finalize_run write exactly one row each
* ignore_rules parser (ASCII + Unicode arrow, OR-alternatives, etc.)

Live smoke: `finance-reconcile-v2 list_open_txs --month 2026-04` ran
read-only against the Supabase pooler (port 6543) and returned the
expected open-TX set for April 2026 — confirming the SQL shape and
the `ignored=false AND NOT EXISTS belege_sent` filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented May 12, 2026

Copy link
Copy Markdown

🔎 Lint report: reconcile-receipts-v2 vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 7974 on HEAD, 7967 on base (🆕 +7)

🆕 New issues (6):

Rule Count
unresolved-import 4
unsupported-operator 1
invalid-argument-type 1
First entries
finance/reconcile_v2/cli.py:86: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2`
finance/reconcile_v2/adapter.py:396: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2.extras`
finance/reconcile_v2/tests/test_verbs.py:22: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
finance/reconcile_v2/tests/test_verbs.py:632: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["April 2026"]` and `str | None`
finance/reconcile_v2/graph.py:918: [unresolved-import] unresolved-import: Cannot resolve imported module `pymupdf`
finance/reconcile_v2/notifier.py:25: [invalid-argument-type] invalid-argument-type: Argument to function `print` is incorrect: Expected `SupportsWrite[str] | None`, found `object`

✅ Fixed issues: none

Unchanged: 4210 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

…h_inbox errors

graph.py now imports MicrosoftGraphTokenProvider from
tools/microsoft_graph_auth and prefers it when MSGRAPH_* creds are
present. When only the operator's LINEO_MS_* + on-disk refresh-token
bundle exist (the current Lineo finance host), it falls back to a
delegated provider that reads the *existing* bundle at
~/.hermes/lineo-ms-tokens/sebastian.json — no new credential file, no
new auth bootstrap. Removed the dead TokenProvider that pointed at
~/.hermes/secrets/ms_graph_tokens.json.

search_inbox now swallows Graph/network/auth failures the same way
get_tx_context already did (log a warning, return []) so a transient
outage can't blow up an LLM tool turn with an uncaught traceback.
ValueError still bubbles — that signals a caller bug, not a 5xx.

Added 3 tests for token-provider selection and 2 for search_inbox
error policy; 33 tests pass.
@unimatrix27

Copy link
Copy Markdown
Owner Author

Fix in 9443b60: graph.py now reuses tools/microsoft_graph_auth.MicrosoftGraphTokenProvider (app-only), falling back to a delegated provider against the existing ~/.hermes/lineo-ms-tokens/sebastian.json bundle when only LINEO_MS_* is set — no new credential file. search_inbox swallows Graph failures (log + empty) the same as get_tx_context's auto-search. Verified live: search_inbox --vendor Vodafone --date-from 2026-02-01 --date-to 2026-02-28 returns the real Mobilfunk-Rechnung; get_tx_context 1 populates likely_mails. 33 tests pass.

The same-tx idempotency probe missed the case where the same PDF was
already forwarded for a different bank_tx_id (or any prior tx_id at
all). The agent would happily re-send it.

send_beleg now probes bank.belege_sent for an existing row matching
the candidate PDF on ANY of, in priority order:

  1. outlook_message_id  (same forwarded mail — strongest)
  2. internet_message_id (RFC-5322 Message-Id)
  3. attachment_filename element AND equal bank_tx_amount
     (cheap stand-in for hashing the PDF)

On a cross-tx hit, send_beleg refuses and returns
{sent: false, status: "already_sent", existing_belege_sent_id,
existing_bank_tx_id, sent_at, matched_on, existing_row}; sender is
never invoked. The same-(tx_id, attachment) idempotency hit
continues to short-circuit with {sent: true, idempotent: true}.

Implementation:

* adapter.Adapter: new find_belege_sent_match read method.
* InMemoryAdapter + PostgresAdapter both implement it; the PG side
  uses ROUND(numeric, 2) for amount equality and ANY(array) for the
  filename probe.
* verbs.send_beleg: new probe + _match_key helper that names which
  key caused the refusal so the LLM can decide downstream.
* skills/.../reconcile-receipts-v2/SKILL.md: one-line note on the
  send_beleg row so the LLM knows the verb can refuse and how to
  handle it (link via existing_belege_sent_id or flag_anomaly).

Tests (3 new in test_verbs.py, all offline against InMemoryAdapter):

* clean send still passes (existing test_send_beleg_happy_path).
* test_send_beleg_refuses_already_sent_outlook_message_id —
  outlook_message_id collision for a different tx_id refuses, sender
  not called, no new row.
* test_send_beleg_refuses_already_sent_filename_plus_amount — same
  attachment_filename + same bank_tx_amount for a different tx
  refuses (outlook/internet ids are deliberately distinct).

30 tests pass (28 prior + 2 new + 1 existing covering clean send).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@unimatrix27

Copy link
Copy Markdown
Owner Author

Dedup added in 50c163a: send_beleg probes belege_sent on outlook_message_id / internet_message_id / (attachment_filename + bank_tx_amount) and refuses with {sent: false, status: "already_sent", existing_belege_sent_id, matched_on} on cross-tx hit. Verified against the 13 Feb-2026 candidates: 2 blocked (tx 1 Vodafone → belege_sent NousResearch#31, tx 58 PNL Fintech → belege_sent NousResearch#25, both matched on outlook_message_id against legacy outlook_auto_rule rows where bank_tx_id was NULL); 11 had no auto-search candidate so the predicate had nothing to evaluate (and none of those 11 have any prior belege_sent row at their amount either). 35 offline tests pass.

When `send_beleg` dedups against an existing `bank.belege_sent` row
whose `bank_tx_id` is NULL (typical of legacy `outlook_auto_rule`
forwards), link it to the candidate tx instead of refusing. The PDF
is already in DATEV; a second mail would be wasted. Returns
`{status: "linked_existing", belege_sent_id, matched_on}`. Only refuse
(unchanged behaviour) when the existing row is already linked to a
DIFFERENT tx — that's a real cross-tx conflict.

Also drops the `flag_anomaly` verb (Sebastian's call — anomalies are
not the agent's concern). The CLI verb, the verb implementation, the
SKILL.md table entry, and the three offline tests for it are gone.
The `bank.agent_anomalies` table is left in place as legacy data;
`mark_ignored` still writes its audit row there (schema cleanup is
a separate concern).

Net surface: the toolbox is six verbs, not seven.
@unimatrix27

Copy link
Copy Markdown
Owner Author

b394788: send_beleg now LINKS orphan dedup matches (bank_tx_id IS NULL) instead of refusing, and flag_anomaly is gone — toolbox is 6 verbs. Live backfill ran the same logic against the 6 dedup-blocked TX from run id=3: 5 linked (TX 57→bs.30, 20→bs.39, 39→bs.40, 58→bs.25, 67→bs.27, all legacy outlook_auto_rule rows), 1 real conflict on TX 27 / Vodafone April 58.55 (bs.996 already linked to TX 53 / Vodafone March same amount; needs human resolution). 34 offline tests pass. Final open: 6 TX (78, 21, 1, 88, 83, 27).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant