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