Categorization review file, review flag, and file inventory [6/6] - #103
Open
terryaney wants to merge 14 commits into
Open
Categorization review file, review flag, and file inventory [6/6]#103terryaney wants to merge 14 commits into
terryaney wants to merge 14 commits into
Conversation
- Add glob pattern examples in settings.yaml.example, clarify formats docs for file globs. - Add CLI tests covering multi-file matching, no-match behavior, diag visibility, and sorted processing order.
This was referenced Aug 4, 2026
terryaney
marked this pull request as ready for review
August 4, 2026 16:37
There was a problem hiding this comment.
Pull request overview
Adds the editable categorization-review workflow and inventory, atop the stacked report and merchant-key improvements.
Changes:
- Generates categorization YAML, hints, schema, and file-review inventory.
- Adds
review:rules, composite merchant keys, recurrence analysis, and report fields. - Expands report UI, documentation, and automated coverage.
Reviewed changes
Copilot reviewed 37 out of 50 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
src/tally/categorization.py |
Generates and merges review files. |
src/tally/categorization_common.py |
Defines shared review-file models and labels. |
src/tally/categorization_schema.py |
Builds editor autocomplete schema. |
src/tally/inventory.py |
Maintains data-file review inventory. |
src/tally/merchant_engine.py |
Parses review: and filters empty fields. |
src/tally/merchant_utils.py |
Propagates rule provenance and review state. |
src/tally/parsers.py |
Adds transaction keys, file paths, and report fields. |
src/tally/analyzer.py |
Adds composite keys and recurrence classification. |
src/tally/report.py |
Generates stable merchant IDs and enhanced report data. |
src/tally/spending_report.html |
Reworks filters, charts, KPIs, and details UI. |
src/tally/config_loader.py |
Loads new report and categorization settings. |
src/tally/format_parser.py |
Adds per-format report fields. |
src/tally/templates.py |
Updates starter configuration. |
src/tally/cli.py |
Documents review generation in CLI help. |
src/tally/cli_utils.py |
Updates starter-template formatting. |
src/tally/commands/run.py |
Integrates inventory and review generation. |
src/tally/commands/explain.py |
Supports composite merchant entries. |
src/tally/commands/diag.py |
Reports recurrence override tags. |
src/tally/commands/reference.py |
Documents new rule and report options. |
src/tally/commands/workflow.py |
Adds review workflow guidance. |
config/settings.yaml.example |
Documents new settings and glob support. |
tests/test_categorization.py |
Tests generation, hints, identity, and merging. |
tests/test_categorization_review.py |
Tests review-row lifecycle. |
tests/test_categorization_schema.py |
Tests generated schema behavior. |
tests/test_inventory.py |
Tests inventory and review: parsing. |
tests/test_report.py |
Tests stable merchant IDs. |
tests/test_analyzer.py |
Tests report fields, recurrence, and composite keys. |
tests/test_cli.py |
Tests globbing and explain output. |
docs/guide.html |
Documents categorization review workflow. |
docs/formats.html |
Adds review-file and inventory reference. |
docs/reference.html |
Documents rule and report-field syntax. |
docs/charts.html |
Adds chart behavior documentation. |
docs/index.html |
Updates assets and navigation. |
docs/quickstart.html |
Links chart documentation. |
docs/sitemap.xml |
Registers chart content and corrected assets. |
Suppressed comments (3)
src/tally/spending_report.html:90
- Each month is implemented as a clickable
div, which cannot be reached or activated from the keyboard. Render these cells asbutton type="button"elements (while retaining the existing class) to expose the control semantics automatically.
src/tally/spending_report.html:151 - The new chart-panel toggles are click handlers on headings, and the nested Peek Mode
spanonly hasrole="button"; neither is keyboard-focusable or handles Enter/Space. Use native buttons for every panel toggle and the peek action rather than non-interactive elements with click handlers.
src/tally/spending_report.html:264 - The Transaction Details disclosure is a clickable
div, so keyboard users cannot collapse or expand it and assistive technology receives no expanded state. Make the header a button and bindaria-expandedto!detailsCollapsed.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Open
The data-source table's file row used markdown backticks, which render literally in an HTML page. Every other cell in that table already uses <code> elements.
`tally up` writes <output>.json beside the HTML and diffs the next run against it, so the same config and data must produce the same bytes. It did not: pattern.tags serialized in arbitrary order. - analyzer.py: sort pattern.tags unconditionally. The isinstance(set) guard was dead - merchant_utils.normalize_merchant already does list(result.tags), so the value arrives as a list carrying the set's arbitrary iteration order. Mirrors the top-level merchant tags field, which was already sorted. - run.py: extract collect_source_names() and dedupe the report subtitle sources while preserving settings.yaml declaration order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…le Info
1. BIGGEST UI GAIN: Revamped date filter to be more functional than simply just a month list
2. IMPORTANT DATA FIX: Two data-correctness bugs - in both, the JS classified transactions using
merchant.tags — the union of every tag across all of a merchant's transactions — instead of the
transaction's own txn.tags (see Bug Fix Details below))
3. Removed year from title to support multi-year data/reporting and made page title match content title
4. Data from... subtitle is distinct list of source
5. `Include Negatives` has badge count and hides when 0
6. Hide all app components (and added progress bar) while VUE was mounting/initializing
7. Charts UI Polish/Changes
- Renamed: "Monthly Trend" -> "Cash Flow Trend", "By Category" -> "Spending
by Category", "Category Trends by Month" -> "Spending by Category Trend".
- Cash Flow Trend now includes Credits to match cash-flow definition.
- Removed the synthetic Income/Investment datasets from the category trend chart;
it is now purely spending by category.
- Legends are visibility toggles on all three charts. The category trend
legend previously added a category filter on click.
- Removed the data-click filter handlers from both category charts. The
Cash Flow Trend bar click (month filter) is retained.
- Both category charts share one ranking: top 10 categories plus an "Other" rollup.
- Series with no data are no longer emitted, so an all-zero Investment
series no longer leaves a phantom legend entry.
- Months with nothing to plot are dropped per chart (a transfer-only month
drew an empty column).
- Cash Flow Trend bar clicks index into monthlyChartMonths — the months
actually plotted — instead of availableMonths. Under a date filter the two
diverged and a bar click filed the wrong month.
8. Transaction Details - container + collapser polish
- Introduce **containment** — wrap the button row + all category collapsers in one **Transaction Details**
- Left-align the view buttons; add collapse/expand controls on the right and polish each collapser
- Fix view-toggle animation cascade in Transaction Details
- Unify Merchant/Subcategory category views into one keyed v-for
- Vue patches <section> nodes in place instead of remounting on a groupByMode toggle
- Wrap the toggle on mobile (<=640px) — view modes on line 1, actions on line 2; Collapse becomes icon-only
- Reworked merchant row action presentation: explicit info and filter links
- Refined expanded transaction row structure for better presentation
- Fixed 'report fields' badge in transaction detail rendering
- Adaptive column sizing architecture
- Introduced dynamic transaction column sizing via CSS variables for
date, account, and amount tracks.
- Added startup-time width profiling and debounced resize recalculation
for desktop-first responsiveness.
9. Rule Info Fixes
- Added explicit rule provenance into popup data (ruleName, pattern, source)
so "Why This Matched" reflects the actual classification path.
- Improved fallback behavior: when no categorization rule matches, promote
the first matching tag rule so popup rule details remain meaningful.
- Switched popup tag display to merchant-level union tags (matching the main grid)
- Cleaned up tag-source reporting to avoid redundant/misleading "from [rule]"
lines when the displayed rule already explains the tag.
10. Save Layout Settings to Local Storage
- Transaction Trends collapsed state
- Transaction Details collapsed state
- Details view mode (Merchant, Subcategory, View)
- Include Negatives toggle
- Per-section collapse state
- Per-item expansion state (merchant/subcategory rows)
- Per-section sort settings
11. Fix merchant filter regression and stale tests from row-UX/date polish
- getFilterDescriptor() used item.id (category-specific) instead of
item.displayName for merchant-type filters, breaking cross-category
merchant filtering when the same merchant appears in multiple categories.
- Test suite is in line with two intentional behavior changes
already on this branch that its own tests hadn't caught:
- Category sections now default to collapsed on a fresh load
(regardless of "Save Layout Settings to Local Storage")
- Transaction dates always show the year now
Bug Fix Details:
Both bugs are the same root cause: the JS classified transactions using
merchant.tags — the union of every tag across all of a merchant's
transactions (analyzer.py:122 builds it that way) — instead of the
transaction's own txn.tags, which report.py already emits per transaction.
categorizeAmount() is a strict income -> investment -> transfer -> sign
chain, so one tagged transaction re-bucketed every other transaction at
that merchant. This changed reported dollar amounts; it is not cosmetic.
Bug 1 — charts and the Filtered View tile (filteredViewTotals,
chartAggregations). Every number in all three charts and in the Filtered
View KPI tile was wrong. A merchant whose union is
[monthly-bill, refund, transfer] had all of its ordinary purchases counted
as transfers and vanish from spending. Now classified per transaction, the
JS reconciles with the Python KPIs to the dollar.
Both KPI tiles now agree line-for-line on unfiltered data.
Bug 2 — Section View percentages (grossSpending). grossSpending was
grandTotal + creditsTotal, and both inputs drop a whole merchant when its
tag union is excluded from spending. It feeds the (X%) badge beside each
section header, which renders whenever typeTotals is absent — that is,
Section View always (Category/Subcategory pass server-computed typeTotals
and were never affected). The denominator ran 15% low: $215,322 against a
true $253,781, hiding $6,910 of Amazon spending, $2,448 of Minnesota
Department of Revenue, and more. grossSpending now derives from the same
per-transaction pass and equals Python's spendingTotal exactly. Monthly
Bills reads 23.8% (was 28.0%), Food & Dining 20.0%.
Documented, not fixed: creditMerchants has the same merchant-union bug. It
is unrendered — the "Credits Applied" section was dropped from the template
in ad9477e while docs/reference.html still documents it — so a comment on
the computed records the bug and the two ways to fix it when the section is
restored.
Report title (report.py, spending_report.js) - Escape the title before it is interpolated into the loading shell's <title> and <h1>; a title carrying markup was executable output. - Escape '</' in the embedded spendingData JSON, so a value containing '</script>' cannot close the script element it is embedded in. - Coerce a non-string title: 'title: 2025' arrives from YAML as an int and raised TypeError inside str.replace(). - Resolve the title once and store it back on spendingData, so the static shell and the mounted app cannot disagree. The two fallbacks differed, so an untitled report was renamed on mount. Date handling (spending_report.js) - parseTypedDate now rejects dates a calendar does not have. 2/31/2026, month 13 and 99/99/2026 passed the shape regexes and became chips that could never match a transaction. - expandMonthRange validates both halves are YYYY-MM before iterating. A hand-edited '#+dr:garbage..garbage' walked to 'NaN-NaN', which compares less than 'garba' forever - the report hung with the array growing without bound. - The date popover now edits include-mode filters only. An excluded month or range rehydrated into the pending set and came back out of Apply as an inclusion, silently inverting the filter. Percentages (spending_report.js, spending_report.html) - Section percentages divided a raw total, which includes income, investment and transfers, by grossSpending, which excludes them. Carry a spending-only subtotal on the same per-transaction basis and use it as the numerator. Performance (spending_report.js) - Cache transaction column measurements by string. Every transaction forced three synchronous layouts, repeated across all three profiles; large reports stalled. Cleared per recompute so a resize re-measures. Accessibility (spending_report.html, spending_report.css) - Year tabs and month cells are buttons rather than click-only divs, so they are keyboard-reachable and announce their selected state. - Name the reset control with aria-label rather than leaving the glyph as its only accessible name. Config and docs - Validate report_fields. A bare 'report_fields: memo' was iterated as four capture names and an empty value raised TypeError; accept the bare string, treat empty as none, and reject other types by name. - Correct the reference: a field: directive's output is not an input to other rules, but the captured column report_fields surfaces is available to every rule as field.<name>.
Reimagines the KPI and chart experience in the HTML spending report while preserving the same core budgeting and analysis intent. The functionality is still there, but presented with a different interaction model. - New KPI dashboard sparkline and consistency layout - New charts: Spending Seasonality, Expense Volatility, Fixed Spending Audit, and Fixed vs Variable Spending - Chart Changes - By Category becomes Spending By Category column chart (for consistency) - Monthly Trend becomes Cash Flow - All column charts have 'group by' buttons, compare-year toggles, and 'focused' line chart mode - Improve responsive label behavior, resize rerendering, and compare-year paging - Debounced chart rerender on viewport/layout changes via resize + ResizeObserver - Disable re-animation for resize-triggered rerenders to avoid redraw jitter - Make x-axis labels adaptive (horizontal/angled/vertical) with autoskip under tight widths - Expand Playwright chart coverage for compare-year windowing/paging and updated chart controls
- KPI Sparklines are based on last month containing data
- Changed KPI to be month based instead of all time
- Reconcile KPI detail math with trend baseline and clarify secondary labels
- align 12 Month Avg detail row with trend prior-12 baseline window
- keep trend on prior-window comparison while preserving anchor-month behavior
- include credits in Income detail primary values; keep gray secondary informational
- add a tooltip spelling out what the gray secondary value contributes
- update KPI styling and report_html coverage for new semantics
- Make Spending by Category legend chips filter the report
- chips are tri-state (regular / selected / not-selected) rather than show/hide;
selection is stored as ordinary category filters, so chips and the filter bar
are the same state. All-on and all-off both collapse back to no filter.
- clicking a bar segment drills into that category plus that bucket's date range
- the "Other" chip renders inert; it can never be filtered on
- the chart's own bars source from a category-exempt aggregation, so selecting a
category dims its peers instead of dropping them off the canvas
- a chip toggle flips dataset visibility on the live chart instead of rebuilding
it, so the stack does not reanimate on every click
- Treat chart-driven filters as transient "peek" state
- tagged source: 'chart' and deliberately not persisted to the URL hash
- a Peek Mode badge on the chart title and a Clear Filters button expose and
clear them; the badge stays visible while the panel is collapsed
- adding any filter by hand (search, table filter button, date apply) ends peek
- Exclude transfer-only merchants from fixed outputs
- Build recurring merchant lists from spending-classified transactions only;
transfer/income/investment-only merchants do not appear in fixed calculations.
- Update Recurring vs Variable footnote to Top 10 Fixed: ..., + N more, ranked
by monthly cost so the "+ N more" tail is the cheap end.
- Update charts documentation wording to match fixed terminology.
- Stabilize chart behavior and default-hide empty legend series
- Remove split-row and single-expanded reactive layout logic
- Rerender when a collapsed panel or the whole Transaction Trends section
expands: a chart measured at zero width bakes rotated, skipped x-axis ticks
into its config that a plain resize never recomputes
- Otherwise rerender only when chart-section width changes, or chart toggles change
- Make chip legends hide zero-data items by default when values are provided
- Fix cash-flow tooltip Net math to exclude Investments
- Fix subcategory filter chips picking up the "N merchants" summary as their text
Recurrence inference (analyzer.py) - months_active counts the months a merchant appears in, which says nothing about how far apart they are. Require the charges to be dense over the merchant's own lifespan as well as covering enough of the reporting period. Three January charges span twenty-five months but read as three active months, so in a January-only export they cleared the existing test and an annual premium was booked as a monthly cost - twelve times its real value. The classification also no longer depends on unrelated merchants' data; the existing tests needed a dummy monthly merchant present purely to hold num_months high enough. Accessibility (spending_report.html, spending_report.css, spending_report.js) - Chart panel collapse controls are buttons inside their headings, carrying aria-expanded and aria-controls, rather than click handlers on the <h3>. Applies to all six panels: category, seasonality, cash, volatility, fixed and audit. The heading stays a heading, so document navigation is unchanged. - The peek badge is a real button rather than a span with role="button", so it is reachable and operable from the keyboard. - A disabled legend chip is now natively disabled instead of only carrying a disabled class. The class dropped the click listener but left the control focusable and announcing as enabled. Documentation (docs/charts.html) - Fixed vs Variable described a mapping of categories. It is a per-merchant split driven by inferred recurrence, with fixed/variable rule tags as overrides. Document what it actually does, including how the cadence is inferred and how to override it.
Allows easier auditing of spending to a merchant.
Of course, if you prefer unique merchant names, that is still possible.
Instead of requiring:
```
[Costco Grocery]
match: contains("COSTCO") and amount <= 200
category: Food
subcategory: Grocery
[Costco Bulk]
match: contains("COSTCO") and amount > 200
category: Shopping
subcategory: Wholesale
```
Can instead be:
```
[Costco]
match: contains("COSTCO") and amount <= 200
category: Food
subcategory: Grocery
[Costco]
match: contains("COSTCO") and amount > 200
category: Shopping
subcategory: Wholesale
```
`tally up` now writes a categorization review file alongside the HTML report when unknown merchants remain and `merchants_file` is set: - categorization.yaml - one row per uncategorized transaction; fill in useRule, newRule, or edits - categorization.hints.yaml - deterministic match data (difflib string comparison against the user's own rules; no AI, no network), regenerated every run, safe to delete - categorization-schema.json - drives editor autocomplete and hover On re-run, rows that now match a rule drop out, rows still unknown keep the user's answers, and id renumbers. Answers reattach by the stable key, never by id. Malformed YAML hard-fails with line/column, leaves the file untouched, and the HTML report is still written. This adds a second, optional workflow alongside the existing one - neither replaces the other: - Prose (unchanged): tell an agent "all VIOC is oil change" or "these three are groceries." Still the right choice when a decision needs judgment. - File (new): answer in an editor at your own pace with autocomplete from your own rules; the agent reads the file back and applies. A convenience, not a requirement. The two can be mixed freely. Set `categorization: false` to disable the file path entirely. Added: - src/tally/categorization.py - identity, deterministic hints, merge, YAML emission - src/tally/categorization_common.py - shared constants, CategorizationStatus, CategorizationError, rule-label formatting - src/tally/categorization_schema.py - draft-07 JSON Schema generator - tests/test_categorization.py, tests/test_categorization_schema.py - 51 tests Modified: - src/tally/parsers.py - surfaces filepath onto transaction dicts; adds transaction_key() (sha1 of source+date+amount+raw_description, 8 chars) and assign_transaction_keys() (ordinal suffix for exact duplicates) - src/tally/commands/run.py - calls the generator after the HTML report is written, HTML path only; prints one status line; CategorizationError exits non-zero - src/tally/config_loader.py - new categorization boolean setting, default true - src/tally/commands/workflow.py - new "Categorization Review File" section in `tally workflow` - src/tally/cli.py - `up` subcommand description - config/settings.yaml.example, docs/guide.html, docs/formats.html - document the new setting and workflow Risk: - parsers.py adds keys (filepath, key) to transaction dicts. Verified safe: analyzer.py and export_csv build explicit row dicts rather than iterating transaction keys, so nothing leaks into the report or CSV export. - `tally up` behavior delta: now writes three files and prints one status line when unknowns exist, and can exit non-zero on malformed YAML where it previously always exited 0. Opt out with `categorization: false`. - `tally discover`, the HTML report, JSON/CSV/markdown output, analysis, and rule matching are all unchanged. - merchant_engine.py is untouched - no rule-engine change in this commit. - 987 tests pass, including tests/test_rule_snapshots.py. No pre-existing test was modified; the only two test files are new. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YTwQ9oPdGs3KapmDWP8YHu
Rule-engine change first, since it carries the risk:
- merchant_engine.py: new optional `review: bool = False` field on
MerchantRule. The parser accepts only `review: true` or
`review: false` - a conditional form like `review: amount > 500` is
a parse error that points the user at putting the condition in the
rule's own match: and flagging that rule instead. `_add_rule` passes
the value through.
- merchant_utils.py: match_info['review'] is set to true when ANY
matching rule carries the flag, in both the categorization branch
and the tag-only branch, so a tag-only rule can request review just
as a categorization rule can.
Risk, stated honestly:
- The parser's hard-failure on unknown properties is unchanged -
deliberately. A test asserts `reviewed:` (a plausible typo) still
raises "Unknown property", proving the parser was not loosened to
get this in.
- `review` defaults to False, so every existing merchants.rules file
behaves identically. tests/test_rule_snapshots.py passes.
- The new match_info key cannot reach the report: analyzer.py and
report.py both read match_info through explicit .get() calls into
freshly-built dicts. Verified empirically - `review` appears zero
times in generated report JSON and HTML.
- rule_cache.py was left alone; it is referenced only by its own test,
so no production path reconstructs rules from cache where a new
property could be silently dropped.
- A bug worth recording: parsing the property was not sufficient -
MerchantRule is constructed with explicit keyword arguments, so the
value was silently dropped until _add_rule was updated to pass it.
It parsed cleanly and did nothing. A test caught it.
Compatibility note for release notes: a merchants.rules containing
`review:` will NOT load on tally builds older than this release,
because the parser hard-fails on unknown properties. This is by
design and was accepted.
New: config/inventory.yaml and confirming a file
- src/tally/inventory.py - reader/writer for config/inventory.yaml,
tally's own register of data files it has parsed. Exactly four keys
per entry: path, source, registered, reviewComplete. Tally
auto-registers files it parses and only ever appends - it never
modifies or removes an existing entry, so a hand-set reviewComplete
always survives. Unknown keys are rejected outright (a typo would
otherwise silently lose the flag). A non-boolean reviewComplete is a
hard error. Malformed YAML hard-fails, leaves the file untouched,
and the report is still written.
- reviewComplete is a boolean, not a date, because the only question
ever asked of it is "is this done?" - and a bare 2026-08-02 loads as
a YAML date object while "2026-08-02" loads as a string, so a
hand-typed value would behave differently depending on quoting.
registered stays a date since tally writes it, never the user.
- categorization.py / categorization_schema.py: categorization.yaml
gains a top-level reviews: list - transactions matched by a
review:-flagged rule, carrying `currently` (how it is categorized
right now - what stands if you do nothing) and `file` (which data
file to confirm). Ids continue the same sequence as unknowns: so a
row number is never ambiguous across the two lists.
- run.py registers parsed files on the HTML path, unconditionally, and
the status line gains the review count, e.g. "Categorization: 36
unknown (0 new, 36 carried forward), 2 awaiting review - confirm to
close".
- workflow.py / reference.py / docs: `review:` added to the rule
reference in `tally reference` and docs/reference.html; a
"Confirming Categorizations" section added to `tally workflow` and
docs/guide.html / docs/formats.html.
Settings property renamed: categorization -> generate_categorization_file
- The old name read as "stop categorizing my transactions" - tally's
entire purpose. `generate_` marks it a boolean; `_file` marks what
it governs; and it avoids implying a path the way bare
`categorization_file` would, since `merchants_file` / `views_file`
in this codebase take paths. Never released, so no migration -
config_loader.py, cli.py, workflow.py, settings.yaml.example, and
the docs all move straight to the new name with no fallback for the
old one.
Field renamed: additionalInfo -> aiNotes, in both unknowns: and
reviews: rows
- A blank additionalInfo read as an unfilled obligation the user
still owed; aiNotes states ownership, so a blank one means "the
agent hasn't written anything here." Semantics unchanged:
agent-owned, filled on request via "annotate", tally never writes or
overwrites it, preserved across regenerations. categorization.py,
categorization_common.py (PRESERVED_FIELDS), categorization_schema.py,
and both docs pages updated. No compatibility shim was kept: the
feature is unreleased, so no file on disk can carry the old field
name, and there is no protective code for a case that cannot occur.
New: a stale-file warning when generation is switched off
- Previously, setting the flag to false left generated files on disk
silently. Now tally prints:
Categorization: generation is off (generate_categorization_file: false)
<path>/categorization.yaml is STALE - written <timestamp>, not
updated since.
Ignore its contents, or delete it. Re-enable to refresh.
It never deletes - categorization.yaml holds answers the user may
have typed, and a config toggle must not destroy data. Reading it is
read-only and never fails the run, even against a malformed or
missing file (categorization.stale_file_notice returns None or a
best-effort notice, never raises).
Reversal: inventory registration is now independent of generation
- register_files no longer lives inside the "generate
categorization.yaml" branch in run.py; it now runs unconditionally
on the HTML path, before the generate/stale-notice branch.
`registered` records when tally first saw a file, so it must keep
advancing even while review generation is off - otherwise
re-enabling would backdate every file seen in the meantime to that
day and lose the real first-seen date. Files registered while off
carry reviewComplete: false and surface once review resumes.
Workflow notes:
- reviewComplete is review-scoped only and must never gate parsing or
analysis. Every transaction always feeds report aggregates and
`tally discover`; confirming a file only stops its review rows
appearing. A test asserts spending totals are identical before and
after confirmation, and that the transaction list is never filtered.
- There is deliberately no tally command to confirm a file - the user
or their agent edits config/inventory.yaml directly. Set
reviewComplete: false to review a file again after appending rows to
its CSV.
- Review rows persist indefinitely - across months and files - until
confirmed. Leaving a row untouched means its existing rule stands.
- Known ordering constraint, accepted: because tally registers files
during `tally up`, running `tally up` before the file-validation
step makes a file look already-registered. Recovery is to delete the
entry from config/inventory.yaml.
- inventory.yaml is hand-editable but PyYAML does not preserve
comments, so comments added to it are lost on rewrite (ruamel.yaml
deliberately not added as a dependency).
Testing: 1019 tests pass, including tests/test_rule_snapshots.py. No
test predating this feature was modified - the only test files
touched are the three belonging to it: tests/test_inventory.py,
tests/test_categorization_review.py, and tests/test_categorization.py
(new TestDisabledGeneration cases for the stale-file notice, plus the
additionalInfo -> aiNotes rename in existing assertions).
No tracked issue clearly matches this feature; the open issue list
(checked davidfowl#88 down to davidfowl#27) has nothing about review, inventory,
confirming categorizations, or the settings rename, so none is
referenced here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTwQ9oPdGs3KapmDWP8YHu
review: scope (merchant_utils.py) - The flag was taken from every rule whose expression matched, including rules that lost the specificity contest and set nothing. A broad catch-all flagged review: therefore leaked onto every transaction a more specific rule had already categorized - the specific-beats-general pattern in docs/guide.html working exactly as documented. Take it from the rules that actually applied: the category, merchant and subcategory winners, plus the rules that contributed tags. Unknowns and reviews are disjoint (categorization.py) - A tag-only review: rule can match a still-uncategorized transaction, so it appeared in both lists under one key. Both lists share a key space, so the blank review row could overwrite an answered unknown row on the next merge. An uncategorized transaction belongs in unknowns; it is the answer being asked for. - Independently, never let an unanswered row displace an answered one when a key appears twice, so a hand-edited file cannot lose an answer either. Resolved files are rewritten, not abandoned (categorization.py) - Resolving the last unknown or review returned early and left the previous file on disk, still listing rows that no longer exist - the opposite of the documented merge contract, and an agent reading it would act on answers already applied. Rewrite it empty instead. The file is never deleted; it is the user's. An empty list is written explicitly, because a bare 'unknowns:' parses as null and would be rejected on the next read. Malformed rows fail loudly (categorization.py) - Only PRESERVED_FIELDS survive a regeneration, so a misspelled answer - 'useRules:' for 'useRule:' - was not applied and then silently dropped the next time the file was written, with no sign it had been there. Reject unrecognized field names, naming the field and suggesting the intended one. inventory.yaml already rejects unknown keys for exactly this reason. Ambiguous rule labels (categorization_common.py) - Labels were de-duplicated, but two rules can agree on merchant, category, subcategory and tags while matching different expressions. useRule then named a rule the agent could not identify and had to guess which to widen. Where labels collide, and only there, append the match expression. Transaction identity width (parsers.py) - The identity was truncated to 32 bits, where the birthday bound puts a collision near 1% by 9,000 distinct transactions. A collision is indistinguishable from a genuine duplicate, so the two rows were ordinalized, their keys shifted, and a stored answer could reattach to the wrong transaction. Use 64 bits.
terryaney
force-pushed
the
feature/categorization
branch
from
August 4, 2026 22:21
ae97423 to
33644a2
Compare
terryaney
added a commit
to terryaney/OpenSource.tally
that referenced
this pull request
Aug 4, 2026
21 code issues fixed across rungs 98/100/101/103, 31 review threads replied to and resolved, behavior changes added to the davidfowl#100, davidfowl#101 and davidfowl#103 bodies and pushed. Records two findings that changed the answer (100-1 was wider than reported, 101-2's stated scenario does not reproduce) and the rebase failure mode that stranded two fix commits.
`examples` documents likely values without restricting them, but the redhat YAML language server won't offer completions from it. A plain enum completes but rejects categories not yet in merchants.rules. anyOf gets both: a bare `category: ` parses as null, which only the enum branch accepts, so the server narrows to it and completes from its enum, while the open string branch keeps any other value valid. Categories are offered as "Category / Subcategory" paths plus the bare category, since Subcategory is its own column in merchants.rules and is allowed to be empty. Also rewrites the generated file's header as a field-usage reminder, and notes that VS Code caches the schema — a run that adds rules completes from the previous list until the window is reloaded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Title: Categorization review file, review flag, and file inventory [6/6]
This PR adds a second, optional way to answer "what is this merchant?" — an editable review file that
tally upgenerates alongside the HTML report — plus areview:rule flag and a data-file register for closing the loop on files you have already checked.This works best with a AI skill. I've included a sample skill file at bottom of this description.
Set
generate_categorization_file: falseto switch the file path off entirely.What it looks like
Rule and Memo

Creating a new Category Tagging Rule
Note: I have rules in my
merchant.rulesfile:Use Rule Suggestion

Changes
Review file —
config/categorization.yamllists one row per uncategorized transaction. Fill inuseRule,newRule, or edits. On re-run, matched rows drop out, unknown rows keep your answers. Answers reattach by a stable key (sha1 of source + date + amount + raw description), never by id. Malformed YAML hard-fails with line and column, leaves your file untouched, and the HTML report is still written.Hints file —
config/categorization.hints.yamlprovides deterministic match suggestions (difflib against your rules; no AI, no network). Regenerated every run, safe to delete.Schema —
categorization-schema.jsondrives editor autocomplete and hover.useRule,edits.categoryandedits.tagsall complete from your own rules. One thing to know before trying it: VS Code caches the schema, so a run that adds rules keeps completing from the previous list until Ctrl+Shift+P → "Developer: Reload Window". That is the extension (redhat-developer/vscode-yaml#309), not tally — the generated file's header andguide.htmlboth say so.review: truerule flag — marks a rule whose categorizations you want to eyeball. Matched transactions appear in areviews:list incategorization.yamlwith their current categorization. SettingreviewCompleteon the data file ininventory.yamlcloses them out.Data inventory —
config/inventory.yamlauto-registers every data file tally parses. SetreviewComplete: trueto stop review rows appearing for that file. Tally only ever appends entries, never modifies or removes them.Behavior changes on
tally upcategorization.yamlorinventory.yaml, where it previously always exited 0generate_categorization_file: false, prints a STALE warning naming the file and when it was written, rather than leaving generated files on disk silently. It never deletes them — the file holds answers you may have typed, and a config toggle must not destroy datareviewCompleteis review-scoped only and never gates parsing or analysis. Every transaction always feeds report aggregates andtally discover; confirming a file only stops its review rows appearingtally discover, the HTML report, JSON/CSV/markdown output, analysis, and rule matching are otherwise unchanged.Added while addressing review feedback
Fewer transactions surface for review.
review:was taken from every rule whose expression matched, including rules that lost the specificity contest and set nothing — so a broad catch-all flaggedreview:pulled in every transaction a more specific rule had already categorized, defeating the specific-beats-general pattern inguide.html. It now comes from the rules that actually applied: the category, merchant and subcategory winners, plus the rules that contributed tags. Tag-only review rules still surface.Resolving the last row rewrites
categorization.yamlempty instead of returning early and leaving the previous rows on disk, where an agent would read answers that had already been applied. The file is still never deleted. The empty list is written explicitly asunknowns: [], because a bareunknowns:parses as null and would be rejected on the next read.An unrecognized field name is now an error, naming the field and suggesting the intended one. Only
PRESERVED_FIELDSsurvive a regeneration, so a typo such asuseRules:was not applied and was then silently dropped when the file was next written — the answer disappeared with no sign it had been there. This matches howinventory.yamlalready treats unknown keys.useRulelabels can now carry a match expression. Two rules can agree on merchant, category, subcategory and tags while matching different things; their labels were identical, souseRulenamed a rule that could not be identified. Where labels collide, and only there, the expression is appended. Unique labels are untouched.Transaction identity is 64 bits, not 32. At 32 bits a collision was around 1% likely by 9,000 distinct transactions, and a collision is indistinguishable from a genuine duplicate — the two rows were ordinalized, their keys shifted, and a stored answer could reattach to the wrong transaction.
This changes every
key, so an in-flightcategorization.yamlwill not reattach its answers. The old key is a strict prefix of the new one, so migrating an existing file is mechanical. No compatibility shim is included, since this branch has never shipped.Added since the last review pass
edits.categorynow completes. It is written as ananyOfof an enum branch and an open string branch, which looks redundant but isn't:examplesis the keyword that suggests values without restricting them, but the redhat YAML server does not treat it as a completion source, so Ctrl+Space had nothing to offer; a plain enum completes but reports a category you are inventing as invalid. A barecategory:parses as null and the enum branch is the only one that accepts null, so the server narrows to it and completes from it, while the open branch keeps any other value valid. Suggestions are theCategory / Subcategorypaths plus the bare category, sinceSubcategoryis its own column inmerchants.rulesand may be empty.useRule/newRule/edits, what each one does to your data, the schema-cache reload) stay; the rest lives inguide.html.Compatibility
review:inmerchants.ruleswill not load on older tally builds (parser hard-fails on unknown properties — by design)reviewdefaults tofalse, so every existingmerchants.rulesbehaves identicallytests/test_rule_snapshots.pypasses unchangedTesting
1067 tests pass across the stack. New test files:
test_categorization.py,test_categorization_schema.py,test_categorization_review.py,test_inventory.py.One pre-existing test in this PR was modified while addressing review feedback:
test_review_rows_persist_across_runs_until_stampedassertedwritten is Falseafter the last review row was stamped, which was the early-return behaviour described above. It now asserts the file is rewritten empty and can be read back.Stack
feature/globbing-documentationfeature/report-json-determinismfeature/ui-tweaksfeature/charts-reimaginedfeature/merchant-composite-keysfeature/categorizationIndependent:
feature/ci-repair— #97 — fixes fork PR builds.Sample Skill File
Note: Still iterating over size and doing prompt testing.