AI classifier correction notes + misread review (AI tab)
All checks were successful
Build and Test / build-and-test (push) Successful in 37s

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:
Jean-Michel Tremblay 2026-06-20 16:04:30 -04:00
parent da7fc56920
commit c715c8e0c0
24 changed files with 1222 additions and 30 deletions

View file

@ -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

View file

@ -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
View 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
View file

@ -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.

View file

@ -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",

View file

@ -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)
} }

View file

@ -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()
} }

View file

@ -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

View 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()
}

View 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
}

View 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()
}

View 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)
}
}

View file

@ -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
View 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
View 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")
}
}
}

View file

@ -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

View file

@ -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; }

View file

@ -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")
) )

View 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}}

View 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}}

View file

@ -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">

View file

@ -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.

View file

@ -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

View file

@ -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)))