AI classifier correction notes + misread review (AI tab)
Add a global, temporal ai_notes list appended to the classifier prompt (seeded once from no-PII defaults, documented in README), managed inline on a new AI tab with a read-only view of the assembled prompt. Every AI-run upload records the browser-round-tripped suggestion blob + model; misreads are derived (final field != AI guess) and reviewed one by one (image + per-field guess-vs-entered + notes-since), attributing which note fixed each or closing unresolved. Update SPEC (new section 10), DESIGN item 15, README, and changelog (0.0.3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
da7fc56920
commit
dff35f099c
24 changed files with 1222 additions and 30 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -4,6 +4,22 @@ All notable changes to this project are documented here. Versions are git tags;
|
||||||
release tags `X.Y.Z` are built and deployed automatically (pre-release tags such
|
release tags `X.Y.Z` are built and deployed automatically (pre-release tags such
|
||||||
as `0.0.0a1` are built and staged only).
|
as `0.0.0a1` are built and staged only).
|
||||||
|
|
||||||
|
## [0.0.3] - 2026-06-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- AI classifier correction notes + misread review (new "AI" tab).
|
||||||
|
- A global list of free-text correction notes is appended to the classifier
|
||||||
|
prompt; managed inline (add/edit/delete). The notes table is temporal (edits
|
||||||
|
soft-delete + insert), seeded once from a no-PII default list (documented in the
|
||||||
|
README), and read live per classification.
|
||||||
|
- A read-only view of the exact assembled prompt.
|
||||||
|
- Every AI-run upload records the suggestion blob + model (round-tripped from the
|
||||||
|
browser). Misreads — where a final field differs from the AI's guess — are
|
||||||
|
derived and listed for review; reviewing shows the image and per-field
|
||||||
|
guess-vs-entered, and lets the user attribute which note(s) fixed it (or close
|
||||||
|
it unresolved).
|
||||||
|
- See SPEC.md §10 and DESIGN.md item 15.
|
||||||
|
|
||||||
## [0.0.2] - 2026-06-20
|
## [0.0.2] - 2026-06-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
94
DESIGN.md
94
DESIGN.md
|
|
@ -457,3 +457,97 @@ Out of scope (for now):
|
||||||
wanted eventually, but not in this item.
|
wanted eventually, but not in this item.
|
||||||
- Adding/removing tags on an already-saved receipt (no edit/detail page yet, same
|
- Adding/removing tags on an already-saved receipt (no edit/detail page yet, same
|
||||||
limitation as attachments in item 10).
|
limitation as attachments in item 10).
|
||||||
|
|
||||||
|
15. AI classifier notes + failure review (implemented — see SPEC.md §10)
|
||||||
|
|
||||||
|
A closed loop for improving receipt classification over time: the user accumulates
|
||||||
|
corrective instructions ("notes") that are appended to the classifier prompt, and
|
||||||
|
reviews past misreads one by one to author and attribute those fixes. Big change;
|
||||||
|
spans storage, the upload path, the classifier prompt, and a new top-level tab.
|
||||||
|
|
||||||
|
Motivation: classification will misread some receipts (a vendor's odd date format,
|
||||||
|
a statement that lists the patient under "Guarantor", etc.). Rather than hardcode
|
||||||
|
ever-more rules, let the two users add their own corrections as they hit failures,
|
||||||
|
and give them a place to study failures and decide what fixed them.
|
||||||
|
|
||||||
|
A. Notes — what they are
|
||||||
|
- A single, GLOBAL, free-text list of correction lines, appended to the system
|
||||||
|
prompt as a "Corrections/Notes" appendix. Global because at classify time the
|
||||||
|
app does not yet know the category/vendor, so scoped notes couldn't be selected.
|
||||||
|
- Authored English, separate from the prompt's DERIVED parts (people + name
|
||||||
|
variants, category names/examples, today's date), which stay computed in code.
|
||||||
|
The static prompt scaffolding (the rules/warnings) stays in code too — notes
|
||||||
|
only AUGMENT it; this item does not externalize the whole prompt.
|
||||||
|
- Curated, not append-forever: each active note costs tokens on every scan and too
|
||||||
|
many dilute the instructions, so delete/edit matter as much as add. Realistically
|
||||||
|
a handful.
|
||||||
|
|
||||||
|
B. Notes — storage (temporal)
|
||||||
|
- Table ai_notes(id, text, created_at, deleted_at). Active set = deleted_at IS
|
||||||
|
NULL, ordered by created_at; that set is what gets appended to the prompt.
|
||||||
|
- EDIT = soft-delete the old row + insert a new one (never update in place), so the
|
||||||
|
full history is preserved. The notes active at any time T are
|
||||||
|
created_at <= T AND (deleted_at IS NULL OR deleted_at > T) — which is what lets a
|
||||||
|
failure be matched against the notes that existed when it happened (section E).
|
||||||
|
- Read per /classify call (live; no restart needed). Rides along in /export/db and
|
||||||
|
the daily backup like everything else.
|
||||||
|
- SEEDED ONCE from a hardcoded default list in code (the same defaults are
|
||||||
|
published in the README for humans), inserted only when the table is completely
|
||||||
|
empty — so a deliberately-deleted default does not resurrect on restart. Never
|
||||||
|
read from a file/config at runtime; no export/mirror file. Defaults carry no PII;
|
||||||
|
user-authored notes live only in the DB (private, not in git).
|
||||||
|
|
||||||
|
C. Capturing classifications (the plumbing)
|
||||||
|
- The /classify result is produced server-side but only reaches the browser (to
|
||||||
|
pre-fill the form); by POST /upload the server no longer holds it. So the browser
|
||||||
|
PERSISTS the suggestion blob + model and sends it back in a hidden field on
|
||||||
|
submit. (The API key is and remains server-side — only the suggestions round-trip.)
|
||||||
|
- Store one row per AI-run upload:
|
||||||
|
classifications(receipt_id PK/FK, model, response_json, created_at,
|
||||||
|
reviewed BOOL, reviewed_at, resolution).
|
||||||
|
Skip-AI or classification-disabled uploads create NO row.
|
||||||
|
- response_json is the /classify blob (suggested amount/date/category_id/person_id,
|
||||||
|
the raw_* text the model read, cost, model). Treat it as DIAGNOSTIC data, not
|
||||||
|
ground truth — it is client-supplied and could be tampered with; nothing
|
||||||
|
security-relevant depends on it. Mild PII (raw read name) but DB-only.
|
||||||
|
|
||||||
|
D. Failures are DERIVED, not stored
|
||||||
|
- A "failure"/miss = at least one of the 4 AI-suggested fields (amount, date,
|
||||||
|
category, who) differs from the receipt's final stored value, where AI returning
|
||||||
|
null/empty and the user filling it COUNTS as an override (the AI missed it).
|
||||||
|
- Receipts are immutable (no edit) and the blob is immutable, so the comparison
|
||||||
|
inputs never change — derived overrides can never drift, so we store no
|
||||||
|
redundant per-field booleans. Per-field detail (which of the 4, AI-guess vs.
|
||||||
|
corrected) is computed on demand from the blob vs. the receipt when a failure is
|
||||||
|
displayed. Volume is tiny, so deriving each time is cheap. (If SQL-level
|
||||||
|
filtering/stats ever matter, a single computed had_override flag could be added
|
||||||
|
purely as an index — not the four booleans.)
|
||||||
|
- Review state (reviewed, reviewed_at, resolution) and the fix links below are the
|
||||||
|
only non-derivable things, so they ARE stored.
|
||||||
|
|
||||||
|
E. Review — the loop
|
||||||
|
- New top-level "AI" tab with three sections (one tab unless it grows):
|
||||||
|
1. Notes — list active notes; add / edit / delete (each edit = soft-delete +
|
||||||
|
insert per section B).
|
||||||
|
2. Prompt — READ-ONLY view of the live assembled system prompt (static
|
||||||
|
scaffolding + injected people/categories/today + current active notes), so
|
||||||
|
the user sees exactly what is sent. View-only for now; not editable.
|
||||||
|
3. Review — unreviewed failures, one at a time.
|
||||||
|
- Reviewing one failure shows: the receipt IMAGE, the MODEL used, which fields were
|
||||||
|
overridden (AI guess vs. corrected, derived from the blob), and the NOTES ADDED
|
||||||
|
SINCE this failure's created_at (from the ai_notes temporal history). The user
|
||||||
|
marks which of those notes were the fix → recorded in miss_fixes(receipt_id,
|
||||||
|
note_id); if none fixed it, close as resolution='unresolved'. Either way set
|
||||||
|
reviewed=1, reviewed_at. ("Switched to a stronger model" as a resolution reason
|
||||||
|
may be added later, since model is recorded.)
|
||||||
|
|
||||||
|
F. Out of scope / future
|
||||||
|
- Per-category or vendor-scoped notes (global only, by design above).
|
||||||
|
- Automatic re-classification/recompute of past failures. The data supports it
|
||||||
|
(model + active-notes-at-time-T + the stored image), but review stays MANUAL —
|
||||||
|
the user eyeballs the image to understand the miss.
|
||||||
|
- Editing the static prompt scaffolding (view-only here) and externalizing the
|
||||||
|
whole prompt.
|
||||||
|
- Accuracy-rate stats across all classifications.
|
||||||
|
- No prompting the user to write a note at upload time — failures are recorded
|
||||||
|
silently and dealt with later in the Review tab (likely on a real computer).
|
||||||
35
README.md
Normal file
35
README.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# HSA Receipt Tracker
|
||||||
|
|
||||||
|
A small, mobile-first web app for two household users to capture and archive
|
||||||
|
HSA-eligible receipts (photo or PDF) for future reimbursement and tax
|
||||||
|
substantiation, with optional AI auto-fill of the amount/date/category/patient.
|
||||||
|
|
||||||
|
- **What it does (current behavior):** [SPEC.md](SPEC.md) — the source of truth.
|
||||||
|
- **Why it's built this way (history & rationale):** [DESIGN.md](DESIGN.md).
|
||||||
|
- **Version log:** [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
It's a single static Go binary (`CGO_ENABLED=0`, pure-Go SQLite). Configure via
|
||||||
|
environment (see [.env.example](.env.example)); `./scripts/build.sh` builds it and
|
||||||
|
`./scripts/run.sh` runs it locally. Deployment notes: [deploy/INSTALL.md](deploy/INSTALL.md).
|
||||||
|
|
||||||
|
## AI classifier correction notes
|
||||||
|
|
||||||
|
When an API key is configured, each upload is read by the model to pre-fill the
|
||||||
|
form. You can steer it with **correction notes** — free-text rules appended to the
|
||||||
|
classifier prompt — managed under the **AI** tab. When the model misreads a
|
||||||
|
receipt, that upload is recorded; the AI tab lets you review misreads one by one
|
||||||
|
and attribute which note fixed each.
|
||||||
|
|
||||||
|
Notes live only in the database (private, never committed, included in `/export/db`
|
||||||
|
backups). On first run the table is seeded once with these **default notes** (no
|
||||||
|
PII), which you can edit or delete:
|
||||||
|
|
||||||
|
1. Amounts that use a comma as the decimal separator (e.g. "12,50") mean 12.50, not 1250.
|
||||||
|
2. When both a service/visit date and a separate statement, print, or due date appear, use the service date.
|
||||||
|
3. "Patient Pay", "You Paid", "Amount Due", and "Patient Responsibility" are the amount actually paid — prefer them over subtotals or insurance-covered amounts.
|
||||||
|
|
||||||
|
These defaults are defined in code (`internal/storage/ai_notes.go`); this list is
|
||||||
|
the human-readable copy. They are only seeded when the notes table is empty, so a
|
||||||
|
deleted default does not come back on restart.
|
||||||
59
SPEC.md
59
SPEC.md
|
|
@ -79,7 +79,7 @@ A missing/invalid catalog is a fatal startup error.
|
||||||
## 4. Data model (SQLite)
|
## 4. Data model (SQLite)
|
||||||
|
|
||||||
- **categories** — `id`, `label` (unique). Seeded; renamable via Manage.
|
- **categories** — `id`, `label` (unique). Seeded; renamable via Manage.
|
||||||
- **people** — `id`, `label` (unique). Seeded from catalog; renamable via Manage.
|
- **people** — `id`, `label` (unique). Seeded from catalog; renamable via Manage. Format is "<FIRST NAME> <LAST NAME>"
|
||||||
- **receipts** — `id` (UUID), `uploaded_by`, `uploaded_at`, `receipt_date`,
|
- **receipts** — `id` (UUID), `uploaded_by`, `uploaded_at`, `receipt_date`,
|
||||||
`amount_cents` (integer — money is never a float), `category_id` (FK, required),
|
`amount_cents` (integer — money is never a float), `category_id` (FK, required),
|
||||||
`person_id` (FK, nullable), `file_path`, `image_data` (BLOB), `file_size_bytes`,
|
`person_id` (FK, nullable), `file_path`, `image_data` (BLOB), `file_size_bytes`,
|
||||||
|
|
@ -89,6 +89,12 @@ A missing/invalid catalog is a fatal startup error.
|
||||||
their own.
|
their own.
|
||||||
- **tags** — `id`, `label` (unique, **case-insensitive** via `COLLATE NOCASE`).
|
- **tags** — `id`, `label` (unique, **case-insensitive** via `COLLATE NOCASE`).
|
||||||
- **receipt_tags** — (`receipt_id`, `tag_id`) many-to-many, composite primary key.
|
- **receipt_tags** — (`receipt_id`, `tag_id`) many-to-many, composite primary key.
|
||||||
|
- **ai_notes** — `id`, `text`, `created_at`, `deleted_at` (nullable). Temporal:
|
||||||
|
edits soft-delete + insert, so the active set at any past time is recoverable.
|
||||||
|
- **classifications** — `receipt_id` (PK/FK), `model`, `response_json` (the AI
|
||||||
|
suggestion blob), `created_at`, `reviewed`, `reviewed_at`, `resolution`.
|
||||||
|
- **miss_fixes** — (`receipt_id`, `note_id`) linking a reviewed misread to the
|
||||||
|
note(s) credited with fixing it.
|
||||||
|
|
||||||
`PRAGMA foreign_keys=ON`, `journal_mode=WAL`, `busy_timeout=5000` are set on open.
|
`PRAGMA foreign_keys=ON`, `journal_mode=WAL`, `busy_timeout=5000` are set on open.
|
||||||
|
|
||||||
|
|
@ -138,9 +144,13 @@ state — the user reviews and submits normally.
|
||||||
computed locally from the response's token usage and a hardcoded per-model rate
|
computed locally from the response's token usage and a hardcoded per-model rate
|
||||||
table. An unknown model id shows token usage but omits the ¢ figure (never a
|
table. An unknown model id shows token usage but omits the ¢ figure (never a
|
||||||
guess).
|
guess).
|
||||||
|
- The active **correction notes** (§10) are appended to the classifier prompt. The
|
||||||
|
suggestion the model returns is round-tripped through the form and **recorded**
|
||||||
|
against the saved receipt, so misreads can be reviewed later (§10). Skip-AI and
|
||||||
|
disabled-classification uploads record nothing.
|
||||||
|
|
||||||
### 5.4 Duplicate warning
|
### 5.4 Duplicate warning
|
||||||
Whenever date and amount are both known, the form calls `GET /duplicates` and warns
|
Whenever date and amount are both known (edit: those are mandatory fields aren't they?), the form calls `GET /duplicates` and warns
|
||||||
if a non-deleted receipt already has the **same `receipt_date` and `amount_cents`**.
|
if a non-deleted receipt already has the **same `receipt_date` and `amount_cents`**.
|
||||||
The warning lists each match (amount, date, category, who, uploader, upload time,
|
The warning lists each match (amount, date, category, who, uploader, upload time,
|
||||||
filename) and **disables submit** until the user ticks "Add it anyway". No match →
|
filename) and **disables submit** until the user ticks "Add it anyway". No match →
|
||||||
|
|
@ -235,11 +245,40 @@ any attachments, and the receipt's tag chips.
|
||||||
- On startup, stray partial-name people (e.g. a leftover "Jude") are merged into the
|
- On startup, stray partial-name people (e.g. a leftover "Jude") are merged into the
|
||||||
unambiguous canonical person ("Jude Tremblay"), reassigning their receipts first,
|
unambiguous canonical person ("Jude Tremblay"), reassigning their receipts first,
|
||||||
so no receipt loses its who. Seeding is idempotent.
|
so no receipt loses its who. Seeding is idempotent.
|
||||||
- Tags are **not** managed here (see §13 gaps).
|
- Tags are **not** managed here (see §15 gaps).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Export
|
## 10. AI classifier notes & misread review
|
||||||
|
|
||||||
|
`GET /ai` — a tab for improving classification over time.
|
||||||
|
|
||||||
|
- **Correction notes:** a single global list of free-text rules, appended to the
|
||||||
|
classifier prompt (§5.3). Managed inline — add (`POST /ai/notes`), edit (`POST
|
||||||
|
/ai/notes/edit`), delete (`POST /ai/notes/delete`). The table is **temporal**: an
|
||||||
|
edit soft-deletes the old row and inserts a new one, so the notes active when any
|
||||||
|
past receipt was classified are recoverable. Seeded once from a hardcoded default
|
||||||
|
list (documented in the README; no PII) only when the table is empty, so a deleted
|
||||||
|
default does not return. Read live on every classify call (no restart).
|
||||||
|
- **Read-only prompt view:** renders the exact system prompt that would be sent today
|
||||||
|
(static scaffolding + injected people/categories/today + active notes), when
|
||||||
|
classification is enabled.
|
||||||
|
- **Misread review:** every AI-run upload stores the suggestion blob + model. A
|
||||||
|
**miss** is *derived* (never stored) — any of the four AI fields (amount, date,
|
||||||
|
category, who) differing from the receipt's final value, where AI-null-then-filled
|
||||||
|
counts as a miss. The review queue lists unreviewed misses; `GET /ai/review/{id}`
|
||||||
|
shows the receipt image, the AI-guess-vs-entered per field, and the notes added
|
||||||
|
*since* that classification. The user ticks which notes fixed it (`POST
|
||||||
|
/ai/review/{id}`) → resolution `fixed` and `miss_fixes` links; ticking none closes
|
||||||
|
it `unresolved`. Either way the row is marked reviewed.
|
||||||
|
|
||||||
|
Notes live only in the DB (private, not in git; included in `/export/db` and
|
||||||
|
backups). The stored suggestion blob is client-supplied diagnostic data, not ground
|
||||||
|
truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Export
|
||||||
|
|
||||||
- `GET /export/db` — downloads a consistent point-in-time copy of the SQLite
|
- `GET /export/db` — downloads a consistent point-in-time copy of the SQLite
|
||||||
database produced via `VACUUM INTO` (the live file is never locked/served
|
database produced via `VACUUM INTO` (the live file is never locked/served
|
||||||
|
|
@ -252,7 +291,7 @@ any attachments, and the receipt's tag chips.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Scheduled backups
|
## 12. Scheduled backups
|
||||||
|
|
||||||
A background goroutine writes **metadata-only** snapshots (blobs stripped from both
|
A background goroutine writes **metadata-only** snapshots (blobs stripped from both
|
||||||
receipts and attachments) into `BACKUP_DIR`:
|
receipts and attachments) into `BACKUP_DIR`:
|
||||||
|
|
@ -267,7 +306,7 @@ receipts and attachments) into `BACKUP_DIR`:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Durability & crash recovery
|
## 13. Durability & crash recovery
|
||||||
|
|
||||||
- WAL mode makes the DB self-healing: an interrupted write is rolled forward if
|
- WAL mode makes the DB self-healing: an interrupted write is rolled forward if
|
||||||
committed, discarded otherwise, bringing the DB up at its last committed state.
|
committed, discarded otherwise, bringing the DB up at its last committed state.
|
||||||
|
|
@ -277,7 +316,7 @@ receipts and attachments) into `BACKUP_DIR`:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Deployment & operations
|
## 14. Deployment & operations
|
||||||
|
|
||||||
- The app builds as a fully static binary (`CGO_ENABLED=0`; the SQLite driver is
|
- The app builds as a fully static binary (`CGO_ENABLED=0`; the SQLite driver is
|
||||||
pure Go), runnable on any `linux/<arch>`.
|
pure Go), runnable on any `linux/<arch>`.
|
||||||
|
|
@ -289,7 +328,7 @@ receipts and attachments) into `BACKUP_DIR`:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Known gaps / not yet implemented
|
## 15. Known gaps / not yet implemented
|
||||||
|
|
||||||
These are intentionally absent today (the schema or storage layer may support some;
|
These are intentionally absent today (the schema or storage layer may support some;
|
||||||
the UI/endpoint does not):
|
the UI/endpoint does not):
|
||||||
|
|
@ -307,3 +346,7 @@ the UI/endpoint does not):
|
||||||
- **Archive/zip export** (`/export/archive`): not implemented; only `/export/db`.
|
- **Archive/zip export** (`/export/archive`): not implemented; only `/export/db`.
|
||||||
- **HEIC** images are not decodable in pure Go; browser camera uploads arrive as
|
- **HEIC** images are not decodable in pure Go; browser camera uploads arrive as
|
||||||
JPEG in practice.
|
JPEG in practice.
|
||||||
|
|
||||||
|
Related future work now that AI notes + review exist (§10): per-category/vendor-
|
||||||
|
scoped notes, automatic re-classification of past misses, editing the static prompt
|
||||||
|
scaffolding, and accuracy-rate stats. See [DESIGN.md](DESIGN.md) item 15.
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,12 @@ func New(apiKey, model string, persons []config.Person, categories []config.Cate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PromptPreview renders the exact system prompt that would be sent today with the
|
||||||
|
// given notes, for the read-only view on the AI tab.
|
||||||
|
func (c *Classifier) PromptPreview(today time.Time, notes []string) string {
|
||||||
|
return BuildSystemPrompt(c.Persons, c.Categories, today.Format("2006-01-02"), notes)
|
||||||
|
}
|
||||||
|
|
||||||
const toolName = "record_receipt"
|
const toolName = "record_receipt"
|
||||||
|
|
||||||
// --- request / response shapes ---
|
// --- request / response shapes ---
|
||||||
|
|
@ -139,8 +145,8 @@ type toolInput struct {
|
||||||
// Classify sends the receipt to the model and returns its normalized reading.
|
// Classify sends the receipt to the model and returns its normalized reading.
|
||||||
// today is used so the date heuristics ("closest to today, never future") are
|
// today is used so the date heuristics ("closest to today, never future") are
|
||||||
// testable; pass time.Now().
|
// testable; pass time.Now().
|
||||||
func (c *Classifier) Classify(ctx context.Context, today time.Time, image []byte, mimeType string) (Suggestion, error) {
|
func (c *Classifier) Classify(ctx context.Context, today time.Time, image []byte, mimeType string, notes []string) (Suggestion, error) {
|
||||||
system := BuildSystemPrompt(c.Persons, c.Categories, today.Format("2006-01-02"))
|
system := BuildSystemPrompt(c.Persons, c.Categories, today.Format("2006-01-02"), notes)
|
||||||
|
|
||||||
imgBlock := apiBlock{
|
imgBlock := apiBlock{
|
||||||
Type: "image",
|
Type: "image",
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ func TestClassifyHappyPath(t *testing.T) {
|
||||||
"raw_amount": "$42.50",
|
"raw_amount": "$42.50",
|
||||||
})
|
})
|
||||||
|
|
||||||
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg")
|
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Classify: %v", err)
|
t.Fatalf("Classify: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +99,7 @@ func TestClassifyNullsAndFallback(t *testing.T) {
|
||||||
"raw_amount": "",
|
"raw_amount": "",
|
||||||
})
|
})
|
||||||
|
|
||||||
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg")
|
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Classify: %v", err)
|
t.Fatalf("Classify: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +122,7 @@ func TestClassifyRejectsNonCanonicalPerson(t *testing.T) {
|
||||||
"person": "Dr. Emily Smith", // a provider, not a configured patient
|
"person": "Dr. Emily Smith", // a provider, not a configured patient
|
||||||
"category": "Medical",
|
"category": "Medical",
|
||||||
})
|
})
|
||||||
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg")
|
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Classify: %v", err)
|
t.Fatalf("Classify: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +148,7 @@ func TestClassifyIntegration(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skipf("set HSA_CLASSIFY_IMG to a receipt image: %v", err)
|
t.Skipf("set HSA_CLASSIFY_IMG to a receipt image: %v", err)
|
||||||
}
|
}
|
||||||
got, err := c.Classify(context.Background(), time.Now(), img, "image/jpeg")
|
got, err := c.Classify(context.Background(), time.Now(), img, "image/jpeg", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Classify: %v", err)
|
t.Fatalf("Classify: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,9 @@ func uniqueCompound(p config.Person, all []config.Person) bool {
|
||||||
// catalog and today's date (YYYY-MM-DD). The catalog's canonical labels are the only
|
// catalog and today's date (YYYY-MM-DD). The catalog's canonical labels are the only
|
||||||
// allowed outputs; variants and examples are presented as illustrations, never as
|
// allowed outputs; variants and examples are presented as illustrations, never as
|
||||||
// data to extract.
|
// data to extract.
|
||||||
func BuildSystemPrompt(persons []config.Person, categories []config.Category, today string) string {
|
// notes are user-authored corrections appended verbatim as a final section; pass nil
|
||||||
|
// for none.
|
||||||
|
func BuildSystemPrompt(persons []config.Person, categories []config.Category, today string, notes []string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
b.WriteString("You extract data from a US health-care receipt for HSA reimbursement. ")
|
b.WriteString("You extract data from a US health-care receipt for HSA reimbursement. ")
|
||||||
|
|
@ -178,6 +180,18 @@ func BuildSystemPrompt(persons []config.Person, categories []config.Category, to
|
||||||
b.WriteString("RAW — also pass back the literal text you read for the name, date, and amount (raw_name, raw_date, raw_amount), exactly as printed, for auditing. Use \"\" if nothing was found.\n")
|
b.WriteString("RAW — also pass back the literal text you read for the name, date, and amount (raw_name, raw_date, raw_amount), exactly as printed, for auditing. Use \"\" if nothing was found.\n")
|
||||||
b.WriteString("If the image holds more than one receipt, read the primary (largest/topmost) one.\n")
|
b.WriteString("If the image holds more than one receipt, read the primary (largest/topmost) one.\n")
|
||||||
|
|
||||||
|
// User corrections learned from past misreads (see the AI tab). Appended last so
|
||||||
|
// they take precedence over the general guidance above.
|
||||||
|
if len(notes) > 0 {
|
||||||
|
b.WriteString("\nADDITIONAL CORRECTIONS — learned from past mistakes on these specific receipts; apply them:\n")
|
||||||
|
for _, n := range notes {
|
||||||
|
n = strings.TrimSpace(n)
|
||||||
|
if n != "" {
|
||||||
|
fmt.Fprintf(&b, " - %s\n", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ func TestBuildSystemPrompt(t *testing.T) {
|
||||||
{Name: "Pharmacy", Examples: []string{"CVS", "Rx"}},
|
{Name: "Pharmacy", Examples: []string{"CVS", "Rx"}},
|
||||||
{Name: "Other"},
|
{Name: "Other"},
|
||||||
}
|
}
|
||||||
p := BuildSystemPrompt(persons, categories, "2026-06-17")
|
p := BuildSystemPrompt(persons, categories, "2026-06-17", []string{"Treat handwritten totals as authoritative."})
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"Jean-Michel Tremblay", // canonical label
|
"Jean-Michel Tremblay", // canonical label
|
||||||
|
|
|
||||||
130
internal/storage/ai_notes.go
Normal file
130
internal/storage/ai_notes.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note is one classifier correction note. Notes are global free-text lines appended
|
||||||
|
// to the classifier prompt. The table is temporal: an edit soft-deletes the old row
|
||||||
|
// and inserts a new one, so the set of notes active at any past time is recoverable.
|
||||||
|
type Note struct {
|
||||||
|
ID int64
|
||||||
|
Text string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultNotes seed the ai_notes table on first run (when it is completely empty).
|
||||||
|
// They carry no PII and are also documented in the README; users curate from here.
|
||||||
|
var defaultNotes = []string{
|
||||||
|
`Amounts that use a comma as the decimal separator (e.g. "12,50") mean 12.50, not 1250.`,
|
||||||
|
`When both a service/visit date and a separate statement, print, or due date appear, use the service date.`,
|
||||||
|
`"Patient Pay", "You Paid", "Amount Due", and "Patient Responsibility" are the amount actually paid — prefer them over subtotals or insurance-covered amounts.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedNotesIfEmpty inserts the default notes only when the table has no rows at all,
|
||||||
|
// so a deliberately-deleted default does not resurrect on the next restart.
|
||||||
|
func seedNotesIfEmpty(db *sql.DB) error {
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM ai_notes`).Scan(&n); err != nil {
|
||||||
|
return fmt.Errorf("count ai_notes: %w", err)
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(rfc3339)
|
||||||
|
for _, t := range defaultNotes {
|
||||||
|
if _, err := db.Exec(`INSERT INTO ai_notes(text, created_at) VALUES (?, ?)`, t, now); err != nil {
|
||||||
|
return fmt.Errorf("seed ai_notes: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveNotes returns the live notes (not soft-deleted), oldest first — the set
|
||||||
|
// appended to the classifier prompt.
|
||||||
|
func (s *Store) ListActiveNotes() ([]Note, error) {
|
||||||
|
return s.queryNotes(`SELECT id, text, created_at FROM ai_notes
|
||||||
|
WHERE deleted_at IS NULL ORDER BY created_at, id`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotesCreatedAfter returns the live notes created strictly after t — the
|
||||||
|
// "rules added since this failure" shown when reviewing a miss.
|
||||||
|
func (s *Store) NotesCreatedAfter(t time.Time) ([]Note, error) {
|
||||||
|
return s.queryNotes(`SELECT id, text, created_at FROM ai_notes
|
||||||
|
WHERE deleted_at IS NULL AND created_at > ? ORDER BY created_at, id`,
|
||||||
|
t.UTC().Format(rfc3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) queryNotes(q string, args ...any) ([]Note, error) {
|
||||||
|
rows, err := s.db.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query notes: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Note
|
||||||
|
for rows.Next() {
|
||||||
|
var n Note
|
||||||
|
var created string
|
||||||
|
if err := rows.Scan(&n.ID, &n.Text, &created); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan note: %w", err)
|
||||||
|
}
|
||||||
|
n.CreatedAt, _ = time.Parse(rfc3339, created)
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNote inserts a new active note and returns its id.
|
||||||
|
func (s *Store) AddNote(text string) (int64, error) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return 0, fmt.Errorf("note cannot be empty")
|
||||||
|
}
|
||||||
|
res, err := s.db.Exec(`INSERT INTO ai_notes(text, created_at) VALUES (?, ?)`,
|
||||||
|
text, time.Now().UTC().Format(rfc3339))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("add note: %w", err)
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNote soft-deletes a note (preserving history).
|
||||||
|
func (s *Store) DeleteNote(id int64) error {
|
||||||
|
_, err := s.db.Exec(`UPDATE ai_notes SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||||
|
time.Now().UTC().Format(rfc3339), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete note: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditNote changes a note's text by soft-deleting the old row and inserting a new
|
||||||
|
// one, so the temporal history (what was active when) is preserved. Returns the new id.
|
||||||
|
func (s *Store) EditNote(id int64, text string) (int64, error) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return 0, fmt.Errorf("note cannot be empty")
|
||||||
|
}
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("begin edit note: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(rfc3339)
|
||||||
|
if _, err := tx.Exec(`UPDATE ai_notes SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`, now, id); err != nil {
|
||||||
|
return 0, fmt.Errorf("retire old note: %w", err)
|
||||||
|
}
|
||||||
|
res, err := tx.Exec(`INSERT INTO ai_notes(text, created_at) VALUES (?, ?)`, text, now)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert edited note: %w", err)
|
||||||
|
}
|
||||||
|
newID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return newID, tx.Commit()
|
||||||
|
}
|
||||||
106
internal/storage/ai_notes_test.go
Normal file
106
internal/storage/ai_notes_test.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func notesTestStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
s, err := Open(filepath.Join(t.TempDir(), "notes.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotes_SeededOnFirstOpen(t *testing.T) {
|
||||||
|
s := notesTestStore(t)
|
||||||
|
notes, err := s.ListActiveNotes()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(notes) != len(defaultNotes) {
|
||||||
|
t.Fatalf("seeded %d notes, want %d", len(notes), len(defaultNotes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotes_DeletedDefaultDoesNotResurrect(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "n.db")
|
||||||
|
|
||||||
|
s, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
all, _ := s.ListActiveNotes()
|
||||||
|
if err := s.DeleteNote(all[0].ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.Close()
|
||||||
|
|
||||||
|
// Reopen: seeding must NOT run again (table is non-empty thanks to history).
|
||||||
|
s2, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s2.Close()
|
||||||
|
notes, _ := s2.ListActiveNotes()
|
||||||
|
if len(notes) != len(defaultNotes)-1 {
|
||||||
|
t.Errorf("after delete+reopen got %d active notes, want %d", len(notes), len(defaultNotes)-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotes_EditKeepsHistory(t *testing.T) {
|
||||||
|
s := notesTestStore(t)
|
||||||
|
id, err := s.AddNote("original")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
newID, err := s.EditNote(id, "revised")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if newID == id {
|
||||||
|
t.Error("edit should create a new row id")
|
||||||
|
}
|
||||||
|
active, _ := s.ListActiveNotes()
|
||||||
|
var texts []string
|
||||||
|
for _, n := range active {
|
||||||
|
texts = append(texts, n.Text)
|
||||||
|
}
|
||||||
|
if contains(texts, "original") {
|
||||||
|
t.Error("old text should no longer be active")
|
||||||
|
}
|
||||||
|
if !contains(texts, "revised") {
|
||||||
|
t.Error("revised text should be active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotes_CreatedAfter(t *testing.T) {
|
||||||
|
s := notesTestStore(t)
|
||||||
|
cutoff := time.Now().UTC()
|
||||||
|
// created_at has second resolution (RFC3339); make sure the new note sorts after.
|
||||||
|
time.Sleep(1100 * time.Millisecond)
|
||||||
|
if _, err := s.AddNote("late note"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, err := s.NotesCreatedAfter(cutoff)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(after) != 1 || after[0].Text != "late note" {
|
||||||
|
t.Fatalf("NotesCreatedAfter = %v, want just the late note", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(ss []string, want string) bool {
|
||||||
|
for _, s := range ss {
|
||||||
|
if s == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
123
internal/storage/classifications.go
Normal file
123
internal/storage/classifications.go
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClassificationRow is a stored AI classification of a receipt, joined with the
|
||||||
|
// receipt's final field values so the caller can derive which fields the user
|
||||||
|
// overrode (a "miss"). The response blob is opaque diagnostic data here — the web
|
||||||
|
// layer parses it; storage never interprets it.
|
||||||
|
type ClassificationRow struct {
|
||||||
|
ReceiptID string
|
||||||
|
Model string
|
||||||
|
ResponseJSON string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Reviewed bool
|
||||||
|
ReviewedAt *time.Time
|
||||||
|
Resolution string
|
||||||
|
|
||||||
|
// Final receipt values, for deriving overrides and display.
|
||||||
|
FinalAmountCents int64
|
||||||
|
FinalReceiptDate time.Time
|
||||||
|
FinalCategoryID int64
|
||||||
|
FinalPersonID *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertClassification records the AI suggestion (its response blob + model) for a
|
||||||
|
// receipt. One row per AI-run upload; skip-AI/disabled uploads insert nothing.
|
||||||
|
func (s *Store) InsertClassification(receiptID, model, responseJSON string, createdAt time.Time) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT INTO classifications(receipt_id, model, response_json, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
receiptID, model, responseJSON, createdAt.UTC().Format(rfc3339))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert classification: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const classificationSelect = `
|
||||||
|
SELECT c.receipt_id, c.model, c.response_json, c.created_at, c.reviewed, c.reviewed_at, c.resolution,
|
||||||
|
r.amount_cents, r.receipt_date, r.category_id, r.person_id
|
||||||
|
FROM classifications c
|
||||||
|
JOIN receipts r ON r.id = c.receipt_id`
|
||||||
|
|
||||||
|
// ListUnreviewedClassifications returns not-yet-reviewed classifications of live
|
||||||
|
// receipts, newest first. Whether each is an actual "miss" is derived by the caller
|
||||||
|
// from the blob vs. the final fields.
|
||||||
|
func (s *Store) ListUnreviewedClassifications() ([]ClassificationRow, error) {
|
||||||
|
rows, err := s.db.Query(classificationSelect +
|
||||||
|
` WHERE c.reviewed = 0 AND r.deleted_at IS NULL ORDER BY c.created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list classifications: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ClassificationRow
|
||||||
|
for rows.Next() {
|
||||||
|
c, err := scanClassification(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClassification returns the classification for one receipt.
|
||||||
|
func (s *Store) GetClassification(receiptID string) (ClassificationRow, error) {
|
||||||
|
return scanClassification(s.db.QueryRow(classificationSelect+` WHERE c.receipt_id = ?`, receiptID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanClassification(sc rowScanner) (ClassificationRow, error) {
|
||||||
|
var c ClassificationRow
|
||||||
|
var createdAt, receiptDate string
|
||||||
|
var reviewed int64
|
||||||
|
var reviewedAt, resolution sql.NullString
|
||||||
|
var personID sql.NullInt64
|
||||||
|
if err := sc.Scan(&c.ReceiptID, &c.Model, &c.ResponseJSON, &createdAt, &reviewed, &reviewedAt, &resolution,
|
||||||
|
&c.FinalAmountCents, &receiptDate, &c.FinalCategoryID, &personID); err != nil {
|
||||||
|
return ClassificationRow{}, fmt.Errorf("scan classification: %w", err)
|
||||||
|
}
|
||||||
|
c.CreatedAt, _ = time.Parse(rfc3339, createdAt)
|
||||||
|
c.FinalReceiptDate, _ = time.Parse(rfc3339, receiptDate)
|
||||||
|
c.Reviewed = reviewed != 0
|
||||||
|
if reviewedAt.Valid {
|
||||||
|
if t, err := time.Parse(rfc3339, reviewedAt.String); err == nil {
|
||||||
|
c.ReviewedAt = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resolution.Valid {
|
||||||
|
c.Resolution = resolution.String
|
||||||
|
}
|
||||||
|
if personID.Valid {
|
||||||
|
c.FinalPersonID = &personID.Int64
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkClassificationReviewed flags a classification reviewed with the given
|
||||||
|
// resolution and links the notes the user credited with fixing it.
|
||||||
|
func (s *Store) MarkClassificationReviewed(receiptID, resolution string, fixNoteIDs []int64) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin review: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`UPDATE classifications SET reviewed = 1, reviewed_at = ?, resolution = ? WHERE receipt_id = ?`,
|
||||||
|
time.Now().UTC().Format(rfc3339), resolution, receiptID); err != nil {
|
||||||
|
return fmt.Errorf("mark reviewed: %w", err)
|
||||||
|
}
|
||||||
|
for _, nid := range fixNoteIDs {
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT OR IGNORE INTO miss_fixes(receipt_id, note_id) VALUES (?, ?)`,
|
||||||
|
receiptID, nid); err != nil {
|
||||||
|
return fmt.Errorf("link fix note: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
69
internal/storage/classifications_test.go
Normal file
69
internal/storage/classifications_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func classifyTestStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
s, err := Open(filepath.Join(t.TempDir(), "c.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifications_InsertListReview(t *testing.T) {
|
||||||
|
s := classifyTestStore(t)
|
||||||
|
insertReceipt(t, s, "r1")
|
||||||
|
|
||||||
|
if err := s.InsertClassification("r1", "haiku", `{"model":"haiku"}`, time.Now().UTC()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.ListUnreviewedClassifications()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].ReceiptID != "r1" || rows[0].Model != "haiku" {
|
||||||
|
t.Fatalf("ListUnreviewedClassifications = %+v", rows)
|
||||||
|
}
|
||||||
|
if rows[0].FinalAmountCents != 100 { // insertReceipt sets 100 cents
|
||||||
|
t.Errorf("joined final amount = %d, want 100", rows[0].FinalAmountCents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review it, crediting a note as the fix.
|
||||||
|
noteID, _ := s.AddNote("the fix")
|
||||||
|
if err := s.MarkClassificationReviewed("r1", "fixed", []int64{noteID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows, _ = s.ListUnreviewedClassifications()
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Errorf("reviewed row still in unreviewed queue: %+v", rows)
|
||||||
|
}
|
||||||
|
got, err := s.GetClassification("r1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !got.Reviewed || got.Resolution != "fixed" || got.ReviewedAt == nil {
|
||||||
|
t.Errorf("review state not persisted: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifications_DeletedReceiptDropsFromQueue(t *testing.T) {
|
||||||
|
s := classifyTestStore(t)
|
||||||
|
insertReceipt(t, s, "r1")
|
||||||
|
if err := s.InsertClassification("r1", "m", `{}`, time.Now().UTC()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.SoftDelete("r1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows, _ := s.ListUnreviewedClassifications()
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Errorf("soft-deleted receipt should not appear in review queue: %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -75,6 +75,27 @@ CREATE TABLE IF NOT EXISTS receipt_tags (
|
||||||
PRIMARY KEY (receipt_id, tag_id)
|
PRIMARY KEY (receipt_id, tag_id)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipt_tags_tag ON receipt_tags(tag_id);
|
CREATE INDEX IF NOT EXISTS idx_receipt_tags_tag ON receipt_tags(tag_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
deleted_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS classifications (
|
||||||
|
receipt_id TEXT PRIMARY KEY REFERENCES receipts(id),
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
response_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
reviewed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reviewed_at TEXT,
|
||||||
|
resolution TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_classifications_reviewed ON classifications(reviewed);
|
||||||
|
CREATE TABLE IF NOT EXISTS miss_fixes (
|
||||||
|
receipt_id TEXT NOT NULL REFERENCES receipts(id),
|
||||||
|
note_id INTEGER NOT NULL REFERENCES ai_notes(id),
|
||||||
|
PRIMARY KEY (receipt_id, note_id)
|
||||||
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
// Open opens (creating if needed) the SQLite database at path, applies the schema,
|
// Open opens (creating if needed) the SQLite database at path, applies the schema,
|
||||||
|
|
@ -107,6 +128,10 @@ func Open(path string) (*Store, error) {
|
||||||
return nil, fmt.Errorf("seed categories: %w", err)
|
return nil, fmt.Errorf("seed categories: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := seedNotesIfEmpty(db); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &Store{db: db}, nil
|
return &Store{db: db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
273
internal/web/ai.go
Normal file
273
internal/web/ai.go
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maisym.com/hsa/internal/receipt"
|
||||||
|
"maisym.com/hsa/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- AI tab: classifier correction notes + failure review ---
|
||||||
|
|
||||||
|
type aiView struct {
|
||||||
|
Notes []storage.Note
|
||||||
|
Misses []missSummary // unreviewed failures
|
||||||
|
Prompt string // live assembled system prompt, or "" when unavailable
|
||||||
|
PromptNote string // why the prompt is unavailable (classification off)
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// missSummary is one unreviewed failure in the review queue.
|
||||||
|
type missSummary struct {
|
||||||
|
ReceiptID string
|
||||||
|
When string
|
||||||
|
Amount string
|
||||||
|
Model string
|
||||||
|
Fields string // comma-joined names of the overridden fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
notes, err := s.store.ListActiveNotes()
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "list notes", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := s.store.ListUnreviewedClassifications()
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "list classifications", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cats, people, err := s.lookups()
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "load lookups", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var misses []missSummary
|
||||||
|
for _, c := range rows {
|
||||||
|
ovs := deriveOverrides(c, cats, people)
|
||||||
|
var names []string
|
||||||
|
for _, o := range ovs {
|
||||||
|
if o.Overridden {
|
||||||
|
names = append(names, o.Field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
continue // not a miss — the AI got every field right
|
||||||
|
}
|
||||||
|
misses = append(misses, missSummary{
|
||||||
|
ReceiptID: c.ReceiptID,
|
||||||
|
When: c.CreatedAt.Local().Format(dateLayout),
|
||||||
|
Amount: dollars(c.FinalAmountCents),
|
||||||
|
Model: c.Model,
|
||||||
|
Fields: strings.Join(names, ", "),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
view := aiView{Notes: notes, Misses: misses, Error: r.URL.Query().Get("error")}
|
||||||
|
if s.classifier != nil {
|
||||||
|
var texts []string
|
||||||
|
for _, n := range notes {
|
||||||
|
texts = append(texts, n.Text)
|
||||||
|
}
|
||||||
|
view.Prompt = s.classifier.PromptPreview(time.Now(), texts)
|
||||||
|
} else {
|
||||||
|
view.PromptNote = "Classification is disabled (no API key configured), so the live prompt is unavailable."
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := aiPage.ExecuteTemplate(w, "base", view); err != nil {
|
||||||
|
s.serverError(w, "render ai", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAddNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, err := s.store.AddNote(strings.TrimSpace(r.FormValue("text"))); err != nil {
|
||||||
|
redirectAI(w, r, "Could not add the note (it may be empty).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectAI(w, r, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleEditNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
redirectAI(w, r, "Invalid note id.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.EditNote(id, strings.TrimSpace(r.FormValue("text"))); err != nil {
|
||||||
|
redirectAI(w, r, "Could not save the note (it may be empty).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectAI(w, r, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
redirectAI(w, r, "Invalid note id.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteNote(id); err != nil {
|
||||||
|
redirectAI(w, r, "Could not delete the note.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectAI(w, r, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// reviewView is the single-failure review page.
|
||||||
|
type reviewView struct {
|
||||||
|
ReceiptID string
|
||||||
|
When string
|
||||||
|
Model string
|
||||||
|
Fields []fieldOverride
|
||||||
|
SinceNotes []storage.Note // notes added after this failure (candidate fixes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
c, err := s.store.GetClassification(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cats, people, err := s.lookups()
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "load lookups", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
since, err := s.store.NotesCreatedAfter(c.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "notes since", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := reviewPage.ExecuteTemplate(w, "base", reviewView{
|
||||||
|
ReceiptID: c.ReceiptID,
|
||||||
|
When: c.CreatedAt.Local().Format("2006-01-02 15:04"),
|
||||||
|
Model: c.Model,
|
||||||
|
Fields: deriveOverrides(c, cats, people),
|
||||||
|
SinceNotes: since,
|
||||||
|
}); err != nil {
|
||||||
|
s.serverError(w, "render review", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleReviewSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var fixIDs []int64
|
||||||
|
for _, v := range r.Form["fix"] {
|
||||||
|
if nid, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
fixIDs = append(fixIDs, nid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolution := "unresolved"
|
||||||
|
if len(fixIDs) > 0 {
|
||||||
|
resolution = "fixed"
|
||||||
|
}
|
||||||
|
if err := s.store.MarkClassificationReviewed(id, resolution, fixIDs); err != nil {
|
||||||
|
s.serverError(w, "mark reviewed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/ai", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectAI(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||||||
|
target := "/ai"
|
||||||
|
if errMsg != "" {
|
||||||
|
target += "?error=" + url.QueryEscape(errMsg)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldOverride is one of the four AI-suggested fields, with the suggestion, the
|
||||||
|
// final value, and whether the user overrode it.
|
||||||
|
type fieldOverride struct {
|
||||||
|
Field string
|
||||||
|
Suggested string
|
||||||
|
Final string
|
||||||
|
Overridden bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveOverrides compares the stored AI suggestion blob against the receipt's final
|
||||||
|
// values to determine which of the four fields the user changed. A field the AI left
|
||||||
|
// null/empty that the user then filled counts as an override (the AI missed it).
|
||||||
|
func deriveOverrides(c storage.ClassificationRow, cats, people []storage.Lookup) []fieldOverride {
|
||||||
|
var cr classifyResponse
|
||||||
|
_ = json.Unmarshal([]byte(c.ResponseJSON), &cr)
|
||||||
|
|
||||||
|
finalAmount := dollars(c.FinalAmountCents)
|
||||||
|
finalDate := c.FinalReceiptDate.Format(dateLayout)
|
||||||
|
finalCat := labelFor(c.FinalCategoryID, cats)
|
||||||
|
finalWho := whoLabel(c.FinalPersonID, people)
|
||||||
|
|
||||||
|
return []fieldOverride{
|
||||||
|
{Field: "amount", Suggested: strPtr(cr.Amount), Final: finalAmount,
|
||||||
|
Overridden: amountOverridden(cr.Amount, c.FinalAmountCents)},
|
||||||
|
{Field: "date", Suggested: strPtr(cr.Date), Final: finalDate,
|
||||||
|
Overridden: dateOverridden(cr.Date, c.FinalReceiptDate)},
|
||||||
|
{Field: "category", Suggested: lookupOr(cr.CategoryID, cats, cr.Category), Final: finalCat,
|
||||||
|
Overridden: int64PtrOverridden(cr.CategoryID, &c.FinalCategoryID)},
|
||||||
|
{Field: "who", Suggested: lookupOr(cr.PersonID, people, cr.Person), Final: finalWho,
|
||||||
|
Overridden: int64PtrOverridden(cr.PersonID, c.FinalPersonID)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func amountOverridden(suggested *string, finalCents int64) bool {
|
||||||
|
if suggested == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
cents, err := receipt.ParseAmountCents(*suggested)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return cents != finalCents
|
||||||
|
}
|
||||||
|
|
||||||
|
func dateOverridden(suggested *string, final time.Time) bool {
|
||||||
|
if suggested == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
d, err := time.Parse(dateLayout, strings.TrimSpace(*suggested))
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return d.Year() != final.Year() || d.Month() != final.Month() || d.Day() != final.Day()
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64PtrOverridden(suggested, final *int64) bool {
|
||||||
|
if suggested == nil || final == nil {
|
||||||
|
return suggested != final // both nil → not overridden; one nil → overridden
|
||||||
|
}
|
||||||
|
return *suggested != *final
|
||||||
|
}
|
||||||
|
|
||||||
|
func strPtr(p *string) string {
|
||||||
|
if p == nil || strings.TrimSpace(*p) == "" {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupOr renders a suggested lookup id as its label, falling back to a label the
|
||||||
|
// blob already carried, then to "—".
|
||||||
|
func lookupOr(id *int64, set []storage.Lookup, fallback string) string {
|
||||||
|
if id != nil {
|
||||||
|
if l := labelFor(*id, set); l != "" {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(fallback) != "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
122
internal/web/ai_test.go
Normal file
122
internal/web/ai_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// uploadWithClassifyBlob posts a receipt plus a round-tripped AI suggestion blob.
|
||||||
|
func uploadWithClassifyBlob(t *testing.T, s *Server, amount, classifyJSON string) {
|
||||||
|
t.Helper()
|
||||||
|
body, ct := multipartUpload(t, map[string]string{
|
||||||
|
"amount": amount,
|
||||||
|
"receipt_date": "2026-06-01",
|
||||||
|
"category_id": aCategoryID(t, s),
|
||||||
|
"classify_json": classifyJSON,
|
||||||
|
}, "receipt", "r.png", fakePNG())
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/upload", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
req.AddCookie(authCookie(t, s))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Routes().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("upload status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAI_MissRecordedAndReviewable(t *testing.T) {
|
||||||
|
s := testServerWithStore(t)
|
||||||
|
|
||||||
|
// AI suggested $99.99; user submitted $12.34 → an amount miss.
|
||||||
|
blob := `{"amount":"99.99","date":"2026-06-01","category_id":null,"person_id":null,"model":"haiku-test"}`
|
||||||
|
uploadWithClassifyBlob(t, s, "12.34", blob)
|
||||||
|
|
||||||
|
// Stored classification exists.
|
||||||
|
rows, err := s.store.ListUnreviewedClassifications()
|
||||||
|
if err != nil || len(rows) != 1 {
|
||||||
|
t.Fatalf("ListUnreviewedClassifications: err=%v len=%d", err, len(rows))
|
||||||
|
}
|
||||||
|
id := rows[0].ReceiptID
|
||||||
|
|
||||||
|
// /ai lists it as a miss touching "amount".
|
||||||
|
page := get(t, s, "/ai").Body.String()
|
||||||
|
if !strings.Contains(page, "haiku-test") || !strings.Contains(page, "amount") {
|
||||||
|
t.Errorf("/ai missing the amount miss: %s", page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review page shows the AI guess vs the entered value.
|
||||||
|
rv := get(t, s, "/ai/review/"+id).Body.String()
|
||||||
|
if !strings.Contains(rv, "99.99") || !strings.Contains(rv, "12.34") {
|
||||||
|
t.Errorf("review page missing guess/final: %s", rv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submitting the review (no fix ticked) closes it as unresolved and clears the queue.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ai/review/"+id, strings.NewReader(url.Values{}.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(authCookie(t, s))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Routes().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("review submit status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
left, _ := s.store.ListUnreviewedClassifications()
|
||||||
|
if len(left) != 0 {
|
||||||
|
t.Errorf("queue not cleared after review: %d left", len(left))
|
||||||
|
}
|
||||||
|
if c, _ := s.store.GetClassification(id); c.Resolution != "unresolved" {
|
||||||
|
t.Errorf("resolution = %q, want unresolved", c.Resolution)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAI_NoMissWhenSuggestionMatches(t *testing.T) {
|
||||||
|
s := testServerWithStore(t)
|
||||||
|
cat := aCategoryID(t, s)
|
||||||
|
// AI nailed every field the user submitted → not a miss.
|
||||||
|
blob := `{"amount":"12.34","date":"2026-06-01","category_id":` + cat + `,"person_id":null,"model":"m"}`
|
||||||
|
uploadWithClassifyBlob(t, s, "12.34", blob)
|
||||||
|
|
||||||
|
page := get(t, s, "/ai").Body.String()
|
||||||
|
if !strings.Contains(page, "No misreads to review") {
|
||||||
|
t.Errorf("expected no-misreads message, got: %s", page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAI_NotesCRUD(t *testing.T) {
|
||||||
|
s := testServerWithStore(t)
|
||||||
|
|
||||||
|
post := func(path string, form url.Values) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(authCookie(t, s))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Routes().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("%s status=%d", path, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post("/ai/notes", url.Values{"text": {"European dates are DD/MM"}})
|
||||||
|
notes, _ := s.store.ListActiveNotes()
|
||||||
|
var added int64
|
||||||
|
found := false
|
||||||
|
for _, n := range notes {
|
||||||
|
if n.Text == "European dates are DD/MM" {
|
||||||
|
added, found = n.ID, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("note not added")
|
||||||
|
}
|
||||||
|
|
||||||
|
post("/ai/notes/delete", url.Values{"id": {strconv.FormatInt(added, 10)}})
|
||||||
|
notes, _ = s.store.ListActiveNotes()
|
||||||
|
for _, n := range notes {
|
||||||
|
if n.ID == added {
|
||||||
|
t.Error("note not deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -64,7 +64,15 @@ func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
data = normalizeOrientation(data, mimeType) // upright image reads better for the AI
|
data = normalizeOrientation(data, mimeType) // upright image reads better for the AI
|
||||||
|
|
||||||
sug, err := s.classifier.Classify(r.Context(), time.Now(), data, mimeType)
|
// Append the user's correction notes to the prompt (best-effort).
|
||||||
|
var notes []string
|
||||||
|
if ns, err := s.store.ListActiveNotes(); err == nil {
|
||||||
|
for _, n := range ns {
|
||||||
|
notes = append(notes, n.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sug, err := s.classifier.Classify(r.Context(), time.Now(), data, mimeType, notes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.serverError(w, "classify receipt", err)
|
s.serverError(w, "classify receipt", err)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -202,3 +202,17 @@ form.row {
|
||||||
form.row input { flex: 1; padding: .4rem; }
|
form.row input { flex: 1; padding: .4rem; }
|
||||||
form.row button { padding: .4rem .7rem; }
|
form.row button { padding: .4rem .7rem; }
|
||||||
.add input { border: 1px solid #137333; }
|
.add input { border: 1px solid #137333; }
|
||||||
|
form.row button.danger { background: #fce8e6; border: 1px solid #d93025; color: #a50e0e; }
|
||||||
|
|
||||||
|
/* AI tab: read-only prompt + miss-review table. */
|
||||||
|
pre.prompt {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background: #f1f3f4;
|
||||||
|
padding: .6rem;
|
||||||
|
border-radius: .4rem;
|
||||||
|
font-size: .78rem;
|
||||||
|
max-height: 28rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
table.tally tr.miss th, table.tally tr.miss td { background: #fef7e0; }
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,6 @@ var (
|
||||||
exportPage = parsePage("export.html")
|
exportPage = parsePage("export.html")
|
||||||
tallyPage = parsePage("tally.html")
|
tallyPage = parsePage("tally.html")
|
||||||
recentPage = parsePage("recent.html")
|
recentPage = parsePage("recent.html")
|
||||||
|
aiPage = parsePage("ai.html")
|
||||||
|
reviewPage = parsePage("ai_review.html")
|
||||||
)
|
)
|
||||||
|
|
|
||||||
49
internal/web/templates/ai.html
Normal file
49
internal/web/templates/ai.html
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
{{define "title"}}AI{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<header>
|
||||||
|
<span><a href="/">← Add receipt</a></span>
|
||||||
|
<span><a href="/recent">Recent</a> · <a href="/tally">Tally</a> · <a href="/manage">Manage</a></span>
|
||||||
|
</header>
|
||||||
|
<h1>AI classifier</h1>
|
||||||
|
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||||
|
|
||||||
|
<h2>Correction notes</h2>
|
||||||
|
<p><small>Free-text rules appended to the classifier prompt to fix recurring misreads. Editing a note keeps the old version in history.</small></p>
|
||||||
|
{{range .Notes}}
|
||||||
|
<form class="row" method="post" action="/ai/notes/edit">
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
<input type="text" name="text" value="{{.Text}}">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<button type="submit" formaction="/ai/notes/delete" class="danger">✕</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<form class="row add" method="post" action="/ai/notes">
|
||||||
|
<input type="text" name="text" placeholder="New correction note">
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>Review misreads {{if .Misses}}({{len .Misses}}){{end}}</h2>
|
||||||
|
{{if not .Misses}}
|
||||||
|
<p><small>No misreads to review — the AI matched what you submitted (or nothing has been classified yet).</small></p>
|
||||||
|
{{else}}
|
||||||
|
<ul class="recent">
|
||||||
|
{{range .Misses}}
|
||||||
|
<li>
|
||||||
|
<a class="amt" href="/ai/review/{{.ReceiptID}}">${{.Amount}}</a>
|
||||||
|
<span class="meta">{{.When}} · changed: {{.Fields}}</span>
|
||||||
|
<span class="when">{{.Model}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h2>Current prompt</h2>
|
||||||
|
{{if .Prompt}}
|
||||||
|
<details>
|
||||||
|
<summary>View the exact system prompt sent to the model</summary>
|
||||||
|
<pre class="prompt">{{.Prompt}}</pre>
|
||||||
|
</details>
|
||||||
|
{{else}}
|
||||||
|
<p><small>{{.PromptNote}}</small></p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
38
internal/web/templates/ai_review.html
Normal file
38
internal/web/templates/ai_review.html
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{{define "title"}}Review misread{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<header>
|
||||||
|
<span><a href="/ai">← AI</a></span>
|
||||||
|
<span>classified by {{.Model}} · {{.When}}</span>
|
||||||
|
</header>
|
||||||
|
<h1>Review misread</h1>
|
||||||
|
|
||||||
|
<p><a href="/receipt/{{.ReceiptID}}/file" target="_blank">Open the receipt image ↗</a></p>
|
||||||
|
|
||||||
|
<table class="tally">
|
||||||
|
<thead><tr><th>Field</th><th>AI guessed</th><th>You entered</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Fields}}
|
||||||
|
<tr{{if .Overridden}} class="miss"{{end}}>
|
||||||
|
<th>{{.Field}}</th>
|
||||||
|
<td>{{.Suggested}}</td>
|
||||||
|
<td>{{.Final}}{{if .Overridden}} ✎{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p><small>Rows marked ✎ are where you overrode the AI.</small></p>
|
||||||
|
|
||||||
|
<form method="post" action="/ai/review/{{.ReceiptID}}">
|
||||||
|
<h2>What fixed it?</h2>
|
||||||
|
{{if .SinceNotes}}
|
||||||
|
<p><small>Notes added since this receipt was classified — tick any that address this misread:</small></p>
|
||||||
|
{{range .SinceNotes}}
|
||||||
|
<label class="check"><input type="checkbox" name="fix" value="{{.ID}}"> {{.Text}}</label>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p><small>No notes were added after this receipt was classified. Add a correction note on the <a href="/ai">AI page</a> first if one would help, then come back — or close this as unresolved.</small></p>
|
||||||
|
{{end}}
|
||||||
|
<button type="submit" class="primary">Done reviewing</button>
|
||||||
|
<p><small>Ticking one or more notes marks this as fixed; ticking none closes it as unresolved.</small></p>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<header>
|
<header>
|
||||||
<span><a href="/">← Add receipt</a></span>
|
<span><a href="/">← Add receipt</a></span>
|
||||||
<span><a href="/tally">Tally</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a></span>
|
<span><a href="/tally">Tally</a> · <a href="/ai">AI</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a></span>
|
||||||
</header>
|
</header>
|
||||||
<h1>{{.Title}}</h1>
|
<h1>{{.Title}}</h1>
|
||||||
<p class="tabs">
|
<p class="tabs">
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<header>
|
<header>
|
||||||
<span>{{.Subject}}</span>
|
<span>{{.Subject}}</span>
|
||||||
<span><a href="/tally">Tally</a> · <a href="/recent">Recent</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></span>
|
<span><a href="/tally">Tally</a> · <a href="/recent">Recent</a> · <a href="/ai">AI</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></span>
|
||||||
</header>
|
</header>
|
||||||
<h1>Add receipt</h1>
|
<h1>Add receipt</h1>
|
||||||
{{range .Errors}}<div class="err">{{.}}</div>{{end}}
|
{{range .Errors}}<div class="err">{{.}}</div>{{end}}
|
||||||
|
|
@ -58,6 +58,7 @@
|
||||||
<ul id="dup-list"></ul>
|
<ul id="dup-list"></ul>
|
||||||
<label class="check"><input type="checkbox" id="dup-ack"> Add it anyway</label>
|
<label class="check"><input type="checkbox" id="dup-ack"> Add it anyway</label>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="hidden" name="classify_json" id="classify_json">
|
||||||
<button type="submit" class="primary" id="submit-btn">Submit</button>
|
<button type="submit" class="primary" id="submit-btn">Submit</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
@ -189,11 +190,16 @@
|
||||||
|
|
||||||
skip.addEventListener("change", function () {
|
skip.addEventListener("change", function () {
|
||||||
note.style.opacity = skip.checked ? "0.5" : "";
|
note.style.opacity = skip.checked ? "0.5" : "";
|
||||||
if (skip.checked) note.textContent = "AI auto-fill skipped — enter the fields by hand.";
|
if (skip.checked) {
|
||||||
else note.innerHTML = baseNote;
|
note.textContent = "AI auto-fill skipped — enter the fields by hand.";
|
||||||
|
setValue("classify_json", ""); // no AI suggestion to record
|
||||||
|
} else {
|
||||||
|
note.innerHTML = baseNote;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fileInput.addEventListener("change", function () {
|
fileInput.addEventListener("change", function () {
|
||||||
|
setValue("classify_json", ""); // a new file invalidates any prior suggestion
|
||||||
var file = fileInput.files[0];
|
var file = fileInput.files[0];
|
||||||
if (!file || skip.checked) return;
|
if (!file || skip.checked) return;
|
||||||
note.style.opacity = "";
|
note.style.opacity = "";
|
||||||
|
|
@ -212,6 +218,7 @@
|
||||||
if (d.date) setValue("receipt_date", d.date);
|
if (d.date) setValue("receipt_date", d.date);
|
||||||
if (d.category_id != null) setValue("category_id", String(d.category_id));
|
if (d.category_id != null) setValue("category_id", String(d.category_id));
|
||||||
if (d.person_id != null) setValue("person_id", String(d.person_id));
|
if (d.person_id != null) setValue("person_id", String(d.person_id));
|
||||||
|
setValue("classify_json", JSON.stringify(d)); // round-trip for misread review
|
||||||
var cost = (d.cost_cents != null) ? " · " + d.cost_cents.toFixed(2) + "¢" : "";
|
var cost = (d.cost_cents != null) ? " · " + d.cost_cents.toFixed(2) + "¢" : "";
|
||||||
note.textContent = "Pre-filled by " + (d.model || model) + cost + " — review and submit.";
|
note.textContent = "Pre-filled by " + (d.model || model) + cost + " — review and submit.";
|
||||||
// Pre-filled date+amount may match an existing receipt — re-check.
|
// Pre-filled date+amount may match an existing receipt — re-check.
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -254,6 +256,16 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the AI suggestion the browser round-tripped, so misreads can be
|
||||||
|
// reviewed later (best-effort, diagnostic — never block the upload on it).
|
||||||
|
if blob := strings.TrimSpace(r.FormValue("classify_json")); blob != "" {
|
||||||
|
var cr classifyResponse
|
||||||
|
_ = json.Unmarshal([]byte(blob), &cr) // model only; blob stored verbatim
|
||||||
|
if err := s.store.InsertClassification(id, cr.Model, blob, time.Now().UTC()); err != nil {
|
||||||
|
log.Printf("warning: store classification for %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save each attachment now that the parent receipt id exists, keyed to the
|
// Save each attachment now that the parent receipt id exists, keyed to the
|
||||||
// receipt's date+amount so the files sort alongside it.
|
// receipt's date+amount so the files sort alongside it.
|
||||||
var attachmentNames []string
|
var attachmentNames []string
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,12 @@ func (s *Server) Routes() http.Handler {
|
||||||
mux.Handle("GET /recent/receipts", s.requireAuth(http.HandlerFunc(s.handleRecentReceipts)))
|
mux.Handle("GET /recent/receipts", s.requireAuth(http.HandlerFunc(s.handleRecentReceipts)))
|
||||||
mux.Handle("GET /receipt/{id}/file", s.requireAuth(http.HandlerFunc(s.handleReceiptFile)))
|
mux.Handle("GET /receipt/{id}/file", s.requireAuth(http.HandlerFunc(s.handleReceiptFile)))
|
||||||
mux.Handle("GET /attachment/{id}/file", s.requireAuth(http.HandlerFunc(s.handleAttachmentFile)))
|
mux.Handle("GET /attachment/{id}/file", s.requireAuth(http.HandlerFunc(s.handleAttachmentFile)))
|
||||||
|
mux.Handle("GET /ai", s.requireAuth(http.HandlerFunc(s.handleAI)))
|
||||||
|
mux.Handle("POST /ai/notes", s.requireAuth(http.HandlerFunc(s.handleAddNote)))
|
||||||
|
mux.Handle("POST /ai/notes/edit", s.requireAuth(http.HandlerFunc(s.handleEditNote)))
|
||||||
|
mux.Handle("POST /ai/notes/delete", s.requireAuth(http.HandlerFunc(s.handleDeleteNote)))
|
||||||
|
mux.Handle("GET /ai/review/{id}", s.requireAuth(http.HandlerFunc(s.handleReview)))
|
||||||
|
mux.Handle("POST /ai/review/{id}", s.requireAuth(http.HandlerFunc(s.handleReviewSubmit)))
|
||||||
mux.Handle("GET /manage", s.requireAuth(http.HandlerFunc(s.handleManage)))
|
mux.Handle("GET /manage", s.requireAuth(http.HandlerFunc(s.handleManage)))
|
||||||
mux.Handle("POST /manage/categories", s.requireAuth(http.HandlerFunc(s.handleAddCategory)))
|
mux.Handle("POST /manage/categories", s.requireAuth(http.HandlerFunc(s.handleAddCategory)))
|
||||||
mux.Handle("POST /manage/categories/rename", s.requireAuth(http.HandlerFunc(s.handleRenameCategory)))
|
mux.Handle("POST /manage/categories/rename", s.requireAuth(http.HandlerFunc(s.handleRenameCategory)))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue