hsa-app/internal/storage/classifications.go
Jean-Michel Tremblay c715c8e0c0
All checks were successful
Build and Test / build-and-test (push) Successful in 37s
AI classifier correction notes + misread review (AI tab)
Add a global, temporal ai_notes list appended to the classifier prompt
(seeded once from no-PII defaults, documented in README), managed inline
on a new AI tab with a read-only view of the assembled prompt. Every
AI-run upload records the browser-round-tripped suggestion blob + model;
misreads are derived (final field != AI guess) and reviewed one by one
(image + per-field guess-vs-entered + notes-since), attributing which
note fixed each or closing unresolved. Update SPEC (new section 10),
DESIGN item 15, README, and changelog (0.0.3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:04:30 -04:00

123 lines
4.1 KiB
Go

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