textmachine/backend/internal/store/ruby.go

67 lines
2.9 KiB
Go

package store
// 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
}
// UpsertRubyReading writes (or converges) one ruby row. Idempotent by design: the
// caller (persistRuby) recomputes the WHOLE-book aggregate on every ingest, so it
// passes the authoritative first_chapter (the full-book MIN) and occurrences (the
// full-book count) already. Both columns are therefore REPLACED on conflict —
// re-ingesting the identical source rewrites identical values (idempotent), and a
// source edit that moves the pair's first appearance re-converges to the truth. A
// MIN-merge here would instead pin first_chapter to a stale-early chapter the pair
// no longer occupies after such an edit (self-review finding), asymmetric with
// occurrences. Callers pass one row per (base,reading); the ON CONFLICT only fires
// across separate ingests of the same book.
func (s *Store) UpsertRubyReading(rr RubyReading) error {
ctx, cancel := opContext()
defer cancel()
_, err := s.w.ExecContext(ctx, `
INSERT INTO ruby_readings (book_id, base, reading, first_chapter, occurrences)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (book_id, base, reading) DO UPDATE SET
first_chapter = excluded.first_chapter,
occurrences = excluded.occurrences`,
rr.BookID, rr.Base, rr.Reading, rr.FirstChapter, rr.Occurrences)
return err
}
// 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) {
ctx, cancel := opContext()
defer cancel()
rows, err := s.r.QueryContext(ctx, `
SELECT base, reading, first_chapter, occurrences
FROM ruby_readings WHERE book_id = ?
ORDER BY first_chapter, base, reading`, bookID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RubyReading
for rows.Next() {
rr := RubyReading{BookID: bookID}
if err := rows.Scan(&rr.Base, &rr.Reading, &rr.FirstChapter, &rr.Occurrences); err != nil {
return nil, err
}
out = append(out, rr)
}
return out, rows.Err()
}