textmachine/backend/internal/store/ruby.go

66 lines
2.8 KiB
Go

package store
import "database/sql"
// ruby.go: the ruby/furigana readings captured on ingest (шаг 3a, v4 schema).
// This is CAPTURE-ONLY storage — the reading layer that epub-v1 text extraction
// would otherwise silently drop (04-unhappy §4 / D9). Memory v2 (шаг 4) reads it
// to seed a glossary name-lock; nothing here injects into a prompt, so no field
// is snapshot- or wire-load-bearing (it never touches request_hash).
//
// The store is dumb storage (mirrors chunkstatus.go): the aggregation "one row per
// (base,reading) with first_chapter=MIN and occurrences=full-book count" happens in
// the pipeline (ingest.go); this layer only upserts and reads back.
// RubyReading is one (book, base, reading) row.
type RubyReading struct {
BookID string
Base string // the ruby body (kanji/base surface form)
Reading string // the <rt> reading (furigana)
FirstChapter int // MIN 1-based chapter the pair appears in
Occurrences int // full-book count of the pair
}
// ReplaceRubyReadings replaces a book's ENTIRE ruby set with rows in one
// transaction (DELETE-by-book, then INSERT each). The caller (persistRuby)
// recomputes the whole-book aggregate on every ingest, so a full replace is the
// correct idempotency model: re-ingesting the identical source rewrites identical
// rows; a source edit converges every column to the truth — including a pair the
// edit REMOVED, which a prior upsert-only path left as a permanent phantom (a
// name-lock on a name no longer in the book — external-review #6). Scoped to
// book_id, so other books are untouched. Each row's BookID must equal bookID.
func (s *Store) ReplaceRubyReadings(bookID string, rows []RubyReading) error {
ctx, cancel := opContext()
defer cancel()
tx, err := s.w.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM ruby_readings WHERE book_id = ?`, bookID); err != nil {
return err
}
for _, rr := range rows {
if _, err := tx.ExecContext(ctx, `
INSERT INTO ruby_readings (book_id, base, reading, first_chapter, occurrences)
VALUES (?, ?, ?, ?, ?)`,
rr.BookID, rr.Base, rr.Reading, rr.FirstChapter, rr.Occurrences); err != nil {
return err
}
}
return tx.Commit()
}
// RubyReadingsForBook returns every captured reading for a book, ordered
// deterministically (first_chapter, base, reading) for a stable report/consumer.
func (s *Store) RubyReadingsForBook(bookID string) ([]RubyReading, error) {
return queryAll(s.r, `
SELECT base, reading, first_chapter, occurrences
FROM ruby_readings WHERE book_id = ?
ORDER BY first_chapter, base, reading`,
func(rows *sql.Rows) (RubyReading, error) {
rr := RubyReading{BookID: bookID}
err := rows.Scan(&rr.Base, &rr.Reading, &rr.FirstChapter, &rr.Occurrences)
return rr, err
}, bookID)
}