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>
130 lines
4.3 KiB
Go
130 lines
4.3 KiB
Go
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()
|
|
}
|