67 lines
2.9 KiB
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()
|
|
}
|