diff --git a/CHANGELOG.md b/CHANGELOG.md index 9513500..1a576d4 100644 --- a/CHANGELOG.md +++ b/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 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 ### Added diff --git a/DESIGN.md b/DESIGN.md index 4319f89..f996e44 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -456,4 +456,98 @@ Out of scope (for now): accumulate cruft and a cleanup surface (akin to item 6 for people) will be wanted eventually, but not in this item. - Adding/removing tags on an already-saved receipt (no edit/detail page yet, same - limitation as attachments in item 10). \ No newline at end of file + 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). \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..72922e1 --- /dev/null +++ b/README.md @@ -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. diff --git a/SPEC.md b/SPEC.md index 471340e..9c95dc1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -79,7 +79,7 @@ A missing/invalid catalog is a fatal startup error. ## 4. Data model (SQLite) - **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 " " - **receipts** — `id` (UUID), `uploaded_by`, `uploaded_at`, `receipt_date`, `amount_cents` (integer — money is never a float), `category_id` (FK, required), `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. - **tags** — `id`, `label` (unique, **case-insensitive** via `COLLATE NOCASE`). - **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. @@ -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 table. An unknown model id shows token usage but omits the ¢ figure (never a 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 -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`**. 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 → @@ -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 unambiguous canonical person ("Jude Tremblay"), reassigning their receipts first, 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 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 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 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 pure Go), runnable on any `linux/`. @@ -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; the UI/endpoint does not): @@ -307,3 +346,7 @@ the UI/endpoint does not): - **Archive/zip export** (`/export/archive`): not implemented; only `/export/db`. - **HEIC** images are not decodable in pure Go; browser camera uploads arrive as 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. diff --git a/internal/classify/classify.go b/internal/classify/classify.go index 9fb00f4..7f09263 100644 --- a/internal/classify/classify.go +++ b/internal/classify/classify.go @@ -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" // --- request / response shapes --- @@ -139,8 +145,8 @@ type toolInput struct { // 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 // testable; pass time.Now(). -func (c *Classifier) Classify(ctx context.Context, today time.Time, image []byte, mimeType string) (Suggestion, error) { - system := BuildSystemPrompt(c.Persons, c.Categories, today.Format("2006-01-02")) +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"), notes) imgBlock := apiBlock{ Type: "image", diff --git a/internal/classify/classify_test.go b/internal/classify/classify_test.go index 2c89bdf..f325241 100644 --- a/internal/classify/classify_test.go +++ b/internal/classify/classify_test.go @@ -67,7 +67,7 @@ func TestClassifyHappyPath(t *testing.T) { "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 { t.Fatalf("Classify: %v", err) } @@ -99,7 +99,7 @@ func TestClassifyNullsAndFallback(t *testing.T) { "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 { t.Fatalf("Classify: %v", err) } @@ -122,7 +122,7 @@ func TestClassifyRejectsNonCanonicalPerson(t *testing.T) { "person": "Dr. Emily Smith", // a provider, not a configured patient "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 { t.Fatalf("Classify: %v", err) } @@ -148,7 +148,7 @@ func TestClassifyIntegration(t *testing.T) { if err != nil { 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 { t.Fatalf("Classify: %v", err) } diff --git a/internal/classify/prompt.go b/internal/classify/prompt.go index 5565e2d..84b36b4 100644 --- a/internal/classify/prompt.go +++ b/internal/classify/prompt.go @@ -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 // allowed outputs; variants and examples are presented as illustrations, never as // 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 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("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() } diff --git a/internal/classify/prompt_test.go b/internal/classify/prompt_test.go index 04e0f30..20588f2 100644 --- a/internal/classify/prompt_test.go +++ b/internal/classify/prompt_test.go @@ -69,19 +69,19 @@ func TestBuildSystemPrompt(t *testing.T) { {Name: "Pharmacy", Examples: []string{"CVS", "Rx"}}, {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{ "Jean-Michel Tremblay", // canonical label "Lynna Nguyen", "Pharmacy", - "CVS", // example injected - "2026-06-17", // today - "AMBIGUOUS", // the ambiguity rule - "IGNORE everyone", // ignore-other-people rule - "null", // unknown handling - "raw_name", // raw echo - "CLOSEST to today", // date tie-breaker + "CVS", // example injected + "2026-06-17", // today + "AMBIGUOUS", // the ambiguity rule + "IGNORE everyone", // ignore-other-people rule + "null", // unknown handling + "raw_name", // raw echo + "CLOSEST to today", // date tie-breaker } { if !strings.Contains(p, want) { t.Errorf("system prompt missing %q", want) diff --git a/internal/storage/ai_notes.go b/internal/storage/ai_notes.go new file mode 100644 index 0000000..800fb3b --- /dev/null +++ b/internal/storage/ai_notes.go @@ -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() +} diff --git a/internal/storage/ai_notes_test.go b/internal/storage/ai_notes_test.go new file mode 100644 index 0000000..03ea578 --- /dev/null +++ b/internal/storage/ai_notes_test.go @@ -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 +} diff --git a/internal/storage/classifications.go b/internal/storage/classifications.go new file mode 100644 index 0000000..6d6188c --- /dev/null +++ b/internal/storage/classifications.go @@ -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() +} diff --git a/internal/storage/classifications_test.go b/internal/storage/classifications_test.go new file mode 100644 index 0000000..94a5ebd --- /dev/null +++ b/internal/storage/classifications_test.go @@ -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) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 114a986..ff765d5 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -75,6 +75,27 @@ CREATE TABLE IF NOT EXISTS receipt_tags ( PRIMARY KEY (receipt_id, 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, @@ -107,6 +128,10 @@ func Open(path string) (*Store, error) { return nil, fmt.Errorf("seed categories: %w", err) } } + if err := seedNotesIfEmpty(db); err != nil { + db.Close() + return nil, err + } return &Store{db: db}, nil } diff --git a/internal/web/ai.go b/internal/web/ai.go new file mode 100644 index 0000000..d528a4e --- /dev/null +++ b/internal/web/ai.go @@ -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 "—" +} diff --git a/internal/web/ai_test.go b/internal/web/ai_test.go new file mode 100644 index 0000000..d9790f9 --- /dev/null +++ b/internal/web/ai_test.go @@ -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") + } + } +} diff --git a/internal/web/scan.go b/internal/web/scan.go index 33be612..424d462 100644 --- a/internal/web/scan.go +++ b/internal/web/scan.go @@ -27,7 +27,7 @@ type classifyResponse struct { RawDate string `json:"raw_date"` RawAmount string `json:"raw_amount"` - Model string `json:"model"` // model that ran, for display + Model string `json:"model"` // model that ran, for display CostCents *float64 `json:"cost_cents,omitempty"` // nil when the model's price is unknown } @@ -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 - 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 { s.serverError(w, "classify receipt", err) return diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 308a664..503c975 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -202,3 +202,17 @@ form.row { form.row input { flex: 1; padding: .4rem; } form.row button { padding: .4rem .7rem; } .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; } diff --git a/internal/web/templates.go b/internal/web/templates.go index a79dcef..5d6705b 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -24,4 +24,6 @@ var ( exportPage = parsePage("export.html") tallyPage = parsePage("tally.html") recentPage = parsePage("recent.html") + aiPage = parsePage("ai.html") + reviewPage = parsePage("ai_review.html") ) diff --git a/internal/web/templates/ai.html b/internal/web/templates/ai.html new file mode 100644 index 0000000..1f700e8 --- /dev/null +++ b/internal/web/templates/ai.html @@ -0,0 +1,49 @@ +{{define "title"}}AI{{end}} +{{define "content"}} +
+ ← Add receipt + Recent · Tally · Manage +
+

AI classifier

+{{if .Error}}
{{.Error}}
{{end}} + +

Correction notes

+

Free-text rules appended to the classifier prompt to fix recurring misreads. Editing a note keeps the old version in history.

+{{range .Notes}} +
+ + + + +
+{{end}} +
+ + +
+ +

Review misreads {{if .Misses}}({{len .Misses}}){{end}}

+{{if not .Misses}} +

No misreads to review — the AI matched what you submitted (or nothing has been classified yet).

+{{else}} +
    + {{range .Misses}} +
  • + ${{.Amount}} + {{.When}} · changed: {{.Fields}} + {{.Model}} +
  • + {{end}} +
+{{end}} + +

Current prompt

+{{if .Prompt}} +
+ View the exact system prompt sent to the model +
{{.Prompt}}
+
+{{else}} +

{{.PromptNote}}

+{{end}} +{{end}} diff --git a/internal/web/templates/ai_review.html b/internal/web/templates/ai_review.html new file mode 100644 index 0000000..fb51c88 --- /dev/null +++ b/internal/web/templates/ai_review.html @@ -0,0 +1,38 @@ +{{define "title"}}Review misread{{end}} +{{define "content"}} +
+ ← AI + classified by {{.Model}} · {{.When}} +
+

Review misread

+ +

Open the receipt image ↗

+ + + + + {{range .Fields}} + + + + + + {{end}} + +
FieldAI guessedYou entered
{{.Field}}{{.Suggested}}{{.Final}}{{if .Overridden}} ✎{{end}}
+

Rows marked ✎ are where you overrode the AI.

+ +
+

What fixed it?

+ {{if .SinceNotes}} +

Notes added since this receipt was classified — tick any that address this misread:

+ {{range .SinceNotes}} + + {{end}} + {{else}} +

No notes were added after this receipt was classified. Add a correction note on the AI page first if one would help, then come back — or close this as unresolved.

+ {{end}} + +

Ticking one or more notes marks this as fixed; ticking none closes it as unresolved.

+
+{{end}} diff --git a/internal/web/templates/recent.html b/internal/web/templates/recent.html index ff98f49..450ca24 100644 --- a/internal/web/templates/recent.html +++ b/internal/web/templates/recent.html @@ -2,7 +2,7 @@ {{define "content"}}
← Add receipt - Tally · Manage · Export + Tally · AI · Manage · Export

{{.Title}}

diff --git a/internal/web/templates/upload.html b/internal/web/templates/upload.html index 87a5795..057d74b 100644 --- a/internal/web/templates/upload.html +++ b/internal/web/templates/upload.html @@ -2,7 +2,7 @@ {{define "content"}}

{{.Subject}} - Tally · Recent · Manage · Export · Log out + Tally · Recent · AI · Manage · Export · Log out

Add receipt

{{range .Errors}}
{{.}}
{{end}} @@ -58,6 +58,7 @@
    + @@ -189,11 +190,16 @@ skip.addEventListener("change", function () { note.style.opacity = skip.checked ? "0.5" : ""; - if (skip.checked) note.textContent = "AI auto-fill skipped — enter the fields by hand."; - else note.innerHTML = baseNote; + if (skip.checked) { + 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 () { + setValue("classify_json", ""); // a new file invalidates any prior suggestion var file = fileInput.files[0]; if (!file || skip.checked) return; note.style.opacity = ""; @@ -212,6 +218,7 @@ if (d.date) setValue("receipt_date", d.date); if (d.category_id != null) setValue("category_id", String(d.category_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) + "¢" : ""; note.textContent = "Pre-filled by " + (d.model || model) + cost + " — review and submit."; // Pre-filled date+amount may match an existing receipt — re-check. diff --git a/internal/web/upload.go b/internal/web/upload.go index 37f52c8..64bf96f 100644 --- a/internal/web/upload.go +++ b/internal/web/upload.go @@ -2,8 +2,10 @@ package web import ( "crypto/rand" + "encoding/json" "fmt" "io" + "log" "net/http" "os" "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 // receipt's date+amount so the files sort alongside it. var attachmentNames []string diff --git a/internal/web/web.go b/internal/web/web.go index 40abc98..b1a4a9c 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -75,6 +75,12 @@ func (s *Server) Routes() http.Handler { 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 /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("POST /manage/categories", s.requireAuth(http.HandlerFunc(s.handleAddCategory))) mux.Handle("POST /manage/categories/rename", s.requireAuth(http.HandlerFunc(s.handleRenameCategory)))