hsa-app/internal/storage/ai_notes.go

131 lines
4.3 KiB
Go
Raw Permalink Normal View History

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